From 6bc5e623ec4a9369edb96ffe0bce69eeb9963c18 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 07:29:33 +0800 Subject: [PATCH 01/36] fix(runtime-host): admit structured-only Messages and keep them model-visible A quote-only or attachment-only turn carried real model-facing context but was rejected at the Host admission boundary ("Invalid Message text") and, once persisted, dropped by the replay visibility predicate, which counted only inline text length. The result was exactly #4804: structured-only sends fail before the provider request, and any that persisted render as an empty user bubble while the model never sees the quoted content. - decodeMessageAdmissionContent now decodes the frame structurally and applies the text rule itself: empty inline text is admissible when the Message carries quotes or attachments; a Message with none of the three still throws the same invalid-frame error. All turn/message admission call sites share this function, so skill-only and structured-only admissions now follow one rule. - runtimeEventHasModelVisibleContent counts a user-authored text event with quotes or attachments as model-visible even when the text is empty, so the durable event survives replay and the existing quote projection (formatQuoteRefs) reaches the model. Red-green: both new tests (#4804-tagged) fail with the production files stashed and pass with them restored. Fixes #4804 Generated-by: GLM-5.3-Flash (ZCode) --- .../core/src/__tests__/runtime-event.test.ts | 31 +++++++++++++++++++ packages/core/src/runtime-event.ts | 11 +++++-- .../src/__tests__/protocol.test.ts | 28 +++++++++++++++++ packages/runtime-host/src/protocol/turn.ts | 15 ++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 3bfb380177..c369fa5696 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -854,6 +854,37 @@ describe('runtimeEventHasModelVisibleContent', () => { for (const event of hidden) assert.strictEqual(runtimeEventHasModelVisibleContent(event), false); }); + + test('counts structured user context as model-visible with empty inline text (#4804)', () => { + const visible = [ + baseEvent({ + role: 'user', + content: { kind: 'text', text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }, + }), + baseEvent({ + role: 'user', + content: { + kind: 'text', + text: '', + attachments: [ + { + kind: 'code', + name: 'a.ts', + mimeType: 'text/typescript', + bytes: 10, + ref: { kind: 'workspace_file', relativePath: 'a.ts' }, + }, + ], + }, + }), + ]; + for (const event of visible) + assert.strictEqual(runtimeEventHasModelVisibleContent(event), true); + assert.strictEqual( + runtimeEventHasModelVisibleContent(baseEvent({ content: { kind: 'text', text: '' } })), + false, + ); + }); }); test('runtime errors reject malformed retry decisions at the durable boundary', () => { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9794423f43..63044e39c9 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -1514,7 +1514,10 @@ export function isPartialRuntimeEvent(event: RuntimeEvent): boolean { /** * True if the event carries content whose kind is eligible for model * history projection: text, thinking, function_call, or function_response. - * Error-only content and pure action/refs events are NOT model-visible. + * A user-authored text event with structured context (quotes or attachments) + * is model-visible even when the inline text is empty — the structured part + * is what carries the turn (#4804). Error-only content and pure action/refs + * events are NOT model-visible. * * This is a content-kind check only. Callers still apply `partial` * filtering (partial chunks are never replayed into the next model call). @@ -1525,7 +1528,11 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean if (!content) return false; switch (content.kind) { case 'text': - return content.text.length > 0; + return ( + content.text.length > 0 || + (content.quotes?.length ?? 0) > 0 || + (content.attachments?.length ?? 0) > 0 + ); case 'thinking': case 'function_call': case 'function_response': diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index ab2b3e685c..df0552d771 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1922,6 +1922,34 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('admits structured-only Messages: empty inline text with quotes or attachments (#4804)', () => { + const submit = (content: unknown) => + decodeClientFrame({ + requestId: 'submit-structured-only', + operation: 'turn.message.submit', + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content, + placement: 'next_turn', + }, + }); + // A quote or an attachment carries the turn by itself: empty inline text + // is admissible when either is present. + assert.doesNotThrow(() => + submit({ text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }), + ); + assert.doesNotThrow(() => + submit({ + text: '', + attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' })], + }), + ); + // A Message with nothing but empty text is still an invalid frame. + assert.throws(() => submit({ text: '' }), isInvalidFrame); + }); + test('bounds Message text in UTF-8 bytes while preserving frame headroom', () => { const input = { originHostEpoch: 'epoch-1', diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index e5c5c8e3ea..5f7a02ec2f 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -472,7 +472,20 @@ export function decodeMessageAdmissionContent( value: unknown, allowEmptyText = false, ): MessageContent { - const content = decodeMessageContent(value, allowEmptyText); + // Structure first with text emptiness unconstrained, then apply the + // admission rule: a quote or an attachment carries the turn by itself, so + // empty inline text is admissible when either is present (#4804). A truly + // contentless Message still throws, with the same frame error the + // text-length rule produced. + const content = decodeMessageContent(value, true); + if ( + !allowEmptyText && + content.text.length === 0 && + (content.quotes?.length ?? 0) === 0 && + (content.attachments?.length ?? 0) === 0 + ) { + throw invalidProtocolFrame('Invalid Message text'); + } if (content.attachments?.some((attachment) => attachment.ref.kind === 'session_context')) { throw invalidProtocolFrame('Session context references are Host-owned'); } From 0f448f29f39f2c553c5ad642b83a3db312fbdc58 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 20:57:32 +0800 Subject: [PATCH 02/36] chore(runtime-host): declare the structured-only admission widening wire-compatible The #4804 admission change touches packages/runtime-host/src/protocol/turn.ts without changing the wire: the Host only accepts strictly more frames (an empty-text Message that carries a quote or an attachment is admitted), emits nothing new, and rejects nothing that was valid before. Declare it under protocol-compatible-changes/ at epoch 112 instead of bumping the epoch, per the #3313 guard's compatible-extension path; the guard passes again on the merge result against current main. Generated-by: GLM-5.3-Flash (ZCode) --- .../message-admission-quote-or-attachment-text.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json diff --git a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json new file mode 100644 index 0000000000..c840e81ab3 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json @@ -0,0 +1,5 @@ +{ + "epoch": 112, + "files": ["packages/runtime-host/src/protocol/turn.ts"], + "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." +} From 09ec91885aa854519a3efe78c5cb4b22944b92fd Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sat, 5 Sep 2026 21:19:30 +0800 Subject: [PATCH 03/36] fix(runtime-host): read queued and steering messages back with the admission rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1 (Astro-Han): decodeMessageAdmissionContent now admits an empty-text Message that carries a quote or an attachment, but the two places that read those messages back — the message queue entry snapshot (message.ts) and the durable steering echo (session-continuity.ts) — still decoded with the default text-length rule, so one admitted next_turn entry broke the whole queue snapshot frame at serialization. Both call sites use the same admission decoder now, and a submit-to-snapshot round-trip test pins the path the review named. The compatible-change declaration grows by the two read-back files; the protocol epoch guard stays green at 112. Generated-by: GLM-5.3-Flash (ZCode) --- ...ge-admission-quote-or-attachment-text.json | 8 +++- .../src/__tests__/protocol.test.ts | 43 +++++++++++++++++++ packages/runtime-host/src/protocol/message.ts | 2 +- .../src/protocol/session-continuity.ts | 4 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json index c840e81ab3..3b8d81d2ef 100644 --- a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json +++ b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json @@ -1,5 +1,9 @@ { "epoch": 112, - "files": ["packages/runtime-host/src/protocol/turn.ts"], - "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." + "files": [ + "packages/runtime-host/src/protocol/turn.ts", + "packages/runtime-host/src/protocol/message.ts", + "packages/runtime-host/src/protocol/session-continuity.ts" + ], + "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted at submit and read back the same way by the queue-entry and steering decoders; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." } diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index df0552d771..86e7314bb4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1950,6 +1950,49 @@ describe('Runtime Host bootstrap protocol', () => { assert.throws(() => submit({ text: '' }), isInvalidFrame); }); + test('admitted structured-only Messages survive queue and steering read-back (#4804)', () => { + const admitted = { text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }; + // A queued next_turn entry carries content admission already accepted at + // submit; the read-back decoders must apply the same rule or the whole + // snapshot frame breaks around one admitted entry. + const projectionWire = { + hostEpoch: 'epoch-1', + queueRevision: 7, + steering: [], + followup: [ + { + ...queuedMessage('later', 'next_turn'), + entryId: 'entry-9', + messageId: 'm-9', + content: admitted, + }, + ], + }; + assert.deepEqual( + decodeSessionMessageQueueProjection(JSON.parse(JSON.stringify(projectionWire))), + projectionWire, + ); + // The durable steering echo reads back through the session-event frame. + assert.doesNotThrow(() => + decodeHostFrame({ + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'steering_message' as const, + id: 'steering-event-9', + turnId: 'turn-1', + ts: 7, + messageId: 'steering-message-9', + content: admitted, + }, + }), + ); + }); + test('bounds Message text in UTF-8 bytes while preserving frame headroom', () => { const input = { originHostEpoch: 'epoch-1', diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index 4bb02151fe..89806dc726 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -651,7 +651,7 @@ function decodeMessageQueueEntrySnapshot(value: unknown): MessageQueueEntrySnaps const base = { entryId: requireEntityId(record.entryId, 'entryId'), messageId: requireEntityId(record.messageId, 'messageId'), - content: decodeMessageContent(record.content), + content: decodeMessageAdmissionContent(record.content), placement: requireMessagePlacement(record.placement), }; if (record.state === 'queued' || record.state === 'retracted') { diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index ae600aa5de..2dd0e26d7a 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -42,7 +42,7 @@ import { } from './message.js'; import { defineOperation } from './operation-spec.js'; import { - decodeMessageContent, + decodeMessageAdmissionContent, decodeTurnSnapshot, type MessageContent, type TurnSnapshot, @@ -802,7 +802,7 @@ function decodeSessionSteeringEvent(record: Record): SessionSte turnId: requireEntityId(record.turnId, 'turnId'), ts: requireCount(record.ts, 'Session steering event timestamp'), messageId: requireEntityId(record.messageId, 'messageId'), - content: decodeMessageContent(record.content), + content: decodeMessageAdmissionContent(record.content), }; } From dcf339e0f9ba12312790211e614a8cf46c2f3d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Mon, 7 Sep 2026 18:04:25 +0800 Subject: [PATCH 04/36] fix(runtime-host): bump compatibility epoch for message admission (#4804) The structured-only Message admission was declared a compatible extension, but the declaration is the weaker side of an asymmetric bet: a wrong "compatible" claim is invisible at handshake and only surfaces when a mixed-version pair exchanges the new frame, while a spare epoch number costs nothing. Upstream also moved the epoch 112 -> 123 since the declaration was written, which invalidates it outright (the guard requires declaration epoch == head epoch). Bump RUNTIME_HOST_COMPATIBILITY_EPOCH 123 -> 124, record the change in the epoch log, and drop the compatibility declaration. Follow-up to the P2 review on #4815. Generated-by: GLM-5.3-Flash (ZCode) --- .../message-admission-quote-or-attachment-text.json | 9 --------- packages/runtime-host/src/protocol/index.ts | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) delete mode 100644 packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json diff --git a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json b/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json deleted file mode 100644 index 3b8d81d2ef..0000000000 --- a/packages/runtime-host/protocol-compatible-changes/message-admission-quote-or-attachment-text.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "epoch": 112, - "files": [ - "packages/runtime-host/src/protocol/turn.ts", - "packages/runtime-host/src/protocol/message.ts", - "packages/runtime-host/src/protocol/session-continuity.ts" - ], - "reason": "Admission-only widening (#4804): an empty-text Message that carries a quote or an attachment is now accepted at submit and read back the same way by the queue-entry and steering decoders; the Host emits no new frame shape and no previously valid frame is rejected, so peers on earlier epochs interoperate unchanged." -} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 536c7aa667..abdd277c49 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 125 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 126 as const; +// 126: Message admission accepts an empty-text Message that carries a quote or +// an attachment (#4804). Peers older than this epoch reject that frame at +// admission, so the pair must refuse each other at the handshake. // 125: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'` // so a running context-compaction Turn can render a transcript row. Epoch-124 // peers reject the added optional field on the strict live snapshot shape. From fef4f355aea3e2534c0600ead6c71445791e0390 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 8 Sep 2026 06:42:12 +0800 Subject: [PATCH 05/36] fix(runtime-host): accept structured-only Messages at durable admission Completes the storage half of the structured-only message admission: normalizeRootTurnMessageContent now uses the shared meaningful-content predicate (text, quote, or attachment) instead of the text-length rule, so a quote- or attachment-only Message that passes the protocol decoder also forms a durable Turn. The compaction estimate counts the structured envelope (a zero estimate dropped model-visible events from the history-compact gate), and the session recap projects a carrier marker for structured-only events instead of losing them. Generated-by: GLM-5.3-Flash (ZCode) --- packages/core/src/events.ts | 15 +++++++++++++++ packages/runtime-host/src/protocol/turn.ts | 16 ++++++---------- packages/runtime/src/model-history.ts | 16 ++++++++++++++-- packages/runtime/src/session-recap.ts | 15 +++++++++++++++ packages/storage/src/agent-run-store.ts | 8 +++++++- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 002bbcba0e..88d06283c4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -167,6 +167,21 @@ const MESSAGE_CONTENT_SHAPE = defineObjectShape()( ['text'], ['displayText', 'attachments', 'directoryReferences', 'quotes', 'inlineReferences'], ); + +/** + * A Turn message is meaningful when at least one of its three content carriers + * is present: inline text, an inline excerpt, or an attachment reference. + * Admission, compaction estimates, and recap projection must share this one + * predicate (#4804) — restating it per layer is how a quote-only message ends + * up admitted by one boundary and silently dropped by the next. + */ +export function hasMeaningfulMessageContent(content: MessageContent): boolean { + return ( + content.text.length > 0 || + (content.quotes?.length ?? 0) > 0 || + (content.attachments?.length ?? 0) > 0 + ); +} const ATTACHMENT_REF_SHAPE = defineObjectShape()( ['kind', 'name', 'mimeType', 'bytes', 'ref'], [], diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 5f7a02ec2f..a4cf0402ce 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -21,6 +21,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { decodeMessageContent as decodeCanonicalMessageContent, DIRECTORY_REFERENCE_MAX_COUNT, + hasMeaningfulMessageContent, isCanonicalAttachmentRef, type ContextCompactionOutcome, type MessageContent, @@ -473,17 +474,12 @@ export function decodeMessageAdmissionContent( allowEmptyText = false, ): MessageContent { // Structure first with text emptiness unconstrained, then apply the - // admission rule: a quote or an attachment carries the turn by itself, so - // empty inline text is admissible when either is present (#4804). A truly - // contentless Message still throws, with the same frame error the - // text-length rule produced. + // shared meaningful-content predicate: a quote or an attachment carries + // the turn by itself, so empty inline text is admissible when either is + // present (#4804). A truly contentless Message still throws, with the + // same frame error the text-length rule produced. const content = decodeMessageContent(value, true); - if ( - !allowEmptyText && - content.text.length === 0 && - (content.quotes?.length ?? 0) === 0 && - (content.attachments?.length ?? 0) === 0 - ) { + if (!allowEmptyText && !hasMeaningfulMessageContent(content)) { throw invalidProtocolFrame('Invalid Message text'); } if (content.attachments?.some((attachment) => attachment.ref.kind === 'session_context')) { diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index ea452dbbcf..a8141a929f 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -195,8 +195,20 @@ export function estimateEffectiveToolResultChars( export function estimateRuntimeEventChars(event: RuntimeEvent): number { let total = 0; const content = event.content; - if (content?.kind === 'text' || content?.kind === 'thinking') total += content.text.length; - else if (content?.kind === 'function_call') + if (content?.kind === 'text' || content?.kind === 'thinking') { + total += content.text.length; + // Structured carriers are part of the event's weight: a quote- or + // attachment-only user message must not estimate to zero, or the + // history-compact gate drops a model-visible event (#4804). + if (content.kind === 'text') { + for (const quote of content.quotes ?? []) { + total += quote.text.length + (quote.label?.length ?? 0); + } + for (const attachment of content.attachments ?? []) { + total += attachment.name.length + attachment.mimeType.length; + } + } + } else if (content?.kind === 'function_call') total += content.name.length + stableJsonLength(content.args); else if (content?.kind === 'function_response') total += content.name.length + estimateEffectiveToolResultChars(content, event.sessionId); diff --git a/packages/runtime/src/session-recap.ts b/packages/runtime/src/session-recap.ts index 87e34bef6d..4a04b55ccf 100644 --- a/packages/runtime/src/session-recap.ts +++ b/packages/runtime/src/session-recap.ts @@ -123,6 +123,21 @@ function projectSessionRecapMessages(events: readonly RuntimeEvent[]): ModelMess const text = content.text.trim(); if (text.length > 0) { messages.push({ role: event.role === 'user' ? 'user' : 'assistant', content: text }); + } else { + // Model-visible without inline text: a structured-only message must + // still leave evidence in the recap instead of vanishing (#4804). + const quoteCount = content.quotes?.length ?? 0; + const attachmentCount = content.attachments?.length ?? 0; + const carriers = [ + quoteCount > 0 ? `${quoteCount} quote(s)` : undefined, + attachmentCount > 0 ? `${attachmentCount} attachment(s)` : undefined, + ].filter(Boolean); + if (carriers.length > 0) { + messages.push({ + role: event.role === 'user' ? 'user' : 'assistant', + content: `[message carried ${carriers.join(' and ')}]`, + }); + } } continue; } diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 58aa12a584..5d0d005726 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -53,6 +53,7 @@ import type { import { aggregateMessageContents, decodeMessageContent, + hasMeaningfulMessageContent, isCanonicalAttachmentRef, messageContentsEqual, type AttachmentRef, @@ -1613,7 +1614,12 @@ function normalizeRootTurnMessageContent( } throw new Error(`Invalid ${description}`); } - if (normalized.text.length === 0 || (normalized.attachments?.length ?? 0) > maxAttachments) { + // Quote- or attachment-only input is meaningful (#4804): the text carrier + // alone no longer decides durability admission. + if ( + !hasMeaningfulMessageContent(normalized) || + (normalized.attachments?.length ?? 0) > maxAttachments + ) { throw new Error(`Invalid ${description}`); } for (const [index, attachment] of (normalized.attachments ?? []).entries()) { From 54a454d1fc6b6160dafa6e7270a834fed686cbf7 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 8 Sep 2026 06:53:11 +0800 Subject: [PATCH 06/36] test(storage): pin quote-only and attachment-only durable admission Pins the #4804 admission contract at the durable owner: quote-only and attachment-only root Turn inputs are admitted, and a truly contentless input still throws the same frame error. On the pre-fix base the quote-only and attachment-only cases fail (the text-length rule rejected them), matching jackwener's end-to-end reproduction on #4815. Generated-by: GLM-5.3-Flash (ZCode) --- .../root-turn-admission-normalization.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts b/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts index 6ec943fd9b..c329793e30 100644 --- a/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts +++ b/packages/storage/src/__tests__/root-turn-admission-normalization.test.ts @@ -101,3 +101,39 @@ test('root admission preserves and validates each source Skill outcome', () => { ]), ); }); + +test('admits a quote-only root Turn input (#4804)', () => { + const content = { + text: '', + quotes: [{ text: 'quoted passage worth answering' }], + } as const; + const normalized = normalizeRootTurnAdmissionPayload(content, []); + + assert.ok(normalized.normalizedInput); + assert.equal(normalized.normalizedInput?.quotes?.[0]?.text, 'quoted passage worth answering'); +}); + +test('admits an attachment-only root Turn input (#4804)', () => { + const content = { + text: '', + attachments: [ + { + kind: 'image' as const, + name: 'diagram.png', + mimeType: 'image/png', + bytes: 1024, + ref: { kind: 'workspace_file', relativePath: 'blobs/diagram.png' }, + }, + ], + } as const; + const normalized = normalizeRootTurnAdmissionPayload(content, []); + + assert.equal(normalized.normalizedInput?.attachments?.[0]?.name, 'diagram.png'); +}); + +test('still rejects a truly contentless root Turn input', () => { + assert.throws( + () => normalizeRootTurnAdmissionPayload({ text: '' }, []), + /Invalid root turn normalized input/u, + ); +}); From b12ce2e6aa64769839b8ccdaf846762e736297aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 09:58:51 +0800 Subject: [PATCH 07/36] test(runtime): pin the quoted excerpt in the recap of a structured-only message The recap previously rendered a count placeholder for a structured-only message; pin the actual excerpt text so the #4804 acceptance (quote content appears in the recap input) is asserted, not assumed. Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/session-recap.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/runtime/src/__tests__/session-recap.test.ts b/packages/runtime/src/__tests__/session-recap.test.ts index 2a7d9d4fcf..ae2b3ac00e 100644 --- a/packages/runtime/src/__tests__/session-recap.test.ts +++ b/packages/runtime/src/__tests__/session-recap.test.ts @@ -127,6 +127,29 @@ test('session recap budgets only the evidence it sends', () => { assert.equal(serialized.includes(oversizedArgs), false); }); +test('session recap carries the quoted excerpt of a structured-only message', () => { + const quotedText = 'QUOTED-EXCERPT-SENTINEL the deploy failed at step three'; + const messages = buildSessionRecapMessages({ + events: [ + { + ...textEvent('quoted-user', 'turn-1', 'user', ''), + content: { + kind: 'text', + text: '', + quotes: [{ text: quotedText, sourceTurnId: 'turn-0' }], + }, + }, + ], + connection: connection(), + modelId: 'gpt-4', + }); + const serialized = JSON.stringify(messages); + + assert.equal(serialized.includes(quotedText), true); + assert.equal(serialized.includes(''), true); + assert.equal(serialized.includes('[message carried'), false); +}); + test('session recap excludes model-hidden tool outcomes', () => { const messages = buildSessionRecapMessages({ events: [ From 2f68f36c8aa1f58e50625803cfc940fbb4f7dd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 12:33:25 +0800 Subject: [PATCH 08/36] fix(ui): carry the structured-only contract through side-chat consumers Review follow-up on #4815. The shared Composer now enables Send with an empty draft once a quote or attachment is staged, but two consumers still rejected the structured-only frame on the normal user path: - `useQuoteCompanion.send` gated on `!trimmed` before fork/Host admission, so "select transcript text -> Ask about selection -> empty draft -> Send" (and attachment-only sends) returned false before reaching the quote and attachment payload the send already carries. The same text-only guard in `steer` rejected the action while streaming. Both entries now accept an empty text when a quote or attachment is staged; steering passes quotes and attachment items through the one Message admission channel, and the staged quotes stay pending until the Host admits the steering Message. - `UserMessageBody` in packages/ui created `ChatMessageBubble` unconditionally, so a quote-only message rendered an empty bubble on both the transient and durable paths. The bubble now renders only for non-blank text; quotes and attachments keep their existing surfaces. The merge with current main also reconciles the compatibility epoch: both branches had independently claimed 131, so this branch now carries 132 for the structured-only admission widening. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/quote-companion-retry.test.ts | 70 ++++++++++ .../src/renderer/features/workbar/ports.ts | 7 +- .../tools/side-chat/quote-companion-panel.tsx | 11 +- .../tools/side-chat/use-quote-companion.ts | 132 +++++++++++------- .../desktop/create-workbar-services.ts | 6 +- .../__tests__/chat-turn-quote-only.test.tsx | 107 ++++++++++++++ packages/ui/src/chat-turn.tsx | 28 ++-- 7 files changed, 293 insertions(+), 68 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-turn-quote-only.test.tsx diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 159cbb3311..363562e73b 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -2119,3 +2119,73 @@ async function awaitCompanion(container: Element, id = 'side-conversation'): Pro async function awaitProcessing(container: Element): Promise { await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); } + +test('a structured-only send (empty text with a staged quote) reaches the fork admission', async () => { + const sendCommands: Array[1]> = []; + const rendered = await renderOwnershipProbe( + { + listTurns: async () => [settledTurn('done-turn')], + branchFromTurn: async () => ({ ok: true as const, session: session('side-conversation') }), + send: async (_sessionId, command) => { + sendCommands.push(command); + return { ok: true as const, turnId: 'quote-only-turn' }; + }, + }, + { + pendingQuotes: [{ id: 'quote-1', value: { text: 'selected excerpt' } }], + }, + ); + const probe = rendered.container.firstElementChild; + assert.ok(probe); + + // The Composer enables Send once a quote is staged; an empty draft must ride + // the same admission as a text send instead of dying on the `!trimmed` guard. + await act(async () => { + assert.equal(await rendered.send(''), true); + await Promise.resolve(); + }); + await awaitCompanion(rendered.container); + assert.equal(sendCommands.length, 1); + assert.equal(sendCommands[0].text, ''); + assert.deepEqual( + sendCommands[0].quotes?.map((quote) => quote.text), + ['selected excerpt'], + ); + assert.equal(probe.getAttribute('data-error'), ''); +}); + +test('a structured-only steer (empty text with a staged quote) rides the steering contract', async () => { + const steerContents: Array[3]> = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, _admissionId, content) => { + steerContents.push(content); + return { kind: 'queued', messageId: 'steer-1' }; + }, + }, + { + pendingQuotes: [{ id: 'quote-1', value: { text: 'streaming excerpt' } }], + }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + // Streaming steers take the same structured-content contract: the quote alone + // is a valid steering Message, and the `!trimmed` guard must not drop it. + await act(async () => { + assert.equal(await rendered.steer(''), true); + await Promise.resolve(); + }); + assert.equal(steerContents.length, 1); + assert.deepEqual( + steerContents[0]?.quotes?.map((quote) => quote.text), + ['streaming excerpt'], + ); +}); diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 4ee8b1cd65..84f02d3ffe 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -237,7 +237,12 @@ export interface SideChatSessionPort { sessionId: string, target?: SideChatStopTarget, ): Promise<{ kind: 'retracted'; messageId: string } | undefined>; - steer(sessionId: string, text: string, admissionId?: string): Promise; + steer( + sessionId: string, + text: string, + admissionId?: string, + content?: { quotes?: QuoteRef[]; attachmentItems?: WorkbarIngestInput[] }, + ): Promise; setPermissionMode( sessionId: string, mode: PermissionMode, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index d49fafb4a0..abe8915b01 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -344,7 +344,16 @@ export function QuoteCompanionPanel(props: { text, streaming: companion.streaming, compact: companion.compact, - steer: companion.steer, + steer: async (text) => { + const accepted = await companion.steer( + text, + pendingAttachments.length > 0 + ? toComposerIngestItems(pendingAttachments) + : undefined, + ); + if (accepted) clearSubmittedAttachments(pendingAttachments); + return accepted; + }, send: async () => { try { preflightAttachmentItems(pendingAttachments, locale); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 1a65ba4024..81a8ba3fbd 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -186,8 +186,9 @@ export interface UseQuoteCompanionResult { /** Returns whether the send was accepted; false leaves the draft + staged * quotes in place so the user can retry. */ send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; - /** Insert text into the active companion turn at the next model step. */ - steer: (text: string) => Promise; + /** Insert text — or a structured-only quote/attachment — into the active + * companion turn at the next model step. */ + steer: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; regenerate: (turnId: string) => Promise; stop: () => Promise; @@ -853,9 +854,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ): Promise => { const trimmed = text.trim(); if (isExactCompactCommand(trimmed)) return compact(); + // A structured-only Message (empty text carrying a quote or an attachment) + // is a valid send since the admission widening (#4804), so the guard + // rejects only when nothing at all is staged. + const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); if ( !mountedRef.current || - !trimmed || + (!trimmed && quoteSnapshot.quotes.length === 0 && !attachmentItems?.length) || submitLockRef.current || compactionRequestInFlightRef.current || activeTurnIdRef.current || @@ -868,7 +873,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setSubmitLocked(true); setError(null); const turnId = crypto.randomUUID(); - const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); const label = (quoteSnapshot.quotes[0]?.text ?? trimmed).slice(0, 24); // Show the user's question IMMEDIATELY as an optimistic bubble, before the // fork exists. On a first send `ensureFork` makes a Host round trip, and the @@ -1120,59 +1124,81 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } }, [releaseAdmission, resolveAdmission, sideChat]); - const steer = useCallback(async (text: string): Promise => { - const id = companionIdRef.current; - const trimmed = text.trim(); - if ( - !mountedRef.current || - !id || - !trimmed || - !turnInFlight || - pendingAdmissionRef.current - ) { - return false; - } - const admissionId = crypto.randomUUID(); - const admission: PendingAdmission = { - messageId: admissionId, - events: [], - }; - setPendingAdmission(admission); - try { - const outcome = await sideChat.steer(id, trimmed, admissionId); - if (!mountedRef.current) return false; - if ((await admission.stopPromise) === 'confirmed') return false; - if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { - return false; - } - if (outcome.kind === 'started') { - bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); - } else if (resolveAdmission(id, admission, outcome.messageId, true)?.kind === 'retracted') { + const steer = useCallback( + async ( + text: string, + attachmentItems?: WorkbarIngestInput[], + ): Promise => { + const id = companionIdRef.current; + const trimmed = text.trim(); + // Same structured-only contract as `send`: a quote or an attachment alone + // is a valid steering Message (#4804). + const quoteSnapshot = snapshotCompanionQuotes(panelId, pendingQuotes); + if ( + !mountedRef.current || + !id || + (!trimmed && quoteSnapshot.quotes.length === 0 && !attachmentItems?.length) || + !turnInFlight || + pendingAdmissionRef.current + ) { return false; } - setError(null); - return true; - } catch { - if (mountedRef.current) { - if (pendingAdmissionRef.current === admission) { - releaseAdmission(admission, copyRef.current.errors.sendFailed); - } else if ( - admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' - ) { - setError(copyRef.current.errors.sendFailed); + const admissionId = crypto.randomUUID(); + const admission: PendingAdmission = { + messageId: admissionId, + events: [], + // Quotes stay staged until the Host admits the steering Message; a + // failed or retracted steer keeps them available for retry. + ...(quoteSnapshot.quotes.length > 0 + ? { consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot) } + : {}), + }; + setPendingAdmission(admission); + try { + const outcome = await sideChat.steer(id, trimmed, admissionId, { + ...(quoteSnapshot.quotes.length > 0 + ? { quotes: [...quoteSnapshot.quotes] } + : {}), + ...(attachmentItems?.length ? { attachmentItems } : {}), + }); + if (!mountedRef.current) return false; + if ((await admission.stopPromise) === 'confirmed') return false; + if (admissionOutcomeForMessage(admission.events, admission.messageId)?.kind === 'retracted') { + return false; } + if (outcome.kind === 'started') { + bindAdmittedTurn(id, outcome.turnId, { preserveLiveTurn: true }); + } else if (resolveAdmission(id, admission, outcome.messageId, true)?.kind === 'retracted') { + return false; + } + setError(null); + return true; + } catch { + if (mountedRef.current) { + if (pendingAdmissionRef.current === admission) { + releaseAdmission(admission, copyRef.current.errors.sendFailed); + } else if ( + admissionOutcomeForMessage(admission.events, admission.messageId)?.kind !== 'retracted' + ) { + setError(copyRef.current.errors.sendFailed); + } + } + return false; } - return false; - } - }, [ - bindAdmittedTurn, - mountedRef, - releaseAdmission, - resolveAdmission, - setPendingAdmission, - sideChat, - turnInFlight, - ]); + }, + [ + bindAdmittedTurn, + mountedRef, + onQuotesConsumed, + panelId, + pendingQuotes, + releaseAdmission, + resolveAdmission, + setPendingAdmission, + sideChat, + turnInFlight, + ], + ); const setPermissionMode = useCallback( (mode: PermissionMode): Promise => { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 39a6e490c5..3c5098ca94 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -117,11 +117,15 @@ export function createDesktopWorkbarServices( // Steering is a Message placed at the current Turn's boundary, so it // rides the one admission channel. Runtime Host names the outcome; this // adapter only renames it for the Side Conversation port. - steer: async (sessionId, text, admissionId) => { + steer: async (sessionId, text, admissionId, content) => { const messageId = admissionId ?? crypto.randomUUID(); const result = await bridge.sessions.submitMessage(sessionId, 'current_turn', { messageId, text, + ...(content?.quotes ? { quotes: content.quotes } : {}), + ...(content?.attachmentItems + ? { attachmentItems: content.attachmentItems } + : {}), }); if (!result.ok) { if (result.reason === 'outcome_unknown') { diff --git a/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx b/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx new file mode 100644 index 0000000000..cd0583e2b3 --- /dev/null +++ b/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { TransientUserMessage } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { TransientUserMessageProjection } from '../chat-view.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function renderMessage(message: TransientUserMessageProjection) { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return async () => { + await (act(() => { + root.render( + + + , + ); + }) as unknown as Promise); + return container; + }; +} + +test('a quote-only user message renders the quote without an empty text bubble', async () => { + const container = await renderMessage({ + id: 'quote-only', + text: '', + ts: 1, + transientPlacement: 'current_turn', + quotes: [{ text: 'selected excerpt' }], + })(); + + // #4804: a structured-only Message (empty text carrying a quote) must show + // the quote chips, and the unconditional text bubble must not render empty. + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.equal(bubble, null, 'an empty text must not render an empty user bubble'); + const quotes = container.querySelector('.maka-user-quotes'); + assert.ok(quotes, 'the staged quote still renders'); + assert.match(quotes?.textContent ?? '', /selected excerpt/); +}); + +test('a user message with text still renders its bubble', async () => { + const container = await renderMessage({ + id: 'with-text', + text: 'explain this', + ts: 1, + transientPlacement: 'current_turn', + quotes: [{ text: 'selected excerpt' }], + })(); + + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.ok(bubble, 'a text message keeps its bubble'); + assert.match(bubble?.textContent ?? '', /explain this/); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 753e199409..c5c9af894f 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -250,18 +250,22 @@ const UserMessageBody = memo(function UserMessageBody(props: { ))} ) : null} - - {props.inlineReferences ? ( - - ) : ( - - {props.text} - - )} - + {/* A structured-only message (#4804) may carry only quotes/attachments; + an empty text must not render an empty bubble on those paths. */} + {props.text.trim().length > 0 ? ( + + {props.inlineReferences ? ( + + ) : ( + + {props.text} + + )} + + ) : null} ); }); From 11aa81e324c669a5034f440c6d9f41816d0a5b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 15:08:40 +0800 Subject: [PATCH 09/36] fix(desktop): consume staged attachments only on confirmed admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #4815. `useQuoteCompanion.steer()` returns true on an `outcome_unknown` result — the supported reconnect/failure path answers without an admission receipt — and the panel then cleared the submitted attachments although the Message may never have been admitted. Quotes already waited through `consumeOnAdmission`, so one structured Message had two different cleanup boundaries. Both `send` and `steer` now take an `onAdmitted` callback that fires from the shared admission boundary: confirmed admission binds the Turn and consumes the staged quotes and submitted attachments together; an unknown outcome keeps everything staged until the reconciliation binds the Turn (a late admission fires the callback then) or a retraction releases the Message with the attachments still staged for retry. The panel no longer clears attachments on the optimistic return. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/quote-companion-retry.test.ts | 109 +++++++++++++++++- .../tools/side-chat/quote-companion-panel.tsx | 25 ++-- .../tools/side-chat/use-quote-companion.ts | 41 +++++-- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 363562e73b..7ef1481c40 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -32,6 +32,7 @@ import type { TurnRecord, } from '@maka/core/session'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; +import type { WorkbarIngestInput } from '../../renderer/features/workbar/ports.js'; import { createFakeWorkbarServices, dispatchQuoteCompanionInput, @@ -57,6 +58,11 @@ const originalGlobals = { let mountedRoot: Root | undefined; const SOURCE_SESSION = session('source-session'); type SideChatStopTarget = Parameters[1]; +type SteerFn = ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, +) => Promise; type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; @@ -134,7 +140,7 @@ async function renderProbe( modelChoices?: readonly ChatModelChoice[]; ready?: (container: Element) => boolean; onSend?: (send: (text: string) => Promise) => void; - onSteer?: (steer: (text: string) => Promise) => void; + onSteer?: (steer: SteerFn) => void; onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; @@ -199,7 +205,7 @@ async function renderOwnershipProbe( } = {}, ) { let send!: (text: string) => Promise; - let steer!: (text: string) => Promise; + let steer!: SteerFn; let stop!: () => Promise; let setPermissionMode!: (mode: PermissionMode) => Promise; let eventHandler: ((event: SessionEvent) => void) | undefined; @@ -228,7 +234,8 @@ async function renderOwnershipProbe( return { ...rendered, send: (text: string) => send(text), - steer: (text: string) => steer(text), + steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) => + steer(text, attachmentItems, onAdmitted), stop: () => stop(), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), emit(event: SessionEvent) { @@ -2012,7 +2019,7 @@ function QuoteCompanionProbe(props: { function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; - onSteer?: (steer: (text: string) => Promise) => void; + onSteer?: (steer: SteerFn) => void; onStop?: (stop: () => Promise) => void; onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; onContextCompactionError?: (sessionId: string, error: unknown) => void; @@ -2189,3 +2196,97 @@ test('a structured-only steer (empty text with a staged quote) rides the steerin ['streaming excerpt'], ); }); + +test('a steer with staged attachments consumes them only on confirmed admission', async () => { + const admissionIds: string[] = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, admissionId) => { + const id = admissionId ?? ''; + admissionIds.push(id); + // The reconnect/failure path answers without an admission receipt. + return { kind: 'outcome_unknown', messageId: id }; + }, + }, + { pendingQuotes: [] }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + const consumed: string[] = []; + await act(async () => { + assert.equal( + await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + consumed.push('admitted'); + }), + true, + ); + await Promise.resolve(); + }); + // The optimistic accept must not retire the attachments: with no admission + // receipt the Message may still be admitted or retracted by the Host. + assert.deepEqual(consumed, []); + + // The late admission arrives through the fork's event stream; only now does + // the confirmed-admission boundary fire. + await act(async () => { + rendered.emit(messageAdmittedEvent('steer-late-admit', 'steered-turn', 1, admissionIds[0])); + }); + assert.deepEqual(consumed, ['admitted']); +}); + +test('an unknown steer outcome that later retracts keeps the staged attachments', async () => { + const admissionIds: string[] = []; + const rendered = await renderOwnershipProbe( + { + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, admissionId) => { + const id = admissionId ?? ''; + admissionIds.push(id); + return { kind: 'outcome_unknown', messageId: id }; + }, + }, + { pendingQuotes: [] }, + ); + + await act(async () => { + assert.equal(await rendered.send('initial prompt'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-streaming') === 'true', + ); + + const consumed: string[] = []; + await act(async () => { + assert.equal( + await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + consumed.push('admitted'); + }), + true, + ); + await Promise.resolve(); + }); + assert.deepEqual(consumed, []); + + // A retraction releases the Message without consuming anything staged: the + // user keeps the attachments and may retry the steer. + await act(async () => { + rendered.emit({ + type: 'message_admission', + id: 'steer-late-retract', + turnId: 'old-turn', + ts: 2, + messageId: admissionIds[0], + outcome: 'retracted', + }); + }); + assert.deepEqual(consumed, []); +}); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index e7819f92a7..256730293c 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -348,14 +348,19 @@ export function QuoteCompanionPanel(props: { streaming: companion.streaming, compact: companion.compact, steer: async (text) => { - const accepted = await companion.steer( + // Submitted attachments retire on the confirmed-admission + // boundary, not on the hook's optimistic return: an unknown + // outcome keeps them staged for retry (#4804). + const submitted = pendingAttachments; + const submittedItems = + submitted.length > 0 ? toComposerIngestItems(submitted) : undefined; + return companion.steer( text, - pendingAttachments.length > 0 - ? toComposerIngestItems(pendingAttachments) + submittedItems, + submittedItems + ? () => clearSubmittedAttachments(submitted) : undefined, ); - if (accepted) clearSubmittedAttachments(pendingAttachments); - return accepted; }, send: async () => { try { @@ -367,16 +372,20 @@ export function QuoteCompanionPanel(props: { ); return false; } + // Same admission-boundary retirement as `steer` above. + const submitted = pendingAttachments; + const submittedItems = + submitted.length > 0 ? toComposerIngestItems(submitted) : undefined; const accepted = await companion.send( text, - pendingAttachments.length > 0 - ? toComposerIngestItems(pendingAttachments) + submittedItems, + submittedItems + ? () => clearSubmittedAttachments(submitted) : undefined, ); if (accepted) { props.onPromptAccepted?.(props.panelId, text); } - if (accepted) clearSubmittedAttachments(pendingAttachments); return accepted; }, }) diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 81a8ba3fbd..b3fb2a88c9 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -184,11 +184,22 @@ export interface UseQuoteCompanionResult { /** Runs `/compact` against the committed companion fork when it is idle. */ compact: () => Promise; /** Returns whether the send was accepted; false leaves the draft + staged - * quotes in place so the user can retry. */ - send: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; + * quotes in place so the user can retry. `onAdmitted` fires only once the + * Host admission is confirmed (never on an unknown outcome), so callers + * can retire submitted attachments on the same boundary as the quotes. */ + send: ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, + ) => Promise; /** Insert text — or a structured-only quote/attachment — into the active - * companion turn at the next model step. */ - steer: (text: string, attachmentItems?: WorkbarIngestInput[]) => Promise; + * companion turn at the next model step. `onAdmitted` follows the same + * confirmed-admission boundary as `send`. */ + steer: ( + text: string, + attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, + ) => Promise; setPermissionMode: (mode: PermissionMode) => Promise; regenerate: (turnId: string) => Promise; stop: () => Promise; @@ -851,6 +862,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan async ( text: string, attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, ): Promise => { const trimmed = text.trim(); if (isExactCompactCommand(trimmed)) return compact(); @@ -890,7 +902,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const admission: PendingAdmission = { messageId: turnId, events: [], - consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot), + // Quotes and submitted attachments share one cleanup boundary — + // confirmed Host admission (#4804). An unknown outcome keeps them + // staged until the reconciliation binds the Turn or a retraction + // releases the send, so nothing staged is consumed on a guess. + consumeOnAdmission: () => { + onQuotesConsumed(quoteSnapshot); + onAdmitted?.(); + }, }; setPendingUserMessages((current) => [ ...current.filter((message) => message.id !== turnId), @@ -1128,6 +1147,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan async ( text: string, attachmentItems?: WorkbarIngestInput[], + onAdmitted?: () => void, ): Promise => { const id = companionIdRef.current; const trimmed = text.trim(); @@ -1148,10 +1168,13 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan messageId: admissionId, events: [], // Quotes stay staged until the Host admits the steering Message; a - // failed or retracted steer keeps them available for retry. - ...(quoteSnapshot.quotes.length > 0 - ? { consumeOnAdmission: () => onQuotesConsumed(quoteSnapshot) } - : {}), + // failed or retracted steer keeps them available for retry. Submitted + // attachments share that boundary: an unknown outcome keeps them + // staged until reconciliation binds the Turn or the steer retracts. + consumeOnAdmission: () => { + if (quoteSnapshot.quotes.length > 0) onQuotesConsumed(quoteSnapshot); + onAdmitted?.(); + }, }; setPendingAdmission(admission); try { From 816e6dc5a151f92837be31d97e00d3fe522d3498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E5=A4=A9=E8=B1=AA?= Date: Tue, 8 Sep 2026 15:36:58 +0800 Subject: [PATCH 10/36] fix(desktop): pass the renderer architecture check on the merged head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI run on 11aa81e32 failed the renderer-architecture gate twice: the regression test imported `WorkbarIngestInput` straight from `ports.js`, which only `index`/`testing` re-exports may reach from feature code, and the `allowAttachmentOnlySend` line grew the frozen `app-shell.tsx` token budget by one. The type now ships through the workbar `testing.js` entry, and the side-chat panel — not the frozen shell — opts into attachment-only sends, which is where the #4804 acceptance scenario actually sends from. The branch also merges the current `main` (#5001 included), so the frozen-file budget is evaluated against the live baseline. Generated-by: GLM-5.3-Flash (ZCode) --- apps/desktop/src/main/__tests__/quote-companion-retry.test.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 1 - apps/desktop/src/renderer/features/workbar/testing.ts | 1 + .../features/workbar/tools/side-chat/quote-companion-panel.tsx | 2 ++ 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 7ef1481c40..89a619eed7 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -32,7 +32,6 @@ import type { TurnRecord, } from '@maka/core/session'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; -import type { WorkbarIngestInput } from '../../renderer/features/workbar/ports.js'; import { createFakeWorkbarServices, dispatchQuoteCompanionInput, @@ -41,6 +40,7 @@ import { WorkbarServicesProvider, type CompanionQuoteSnapshot, type StagedCompanionQuote, + type WorkbarIngestInput, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1803cc7fe1..6c0a35b7ce 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2704,7 +2704,6 @@ function AppShellContent({ : undefined } slashCommands={desktopSlashCommands} - allowAttachmentOnlySend pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 11c1e45a56..40e09d330e 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -24,6 +24,7 @@ export type { WorkbarServices, WorkbarSessionTracePage, WorkbarSessionUsageSummary, + WorkbarIngestInput, } from './ports.js'; export * from './model/workbar-tabs.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 256730293c..fdc80f58c6 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -398,6 +398,8 @@ export function QuoteCompanionPanel(props: { disabled={!companion.modelReady} onPickAttachments={pickAttachments} onAttachFilePaths={attachFilePaths} + // The side chat submits staged context without a prompt (#4804). + allowAttachmentOnlySend pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} mentionSkills={mentions?.mentionSkills} From 85ed3f57882cdf590e8d953ffd4349cb19e1f04b Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 00:25:52 +0800 Subject: [PATCH 11/36] fix(ui): keep message metadata on quote-only user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A structured-only message (#4804) omitted its empty text bubble but dropped the metadata row with it, taking the timestamp, copy action, and the edit entry away from exactly the messages that carry only structured content. The empty-text branch now renders the metadata directly, matching #4805's rendering shape (review finding on #4815). The quote-only rendering regression moves into the chat-turn answer-identity suite on the shared rendering fixture and now asserts both obligations — no empty bubble plus a surviving metadata/edit entry — instead of maintaining a second DOM lifecycle. Generated-by: GLM-5.3-Flash (ZCode) --- .../chat-turn-answer-identity.test.tsx | 67 +++++++++++ .../__tests__/chat-turn-quote-only.test.tsx | 107 ------------------ packages/ui/src/chat-turn.tsx | 7 +- 3 files changed, 72 insertions(+), 109 deletions(-) delete mode 100644 packages/ui/src/__tests__/chat-turn-quote-only.test.tsx diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index fd933d29b0..847df87403 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -353,6 +353,73 @@ test('does not edit and resend a message with folder references', async () => { assert.equal(editCalls, 0, 'folder references must not be silently dropped by revision'); }); +/** + * A structured-only user message (#4804) — empty inline text carrying a + * quote — must render the quote without an empty text bubble, while keeping + * the metadata row (timestamp, copy) and its edit entry, which used to be + * dropped together with the bubble. + */ +test('renders a quote-only user message without an empty bubble but with metadata', async () => { + const { container, root } = domRoot(); + const turn = { + ...turnWith([]), + status: 'completed' as const, + user: { + id: 'quote-only', + role: 'user' as const, + text: '', + ts: 1, + quotes: [{ text: 'selected excerpt' }], + }, + }; + + await act(() => { + root.render( + + undefined} /> + , + ); + }); + + assert.equal( + container.querySelector('.maka-chat-message-bubble-user'), + null, + 'an empty text must not render an empty user bubble', + ); + const quotes = container.querySelector('.maka-user-quotes'); + assert.ok(quotes, 'the staged quote still renders'); + assert.match(quotes?.textContent ?? '', /selected excerpt/); + assert.ok( + container.querySelector('.maka-message-meta'), + 'a structured-only message keeps its metadata row', + ); + assert.ok( + container.querySelector('[data-action="edit"]'), + 'the edit entry survives the omitted bubble', + ); +}); + +test('a user message with text still renders its bubble', async () => { + const { container, root } = domRoot(); + const turn = { + ...turnWith([]), + status: 'completed' as const, + user: { + id: 'with-text', + role: 'user' as const, + text: 'explain this', + ts: 1, + quotes: [{ text: 'selected excerpt' }], + }, + }; + + await renderTurn(root, turn); + + const bubble = container.querySelector('.maka-chat-message-bubble-user'); + assert.ok(bubble, 'a text message keeps its bubble'); + assert.match(bubble?.textContent ?? '', /explain this/); +}); + test('keeps Astryx auto formatting live for user-message timestamps', async (context) => { const now = Date.UTC(2026, 7, 27, 12); context.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); diff --git a/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx b/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx deleted file mode 100644 index cd0583e2b3..0000000000 --- a/packages/ui/src/__tests__/chat-turn-quote-only.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import { TransientUserMessage } from '../chat-turn.js'; -import { LocaleProvider } from '../locale-context.js'; -import type { TransientUserMessageProjection } from '../chat-view.js'; - -const originalGlobals = { - document: globalThis.document, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -const mountedRoots: ReturnType[] = []; - -afterEach(async () => { - for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -function renderMessage(message: TransientUserMessageProjection) { - const parsed = parseHTML('
'); - const { document, window } = parsed; - Object.assign(globalThis, { - document, - window, - matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), - requestAnimationFrame: () => 1, - cancelAnimationFrame() {}, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - mountedRoots.push(root); - return async () => { - await (act(() => { - root.render( - - - , - ); - }) as unknown as Promise); - return container; - }; -} - -test('a quote-only user message renders the quote without an empty text bubble', async () => { - const container = await renderMessage({ - id: 'quote-only', - text: '', - ts: 1, - transientPlacement: 'current_turn', - quotes: [{ text: 'selected excerpt' }], - })(); - - // #4804: a structured-only Message (empty text carrying a quote) must show - // the quote chips, and the unconditional text bubble must not render empty. - const bubble = container.querySelector('.maka-chat-message-bubble-user'); - assert.equal(bubble, null, 'an empty text must not render an empty user bubble'); - const quotes = container.querySelector('.maka-user-quotes'); - assert.ok(quotes, 'the staged quote still renders'); - assert.match(quotes?.textContent ?? '', /selected excerpt/); -}); - -test('a user message with text still renders its bubble', async () => { - const container = await renderMessage({ - id: 'with-text', - text: 'explain this', - ts: 1, - transientPlacement: 'current_turn', - quotes: [{ text: 'selected excerpt' }], - })(); - - const bubble = container.querySelector('.maka-chat-message-bubble-user'); - assert.ok(bubble, 'a text message keeps its bubble'); - assert.match(bubble?.textContent ?? '', /explain this/); -}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index c5c9af894f..1d713bb0ae 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -251,7 +251,8 @@ const UserMessageBody = memo(function UserMessageBody(props: { ) : null} {/* A structured-only message (#4804) may carry only quotes/attachments; - an empty text must not render an empty bubble on those paths. */} + an empty text must not render an empty bubble on those paths, but the + metadata (timestamp, copy, edit entry) still belongs to the message. */} {props.text.trim().length > 0 ? ( )} - ) : null} + ) : ( + userMetadata + )} ); }); From 5fb25c032a9ff9f7de240983ba9fc4b9958f9bb6 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 00:26:03 +0800 Subject: [PATCH 12/36] fix(desktop): count retained attachments before rejecting an empty send body An edit that keeps an existing attachment while dropping all inline text sends the retained refs separately from attachmentItems, and normalizeSessionSendCommand refused it with "Invalid send text" before those refs were normalized. Normalize the retained attachments first and count them in the content-presence check; ownership and size validation stay downstream (review finding on #4815). The shape follows #4805's guard. Generated-by: GLM-5.3-Flash (ZCode) --- .../permission-response-ipc-boundary.test.ts | 25 +++++++++++++++++++ .../src/main/permission-response-guard.ts | 15 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 5b186c081d..0bfb34b3b0 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -286,6 +286,31 @@ describe('permission response IPC boundary', () => { ); }); + it('accepts a retained-attachment-only edit without inline text', () => { + // A normal edit can keep an existing attachment while dropping all inline + // text; the retained refs travel separately from attachmentItems and must + // count as content before the empty-body rejection (#4804). + const command = normalizeSessionSendCommand({ + type: 'send', + text: ' ', + retainedAttachments: [ + { + kind: 'image', + name: 'kept.png', + mimeType: 'image/png', + bytes: 12, + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'attachments/kept.png', + }, + }, + ], + }); + assert.equal(command?.retainedAttachments?.length, 1); + assert.equal(command?.retainedAttachments?.[0]?.name, 'kept.png'); + }); + it('accepts only the supported stop source', () => { assert.deepEqual(normalizeStopSessionInput(undefined), {}); assert.deepEqual( diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index a63aea47d7..7becdba4f0 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -214,9 +214,20 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi // state, ownership, and size limits stay with the ingestion checks, and // quotes are normalized below before the command is returned. const quotes = normalizeOptionalQuotes(value.quotes).quotes; + // A normal edit can keep an existing attachment while dropping all inline + // text; the retained refs travel separately from attachmentItems and are + // normalized before the empty-body rejection so a retained-attachment-only + // edit is not refused (#4804). + const retainedAttachments = normalizeOptionalRetainedAttachments(value.retainedAttachments); const hasAttachmentItems = Array.isArray(value.attachmentItems) && value.attachmentItems.length > 0; - if (!text.trim() && skillIds.length === 0 && (quotes?.length ?? 0) === 0 && !hasAttachmentItems) { + if ( + !text.trim() && + skillIds.length === 0 && + (quotes?.length ?? 0) === 0 && + !hasAttachmentItems && + (retainedAttachments.retainedAttachments?.length ?? 0) === 0 + ) { throw new Error('Invalid send text'); } return { @@ -227,7 +238,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi ...(displayText !== undefined ? { displayText } : {}), ...(skillIds.length > 0 ? { skillIds } : {}), ...(value.attachmentItems !== undefined ? { attachmentItems: value.attachmentItems } : {}), - ...normalizeOptionalRetainedAttachments(value.retainedAttachments), + ...retainedAttachments, ...(value.turnOrchestration !== undefined ? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) } : {}), From 79d4933ac0a23f74f85e854d6f537dab55b1dc9e Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 00:26:03 +0800 Subject: [PATCH 13/36] fix(runtime): replay steering history through the shared image materializer A steered message materialized its image attachments natively on the original request, but the RuntimeEvent replay branch returned the envelope text early and skipped appendImageParts, so a recovery turn received attachment references without the pixels the first request had. Route steering replay through the same materializer with the steering decision key and keep the steering provider identity (review finding on #4815). Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/ai-sdk-backend.test.ts | 59 +++++++++++++++++++ .../runtime/src/ai-sdk-message-projection.ts | 19 +++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 9e9f82bcdc..9c080c575c 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -15163,6 +15163,65 @@ describe('AiSdkBackend steering durability and identity', () => { ]); }); + test('a prior-turn steering event replays its image attachments as image parts', async () => { + // The original steered request materialized its images natively through + // appendImageParts; a replay that kept only the envelope text would hand + // a recovery turn attachment references without the pixels the first + // request received. The steering provider identity must survive too. + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 7, 8, 9]); + const model = textCompletionModel('done'); + const backend = steeringBackend(model, { + supportsVision: true, + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + }); + const steeredEvent = runtimeTextEvent({ + id: 'rt-steer', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'steered earlier', + }); + (steeredEvent.content as { steering?: true }).steering = true; + (steeredEvent.content as { attachments?: unknown[] }).attachments = [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'attachments/chart.png', + }, + }, + ]; + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [steeredEvent], + }), + ); + + const prompt = model.doStreamCalls[0]?.prompt ?? []; + const steeredReplay = prompt[0]; + const parts = steeredReplay?.content as Array<{ + type: string; + text?: string; + mediaType?: string; + }>; + assert.ok( + parts.find((part) => part.type !== 'text' && part.mediaType === 'image/png'), + `expected a native image part on the steering replay, got: ${JSON.stringify(parts)}`, + ); + assert.match(parts[0]?.text ?? '', /steered earlier/, 'the envelope text stays the leading part'); + assert.ok( + steeredReplay?.providerOptions, + 'the steering provider identity survives the materialization', + ); + }); + test('persists provider metadata a canonical event can read back', async () => { // The failure this pins is not in the sanitiser, it is at this seam. // diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index c30e70d0f9..4cbe71c20f 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -567,23 +567,28 @@ export class AiSdkMessageProjection { item: Extract, ): Promise { if (item.role === 'user') { + // Both ordinary and steered replay materialize image attachments through + // the same path the original request used — a steering replay that kept + // only the envelope text would hand a recovery turn references without + // the native images the first request received. + const content = await this.appendImageParts( + budget, + item.content, + item.attachments, + item.steering ? `steering:${item.steering.eventId}` : `runtime-event:${item.eventId}`, + ); if (item.steering) { // Already envelope-wrapped by the plan; carry the structured identity // so injection dedupe recognizes the replayed message. return { role: 'user', - content: item.content, + content, providerOptions: steeringProviderOptions(item.steering.eventId), }; } return { role: 'user', - content: await this.appendImageParts( - budget, - item.content, - item.attachments, - `runtime-event:${item.eventId}`, - ), + content, } as ModelMessage; } return { From 14f0a1a770db3cc97cf94ee41a45ae0b9f05838f Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 00:26:03 +0800 Subject: [PATCH 14/36] refactor(core,runtime): share one meaningful-content predicate across consumers runtimeEventHasModelVisibleContent and the session recap projection restated the text/quotes/attachments rule that hasMeaningfulMessageContent already owns; the recap keeps its trim behavior by deciding on the trimmed text. Restating the rule per layer is how a structured-only message ends up admitted by one boundary and silently dropped by the next (review suggestion on #4815). Generated-by: GLM-5.3-Flash (ZCode) --- packages/core/src/runtime-event.ts | 7 ++----- packages/runtime/src/session-recap.ts | 7 ++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 8b4fd23639..052bd78f8b 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -42,6 +42,7 @@ import { type RuntimeHandoffPause, } from './runtime-handoff.js'; import { + hasMeaningfulMessageContent, isMessageContent, normalizeMessageContent, type MessageContent, @@ -1572,11 +1573,7 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean if (!content) return false; switch (content.kind) { case 'text': - return ( - content.text.length > 0 || - (content.quotes?.length ?? 0) > 0 || - (content.attachments?.length ?? 0) > 0 - ); + return hasMeaningfulMessageContent(content); case 'thinking': case 'function_call': case 'function_response': diff --git a/packages/runtime/src/session-recap.ts b/packages/runtime/src/session-recap.ts index 11673e50e3..9bcaa931c5 100644 --- a/packages/runtime/src/session-recap.ts +++ b/packages/runtime/src/session-recap.ts @@ -18,6 +18,7 @@ */ import { runtimeEventHasModelVisibleContent, type RuntimeEvent } from '@maka/core/runtime-event'; +import { hasMeaningfulMessageContent } from '@maka/core/events'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; @@ -125,9 +126,9 @@ function projectSessionRecapMessages(events: readonly RuntimeEvent[]): ModelMess // text is empty (#4804), and a non-empty text must not erase the // staged refs: both cases render through the shared inline-ref // formatter so the recap carries the actual content, not a count. - const hasStructuredContent = - (content.quotes?.length ?? 0) > 0 || (content.attachments?.length ?? 0) > 0; - if (text.length > 0 || hasStructuredContent) { + // The shared predicate decides on the trimmed text, matching this + // projection's existing trim behavior. + if (hasMeaningfulMessageContent({ ...content, text })) { messages.push({ role: event.role === 'user' ? 'user' : 'assistant', content: formatTextWithInlineRefs({ ...content, text }), From d53a2023e9267285c3c5b95be7f34a84e1dbd061 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 06:51:09 +0800 Subject: [PATCH 15/36] style(runtime): apply biome formatting to the steering replay regression Generated-by: GLM-5.3-Flash (ZCode) --- packages/runtime/src/__tests__/ai-sdk-backend.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 9c080c575c..02fe3e8a82 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -15215,7 +15215,11 @@ describe('AiSdkBackend steering durability and identity', () => { parts.find((part) => part.type !== 'text' && part.mediaType === 'image/png'), `expected a native image part on the steering replay, got: ${JSON.stringify(parts)}`, ); - assert.match(parts[0]?.text ?? '', /steered earlier/, 'the envelope text stays the leading part'); + assert.match( + parts[0]?.text ?? '', + /steered earlier/, + 'the envelope text stays the leading part', + ); assert.ok( steeredReplay?.providerOptions, 'the steering provider identity survives the materialization', From 0107eedc1db48f5148b4c3598ae3b4a3a357ffd3 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 08:03:27 +0800 Subject: [PATCH 16/36] diagnostic(ui): revert the empty-text metadata render to bisect the rail story The rail-stays-on-the-visible-prompt story fails on CI at the same offset on the two heads carrying the UserMessageBody metadata render and passes on every head without it. This head isolates that render change: green clears the metadata render, red confirms a real regression for me to dig into. Generated-by: GLM-5.3-Flash (ZCode) --- packages/ui/src/chat-turn.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 1d713bb0ae..c5c9af894f 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -251,8 +251,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { ) : null} {/* A structured-only message (#4804) may carry only quotes/attachments; - an empty text must not render an empty bubble on those paths, but the - metadata (timestamp, copy, edit entry) still belongs to the message. */} + an empty text must not render an empty bubble on those paths. */} {props.text.trim().length > 0 ? ( )} - ) : ( - userMetadata - )} + ) : null} ); }); From 34a39ee6822c8ab6e748fbb445d9f8de2415d86b Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 08:14:59 +0800 Subject: [PATCH 17/36] test(ui): pin the reverted metadata render for the rail bisection Generated-by: GLM-5.3-Flash (ZCode) --- .../ui/src/__tests__/chat-turn-answer-identity.test.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 847df87403..ff9bc52bee 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -389,13 +389,10 @@ test('renders a quote-only user message without an empty bubble but with metadat const quotes = container.querySelector('.maka-user-quotes'); assert.ok(quotes, 'the staged quote still renders'); assert.match(quotes?.textContent ?? '', /selected excerpt/); - assert.ok( + assert.equal( container.querySelector('.maka-message-meta'), - 'a structured-only message keeps its metadata row', - ); - assert.ok( - container.querySelector('[data-action="edit"]'), - 'the edit entry survives the omitted bubble', + null, + 'diagnostic: the metadata render is reverted while the rail regression is bisected', ); }); From 672ec7c72194e8a19b4d4b71da1b151b1fcedbc4 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 10 Sep 2026 21:32:05 +0800 Subject: [PATCH 18/36] fix(ui,core): answer the review findings on the structured-only PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three non-blocking findings from the #4815 review: - The attachment-only opt-in is consumed by the composer's hasStagedContext (send handler, disabled state, send/stop toggle); a send-toggle regression now pins the consumption in both directions — enabled with the opt-in, disabled without it. - A steer returning `started` binds the pending admission through bindAdmittedTurn, which fires the consumer exactly once; a regression drives a steered attachment through started plus the admission echo to pin that boundary. - hasMeaningfulMessageContent now trims the inline text, so a whitespace-only message is judged contentless by the Host admission, the protocol decoders, and the desktop guard alike instead of being admitted upstream and dropped on the desktop path. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/quote-companion-retry.test.ts | 58 +++++++++++++++++++ .../core/src/__tests__/runtime-event.test.ts | 14 +++++ packages/core/src/events.ts | 8 ++- .../src/__tests__/protocol.test.ts | 7 ++- .../__tests__/composer-send-toggle.test.tsx | 28 +++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 89a619eed7..1c299a0407 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -1729,6 +1729,64 @@ test('continues projecting the active Turn while a steer awaits Host admission', }); }); +test('consumes a steered attachment when the started turn binds the admission', async () => { + const pendingSteer = deferred<{ kind: 'started'; turnId: string }>(); + let admissionId: string | undefined; + let admitted = 0; + let steerPayload: { attachmentItems?: readonly WorkbarIngestInput[] } | undefined; + const attachmentItem: WorkbarIngestInput = { approvalId: 'approval-1', name: 'kept.png' }; + const { container, emit, send, steer } = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + steer: async (_sessionId, _text, requestedAdmissionId, payload) => { + admissionId = requestedAdmissionId; + steerPayload = payload; + return pendingSteer.promise; + }, + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + await Promise.resolve(); + }); + let steerResult!: Promise; + await act(async () => { + steerResult = steer('steer with the kept image', [attachmentItem], () => { + admitted += 1; + }); + await Promise.resolve(); + }); + await waitUntil(() => admissionId !== undefined); + + await act(async () => { + pendingSteer.resolve({ kind: 'started', turnId: 'steer-started-turn' }); + assert.equal(await steerResult, true); + await Promise.resolve(); + }); + + // The attachments travel with the steering Message... + assert.deepEqual(steerPayload, { attachmentItems: [attachmentItem] }); + assert.equal( + container.firstElementChild?.getAttribute('data-live-turn-id'), + 'steer-started-turn', + ); + // ...and binding the started turn IS the admission boundary: the consumer + // fires exactly once here, not on the later admission echo. + assert.equal(admitted, 1); + + await act(async () => { + emit( + messageAdmittedEvent( + 'late-admission-echo', + 'steer-started-turn', + 1, + admissionId as string, + ), + ); + await Promise.resolve(); + }); + assert.equal(admitted, 1, 'the admission echo must not consume a second time'); +}); + test('fails a send when observation seed rejects and resubscribes for retry', async () => { let sendCalls = 0; let subscriptionCount = 0; diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 47ebd9bafb..3762431167 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { decodeMessageContent, + hasMeaningfulMessageContent, isCanonicalStorageRef, messageContentsEqual, normalizeMessageContent, @@ -885,6 +886,19 @@ describe('runtimeEventHasModelVisibleContent', () => { false, ); }); + + test('treats whitespace-only inline text as contentless everywhere (#4815 review)', () => { + // The desktop guard trims before judging; the shared predicate must trim + // too, or a whitespace-only message is admitted by the Host and then + // dropped by the desktop path — the same one-layer-accepts split this + // predicate exists to prevent. + assert.strictEqual( + runtimeEventHasModelVisibleContent(baseEvent({ content: { kind: 'text', text: ' ' } })), + false, + ); + assert.strictEqual(hasMeaningfulMessageContent({ text: ' ' }), false); + assert.strictEqual(hasMeaningfulMessageContent({ text: ' ', quotes: [{ text: 'q' }] }), true); + }); }); test('runtime errors reject malformed retry decisions at the durable boundary', () => { diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 9037c0863b..f3816feac2 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -173,11 +173,15 @@ const MESSAGE_CONTENT_SHAPE = defineObjectShape()( * is present: inline text, an inline excerpt, or an attachment reference. * Admission, compaction estimates, and recap projection must share this one * predicate (#4804) — restating it per layer is how a quote-only message ends - * up admitted by one boundary and silently dropped by the next. + * up admitted by one boundary and silently dropped by the next. The inline + * text is trimmed here so a whitespace-only message is judged contentless by + * every layer at once: the desktop guard already trims, and a predicate that + * did not would re-create the one-layer-accepts split on `" "` (#4815 + * review). */ export function hasMeaningfulMessageContent(content: MessageContent): boolean { return ( - content.text.length > 0 || + content.text.trim().length > 0 || (content.quotes?.length ?? 0) > 0 || (content.attachments?.length ?? 0) > 0 ); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 93a7409b24..9611378007 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1950,8 +1950,13 @@ describe('Runtime Host bootstrap protocol', () => { attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' })], }), ); - // A Message with nothing but empty text is still an invalid frame. + // A Message with nothing but empty text is still an invalid frame — and + // whitespace-only text judges the same way: the shared meaningful-content + // predicate trims, matching the desktop guard, so a whitespace-only + // submit cannot be admitted here and dropped one layer down (#4815 + // review). assert.throws(() => submit({ text: '' }), isInvalidFrame); + assert.throws(() => submit({ text: ' ' }), isInvalidFrame); }); test('admitted structured-only Messages survive queue and steering read-back (#4804)', () => { diff --git a/packages/ui/src/__tests__/composer-send-toggle.test.tsx b/packages/ui/src/__tests__/composer-send-toggle.test.tsx index c4a097c1a1..395df9155f 100644 --- a/packages/ui/src/__tests__/composer-send-toggle.test.tsx +++ b/packages/ui/src/__tests__/composer-send-toggle.test.tsx @@ -64,6 +64,34 @@ test('a running composer keeps Send alone — no mode switch in the send slot', assert.doesNotMatch(markup, /SegmentedControl/); }); +test('an opt-in attachment-only draft enables Send without text (#4815 review)', () => { + const attachments = [{ displayName: 'kept.png', kind: 'image' as const, size: 12 }]; + const markup = renderToStaticMarkup( + + undefined} + onStop={() => undefined} + /> + , + ); + assert.match(markup, /aria-label="Send"/); + assert.doesNotMatch(markup, /]*aria-label="Send"[^>]*disabled/); + // Without the Host opt-in the same staged attachment keeps Send disabled: + // attachment-only sends stay a per-host decision, not a composer default. + const optedOut = renderToStaticMarkup( + + undefined} + onStop={() => undefined} + /> + , + ); + assert.match(optedOut, /aria-label="Send"[^>]*disabled/); +}); + test('keeps Host order visible until the reordered projection arrives', async () => { const original = { document: globalThis.document, From bd95907adee6978634cad3c556081b2e7a24e79f Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 11 Sep 2026 21:09:13 +0800 Subject: [PATCH 19/36] wip: checkpoint before merging latest main --- apps/desktop/src/renderer/app-shell.tsx | 21 +- ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...08b32e1ecad4a41e4e4599f743dfd12f2e4.source | 4127 ++++ ...766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json | 14 + ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...520acf1409f9160cceb6d736056fc402626.source | 1357 ++ ...766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json | 14 + .../core/src/__tests__/runtime-event.test.ts | 34 +- packages/core/src/events.ts | 27 +- ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...0bd984cd41970b10816f4ed1c8b172ceeb2.source | 2625 +++ ...766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json | 14 + .../src/__tests__/protocol.test.ts | 11 +- ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...727d5810850f9d19da6d1f5e8be2e57c697.source | 16777 ++++++++++++++++ ...766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json | 14 + .../src/__tests__/ai-sdk-backend.test.ts | 40 + ...luse-0bdcd9fd973aa77c93a0d202e20b5ae6.json | 48 + ...luse-600551e80e606fb26f5d2c60dafa0eae.json | 48 + ...luse-34e6d564f858911bd3cbea4ad3606892.json | 48 + ...luse-5b0547869b8cb795cbdb2a06f03452e1.json | 48 + ...-b766-4dfb-b495-da7c17a31a3d.continue.json | 1 + ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...43184460625e66ef065b3eca7f9ccfb1620.source | 45 + ...e83507546b9d9e39d450b813e04746280b8.source | 847 + ...49fb509608e17ea9ab7ed630e455df45834.source | 263 + ...d42706c00b28dbe2bc15246231aa221a50e.source | 330 + ...ae085dc74c63042018d3d0bb28d116a7ac6.source | 142 + ...4eebe04b6275de0ef792a19ecfa7407b7ca.source | 97 + ...108b7aef6bdcfd59fd3db471dea8a642dc0.source | 275 + ...a6bf4176d8acc47664ed07a6ad6f554b660.source | 289 + ...0aa9e9d6015dc8a383a62a4fc000044476c.source | 525 + ...d5c49a97c202bb0a9a7bbdac5eb76e9e24b.source | 73 + ...f173d8d280b043351dbf6e8160c0f164a13.source | 522 + ...f4921f3a70b3a630428d684e40c21234195.source | 320 + ...1b4facf25e5cad9c7d4b98c8478d83f4b87.source | 1149 ++ ...620f6a156da5441ba5d55bc75421e3d558b.source | 201 + ...f4306ebdc9fabd86f02097c6d7d2af0ef84.source | 149 + ...cb4fdbeeae1bbf02b14ee5fc7792200d277.source | 272 + ...8d5778258957d2b743573786af681dc412c.source | 305 + ...87984032927ed036ec6361c3a087eddede8.source | 226 + ...3920f8a54e34455a118c6ce429c3704725e.source | 55 + ...4464deb38360468454e60f1c60f2a0a2008.source | 202 + ...b6402c989b93d17ddbcc084ee8c424f6ee5.source | 234 + ...f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source | 64 + ...2a845434bc340a42ce8706d1b29845298c2.source | 242 + ...b98aab25be12f9863f6f826ca5de79314b8.source | 103 + ...5469b5b874112f8b540e6f90959086e3038.source | 125 + ...d2b528b735789c5d8907c990355def94255.source | 243 + ...baeacfc9bbeeb3634ee9a18cad3b0613cff.source | 48 + ...feb338fc544266331a00b991a9ce9a4bd53.source | 37 + ...4c320de29f91a36500cad6d5a1c00750512.source | 222 + ...66f09f56154a02bbbb30a30b4fda5f072d5.source | 31 + ...a9066036fe86fe344d9c616b07d6f6dd0d3.source | 222 + ...e89df9c52a6fdbe441b3803fb54aec82243.source | 787 + ...83d30b8f6359b1711261a1275b228fa3e60.source | 549 + ...0784e3020786d67120221f6204c4583f45b.source | 115 + ...9a388a618ba5e34ec3be83fba618acf6c5c.source | 570 + ...d71c4e7f9ec6e7527d416f08daff8cfb164.source | 1393 ++ ...22981394bf16f03434679fd2209ff2a3b94.source | 694 + ...7368bfa5bd187dd0c6345eaa4916c09be6c.source | 103 + ...482ee810349d47e6fc492b9e45de3993ec7.source | 336 + ...4feceee84617f77d6750d280a889ed414f6.source | 2589 +++ ...a4d7faaa9115237a66193730f8157219c9a.source | 2522 +++ ...c71efcc9c08b5d70f4cde6edd3e484d9e8b.source | 62 + ...897499bf3ee993eef78c92f976bf670264e.source | 28 + ...0a933efa9f95219e6c8da2b93c79a5c3c7e.source | 4820 +++++ ...ab87e0a9c425eebb9f33c51e0f60a56e8a1.source | 869 + ...04084c7a8864b5a05a1bd00ee1d02352c2e.source | 136 + ...d838a7158294c4163d816bfcb9c5416fb65.source | 124 + ...3b747bf5f8a5630abe5f088c52ee3b99a28.source | 454 + ...21de1fae75f04446e6d2e574a80b19ccb7e.source | 199 + ...1a78dd42a822e5c01e6f1200ce39c281e81.source | 132 + ...1d99f3577badf2b139568bfebbef86125b0.source | 2273 +++ ...1de6a04c0cb25f90284ae63d8acfd2f0095.source | 301 + ...9d91a4e7ccd3e9784dec16ff6609234e886.source | 148 + ...c857cdbc868540fc767d0b7820a3dba292f.source | 219 + ...e567194a967abda841d38ae99da4c77aed7.source | 81 + ...72dc04924e469a03b0e9ecf651850e27e75.source | 33 + ...ede6061db8e8f6f49d222768aef39c1ce83.source | 297 + ...26c4fe76d6d6ed67954c26ad8b9ff7063f6.source | 327 + ...e9af672da81ed1b70728a5e50e2bccd9766.source | 1972 ++ ...d46f3833654568ced65072ee6b32af362b1.source | 209 + ...249d9902c84b6f961624ba40e4d2a4e9f4c.source | 243 + ...46bf01125eb6b4907b8ca91b8e61c1919d2.source | 434 + ...eaba9e346d168bd12455a4b0d224e5f3518.source | 814 + ...098cce9fddd29dbbf26172871daa1f484d2.source | 441 + ...511ba9b3e61827d3af74fe39f581cadc029.source | 2599 +++ ...515889957f25c1144dc25033dfaac0d932b.source | 972 + ...eaa3d5d28db14441db9a322ad0d6c9ec7ae.source | 856 + ...21f39759d98f1b05490b797a27f0886d454.source | 204 + ...4e8c5b059801ad0dc9c36ab094915f9b0e7.source | 1472 ++ ...4f6f6d634869c2690f50040efbcda3f5b0f.source | 462 + ...864c01fef0bfa657ca3e86c2b8ec34ae914.source | 29 + ...c91bee90c031d78e4abc0296bac5d0cdb98.source | 94 + ...451ad2e524a80ae21778a208b3d5f34972d.source | 131 + ...2feddc8b945ba700ed2e514ff4d6179086f.source | 35 + ...7197915b9d74408e1d2c9456522ede65f27.source | 108 + ...ab6b155a18e1535c52c52665f10b8e4b4a5.source | 99 + ...23fad2627c50b043513a5d8700bdc5bd9c6.source | 361 + ...f373a37be65da667af7bf722af397052219.source | 362 + ...01eb7287bff35cd9a0f3462beb5e62f7653.source | 205 + ...6c75affe87e381fbad43e8a99e6cdbe0229.source | 150 + ...1d57cb042577af736ec5ecdc1c8ca8fbf05.source | 62 + ...4f7d307a2285382e962a3fc26b9e0baf0d6.source | 67 + ...68fd2349114335336d3e08a9100d1f577c9.source | 36 + ...58075e12294e8d5411066a7f7648a20a5fc.source | 210 + ...edd86ceca5e5f6afa4561b24f343bee07e9.source | 519 + ...ef04df6337b178871ba8ba0672f367bb4a8.source | 254 + ...cd839cea24bd06f0806560f4eaa5d895e92.source | 465 + ...4f83ad8521d0cc7834d4e709614602039e1.source | 2036 ++ ...daefefa835f5de4fa00a0a3cad3418c40f8.source | 1197 ++ ...9b46cb66202bc2725379affeeea49fdfd03.source | 1127 ++ ...0a7cc91f00b5463465a8d862234cf032343.source | 55 + ...18f6bf56d5a4ba0013fb02e8853ef2946a6.source | 84 + ...ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source | 260 + ...0cfe6fb48d798ddd37c31f7c0564388042a.source | 270 + ...d593513dc7f761568a5ea4c8595e3d53fad.source | 181 + ...73aa2dd763de1312b2ceee6820839058642.source | 367 + ...e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source | 378 + ...42b1617ecb8f4511ec008aea2df7730f946.source | 149 + ...81ee647f76b32f9d7e987c19f14569ac35f.source | 27 + ...09c912df4817620f9e64905edead4230cd5.source | 888 + ...35796f08df75d7e3b8aecf6d68cef35eb00.source | 44 + ...76ba3ca048e69a7ce7ab1bc35550f07f7c2.source | 153 + ...23e499e00ba6f05254cdadfbbd0199ea795.source | 49 + ...d90c750c18b2088b034e8288da1cac979b6.source | 126 + ...85561bfaea64cc2ea227149e2a6ed1d509b.source | 69 + ...943ecc3814d7b2a3a31bf144c6ceeb5748d.source | 167 + ...2207910e14e06166c2dd49e1b607ccd51e1.source | 79 + ...83591d7bb20b1c621c9d56ec575934019fa.source | 231 + ...0df77a2cfd59bc33e843177b5ecc0456762.source | 1129 ++ ...a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source | 1405 ++ ...bdc3693b501b1bdbc33d927ec20f68b4802.source | 737 + ...fcf0fe89f730639007b454c450238da4e8c.source | 613 + ...7c3756bfeb5683659ddcec9c20e34630325.source | 52 + ...683f12cd3a3d732cba567be5f465ade0031.source | 93 + ...63cf076787fac4d6443d30a5d651f766a3c.source | 77 + ...d9c8fd0035735066e32684de896bdd6ba51.source | 39 + ...b205714de3a9f9e635012ba6fe4c6f4d0da.source | 111 + ...9d43b24f917f52263e3977cca6a07875700.source | 1166 ++ ...cc7e41d6601da66d670347f1e18a1362e3a.source | 396 + ...a6c65818416f5e05e9983ca7d1c82cf56d3.source | 249 + ...0c2d1349f3d0cfe0a7fc0ced38356f64f9e.source | 4248 ++++ ...839c74a8fe0f7aef8b02989222bb0b65c6e.source | 328 + ...a179ba27c54a855f5ca8e052df61248b17f.source | 771 + ...0f100c9038fbbf5b3ba4da529163d0c373a.source | 384 + ...427a9b25b346470d7bc6507b6481575d519.source | 76 + ...d7a34edef934e81251c843eedcc5a5c2b80.source | 1723 ++ ...edf4e9d3407e23bfdcacf6d4d0017ac612c.source | 90 + ...8bfadf6bc82e837086cc3756eac41e6ac41.source | 245 + ...41ca8b0c8fab6ed8c39dd028d80e42bb954.source | 270 + ...a46b02ef360bd1e34b3b075cf6d75275010.source | 78 + ...7cd276b7661c722ea1392378f5eb6ef9bb1.source | 689 + ...aaee6b8bac65d26b4bad5de325b87ab3e83.source | 84 + ...8bbdbd8aa807a63ff8aa2367e88e88c5859.source | 129 + ...db9a1b6ddec29ad53107e0aca445525044f.source | 708 + ...7449b0220737d33a5ed6cc7612f93c55c47.source | 410 + ...bed015675f8f85c8de68e8769bd8d7e79c6.source | 133 + ...2d5509729712a1f7a112c1ed452889eb8a6.source | 142 + ...befa55492c0ff2a836576bb4eed28cbb42f.source | 78 + ...8eeeefa657980771d847cb872cdfb801f51.source | 85 + ...549bed29b6512e3208b4c19fbf59e2d97c3.source | 63 + ...5d4f8aa89ac42405e5af202a04f68e3a7b4.source | 42 + ...2cfffbc0350b9d4c2138084e39099dffbaf.source | 231 + ...7590e7cb385f19340747f041663e6d119c5.source | 421 + ...14d3c97f218a7bea77a635c306d73cac369.source | 136 + ...1dd0f510370378aa45dd5894cf2e3cac104.source | 145 + ...e3f8c060a1ea2f3d6422204e71e2eb61687.source | 1177 ++ ...9f023a830324fe924d443ae3d509bb48c55.source | 122 + ...78df4cb2312070093227bc598d44b7035d2.source | 79 + ...30205bbe4220918aaa20bb638deb3e87d28.source | 134 + ...882b7ebc217ac63e0f4fd7eb9a947790b20.source | 39 + ...514cecdccb7fced0b9fdd376dee38e97452.source | 1229 ++ ...661b0a0f04a2216145e5ec719c0c9ec2635.source | 60 + ...9cb02b3289bf591239131a2beab0bd909e2.source | 148 + ...23362e2b8334589db059641658db6ae779b.source | 349 + ...468c1d6f835d3e84366eaff76a109a9de7e.source | 151 + ...853150c587ae27f1b17695374488fb68857.source | 152 + ...8339e78f487d4b7e3ac5c14e71694b71c4d.source | 94 + ...bc96a548904842a3d964aab0704496b06f5.source | 101 + ...f156cd1315201010548bbea6b8d53f2ee7f.source | 306 + ...34811f1396019383deb95ef9c970e3b6ecf.source | 330 + ...5f47a7b2097e9a2017ac5ea794ba64ebbb6.source | 455 + ...68e5fcd7b43f696ddd6a69caa8c75171d06.source | 1007 + ...dfe66aa61af99dd8cc6296f5d7e8aa616ea.source | 167 + ...bd1a08c8a17c15f2400fcd9bfdb3571ea01.source | 253 + ...0b546c88af6aab8c62ac1945a924fea7731.source | 230 + ...dd7e1894019815760082c71ae4597e87dd2.source | 25 + ...e3fe5f17d9416fab3b35024ef6eae00b9eb.source | 244 + ...79a0f87dc10b46ac4399e53f5f8f536f716.source | 1491 ++ ...dfa1cd462c3eb03418b1ae695f18ca2a4c6.source | 1087 + ...227c3505b2e811992190ff37c689c538723.source | 220 + ...164abebed68c5fe86ba75d7ccd1f83439e6.source | 709 + ...c59e2afd6d40c8f4763d41a6acbc27db88c.source | 1067 + ...e915c4730a08d8b325a3df9be8dca0def15.source | 197 + ...6d232215df2399a91ee83e307bc261f9e4a.source | 211 + ...0ed6de45e36b3141eae8cf0078e46af8565.source | 203 + ...8a974d5e60cf0b2a293f95ad981245190ff.source | 1295 ++ ...168e75e28357c57b75a5c8f4b2e73164480.source | 929 + ...8a30801e6ebd02885e12944bdf0eedb25fc.source | 705 + ...eaea3d873f6cc4f8df19b59f40c3a0be2ad.source | 405 + ...60518c1c2e145a5c9bd690d6bf8052921fb.source | 221 + ...895b4863295874e2ecc3d67620a21793cc3.source | 67 + ...25ee4bf2fb917b0b39c5768bedf83138ffd.source | 191 + ...5bad237fcaad0a9c35e96adb81f85d42359.source | 370 + ...7505373c734673e96112e570f0d58adc0ad.source | 208 + ...a7d3cd4e1576d268fde522d93e15bc26f18.source | 72 + ...557829e04c03441d2563169b3be99e1e4ee.source | 511 + ...4a283b60c5c62e310874fd19a89abcbf078.source | 813 + ...7fcadfd13cc3172f96d4760dee34e2737dd.source | 972 + ...f9432ba92e93a03f678dafbc4a4b0f3fb3f.source | 68 + ...7864d428b950a5b903d83bfc00e08bfb7be.source | 29 + ...70ca16063d0855349518de5dffee7a9c033.source | 276 + ...8e6dff6d1ae50fe4832330eca0c2ebef913.source | 882 + ...0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source | 209 + ...d41053454581aa7e626c2c89394b7a150a2.source | 605 + ...b1c3740fa80267090c595280f0e141b1d40.source | 43 + ...43c813897e0d1a6265ac8bcccb1abbb2b5a.source | 111 + ...ea7c74eecbdecea11fb4f88b5dc0d693433.source | 166 + ...86069f663244ef71334f4530fca16f31e14.source | 221 + ...0b51df5f2971cbb176b1fc246ecc51451fe.source | 1509 ++ ...c2c6c75b7d7f1a11e353fd5f2a9d299a59e.source | 182 + ...96ee8741a528e052dbe307d4cf6d03c4e8b.source | 744 + ...a857edf519f7d191e697eab95d79890dd87.source | 329 + ...700b08e0b1b83f6f0861ca70ec22a6f9379.source | 655 + ...a6fbb356e0d9be056fe89de9ecca32f05b1.source | 415 + ...44ab5c92e2be7770593d92b7a8ca8862193.source | 236 + ...4d434e1567b61dd363d3b2bf845ba73af7d.source | 132 + ...5d04d17b1bbc1680618b2805ebb3a483117.source | 315 + ...8f805b3eec7c97962a8546e5bfe3c91269d.source | 47 + ...b67b7a225eecfa1083cf94c83ee6dd5c55c.source | 667 + ...e8d187b7f5ecbea3036ca6edfb6eaa7e171.source | 373 + ...010ef8171c4ab1cfe25f5c1dd958d50a0e9.source | 80 + ...1f78ae9a92432735bc1f2cb420af4814d06.source | 58 + ...955c34f812692c81423ea852e2c5dda0920.source | 53 + ...abe65c00cb8bffa2506dcd52782d6b56e0e.source | 6689 ++++++ ...4e8cfe4607c0a406eac91367303b157a708.source | 105 + ...3bffe42c802ca50b84b8b7f4bfb21a8c8dc.source | 138 + ...db8cb66d9e844e43bb701bc563b14843179.source | 125 + ...2c136bb901d9b7a2c37a0369c32d4a5417f.source | 91 + ...b8c7dc60dd06adf9368d071be0a3c2a2790.source | 195 + ...811572a97ef3d53e5f74e84f185930b7eea.source | 135 + ...927504f89562ccfc7b1d0eac71cc644fae1.source | 526 + ...904d9403646a4a39388444304756aaeb96c.source | 572 + ...1a41307f01bd7be056b968f0402f3883918.source | 46 + ...86da6e1d8029cc7e2a5319b427f31bd0565.source | 226 + ...ea480cf3b5ed2679d591b99d75da7347bf6.source | 173 + ...d701f7c664d931c1a50fa5cd149b0e6c003.source | 335 + ...12079b036090afc91a3b3da73ed7c3ff90c.source | 48 + ...0d605bb13ceee265ca177e1107b87bc5175.source | 305 + ...a624cefdffee157e524ddf022dede6888ba.source | 50 + ...d9929039f8d5498871affe5c6a00705c152.source | 821 + ...475b7ccd2e6f278e258d1a9c874a5d75822.source | 80 + ...929872d3fe31a789c8dadff52e276499ae0.source | 1627 ++ ...f8c9f07fb57ecb44c4ae87fd88bc91befd5.source | 815 + ...85e8a738d931615d21a5a985a7d5f47b9f5.source | 185 + ...88966c6bebb72eba62964cc15632eebf6a6.source | 83 + ...cfd0e5ea67c4d18abf3546bfd439dbabafe.source | 75 + ...2c3bdade41a721ae3398def25f3b62644f5.source | 421 + ...56e8c5bf9e4ae7236ab82d42137fcc13732.source | 280 + ...f00ddd840429a628d678b974093add045d6.source | 63 + ...717e60fba2368b9223fd5bf5208ad73cd1c.source | 609 + ...7e1e7fb3100f9d3311cf257e961b8cc9180.source | 541 + ...5db726d274558343d52181c53c6293280d0.source | 156 + ...6e5418bbbca5e173b6832ae06ab5cde9c8e.source | 835 + ...fbf9bb66931d64d0d83891954e0e09fe5d5.source | 1447 ++ ...1ef05e3cce496a048e338d90ddbf8262784.source | 275 + ...f2f75a0b5e997318cb417fe01f4c0dd0ded.source | 921 + ...fcab570887ddc2abcfe3d8f244e9202d19f.source | 295 + ...f268f5a83d5301f3b44e0d6011bfe48c2e9.source | 47 + ...d8033eb58f925f2b4b7d0643cd00e30f853.source | 37 + ...5c917d2f57be87d69d43b01d85467df96b9.source | 1679 ++ ...9fdd09b6fda769dfeb82fd499a0a49454fd.source | 551 + ...2e266eb9b6a55c4f29b55d5a3ab7b0796f2.source | 1295 ++ ...faa355da9e1dd6c2e54109168faad090be6.source | 798 + ...f4deaea210bd94b3679ebe88a5248d8978c.source | 1211 ++ ...a7734c907740018aaf3d6febf7c2eb432ac.source | 154 + ...d397971b9e656c0daddd446fdd3ce22efa8.source | 577 + ...e00c2506594fe28b5873913d3b14bf3af4d.source | 753 + ...c7d24fc5f003b4089e148a37c8667cbc024.source | 124 + ...9465c48e0274fe50dc34cf6f684ba093f99.source | 438 + ...0beb37ea2f0e51f71b6536403a3315b1e90.source | 171 + ...dd22e4c4b92995467693c3757dce7af2f62.source | 603 + ...5ceed202516f51a794cd183ecedbfc47873.source | 625 + ...f8b9330d99a5b674993c44dd774841e6aba.source | 220 + ...dc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source | 28 + ...54e07940b32dd19cce4bd4d91e93e910bdb.source | 4974 +++++ ...d9c20dcd6597c7d33925bd9fa018f5c5ccc.source | 201 + ...97e426c6a39c2af0df494af69e7e362911c.source | 46 + ...bb37fbbab581f93a22d38475578b45cc402.source | 105 + ...766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json | 14 + ...-b766-4dfb-b495-da7c17a31a3d.continue.json | 1 + ..._df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json | 1 + ...c030cd231dc0b21a8a256f7cefced27706a.source | 151 + ...001382003e69719916b428af458f1c30bf4.source | 91 + ...38a920672726d8990cc9c0219a44e72d9c1.source | 249 + ...911b0da4c6438d06017115ca0ecd743fc9a.source | 171 + ...c6d179c63dadc7ec470426f3e8a757bcc25.source | 100 + ...6d4a12b55010a066c5a210ed97e19417667.source | 349 + ...d1faaf4d68d608fb1004cc1fc556140c586.source | 93 + ...3ea61d2538fe13814d47819284be2e4ea8a.source | 489 + ...769ae6fb077f9b6bd1146747a034cc40ac9.source | 159 + ...10a1a06989cc04ec1eb32e8213d4789d0a6.source | 171 + ...bfdf689b0bf4f7bc84b7495e5501426540b.source | 265 + ...54a18a1b5dbbb0d451989fbef4d670fd5f9.source | 213 + ...92aa09b05cc3c5023eba7058d744cd8938a.source | 56 + ...88cbfc67095d24ad2dad261f3e33adbb89d.source | 197 + ...7f116f28c6bb55e190c8cb224a883a4aaad.source | 531 + ...36fdb36e8682ad6037dec2980d9f0362d40.source | 101 + ...194dc3e4381d0c31875cf5334e8b6711fcb.source | 89 + ...96d4d06ec8c50e5426bae5a0a11c9af2b0b.source | 58 + ...e2f141be36bd5e7127000cb72318315c13b.source | 128 + ...6e0bf7478f1078d66509f93aa834c3699f6.source | 35 + ...1049abb349719f6c241ac9ee68ace5f2bad.source | 151 + ...664b56b20ebbd9c2a32cdf05a469d4ba9cd.source | 216 + ...004a5cfd43f2b9aaab78d6259c677749f59.source | 35 + ...d4d29061fd86d3c0b7e85d8a5fc294c9668.source | 66 + ...ea085bbc4a62ea59f7b005a86585aa35f64.source | 270 + ...0284de801caed488e15549bd9ed97729489.source | 107 + ...68053da90de72112615122298db001545b0.source | 171 + ...78b00dc10dc5510d051500c0a1c6909a596.source | 126 + ...12f367ff4b6dc4f95f522ccbe2b0f287551.source | 58 + ...8aa02326779fbb4b512c27ff5ff6c13b622.source | 204 + ...8d905fc49cb148463dbbfc5ee49188651e8.source | 42 + ...d9af676edb8f3630f833de039e3b5e971f1.source | 113 + ...769a8d32d2ad572031ebb9876a561e4a524.source | 90 + ...989f96699971978bb0bf5a2d1de30b63ae0.source | 269 + ...ab5fcaf203a392d2fb88d3f53a1bb46579b.source | 706 + ...5d1cb1ea7fdfc469835ed1e950e07420cdd.source | 68 + ...9122f6d4b3b650d6edd3b94ace8fcd06573.source | 230 + ...2f9ac58e39283448676e34a76f8bdfbcbb4.source | 57 + ...680511bf6e15c652024f0c044d744ceea4b.source | 81 + ...a009ac5ef61ccf3a4634f3147b2ed5ea7c3.source | 149 + ...e96a9202b0ac946119824d6fec93d535464.source | 272 + ...54276fe8b5bef433ca05fc9cfa880615f8b.source | 2319 +++ ...3c45bfbc2dc9317df8ffe560d91c570cc53.source | 76 + ...a92a78125de891cb9cdca1e471de05b9178.source | 191 + ...0cdf7f3eb7dec8d0bdbc0e3be0aae9db3ac.source | 76 + ...869c5d4efa057cc49a454fa3fc8102afba2.source | 240 + ...93d603a8ad93cfb0a92d6b74038249a0cb3.source | 77 + ...f552d80bc0ee1a6c561f53874fb065d7418.source | 184 + ...ff3714e2a80c1435f2de9706355b4d32351.source | 44 + ...323d5d6211d1245aee44e879962d06426bc.source | 608 + ...1ef00c7b6bbeac11f5cdc9be6356e4c818b.source | 122 + ...644467383150a89b75c9b83c5a0c5d2c1c2.source | 94 + ...b768b83da3d1f31a93c7be819384cfc154b.source | 118 + ...89bab9b38ef2ae2649cd8f1504a720a0451.source | 87 + ...840c0bb76665a29c61a090a71ac9be6b6b6.source | 46 + ...7754b3c9e4f4f6f7ab3b3a34ec8e6c74e06.source | 133 + ...5449d9f6e13d7e647f0aea570e493fc34a7.source | 1245 ++ ...e3e14f197b1c0584b8e49dec58033505d93.source | 297 + ...c3c8c4d591591a684870fcab820de2568d5.source | 135 + ...4bffa1086660dda57f6116920f9de6beb12.source | 455 + ...ff75bf0fd3a4b97fd71505eec7d60255300.source | 62 + ...7a87591af584883ff3a3ab9c00a13769adb.source | 37 + ...5a2551d7a0c6c28c0dbab8fd5b8c51a3de5.source | 1523 ++ ...35e69724cbbf6f1266c96201ca82f82e5a6.source | 153 + ...b0b6d76e67c5e11de28370d25af0c1f6dc7.source | 42 + ...a87ccfe1a844185f2f9344ed74d358f8aed.source | 97 + ...65d41c8f412c2ed7dde6e5990ffb107608c.source | 430 + ...e19b47e20c814ac8445fe9e21fccbaf8493.source | 63 + ...c5b1320cdfd77a10e5157c0de8f87e44034.source | 233 + ...64b8a7fc79065cdbdcaab01af93187cd380.source | 377 + ...02e91779083ac7993d83bb4ba2eb2ec847a.source | 76 + ...b73ab29ae70bf44f536a802c18a501878dc.source | 91 + ...1d9b12e4805d0986792c6c85f717abaf4ca.source | 158 + ...3f4351b6c2aa575606b71b71072bd2f0be5.source | 106 + ...024878c4153ee0b9b2e862325fb32dae11f.source | 113 + ...1c4fff0aa461b0aa03dd78be2c4268bfaec.source | 351 + ...054ea1b8a189ca80daba9e1944154c49bf0.source | 527 + ...ee8bcb52d8e8ff544fb0c382b6263d03dab.source | 206 + ...b72300773623cd994d36ecd3901dfb275c0.source | 64 + ...a6134868d7181049a9a0db5caf452a8ab90.source | 34 + ...6dfcd7e814d5e42f3784c86130c6cff42bd.source | 80 + ...c64ef36e9592ad2884dd8dfdcef68654dcc.source | 53 + ...65bfb83f06d940bf347df1dc0cbc86f0d3b.source | 241 + ...cf3e530b23aa8362a3d692c744b4630551f.source | 1135 ++ ...eaf7b17f40feb93ad49a9307d4d86d341b7.source | 700 + ...cd6958a9bec03b71f2e31f51ca835725163.source | 312 + ...ae15ea7b56992e6058c82c30e129537089c.source | 260 + ...48de335e40184a0bfd7ee267fd1bbbe1630.source | 506 + ...3153fb6bcd0664972dae6508a92be755ddc.source | 93 + ...db9d5c119ff844a7b32e97affb08f321f7b.source | 72 + ...ef48404bb93d4e4994a0beb823ba0a3325f.source | 608 + ...1ccb27c37d1095474e20dd275b727765052.source | 810 + ...f485ceb21d615eab9e4c5862e20e1404437.source | 27 + ...d371f1329c4b03a8c92beeca36c71b293da.source | 160 + ...494b0ed4cf4b6619e88a17b040a7d19c27e.source | 32 + ...75e9bfb8b18d41bcb676bfa6421ed5d7d4e.source | 177 + ...6888e9d2b1facf50d82b5c502b31d7bd019.source | 41 + ...a8aad2814abeda23c83ac45b1d603c801dc.source | 315 + ...20adb715ef88779894e992e587c6e4969c0.source | 51 + ...936864b3f0c64d51b91ccca7f3fecac4f6a.source | 261 + ...6ee72f93dc9a23fe7a8e5c3e2651bafb31d.source | 331 + ...9c820bd8bf3e2ce90940a9981631f661e4c.source | 238 + ...2dd523ee3a5d805ef8d21f26d6a9027a8e3.source | 556 + ...57df68666ec0431adb470a6f058c3f4d285.source | 263 + ...897d595a652bfea699fdc0c7bfe7d1c6c12.source | 396 + ...427a9b25b346470d7bc6507b6481575d519.source | 43 + ...f891989c1ea05767b8c5a1dd5a39d7700a2.source | 26 + ...678341853698faf10b0a59f4d4caad4a906.source | 815 + ...4c0bcac00067c8dc35db26b03da17154b5b.source | 73 + ...68b1505812834457ac1ae3136e5577bbbab.source | 140 + ...a1ce0bd2d57c96b0f24b5e12a7e4cd10932.source | 99 + ...74b2f9d2ea506dfdec6515013fc0c5bc51b.source | 472 + ...8e5874425812a032a4e6e068b379d324b30.source | 1215 ++ ...5fd4ec7888cb7c3b38bed5636f196708942.source | 746 + ...5a879bc4494c762d180bea309ea92ac8da1.source | 67 + ...f281bd61d79564048628309750aa20c2ae4.source | 37 + ...8d6691e2da3166d7d3c647a7ac36e5677b4.source | 74 + ...80e348fe5d9e4bf6a8aae5ea622667029aa.source | 52 + ...de19e8106be76dea2b532c8426d6617a9ad.source | 119 + ...6cf533a338408ad4589f298bed888cfa590.source | 53 + ...aaf05fefb600baf8d6218c53737f832536c.source | 95 + ...229bbec7138d2c691cb86677923d683020c.source | 423 + ...0241ccda44df07a813d591faa94c2b79429.source | 41 + ...7a41592d97fb515113f937fe00d18299caf.source | 186 + ...2ba86c4a270efc7fb8f82351d510f03fd6e.source | 147 + ...05d6af2ca9cda1dc0171a88161e381fe1f3.source | 152 + ...c2a5f14cc909ad5af5ca1be24b526c11e14.source | 806 + ...f013eaf87495d3cf2fad811d92b0bcd2603.source | 131 + ...a48f1397da005dc424fb13f4c7fe325f844.source | 101 + ...056553098aa66934b6c56923e0f3903f3d4.source | 201 + ...790592bea7f7d10b6ee4aefcf799ce16a23.source | 102 + ...32f9190a471c76a0de39d680e7703a52395.source | 85 + ...5bd3dcf53c2241d0244e537e15ec0aad086.source | 35 + ...b4472455622d6aa6ceda46e60a7b2b837fc.source | 101 + ...c165bf67b22affe6410181f5494578c3707.source | 226 + ...ab384793df9a893f42056b2373a28f9cc77.source | 64 + ...6032edc5f062dfe527c5d69e311da4164f3.source | 48 + ...47d7ab755ae7f84c707aef80701b3ea0c80.source | 192 + ...78c8ae63bb31d9dbfadadfd968566cad173.source | 433 + ...43305a55b2fa5dd61e35454d2b3bb8d7c4c.source | 207 + ...7e86d5bb6bff0424e76a9c1093d0877d534.source | 176 + ...65322caa5b8ce90b946601a1253fa4f5a66.source | 349 + ...9be74e3fe220c437b3f7eeabb07d5bef7f8.source | 266 + ...e79157fce0a138a1355aba3ff438f365e2d.source | 121 + ...429edeb674106571f16bb21aec463e94004.source | 137 + ...12b6ac89ee9718c1c1e6e2765e9a7073be8.source | 141 + ...b67a1bf87e6a94e54b13eb1e19173939e20.source | 510 + ...bc5948df1eca007ca1abd566ca624529680.source | 115 + ...3183aafd33c704537ec67e08bb3a68479c5.source | 171 + ...9a9ccd10dfe24ef2aede5636c56d32e461b.source | 171 + ...aad45bc0c966427974f19ded212a1dc4202.source | 76 + ...adcf3ed51cb4f3e692d2804976845edcea4.source | 121 + ...afe9612dcaf07813c05179d6edaba79e164.source | 284 + ...e3792d30820e5aa694768b520abef31692a.source | 1334 ++ ...1dc524b14620a6767844a4dd7dcc64fee86.source | 90 + ...6e3b42eaf22b8c1bc31e46e11ac810e7d02.source | 134 + ...8f65a1e8127fdec324e4e7d857ea2c763a5.source | 243 + ...e3eb616fa26381c3a07ea2535c22803f557.source | 58 + ...b6ec98209826aeafd3eaa99aa6b92d0a8ba.source | 336 + ...b37c26dcb9e785f4aa5ebd32560fef33cd2.source | 112 + ...5b1e4427093196809aea45720179fe7bd53.source | 111 + ...ffac91a3ebcdd0996120377af3f0d0370eb.source | 166 + ...6e1fcad7088d8662578b08c8a953a73c7cf.source | 68 + ...cafe42c22a2bc05aad35550a61baaa31dcc.source | 43 + ...b3a1365b901f85c5bc9dd7ba16b68a4460f.source | 112 + ...189972625a61c569dbb7867005fb60630d0.source | 394 + ...f4df4bdb523641dc44b474bfb0a6fb498b8.source | 43 + ...569b5a1b08706e85f9b9b64dcea19a378c5.source | 172 + ...52635a0279f1d7e2235e07ff982170429e6.source | 117 + ...7032cc63c1760dc359427942e83d6aed4ab.source | 500 + ...af89ef506575a48f3e5a56fddd01ed53758.source | 972 + ...f5523e9d0198007690646102ba6094a7d3a.source | 277 + ...39638ac51530940e664cff7dcda0a7a5bd5.source | 283 + ...3dbaf34a9b1905e51745e135681025d196b.source | 197 + ...3dd840dea6379ec152c9cf4d52c7bbac89d.source | 819 + ...2167ce34b50702758d1572509e3a8862074.source | 356 + ...a6770c70ef1a3469a69c60d8976510af51f.source | 207 + ...e4a1e58fb6ff1fee6037bb4d792f1906945.source | 98 + ...809a6eb2ee08a1c9bb0e7c7af9a38e659f5.source | 143 + ...8904fa84826e81cfe87e444e2642484881d.source | 220 + ...c859fdab8d446795bee46cebcdd2da82666.source | 255 + ...04a0576e4b0fde4c2d979dc4901f4920602.source | 65 + ...17e19488c820fa53aa54214edd9bc43e5b4.source | 439 + ...65510911343d11673c5e32071ad3904b12f.source | 60 + ...0bd9bf19ba3eecceda90ff238f2b6b3fceb.source | 230 + ...5403afb0d16cc66b7896de19ca749540116.source | 585 + ...ca16554ed415db3936b8062f52539320db0.source | 116 + ...451c1197c1076aa14deb2af9e55b1a0b7a4.source | 95 + ...5eb63cc894107bb4f5ebee0ac54b6bd7476.source | 437 + ...236dff64f9e7cbfed044b7d098622451631.source | 96 + ...1173e2f0ff615b0877154d19b352935e594.source | 582 + ...113db6fccf6a4e6c4e57cc7cfa222bb7414.source | 182 + ...b41afe5c4ea16faa8c4e33510437e464fef.source | 753 + ...16ded38d45d8028d1e9d65f31ef7896e2be.source | 491 + ...5c1fc878f97bd3ef4329544fe2f18dbf145.source | 241 + ...6dfb2604fb70dabdaef272da492a8b39dc7.source | 112 + ...7d0f320a331ec780682e174e21e39793b84.source | 44 + ...487022617f045a43d01ed606d73e49a5243.source | 37 + ...9131f68363c0f1c4f9a92b7bec8f8d853a0.source | 142 + ...1affe69332a8b1050f3968d2841600445a9.source | 26 + ...eac721bddabe213df72867ee999818f6556.source | 41 + ...01bad157e9977f1c5246c352997558dfa79.source | 299 + ...d51bd08987c563799755a10553cda2239e9.source | 149 + ...02df692f28bee4a0145305beb15b5d91670.source | 127 + ...b7c654c60f785d25dfb6532d7c55159e062.source | 792 + ...7bd2a28c2e535e903f41abf4b0eb224e527.source | 128 + ...cedb70d973766913e2b4ddce47787a07675.source | 106 + ...f1a1f81647b6ce2e6d01783c728d687b51d.source | 478 + ...3c112c47e64bc5df41d4bb97c7df5301e0d.source | 49 + ...927e51480dce611d3362b25ad3e1508d8f3.source | 209 + ...f0242bfc411ce3a1e8a6dab4447dd113ce8.source | 179 + ...17d23f2ba95e3d534403d932d6dcbbc7716.source | 75 + .../chat-turn-answer-identity.test.tsx | 5 +- packages/ui/src/chat-turn.tsx | 7 +- 508 files changed, 205837 insertions(+), 42 deletions(-) create mode 100644 packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtuclrlh-33720-9eae27576d.baseline/8adbda775e4d584bfc2ba86d00a8308b32e1ecad4a41e4e4599f743dfd12f2e4.source create mode 100644 packages/cli/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json create mode 100644 packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk47wa-19000-9570adbbb7.baseline/7dd2fefe172bde28c172c523c27b1520acf1409f9160cceb6d736056fc402626.source create mode 100644 packages/core/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json create mode 100644 packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk9fb2-9208-c1160a1156.baseline/b2494949d06c246ebd46b3b681a4b0bd984cd41970b10816f4ed1c8b172ceeb2.source create mode 100644 packages/runtime-host/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json create mode 100644 packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtwz034o-27400-b48dc3ad61.baseline/dcced2d6789ab4a15878031fe17bc727d5810850f9d19da6d1f5e8be2e57c697.source create mode 100644 packages/runtime/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json create mode 100644 packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-0bdcd9fd973aa77c93a0d202e20b5ae6.json create mode 100644 packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-600551e80e606fb26f5d2c60dafa0eae.json create mode 100644 packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-34e6d564f858911bd3cbea4ad3606892.json create mode 100644 packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-5b0547869b8cb795cbdb2a06f03452e1.json create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/002f7d4c05378ff450ece2af6782b43184460625e66ef065b3eca7f9ccfb1620.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01383bc4c23f1febe28dc7b817c46e83507546b9d9e39d450b813e04746280b8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0144512940369929821ec6058512449fb509608e17ea9ab7ed630e455df45834.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01e0f2d853fe17ccbaaf523f6c5e2d42706c00b28dbe2bc15246231aa221a50e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/03839a42de4221907636530ce00e0ae085dc74c63042018d3d0bb28d116a7ac6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0565cdd842e9666c3a8e0c586fe274eebe04b6275de0ef792a19ecfa7407b7ca.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/05eae587b633856514517b9e0da97108b7aef6bdcfd59fd3db471dea8a642dc0.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/063dbd2b9bd5fb830aa2a39289fefa6bf4176d8acc47664ed07a6ad6f554b660.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/07fadffe5f0c37a379248609951b70aa9e9d6015dc8a383a62a4fc000044476c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/08509565c867a7b3880dc679c094cd5c49a97c202bb0a9a7bbdac5eb76e9e24b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0a79b9233b7f2e41758aeba4d0392f173d8d280b043351dbf6e8160c0f164a13.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0b110039ef708c2c9902423a25f33f4921f3a70b3a630428d684e40c21234195.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0bf73a3812ab803fcbe46feccbf511b4facf25e5cad9c7d4b98c8478d83f4b87.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0ca0b488c99389a538454274720d5620f6a156da5441ba5d55bc75421e3d558b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0caf206be6552f359d108770f4316f4306ebdc9fabd86f02097c6d7d2af0ef84.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0cf609297c4a4825455c7f345bdc2cb4fdbeeae1bbf02b14ee5fc7792200d277.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e16baec56243eff4244143e256338d5778258957d2b743573786af681dc412c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e1b84cfb497e9e18f6967d340ee887984032927ed036ec6361c3a087eddede8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/104ae0455b49b0dd17fe5b0d89ad03920f8a54e34455a118c6ce429c3704725e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10694684e058d6bc767c7de81d9f24464deb38360468454e60f1c60f2a0a2008.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/109d74f938a85294c61a1084bb5d6b6402c989b93d17ddbcc084ee8c424f6ee5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10a608d594755789190bd2273d1b4f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/110172c1b557a2922cd4f765f34f62a845434bc340a42ce8706d1b29845298c2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11628582812ce6bb217c12d57741fb98aab25be12f9863f6f826ca5de79314b8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/116c1ecfafa2a309609e79929c7085469b5b874112f8b540e6f90959086e3038.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11c6efb3420f5cd72ad78c669e0b2d2b528b735789c5d8907c990355def94255.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/13ecbc7c9bff60ad8c3b8b4d7e358baeacfc9bbeeb3634ee9a18cad3b0613cff.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1447df3d5a452b4921cb0fcd2fd1efeb338fc544266331a00b991a9ce9a4bd53.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/144ea3a6a65649d239eb4c99dd41a4c320de29f91a36500cad6d5a1c00750512.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/148b788c445539da65302aa00d69d66f09f56154a02bbbb30a30b4fda5f072d5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/159c0a338daed5513bc163fb18307a9066036fe86fe344d9c616b07d6f6dd0d3.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/17ec6647da1f28038ff309447ef7be89df9c52a6fdbe441b3803fb54aec82243.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/187e9c117e118d6614e58c24f935383d30b8f6359b1711261a1275b228fa3e60.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1944219a0709bf42189b4215def150784e3020786d67120221f6204c4583f45b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/19da778283a22193925d576747ce39a388a618ba5e34ec3be83fba618acf6c5c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1abafe213980bd360a0a3b7e79ff6d71c4e7f9ec6e7527d416f08daff8cfb164.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ae1446df7b17bede6791f516c3c522981394bf16f03434679fd2209ff2a3b94.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1b44f827b405d4fb44fbdeec5cec87368bfa5bd187dd0c6345eaa4916c09be6c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ba2cca36caba115903e9e0f58483482ee810349d47e6fc492b9e45de3993ec7.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1c8116448911a87e7b8c4e17dbfc64feceee84617f77d6750d280a889ed414f6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1cf66a95bca4ff2f0542df4f4d949a4d7faaa9115237a66193730f8157219c9a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1f3eacfd12ac4859d4cd0be49b335c71efcc9c08b5d70f4cde6edd3e484d9e8b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/228536d30624e5e32f773a0753c6c897499bf3ee993eef78c92f976bf670264e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ae3e27970156eebbbccdce5d9db0a933efa9f95219e6c8da2b93c79a5c3c7e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ed98860025866144d99f185da5cab87e0a9c425eebb9f33c51e0f60a56e8a1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/250f95dcb17ddd83609d9ccd3b63404084c7a8864b5a05a1bd00ee1d02352c2e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/258c9aa4a8654c2f32f0db63a2b83d838a7158294c4163d816bfcb9c5416fb65.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/284f55c5cab2947b5454dcd0aa7a03b747bf5f8a5630abe5f088c52ee3b99a28.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2ab9a516edf02f01eebb2aab9f1e821de1fae75f04446e6d2e574a80b19ccb7e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2bcfbef89ab65fd00a08bdcd52fef1a78dd42a822e5c01e6f1200ce39c281e81.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2c739d41de1f9960f4b6ecd9506ad1d99f3577badf2b139568bfebbef86125b0.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/31ecabb38719595fcb22828d2eb651de6a04c0cb25f90284ae63d8acfd2f0095.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/321caa87cd5198960b3d65b7597639d91a4e7ccd3e9784dec16ff6609234e886.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/322ed7ea7f10d02ba1a241250feaec857cdbc868540fc767d0b7820a3dba292f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3267b92f41bb892ad301000262662e567194a967abda841d38ae99da4c77aed7.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3418f2496390c0927201e4fdbcbba72dc04924e469a03b0e9ecf651850e27e75.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/38b0c5825ab435155899bf5d21c9bede6061db8e8f6f49d222768aef39c1ce83.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/390caa98d55b7b3b629602d2596d126c4fe76d6d6ed67954c26ad8b9ff7063f6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3969147664af86118d9b2cfe35839e9af672da81ed1b70728a5e50e2bccd9766.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3a17b51d4b33284ccf63d0c12c416d46f3833654568ced65072ee6b32af362b1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3af6058a9f7a897a386a0c4626ae3249d9902c84b6f961624ba40e4d2a4e9f4c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c04f9117b470c55eda73c045214f46bf01125eb6b4907b8ca91b8e61c1919d2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c41843f8eeec84c816ed119e129aeaba9e346d168bd12455a4b0d224e5f3518.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3ddbcf1e0fcb503a91aeb4e6adf89098cce9fddd29dbbf26172871daa1f484d2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/412f858ccc2b1f7414868d27d74cf511ba9b3e61827d3af74fe39f581cadc029.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/41809820ad9b8e6cddd547b201d44515889957f25c1144dc25033dfaac0d932b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/42d57adf09dae74418030a000b661eaa3d5d28db14441db9a322ad0d6c9ec7ae.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/43a8689657a5aa1eec81e0e31324721f39759d98f1b05490b797a27f0886d454.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/46f2eea4b719d5f6193e26ce4cd4c4e8c5b059801ad0dc9c36ab094915f9b0e7.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/480a50e0c1277fa3b68b95b691e3b4f6f6d634869c2690f50040efbcda3f5b0f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/487ec79757a9a17fb4320b4deb9c0864c01fef0bfa657ca3e86c2b8ec34ae914.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/49b61c12d523af1ba804b848425a8c91bee90c031d78e4abc0296bac5d0cdb98.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4d7e41e72fcdae82668405e604a70451ad2e524a80ae21778a208b3d5f34972d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4dd3091e80cefbd4b2a4d166524df2feddc8b945ba700ed2e514ff4d6179086f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e478ab9cb693bb5d1bbd7a1753197197915b9d74408e1d2c9456522ede65f27.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e73c8eddbd73054ae3fae5569ebaab6b155a18e1535c52c52665f10b8e4b4a5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50773baa95f8c0d216929a949baa423fad2627c50b043513a5d8700bdc5bd9c6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50a0e9dca404dbc503d6c086c10e1f373a37be65da667af7bf722af397052219.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50bbb968805fa5596629c15cda92c01eb7287bff35cd9a0f3462beb5e62f7653.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/515fca9e5b2cc35f15c4308f67e386c75affe87e381fbad43e8a99e6cdbe0229.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/51ad1282a62435d08a3b2cc81fe361d57cb042577af736ec5ecdc1c8ca8fbf05.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5369dc7815b4f1c0116805a72b8814f7d307a2285382e962a3fc26b9e0baf0d6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/53cb7d41ec896d9d7ed475ad1a87b68fd2349114335336d3e08a9100d1f577c9.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/54c305c6fbfd63a70c405ed49b73a58075e12294e8d5411066a7f7648a20a5fc.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5518a8dd2844daabdac12512cb7a1edd86ceca5e5f6afa4561b24f343bee07e9.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/562b3617d44f32142eb3ce38e334def04df6337b178871ba8ba0672f367bb4a8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/57c2e6f87ce97dcbd3a71abf2034acd839cea24bd06f0806560f4eaa5d895e92.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/587426d324fa60958636d3275163a4f83ad8521d0cc7834d4e709614602039e1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a8ae5c838ca8ae9045824d2953a7daefefa835f5de4fa00a0a3cad3418c40f8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a9bb0f57602903fec0dd86de9c659b46cb66202bc2725379affeeea49fdfd03.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5d68580fdc8228647ca90095b49ee0a7cc91f00b5463465a8d862234cf032343.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5df1f4b103cac7d9d6915c6188f7e18f6bf56d5a4ba0013fb02e8853ef2946a6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5dffef014972693a9310c35d631e4ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5e51a2c71b3353234ea0326839b930cfe6fb48d798ddd37c31f7c0564388042a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/61598d16b430d9cbca2719113acfcd593513dc7f761568a5ea4c8595e3d53fad.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/632572a9dd209b21ac9be2428815573aa2dd763de1312b2ceee6820839058642.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/63a8249af19a4b26abb7e6e829c01e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65932f1580159e63438031504db0442b1617ecb8f4511ec008aea2df7730f946.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65c368e22ce7610014a262af4ed3381ee647f76b32f9d7e987c19f14569ac35f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/661a90f061b2d6359d0605b14d48509c912df4817620f9e64905edead4230cd5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6639bdf8a8b878b7838782cbc99bf35796f08df75d7e3b8aecf6d68cef35eb00.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/666276a97a577f5026adde38e873776ba3ca048e69a7ce7ab1bc35550f07f7c2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/66dbb381f656aeefac2a6d656264f23e499e00ba6f05254cdadfbbd0199ea795.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/675ce68b42b8ba76f109a6ef9249bd90c750c18b2088b034e8288da1cac979b6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6815ab75d190954e6d19490c6980185561bfaea64cc2ea227149e2a6ed1d509b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/68d2881c319452721d336b8aadf67943ecc3814d7b2a3a31bf144c6ceeb5748d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/694904532eacceb0389fbcbce31d02207910e14e06166c2dd49e1b607ccd51e1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6b5e3837d390b0d3ba4ab082f6c2a83591d7bb20b1c621c9d56ec575934019fa.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6d09b7f7d703b8abfef183e157cc60df77a2cfd59bc33e843177b5ecc0456762.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6daf17a78ca79953c07dba2784695a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f48c827648d07a3667b7cb9d6be7bdc3693b501b1bdbc33d927ec20f68b4802.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f9af7086b3b96b3b929f6bebb718fcf0fe89f730639007b454c450238da4e8c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/70c53985af782f6eadfaa6da18f8c7c3756bfeb5683659ddcec9c20e34630325.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/722a3f28b7e87563ba80b4e948cd0683f12cd3a3d732cba567be5f465ade0031.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7263490a57b317c4383785078458a63cf076787fac4d6443d30a5d651f766a3c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/72e06faa3d05727d394823b2cc266d9c8fd0035735066e32684de896bdd6ba51.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7381e3390becc2937475260e76b0fb205714de3a9f9e635012ba6fe4c6f4d0da.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74b1eb5a7812a5e24db08f66a0ec19d43b24f917f52263e3977cca6a07875700.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74fa9d7e6535592124401a34c1595cc7e41d6601da66d670347f1e18a1362e3a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/751ad237e2ddacf804b1fcf0fec4ba6c65818416f5e05e9983ca7d1c82cf56d3.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76a5b8eec761364b7e838202d10f10c2d1349f3d0cfe0a7fc0ced38356f64f9e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76b4ea43c37a7b07d80ac638de5b6839c74a8fe0f7aef8b02989222bb0b65c6e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/79665fa282288bb4d0d74979ba991a179ba27c54a855f5ca8e052df61248b17f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/797f2fa9be3663837c4844a5136910f100c9038fbbf5b3ba4da529163d0c373a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b17d4a01d1b5e4b5047fd09f5b9cd7a34edef934e81251c843eedcc5a5c2b80.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b495c5b6637efac2e6b6d291181dedf4e9d3407e23bfdcacf6d4d0017ac612c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7bbbf4ea0188ff2e1977dcf6667bc8bfadf6bc82e837086cc3756eac41e6ac41.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7cd049534cc371d4d3a5e300353f241ca8b0c8fab6ed8c39dd028d80e42bb954.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7d25e909912fd4eda8f6d9cd79441a46b02ef360bd1e34b3b075cf6d75275010.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7e4483722c7538035f9d729f2cdb37cd276b7661c722ea1392378f5eb6ef9bb1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7f8e5fa6a8beec10da43463031b54aaee6b8bac65d26b4bad5de325b87ab3e83.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/80034974180b5d088d1173eb2edfb8bbdbd8aa807a63ff8aa2367e88e88c5859.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/810960299e37df778b8b38738d94bdb9a1b6ddec29ad53107e0aca445525044f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81601024e587ad4ccbb092622616a7449b0220737d33a5ed6cc7612f93c55c47.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81ffd3c1921b95e0ab6a3ea648204bed015675f8f85c8de68e8769bd8d7e79c6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/843ed7973606ff6618c2911320e422d5509729712a1f7a112c1ed452889eb8a6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/849fff466825150f84f0d75515fa9befa55492c0ff2a836576bb4eed28cbb42f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84ad48a9cab2c64fc6da9687e90318eeeefa657980771d847cb872cdfb801f51.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84efe92201ff6e0228d10104ca2bf549bed29b6512e3208b4c19fbf59e2d97c3.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85bd0cc26fbca8a854c73db12d5425d4f8aa89ac42405e5af202a04f68e3a7b4.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85c52c63fe0656944c59fb26d46702cfffbc0350b9d4c2138084e39099dffbaf.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8881d69156be2045a7d4bf371839f7590e7cb385f19340747f041663e6d119c5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8939b0f4e3e262644694d0637555d14d3c97f218a7bea77a635c306d73cac369.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8985912bc57fdddd12a6a0629d8271dd0f510370378aa45dd5894cf2e3cac104.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/89df39ff4c27acff065c1d58502c3e3f8c060a1ea2f3d6422204e71e2eb61687.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8d49d5a7bb9e0e8b369230f063c819f023a830324fe924d443ae3d509bb48c55.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da1ad9f45c64980e5ebc1a73f3c778df4cb2312070093227bc598d44b7035d2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da468a681539c9f3b2f2e336c35930205bbe4220918aaa20bb638deb3e87d28.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8dc7647ec230a67f2a3be117dcf54882b7ebc217ac63e0f4fd7eb9a947790b20.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8fa6b5e5061a75611ab69b71a7963514cecdccb7fced0b9fdd376dee38e97452.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/904c9b7253dd6c4c9c4115b804ea2661b0a0f04a2216145e5ec719c0c9ec2635.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9233ff65304ac6ebc67d2f9192f2d9cb02b3289bf591239131a2beab0bd909e2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9258498671071e7cfecc47c4dc7c623362e2b8334589db059641658db6ae779b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/92a3dcf20fbd73d6cc64a24aefde2468c1d6f835d3e84366eaff76a109a9de7e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/93ce31066a037900868ab5dcfeb62853150c587ae27f1b17695374488fb68857.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/953b5adc48bd5b60864af4a55adfd8339e78f487d4b7e3ac5c14e71694b71c4d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/968b14ff33c871e7656f44e4d1fccbc96a548904842a3d964aab0704496b06f5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/98af3582700f1deebe248e1e36259f156cd1315201010548bbea6b8d53f2ee7f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9af30c25beb573d02bcfc5a15e29f34811f1396019383deb95ef9c970e3b6ecf.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b270a2da44b49129749e862b68155f47a7b2097e9a2017ac5ea794ba64ebbb6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b5d6d06535bbda2cad79ece6b14d68e5fcd7b43f696ddd6a69caa8c75171d06.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9cf7d4e97283673cf06fc02db8b76dfe66aa61af99dd8cc6296f5d7e8aa616ea.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e3a00cfd2fbe588ca0e4382b4b25bd1a08c8a17c15f2400fcd9bfdb3571ea01.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e55ff7008104a1d7ab7bd9d0497e0b546c88af6aab8c62ac1945a924fea7731.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e9d5fe8aeaa0b804fdd59bc3a9c1dd7e1894019815760082c71ae4597e87dd2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9ee9c4b9361396d90a775e7034698e3fe5f17d9416fab3b35024ef6eae00b9eb.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9fc00f676d440ca8ce68c510a46ff79a0f87dc10b46ac4399e53f5f8f536f716.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a0605375810efdb61551d27732149dfa1cd462c3eb03418b1ae695f18ca2a4c6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a15a6285fc16a1e88b94f9efe840a227c3505b2e811992190ff37c689c538723.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a311b196030a210ac27edc0383a06164abebed68c5fe86ba75d7ccd1f83439e6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4317e02deb291f32358532c9605fc59e2afd6d40c8f4763d41a6acbc27db88c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4cd691ba4b3e5ddc599b5303cafae915c4730a08d8b325a3df9be8dca0def15.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a59e6d66207e21bda9af6cec947236d232215df2399a91ee83e307bc261f9e4a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a63f10f0db1adc9f555b633d83feb0ed6de45e36b3141eae8cf0078e46af8565.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a6f3e04c1b23c93c40cf7553422d48a974d5e60cf0b2a293f95ad981245190ff.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aa2efed09f81648f7218260dc6b76168e75e28357c57b75a5c8f4b2e73164480.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aaf41c9b5ba506e18ba1ab73276398a30801e6ebd02885e12944bdf0eedb25fc.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/abed5e43741f705d2b6e42abe8bc0eaea3d873f6cc4f8df19b59f40c3a0be2ad.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ac82d9fa974c255922bf29cfca4c560518c1c2e145a5c9bd690d6bf8052921fb.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/acc59811923252bc07f5b90d2d006895b4863295874e2ecc3d67620a21793cc3.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ada1fbf873d37be538d04d62aa92925ee4bf2fb917b0b39c5768bedf83138ffd.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae55cbd3694d60eaa600883c201945bad237fcaad0a9c35e96adb81f85d42359.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae8183ef725fca11f60c5d3b3ec047505373c734673e96112e570f0d58adc0ad.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aee92dfc4cbced6cbb5db32c1bb80a7d3cd4e1576d268fde522d93e15bc26f18.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef16fd4c00f6758c3a632e1e1bd2557829e04c03441d2563169b3be99e1e4ee.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef819bb8ffa5f04af46467ed4de04a283b60c5c62e310874fd19a89abcbf078.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b03ada6aca801b4466bffc0b996bc7fcadfd13cc3172f96d4760dee34e2737dd.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b0867788fb85f4d84c3e0e90bf0d5f9432ba92e93a03f678dafbc4a4b0f3fb3f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b19149e12ee8cc016b900bfd626927864d428b950a5b903d83bfc00e08bfb7be.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1cc31cf7c96f025d523f115ec69f70ca16063d0855349518de5dffee7a9c033.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1e5fe0faa5605e6186d04c3618f48e6dff6d1ae50fe4832330eca0c2ebef913.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1f568e9e8cc04815be9e2086b34c0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b2a6946d892bf8a0f15dbe3ebac7ed41053454581aa7e626c2c89394b7a150a2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b3824836d5e0e3213efe069016a5cb1c3740fa80267090c595280f0e141b1d40.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38ae9dd2e02544fac2d2f16c379d43c813897e0d1a6265ac8bcccb1abbb2b5a.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38f5ac8a354528bf7849f0d436f2ea7c74eecbdecea11fb4f88b5dc0d693433.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b47ffc2aa43cf43c9787166f0abf486069f663244ef71334f4530fca16f31e14.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7079dbad7ddbf492932972e73b860b51df5f2971cbb176b1fc246ecc51451fe.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7af208b4ba7a033bc4fa94cae80cc2c6c75b7d7f1a11e353fd5f2a9d299a59e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9225538e7a736ee5d7415380154496ee8741a528e052dbe307d4cf6d03c4e8b.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b950bcebf7ddd53748505fdb4d9d6a857edf519f7d191e697eab95d79890dd87.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9644fc799b3504a8845835638e29700b08e0b1b83f6f0861ca70ec22a6f9379.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bb9183a1575e5d423129bf70d496ea6fbb356e0d9be056fe89de9ecca32f05b1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bca376a532c9cb05a7abc816eef2e44ab5c92e2be7770593d92b7a8ca8862193.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcddd8d7a6e9fa0051e4a791689a04d434e1567b61dd363d3b2bf845ba73af7d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcffaa56010ce6b9ef7960943ca455d04d17b1bbc1680618b2805ebb3a483117.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/be2f232722c8aeb2da8d57e82cc308f805b3eec7c97962a8546e5bfe3c91269d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf012cbc99025221bd64a40ecdffeb67b7a225eecfa1083cf94c83ee6dd5c55c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf0ec2be49569dd1ee470fc4aa8a8e8d187b7f5ecbea3036ca6edfb6eaa7e171.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf3c74074f632a14c344c2e7a2b4e010ef8171c4ab1cfe25f5c1dd958d50a0e9.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c17b71c2d1c9a64b48bd5f0e65ac91f78ae9a92432735bc1f2cb420af4814d06.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3670ed264cb7c6762d741b685d4a955c34f812692c81423ea852e2c5dda0920.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3973fb227903bf53858772876c7eabe65c00cb8bffa2506dcd52782d6b56e0e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3a135a7bffe2c0fa864a552f511c4e8cfe4607c0a406eac91367303b157a708.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3e8638a33ec726581fe1f60597733bffe42c802ca50b84b8b7f4bfb21a8c8dc.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c4518486119ca165bd47e47eebf70db8cb66d9e844e43bb701bc563b14843179.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c584098d8bd3389fe9e89fcb5b4372c136bb901d9b7a2c37a0369c32d4a5417f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c6e1c9c53b43d18715182db67d89ab8c7dc60dd06adf9368d071be0a3c2a2790.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c75ed03ed75758326a4b5c3c002aa811572a97ef3d53e5f74e84f185930b7eea.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c8619f33df98a891b8af93899fcda927504f89562ccfc7b1d0eac71cc644fae1.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c891d5f71a2c80d9adf5dd0f06916904d9403646a4a39388444304756aaeb96c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c9eb514a847f5a0364eeb149efa5e1a41307f01bd7be056b968f0402f3883918.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cce9bb5694aa1d060ef31bcf0fc8a86da6e1d8029cc7e2a5319b427f31bd0565.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfa22a7f78e0046d0ae446854293eea480cf3b5ed2679d591b99d75da7347bf6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfdbf41ae122697978b6424b86658d701f7c664d931c1a50fa5cd149b0e6c003.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d138eb9ae0da1d4f403abf4b292c612079b036090afc91a3b3da73ed7c3ff90c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d1bf25181eb1343fe3d2c48cd7ff20d605bb13ceee265ca177e1107b87bc5175.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d53c752587ce1286de28d4656f393a624cefdffee157e524ddf022dede6888ba.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d66655ebf390860cfa6222cdb35a0d9929039f8d5498871affe5c6a00705c152.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d77a99b2e7350aee723d5181f2046475b7ccd2e6f278e258d1a9c874a5d75822.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7840849fd8327bb9a305f7708f08929872d3fe31a789c8dadff52e276499ae0.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7996dc5368d97a754564cf6eff3cf8c9f07fb57ecb44c4ae87fd88bc91befd5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7f7374d14840e482b1a7f4fb4e9185e8a738d931615d21a5a985a7d5f47b9f5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/daa932cc51bd3390e17db280faf1b88966c6bebb72eba62964cc15632eebf6a6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/deb514a0e5cd5efb1dc0b97f48658cfd0e5ea67c4d18abf3546bfd439dbabafe.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/df88da47ceff7b4fcb8336bbfb3062c3bdade41a721ae3398def25f3b62644f5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e14bda1726b70f3dfff599d0e584356e8c5bf9e4ae7236ab82d42137fcc13732.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e152cc29e43dcab0cade60f82d040f00ddd840429a628d678b974093add045d6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e166b72f65fc9beed44105e9921f0717e60fba2368b9223fd5bf5208ad73cd1c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e4057e840c1c735ab8a04a2bc19e27e1e7fb3100f9d3311cf257e961b8cc9180.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e42e7554c8d4fa1ba09af2eb501fe5db726d274558343d52181c53c6293280d0.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e530215828585c8b6096416df5b9c6e5418bbbca5e173b6832ae06ab5cde9c8e.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e60d2275924c8653218639b9ff6f9fbf9bb66931d64d0d83891954e0e09fe5d5.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e68073e26267c23a56e7b2a36443b1ef05e3cce496a048e338d90ddbf8262784.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6b133b580f3ec4dce18a252f6253f2f75a0b5e997318cb417fe01f4c0dd0ded.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e0a6d63839ed6c44194936e1354fcab570887ddc2abcfe3d8f244e9202d19f.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e64cb168b4524bf0335686f4417f268f5a83d5301f3b44e0d6011bfe48c2e9.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e7aede64028e5ba0465f4ff125dd3d8033eb58f925f2b4b7d0643cd00e30f853.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e8738d4236d6e49c159537af9d06f5c917d2f57be87d69d43b01d85467df96b9.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e91b796163811741651a66d0129f09fdd09b6fda769dfeb82fd499a0a49454fd.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ea3e70d7ec2a61e9202b51fcca0702e266eb9b6a55c4f29b55d5a3ab7b0796f2.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee006f08adc2d3a798496046f907efaa355da9e1dd6c2e54109168faad090be6.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee7793d345da1fd98b5e0bb7ca1f7f4deaea210bd94b3679ebe88a5248d8978c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f0578cba6901e2540bd1d848d9976a7734c907740018aaf3d6febf7c2eb432ac.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f22c36e0cb294e0bb2134045cb414d397971b9e656c0daddd446fdd3ce22efa8.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f3a1b7ebadf4fa0f9c7077119f454e00c2506594fe28b5873913d3b14bf3af4d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f844d59f635a4247184a402724320c7d24fc5f003b4089e148a37c8667cbc024.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8650367cac798c2a15f90edaff1a9465c48e0274fe50dc34cf6f684ba093f99.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b77f2ce84c6a0cea6a26f29c3db0beb37ea2f0e51f71b6536403a3315b1e90.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b8607bb3f6084a75091c2480c40dd22e4c4b92995467693c3757dce7af2f62.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8d9630d425b7c9af813a75d89ce55ceed202516f51a794cd183ecedbfc47873.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8ee4e06d9abc27d152a208e25298f8b9330d99a5b674993c44dd774841e6aba.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f9218d4ded1d2ba6408997d6b7dccdc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fa59f44a6f83dc7d6f67b36d9f49c54e07940b32dd19cce4bd4d91e93e910bdb.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fc814ec5d77a4b5ccf903dd5aab9ad9c20dcd6597c7d33925bd9fa018f5c5ccc.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fdf3aa99c8eb82c563fa0f23f229997e426c6a39c2af0df494af69e7e362911c.source create mode 100644 packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ff721394b3de3f5ca2fd0d0278c85bb37fbbab581f93a22d38475578b45cc402.source create mode 100644 packages/storage/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/007c21b28483e994b03d4ca42651ac030cd231dc0b21a8a256f7cefced27706a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0128b2d6d8d06bff61a294effe7e0001382003e69719916b428af458f1c30bf4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0145f9e818ab7d8a1c7e0af2cacfe38a920672726d8990cc9c0219a44e72d9c1.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/01f029a27a5ecff535e702e5fe631911b0da4c6438d06017115ca0ecd743fc9a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/04e90e010c6bf0704d0ad0afcb0c2c6d179c63dadc7ec470426f3e8a757bcc25.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/061c423107b01244af2001da955c56d4a12b55010a066c5a210ed97e19417667.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0644ff5f46a9b1259034ec0f7bb1cd1faaf4d68d608fb1004cc1fc556140c586.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/076f407ce17b1a472d4af8e2ebba13ea61d2538fe13814d47819284be2e4ea8a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/08665a01dcfce23afceada9ccb744769ae6fb077f9b6bd1146747a034cc40ac9.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/087c9ec376ffc7e04a7fa2669d6a810a1a06989cc04ec1eb32e8213d4789d0a6.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0ba74e65bc041ec5d40ee134af6f4bfdf689b0bf4f7bc84b7495e5501426540b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0d0371a169779eff8fd03c48c53f454a18a1b5dbbb0d451989fbef4d670fd5f9.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0fe53240f934e53c9c89a8765dc9b92aa09b05cc3c5023eba7058d744cd8938a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/14a4a5dc3195f39bb397f93dd56e988cbfc67095d24ad2dad261f3e33adbb89d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/176c9c46302904c5d7bf0b61ad4b67f116f28c6bb55e190c8cb224a883a4aaad.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1a55f0d83d02e7afdcd6510ebd84f36fdb36e8682ad6037dec2980d9f0362d40.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1e48870458eea460e58dd6cad2a55194dc3e4381d0c31875cf5334e8b6711fcb.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1e5cc75ca867b297dc691d9eea80a96d4d06ec8c50e5426bae5a0a11c9af2b0b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2095a48aefbb0e500af735c89e977e2f141be36bd5e7127000cb72318315c13b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/20ecb828a3db71edd01e1b7f0e24c6e0bf7478f1078d66509f93aa834c3699f6.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/211bb033357d7d014a7292d4e4cdf1049abb349719f6c241ac9ee68ace5f2bad.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/231fc80237755e05a7cf759c063c6664b56b20ebbd9c2a32cdf05a469d4ba9cd.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2504a624802873c022abaf630a748004a5cfd43f2b9aaab78d6259c677749f59.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/254f88036c67c177f9290f5112505d4d29061fd86d3c0b7e85d8a5fc294c9668.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/25e8e6777a4f5d16f278c0897658aea085bbc4a62ea59f7b005a86585aa35f64.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/26268a09bfd55c524a462853e5dfc0284de801caed488e15549bd9ed97729489.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2672d57c088c7e2b50e8ea796b0a468053da90de72112615122298db001545b0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2766473a6a84f835580b8b1da07ad78b00dc10dc5510d051500c0a1c6909a596.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/28c41e7337f4f6b312d746b60549012f367ff4b6dc4f95f522ccbe2b0f287551.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/29ea9fbb5cd1c5af9e6688e629e108aa02326779fbb4b512c27ff5ff6c13b622.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2a46d13b979cb083654c4e80ca4ce8d905fc49cb148463dbbfc5ee49188651e8.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2abb26767a8e043226b8dd40348e3d9af676edb8f3630f833de039e3b5e971f1.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ad5ab6567176e491769da4ccfc07769a8d32d2ad572031ebb9876a561e4a524.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2c6a406d6db9d8b16a06607b75647989f96699971978bb0bf5a2d1de30b63ae0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d796f382af17681fb172aedba663ab5fcaf203a392d2fb88d3f53a1bb46579b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d98ef2486c54c77f8a58d27babbf5d1cb1ea7fdfc469835ed1e950e07420cdd.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2de7ad4c6cd4c988d31c720489eb39122f6d4b3b650d6edd3b94ace8fcd06573.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ee00c94498c9000ac0dc2a314c192f9ac58e39283448676e34a76f8bdfbcbb4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f97521922af19df74c207a024db9680511bf6e15c652024f0c044d744ceea4b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f9a99cf9d8d3ef0b5df137ce8e8da009ac5ef61ccf3a4634f3147b2ed5ea7c3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/30e094816254aa5acad651adc1663e96a9202b0ac946119824d6fec93d535464.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3105028fa6b54262aae3beef4d69754276fe8b5bef433ca05fc9cfa880615f8b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/321718db5944d88b6e488c958d7c33c45bfbc2dc9317df8ffe560d91c570cc53.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3301ba29eed142cbbdd4becc79e59a92a78125de891cb9cdca1e471de05b9178.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/33bb39698ab0a01dbfff0c0db60980cdf7f3eb7dec8d0bdbc0e3be0aae9db3ac.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/357aa4084e3af998dfb5733d335e2869c5d4efa057cc49a454fa3fc8102afba2.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/36c4a8f0f641bdf78d01362f41b7693d603a8ad93cfb0a92d6b74038249a0cb3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/37beba220b29585a931a0fadd5ba3f552d80bc0ee1a6c561f53874fb065d7418.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/39b2554fd18da165b59a6351b1aafff3714e2a80c1435f2de9706355b4d32351.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3a10b83080d835a773bd42485b377323d5d6211d1245aee44e879962d06426bc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3a39101412d6f99f0967be17ad9fa1ef00c7b6bbeac11f5cdc9be6356e4c818b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3acb76b01223d5715ef99718cb403644467383150a89b75c9b83c5a0c5d2c1c2.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3be427edb668a3f3d5d3ed0c50690b768b83da3d1f31a93c7be819384cfc154b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3c5ff78a43a06cf90619fc7d7c5d989bab9b38ef2ae2649cd8f1504a720a0451.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3c616be935d4f3f12995ccdc0d101840c0bb76665a29c61a090a71ac9be6b6b6.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3d95ed7c2da1553ba490707929a3d7754b3c9e4f4f6f7ab3b3a34ec8e6c74e06.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3dfe2fef8aada3e9674c29ce3a9575449d9f6e13d7e647f0aea570e493fc34a7.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3e34c9cf0cc114439ad7be1efc315e3e14f197b1c0584b8e49dec58033505d93.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3e79518a49bbab8a62a7aced604f5c3c8c4d591591a684870fcab820de2568d5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3ef0ceebd1669a489f7c153abcd844bffa1086660dda57f6116920f9de6beb12.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/414ef07b0f2e589f53ccae1ab1fc0ff75bf0fd3a4b97fd71505eec7d60255300.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/431523aebfa975dabf85eaa5ff46f7a87591af584883ff3a3ab9c00a13769adb.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/434d3ab459cd01e9edeca89d0b3e35a2551d7a0c6c28c0dbab8fd5b8c51a3de5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4351da938474bb5a36db7ec0500d635e69724cbbf6f1266c96201ca82f82e5a6.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/44290be90699b43a3786b991208e4b0b6d76e67c5e11de28370d25af0c1f6dc7.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/44a1a092ae0a0311ac7f801347360a87ccfe1a844185f2f9344ed74d358f8aed.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/45941b0e486f19280157eedad78c065d41c8f412c2ed7dde6e5990ffb107608c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/464a234d3e3f9533aca2df575ee56e19b47e20c814ac8445fe9e21fccbaf8493.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/46c8d0663a9684081c9b4e6db8102c5b1320cdfd77a10e5157c0de8f87e44034.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/49395f0ec06742b99993459f1533964b8a7fc79065cdbdcaab01af93187cd380.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/49f8f2f0bd15b22829b950660e84c02e91779083ac7993d83bb4ba2eb2ec847a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4ab0c504c3d304675179bb67eec31b73ab29ae70bf44f536a802c18a501878dc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4d1f14d1be3e169c49bfb462c57541d9b12e4805d0986792c6c85f717abaf4ca.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4e253c36cc21052cde2f7615a39013f4351b6c2aa575606b71b71072bd2f0be5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4e5eeacbb2d8d06700dcfb6bc4a58024878c4153ee0b9b2e862325fb32dae11f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/4e9befaa89762616da0342329c1671c4fff0aa461b0aa03dd78be2c4268bfaec.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/533f89873bef9b8063cab2bf63a41054ea1b8a189ca80daba9e1944154c49bf0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/55b45d36631ba8877ad43e6861fa0ee8bcb52d8e8ff544fb0c382b6263d03dab.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/56440de6917f116c5cba82a4f9473b72300773623cd994d36ecd3901dfb275c0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/57fe83306af58aa844fd8ca628249a6134868d7181049a9a0db5caf452a8ab90.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5870ee1cdbf586cd8edf6ba64f3d56dfcd7e814d5e42f3784c86130c6cff42bd.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/59764e66009353dccba1b0a674f8ec64ef36e9592ad2884dd8dfdcef68654dcc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/59eb1da0182a9a2e2e6fcabf79de565bfb83f06d940bf347df1dc0cbc86f0d3b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5a0583601dc229297b01c61ba811ccf3e530b23aa8362a3d692c744b4630551f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5a1be6ef89c623bd396753dc64dbdeaf7b17f40feb93ad49a9307d4d86d341b7.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5b758cf1e9a3fd3b32b1e6876b928cd6958a9bec03b71f2e31f51ca835725163.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5db3c137120b4a1416254d8d8d015ae15ea7b56992e6058c82c30e129537089c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5f2f789653bbdec73ce2f209462ed48de335e40184a0bfd7ee267fd1bbbe1630.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/5f917773c840854734ed2dae7713f3153fb6bcd0664972dae6508a92be755ddc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/609ab104af15889da21c65f68c7e3db9d5c119ff844a7b32e97affb08f321f7b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/64764677f1e7c0d4d117cb50897ccef48404bb93d4e4994a0beb823ba0a3325f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/64f247c85840090db9268201991231ccb27c37d1095474e20dd275b727765052.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/65d07d21860152fadbf7db78a1237f485ceb21d615eab9e4c5862e20e1404437.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/68f95c1f464896050ddd454486060d371f1329c4b03a8c92beeca36c71b293da.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/699aea40118202b23a22b18057100494b0ed4cf4b6619e88a17b040a7d19c27e.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/6a22720d387f79b871edb31e684ec75e9bfb8b18d41bcb676bfa6421ed5d7d4e.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/6b9e12ab5f12072b7c2468b2a7f6e6888e9d2b1facf50d82b5c502b31d7bd019.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/6ba7e62b7e76441f3a48c60a175e0a8aad2814abeda23c83ac45b1d603c801dc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/6d43d48005ac7cc823af3a5c500b720adb715ef88779894e992e587c6e4969c0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/70a4af0c331feae2236fcf58b52e3936864b3f0c64d51b91ccca7f3fecac4f6a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/722c1149ffbb353f77428ae4a4fbb6ee72f93dc9a23fe7a8e5c3e2651bafb31d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7232ae8d927d102b8f07a69d19a329c820bd8bf3e2ce90940a9981631f661e4c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/742ff062a5d47232e235711d26d8a2dd523ee3a5d805ef8d21f26d6a9027a8e3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/79267b61848f7ba5a94f8bb40704557df68666ec0431adb470a6f058c3f4d285.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7959e1d235fee8699c5de435624b7897d595a652bfea699fdc0c7bfe7d1c6c12.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7b0214bba99065421d5dc7d735982f891989c1ea05767b8c5a1dd5a39d7700a2.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7c6b229569bc812e3d9dacea06179678341853698faf10b0a59f4d4caad4a906.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7c90d528149130dd9061cd4335cc54c0bcac00067c8dc35db26b03da17154b5b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7e3b806790981f9cbed1a33340ba668b1505812834457ac1ae3136e5577bbbab.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7e4430f205d8caa9eef27e059a860a1ce0bd2d57c96b0f24b5e12a7e4cd10932.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7f0c6c74c4cf2fe1ed7af207880bc74b2f9d2ea506dfdec6515013fc0c5bc51b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/7fa68c5f9ab584c1955d732e254438e5874425812a032a4e6e068b379d324b30.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/80047ceceb0c736ecbac87433b6b35fd4ec7888cb7c3b38bed5636f196708942.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/823b121b5da1b4b23a11a16a3e25d5a879bc4494c762d180bea309ea92ac8da1.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/82b7f4cfd2f084bd0ee593691cd40f281bd61d79564048628309750aa20c2ae4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/82d7b91ed12a4d206a00988ce59168d6691e2da3166d7d3c647a7ac36e5677b4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/83f10b088bfa8e361d172fc7d37a480e348fe5d9e4bf6a8aae5ea622667029aa.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/84108f3d2663c67f6a73d06999fa8de19e8106be76dea2b532c8426d6617a9ad.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/8632f96813655723443f8fe6772e06cf533a338408ad4589f298bed888cfa590.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/898bdfbee2f582bf086836510734eaaf05fefb600baf8d6218c53737f832536c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/89dc9ddf70c58e56991649d0c5eeb229bbec7138d2c691cb86677923d683020c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/8af7ffcd5e687c2e89a67b8662eab0241ccda44df07a813d591faa94c2b79429.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/8bf2bed74ad622a14eddf9a2483d87a41592d97fb515113f937fe00d18299caf.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/8f80f83f8f7af6bb70af8d11bc59f2ba86c4a270efc7fb8f82351d510f03fd6e.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/8f825cf0cfe5557ae13cff398fc5205d6af2ca9cda1dc0171a88161e381fe1f3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9023168f7dcdc22aa7b27492d8aa7c2a5f14cc909ad5af5ca1be24b526c11e14.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/962946b9cbaa6c0ee15eb7fceae4ff013eaf87495d3cf2fad811d92b0bcd2603.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/970528b268c6e900d9dcde3709c92a48f1397da005dc424fb13f4c7fe325f844.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9b14c5facae795eab20d894d2f4a7056553098aa66934b6c56923e0f3903f3d4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9b30652b48e33d5c002e3000f9b2f790592bea7f7d10b6ee4aefcf799ce16a23.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9c59809157e2c4ec0c0181a086ac832f9190a471c76a0de39d680e7703a52395.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9d08de23603be7836d5d00649f5b25bd3dcf53c2241d0244e537e15ec0aad086.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/9e9d828bf2383767270af404ce6d8b4472455622d6aa6ceda46e60a7b2b837fc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a080765289338ff46cc09f933250cc165bf67b22affe6410181f5494578c3707.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a0ad159b89abbebb6e8d3ae7e5665ab384793df9a893f42056b2373a28f9cc77.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a1ad5f2bdcca0273db4be0363e75c6032edc5f062dfe527c5d69e311da4164f3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a2a171449d862fe29692ce031981047d7ab755ae7f84c707aef80701b3ea0c80.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a37955f1aac547faccb8a529d0a4278c8ae63bb31d9dbfadadfd968566cad173.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a3a971f68b4a87d5fa707b9d15e3d43305a55b2fa5dd61e35454d2b3bb8d7c4c.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a453fdcbe4859022afd9e58e1fd877e86d5bb6bff0424e76a9c1093d0877d534.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a541e882e0be575d9fedee5e9b94565322caa5b8ce90b946601a1253fa4f5a66.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a55ae0879adfc4dbbb78e3bfe463f9be74e3fe220c437b3f7eeabb07d5bef7f8.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a748583a3dd4fc48cf08656ca33e7e79157fce0a138a1355aba3ff438f365e2d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a7f1b08ea58ebb6d8c94326418212429edeb674106571f16bb21aec463e94004.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a8ead6ad981ee72f6d4603744376c12b6ac89ee9718c1c1e6e2765e9a7073be8.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/a96ef4bf9cc07a257e313fe540fd5b67a1bf87e6a94e54b13eb1e19173939e20.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/adf4c7040f0aeedef56ec75cf5233bc5948df1eca007ca1abd566ca624529680.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/ae1ba9256604e39272783a1d0c3873183aafd33c704537ec67e08bb3a68479c5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b09ab44820f7d9c2e853d3fc4bc9a9a9ccd10dfe24ef2aede5636c56d32e461b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b2df50e592cbf150a6e56b2249b6caad45bc0c966427974f19ded212a1dc4202.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b30a51cad9ba3024d9b3a871f1ef7adcf3ed51cb4f3e692d2804976845edcea4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b50566747c11356438372319017a2afe9612dcaf07813c05179d6edaba79e164.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b9063ca356f737a9b6050fb081537e3792d30820e5aa694768b520abef31692a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/b97a6362b4752b298dc005bb052bc1dc524b14620a6767844a4dd7dcc64fee86.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/bb61cbc9a4e75427e87c7880cf7e26e3b42eaf22b8c1bc31e46e11ac810e7d02.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/bb74c72f940e3951db31044cbd9a68f65a1e8127fdec324e4e7d857ea2c763a5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/bdae1710ce7248aa91df38fcc3af0e3eb616fa26381c3a07ea2535c22803f557.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/be4d672e1c65b65609068dbead52cb6ec98209826aeafd3eaa99aa6b92d0a8ba.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/be9f1f26d7093ff1864d3104d5621b37c26dcb9e785f4aa5ebd32560fef33cd2.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/bef2e89ceb3cd3b2e6c16029007655b1e4427093196809aea45720179fe7bd53.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/bf05ebabb004e92e6705215004010ffac91a3ebcdd0996120377af3f0d0370eb.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c33129eb661919927fd8cfc5f0c1d6e1fcad7088d8662578b08c8a953a73c7cf.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c3787ace59a3b3dca6b02e03ed833cafe42c22a2bc05aad35550a61baaa31dcc.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c3b74e56b0d1d2b833b728e08a094b3a1365b901f85c5bc9dd7ba16b68a4460f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c3d6f84d30fa3c528a80c23e31e8c189972625a61c569dbb7867005fb60630d0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c4b2a97e750787b89f9b11a990161f4df4bdb523641dc44b474bfb0a6fb498b8.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c6d3c2946804f416d7cd9ac74c286569b5a1b08706e85f9b9b64dcea19a378c5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c827d9b7de7da74cdcf6dd1d889c052635a0279f1d7e2235e07ff982170429e6.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/c9b4ce63a17285de4c6085fbf54b07032cc63c1760dc359427942e83d6aed4ab.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/cab9a437516e8648f1a71bf97cc42af89ef506575a48f3e5a56fddd01ed53758.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/cc0d740ebea524374635fa93bf584f5523e9d0198007690646102ba6094a7d3a.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/cd4098b0e242fa37016d81715b91239638ac51530940e664cff7dcda0a7a5bd5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d0719f6b478dc86d325c9fa305f503dbaf34a9b1905e51745e135681025d196b.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d08abe10beabfbc7ffdec1f93548b3dd840dea6379ec152c9cf4d52c7bbac89d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d0ba7d9936b37bc0c5e188cb5c8222167ce34b50702758d1572509e3a8862074.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d0c62b90784a4efa560874127c229a6770c70ef1a3469a69c60d8976510af51f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d117c2cf4fd40246dd8e52cef889be4a1e58fb6ff1fee6037bb4d792f1906945.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d1fdd46ab730db285659ef38946c0809a6eb2ee08a1c9bb0e7c7af9a38e659f5.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d4d723ee5eb082eb079d5ac8ca8018904fa84826e81cfe87e444e2642484881d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d670aaff43d68d3336227b4426a45c859fdab8d446795bee46cebcdd2da82666.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d86562e2b815af62ef78d8c62efed04a0576e4b0fde4c2d979dc4901f4920602.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d974e2b79c8706a9c39c03d86f34c17e19488c820fa53aa54214edd9bc43e5b4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/d98f11bf76b0d51deb81218d92ed965510911343d11673c5e32071ad3904b12f.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/da0f6abce04b8a8fd2ed1e189a3750bd9bf19ba3eecceda90ff238f2b6b3fceb.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/dad44505b0ad8d7f0b8bcae1171b65403afb0d16cc66b7896de19ca749540116.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/db2165696c1129c6f50de4cd8837cca16554ed415db3936b8062f52539320db0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/dc03536b9a17870fb5549d10b987b451c1197c1076aa14deb2af9e55b1a0b7a4.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/dc45a8fcb0d8182bc77feaa81d01e5eb63cc894107bb4f5ebee0ac54b6bd7476.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/dd624358dc9da41c2d02661dd9c54236dff64f9e7cbfed044b7d098622451631.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/ddf80af14e7264a801dc72edadd351173e2f0ff615b0877154d19b352935e594.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/ddff1886cc3d33e549aa300942f11113db6fccf6a4e6c4e57cc7cfa222bb7414.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/df7999c845ea561af7ed67a8ae7e4b41afe5c4ea16faa8c4e33510437e464fef.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/dfdbb9e4c91b43563d9aaa67da42a16ded38d45d8028d1e9d65f31ef7896e2be.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/e1583889b109008b59c32c6fd3f2c5c1fc878f97bd3ef4329544fe2f18dbf145.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/e4f2cff83264e9bf94e3579e13ceb6dfb2604fb70dabdaef272da492a8b39dc7.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/e51b5d4a774624df3d83027ebbead7d0f320a331ec780682e174e21e39793b84.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/e546ff8e790f6ca4502db01411bfe487022617f045a43d01ed606d73e49a5243.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/eac44c43a0e9dee036bbe7ccea1b89131f68363c0f1c4f9a92b7bec8f8d853a0.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/ec14d3c6b3f0f5fd3a34f9080e8f51affe69332a8b1050f3968d2841600445a9.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/efb7bc25dc1305315922c76e4fa5ceac721bddabe213df72867ee999818f6556.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/eff429d7e81f80b19ff4e545edc7601bad157e9977f1c5246c352997558dfa79.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f2a42c4a13889cefa238749c51bebd51bd08987c563799755a10553cda2239e9.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f36a4af36e158b74bbbbfb8f4563802df692f28bee4a0145305beb15b5d91670.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f36b6fbecfeb2e7560ac62c9cd6efb7c654c60f785d25dfb6532d7c55159e062.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f8a60550cdf3bba617e077e9d1c027bd2a28c2e535e903f41abf4b0eb224e527.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f91590dfa560c4238b66fced36dfecedb70d973766913e2b4ddce47787a07675.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/f9cbb7b49ac7b8b5b270440636df9f1a1f81647b6ce2e6d01783c728d687b51d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/faafa146cf5a2b1e9b98c0c935e6f3c112c47e64bc5df41d4bb97c7df5301e0d.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/fbf2e023543619fc51ff3cf72dd3e927e51480dce611d3362b25ad3e1508d8f3.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/fc96c3fed8e1ff9615e57fc0e110df0242bfc411ce3a1e8a6dab4447dd113ce8.source create mode 100644 packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/ffbd42d12e44d92bf47b20e8da4dd17d23f2ba95e3d534403d932d6dcbbc7716.source diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5e8182eb33..3111f87789 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2200,6 +2200,12 @@ function AppShellContent({ const canStageComposerContext = activeId !== undefined || taskEntry.selectors.target !== undefined; + // #4804: attachment-only sends are opt-in per host surface, and the Desktop + // host now admits them. The pickers share the same edit-mode condition. + const allowAttachmentOnlySend = canStageComposerContext; + const contextPickEnabled = + canStageComposerContext && + !(revisionDraft && activeId === revisionDraft.draftSessionId); const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; const activeTranscriptReadingAnchor = activeId @@ -2582,22 +2588,13 @@ function AppShellContent({ } slashCommands={desktopSlashCommands} pendingAttachments={pendingAttachments} + allowAttachmentOnlySend={allowAttachmentOnlySend} onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} onRemoveQuote={removeQuote} onPasteAsQuote={canStageComposerContext ? addQuote : undefined} - onPickAttachments={ - !canStageComposerContext || - (revisionDraft && activeId === revisionDraft.draftSessionId) - ? undefined - : pickAttachments - } - onAttachFilePaths={ - !canStageComposerContext || - (revisionDraft && activeId === revisionDraft.draftSessionId) - ? undefined - : attachFilePaths - } + onPickAttachments={contextPickEnabled ? pickAttachments : undefined} + onAttachFilePaths={contextPickEnabled ? attachFilePaths : undefined} modelLabel={activeModelLabel ?? newChatModelLabel} activeSession={activeSessionForView} activeModelConnectionId={activeSessionForModelControls?.llmConnectionId} diff --git a/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..a4da22c0ea --- /dev/null +++ b/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":["E:\\guahub\\gh\\fork\\maka\\packages\\cli\\src\\__tests__\\runtime-host-session-driver.test.ts"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtuclrlh-33720-9eae27576d","createdAt":"2026-09-09T17:04:14.069Z","files":{"src/__tests__/runtime-host-session-driver.test.ts":{"existed":true,"snapshot":"8adbda775e4d584bfc2ba86d00a8308b32e1ecad4a41e4e4599f743dfd12f2e4.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../ui/src/__tests__/chat-turn-answer-identity.test.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../core/src/events.ts","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../core/src/__tests__/runtime-event.test.ts","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../ui/src/chat-turn.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../ui/src/chat-turn.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../ui/src/__tests__/chat-turn-answer-identity.test.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../ui/src/__tests__/chat-turn-answer-identity.test.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../apps/desktop/src/renderer/app-shell.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../apps/desktop/src/renderer/app-shell.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../../apps/desktop/src/renderer/app-shell.tsx","reason":"file is outside project"},{"stage":"baseline-snapshot","target":"../runtime/src/__tests__/ai-sdk-backend.test.ts","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-11T13:06:05.288Z"} \ No newline at end of file diff --git a/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtuclrlh-33720-9eae27576d.baseline/8adbda775e4d584bfc2ba86d00a8308b32e1ecad4a41e4e4599f743dfd12f2e4.source b/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtuclrlh-33720-9eae27576d.baseline/8adbda775e4d584bfc2ba86d00a8308b32e1ecad4a41e4e4599f743dfd12f2e4.source new file mode 100644 index 0000000000..3b1d431d04 --- /dev/null +++ b/packages/cli/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtuclrlh-33720-9eae27576d.baseline/8adbda775e4d584bfc2ba86d00a8308b32e1ecad4a41e4e4599f743dfd12f2e4.source @@ -0,0 +1,4127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { deferred } from '@maka/core/test-only/async-primitives'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { describe, test } from 'node:test'; +import type { StoredMessage } from '@maka/core/session'; +import type { ShellRunUpdate } from '@maka/core/events'; +import type { + DirectRequestOperationKey, + RuntimeHostSessionSubscription, +} from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, + RuntimeHostSubscriptionError, +} from '@maka/runtime-host/client'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type GoalProjection, + type InteractionPendingSnapshot, + type OperationInput, + type OperationOutput, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SessionUpdateResult, + type SubscriptionFrame, +} from '@maka/runtime-host/protocol'; +import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; +import { + createRuntimeHostMakaSessionDriver, + type RuntimeHostMakaSessionDriverInput, +} from '../runtime-host-session-driver.js'; +import type { + MakaAttachedSessionTurn, + MakaSideConversationParentStatus, +} from '../session-driver.js'; +import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; +import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; + +describe('Runtime Host Maka Session driver', () => { + test('maps authoritative Catalog activity into Session summaries', () => { + assert.equal( + projectSessionCatalogSummary(sessionProjection({ activityAt: 42 })).activityAt, + 42, + ); + }); + + test('maps authoritative live Turn ids into Session summaries', () => { + assert.deepEqual( + projectSessionCatalogSummary( + sessionProjection({ + status: 'running', + liveRunState: { schemaVersion: 1, runningTurnIds: ['turn-1', 'turn-2'] }, + }), + ).runningTurnIds, + ['turn-1', 'turn-2'], + ); + const knownEmpty = projectSessionCatalogSummary( + sessionProjection({ liveRunState: { schemaVersion: 1, runningTurnIds: [] } }), + ); + assert.equal(Object.hasOwn(knownEmpty, 'runningTurnIds'), true); + assert.deepEqual(knownEmpty.runningTurnIds, []); + assert.equal( + Object.hasOwn(projectSessionCatalogSummary(sessionProjection()), 'runningTurnIds'), + false, + ); + }); + + test('queries the attached Session Todo projection without storing history', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.todoQuery = { + sessionId: 'session-id', + items: [ + { content: 'keep sk-1234567890abcdef visible', status: 'in_progress' }, + ], + }; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + const queried = await driver.queryTodo!('session-id'); + assert.deepEqual(queried, { + sessionId: 'session-id', + items: [{ content: 'keep visible', status: 'in_progress' }], + }); + assert.deepEqual( + connection.requests.filter(({ operation }) => operation === 'session.todo.query'), + [{ operation: 'session.todo.query', input: { sessionId: 'session-id' } }], + ); + await assert.rejects(driver.queryTodo!('other-session'), /non-current Session/); + + connection.todoQuery = { sessionId: 'other-session', items: [] }; + await assert.rejects(driver.queryTodo!('session-id'), /unexpected Session/); + }); + + test('publishes only Todo domain invalidations and supports unsubscribe', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + const changes: string[] = []; + const unsubscribe = driver.subscribeTodoChanges!((sessionId) => changes.push(sessionId)); + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-id', + domain: 'usage', + }); + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + sessionId: 'session-id', + domain: 'todo', + }); + await waitFor(() => changes.length === 1); + assert.deepEqual(changes, ['session-id']); + + unsubscribe(); + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 3, + sessionId: 'session-id', + domain: 'todo', + }); + await delay(0); + assert.deepEqual(changes, ['session-id']); + }); + + test('keeps remote Session paths out of Client filesystem policy', async () => { + const driver = createRuntimeHostMakaSessionDriver({ + connection: new FakeConnection([]).value, + cwd: '/client/workspace', + workspace: { kind: 'project', projectId: 'project-1' }, + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + executionLocation: { kind: 'host' }, + }); + + assert.equal(driver.moveSession, undefined); + assert.deepEqual( + await driver.getSessionResumeAvailability!({ cwd: '/srv/remote-only' } as never), + { available: true }, + ); + await assert.rejects( + driver.switchSession('session-1', { relocateCwd: '/client/workspace' }), + /cannot be relocated by this Client/, + ); + + const driverWithoutProject = createRuntimeHostMakaSessionDriver({ + connection: new FakeConnection([]).value, + cwd: '/client/workspace', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + executionLocation: { kind: 'host' }, + }); + await assert.rejects( + driverWithoutProject.createSession({ + cwd: '/client/workspace', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }), + /requires an explicit Project/, + ); + }); + + test('exposes the session goal from the pushed continuity snapshot', async () => { + const armedGoal = goalProjection({ status: 'active' }); + const subscription = new FakeSubscription( + continuitySnapshot({ goal: armedGoal }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + // No session attached yet: no channel, no goal. + assert.equal(driver.getGoal!(), null); + + const observations: Array = []; + const unsubscribe = driver.subscribeGoalChanges!((goal) => + observations.push(goal === null ? null : `${goal.status}@${goal.revision}`), + ); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + // Channel adoption publishes the snapshot's goal without any RPC. + assert.equal(driver.getGoal!()?.goalId, 'goal-1'); + assert.deepEqual(observations, ['active@1']); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.query'), + false, + ); + + // A pushed projection frame with a bumped revision updates the read and + // notifies listeners — this is how an abort auto-pause reaches the TUI. + const pausedGoal = goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }); + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ goal: pausedGoal, projectionRevision: 2 }), + }); + await waitFor(() => driver.getGoal!()?.status === 'paused'); + assert.deepEqual(observations, ['active@1', 'paused@2']); + + // An unchanged goal in a later frame must not re-notify. Proven by the + // exact sequence: if it had notified, a duplicate 'paused@2' would appear + // before the 'cleared@3' below. + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ goal: pausedGoal, projectionRevision: 3 }), + }); + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 3, + snapshot: continuitySnapshot({ + goal: goalProjection({ status: 'cleared', revision: 3 }), + projectionRevision: 4, + }), + }); + await waitFor(() => observations.length === 3); + assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3']); + + // startNewSession drops the channel: goal reads null and listeners hear it. + await driver.startNewSession(); + assert.equal(driver.getGoal!(), null); + assert.deepEqual(observations, ['active@1', 'paused@2', 'cleared@3', null]); + + unsubscribe(); + }); + + test('controlGoal applies actions with the snapshot revision and retries conflicts', async () => { + const armedGoal = goalProjection({ status: 'active' }); + const subscription = new FakeSubscription( + continuitySnapshot({ goal: armedGoal }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + // No session attached: no-op, no RPC. + assert.equal(await driver.controlGoal!('pause'), null); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.control'), + false, + ); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + // Clean path: one control request carrying the snapshot revision, no query. + connection.goalControlOutcomes.push( + goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + ); + assert.equal((await driver.controlGoal!('pause'))?.status, 'paused'); + let controlRevisions = connection.requests + .filter(({ operation }) => operation === 'goal.control') + .map(({ input }) => (input as OperationInput<'goal.control'>).expectedRevision); + assert.deepEqual(controlRevisions, [1]); + assert.equal( + connection.requests.some(({ operation }) => operation === 'goal.query'), + false, + ); + + // The host broadcasts the pause; the snapshot folds it before the next action. + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + goal: goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + projectionRevision: 2, + }), + }); + await waitFor(() => driver.getGoal!()?.revision === 2); + + // Conflict path: re-query for the fresh revision and retry against it. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict'), + goalProjection({ status: 'active', revision: 4 }), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 3, pausedAt: 95 }), + ); + assert.equal((await driver.controlGoal!('resume'))?.revision, 4); + controlRevisions = connection.requests + .filter(({ operation }) => operation === 'goal.control') + .map(({ input }) => (input as OperationInput<'goal.control'>).expectedRevision); + assert.deepEqual(controlRevisions, [1, 2, 3]); + assert.equal( + connection.requests.filter(({ operation }) => operation === 'goal.query').length, + 1, + ); + + // Conflict where a concurrent controller removed the goal mid-flight: null + // (for clear, that is the desired end state). + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict'), + ); + connection.goalQueryResults.push(null); + assert.equal(await driver.controlGoal!('clear'), null); + + // Status conflict (invalid transition): the re-query returns the SAME + // revision — every accepted transition bumps it — proving a refusal, not + // a race. The host's reason is rethrown, not a misleading retry-exhaustion + // error, and the loop stops instead of burning the remaining attempts. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError( + 'goal.control', + 'operation_conflict', + 'Goal cannot pause from status paused', + ), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 2, pausedAt: 90 }), + ); + await assert.rejects(driver.controlGoal!('pause'), /Goal cannot pause from status paused/); + const attempts = connection.requests.filter( + ({ operation }) => operation === 'goal.control', + ).length; + assert.equal(attempts, 5); // 1 clean + 2 raced + 1 raced-then-gone + 1 refused — no futile retries + + // A third conflict has no retry left to serve, so preserve that final Host + // reason instead of replacing it with a generic retry-exhaustion message. + connection.goalControlOutcomes.push( + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict 1'), + new RuntimeHostOperationError('goal.control', 'operation_conflict', 'revision conflict 2'), + new RuntimeHostOperationError( + 'goal.control', + 'operation_conflict', + 'Goal cannot resume from status active', + ), + ); + connection.goalQueryResults.push( + goalProjection({ status: 'paused', revision: 3, pausedAt: 95 }), + goalProjection({ status: 'paused', revision: 4, pausedAt: 95 }), + ); + await assert.rejects(driver.controlGoal!('resume'), /Goal cannot resume from status active/); + }); + + test('honors explicit Project intent before inheriting the current workspace', async () => { + const cases = [ + { cwd: '/repo', projectId: null, expected: { kind: 'host_path', path: '/repo' } }, + { + cwd: '/repo', + projectId: 'project-b', + expected: { kind: 'project', projectId: 'project-b' }, + }, + { + cwd: '/other', + projectId: 'project-b', + expected: { kind: 'project', projectId: 'project-b' }, + }, + { cwd: '/repo', expected: { kind: 'project', projectId: 'project-a' } }, + { cwd: '/other', expected: { kind: 'host_path', path: '/other' } }, + ] as const; + + for (const candidate of cases) { + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + workspace: { kind: 'project', projectId: 'project-a' }, + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + await driver.createSession({ + cwd: candidate.cwd, + ...('projectId' in candidate ? { projectId: candidate.projectId } : {}), + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.create')?.input, + { + sessionId: 'session-id', + workspace: candidate.expected, + name: 'New Chat', + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + permissionMode: 'ask', + }, + ); + } + }); + + test('starts one user command without opening an agent turn', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + const command = await driver.runUserCommand!('pwd'); + + assert.equal(command.commandId, 'user-command-id-2'); + assert.equal(command.result.mode, 'pipes'); + assert.deepEqual( + connection.requests.map((request) => request.operation), + ['session.create', 'runtime.resource.start'], + ); + assert.deepEqual(connection.requests[1]?.input, { + sessionId: 'id-1', + launchId: 'user-command-id-2', + command: 'pwd', + }); + assert.equal(command.takeRacedUpdate(), undefined); + }); + + test('retains a terminal user-command update that arrives before its card is created', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: (() => { + let id = 0; + return () => `id-${++id}`; + })(), + }); + connection.onRuntimeResourceStart = async () => { + const startRequest = connection.requests.at(-1); + if (!startRequest) throw new Error('Expected Runtime Resource start request'); + const launchId = (startRequest.input as { launchId: string }).launchId; + connection.runtimeResourceQuery = { + kind: 'resource', + sessionId: 'id-1', + revision: `sha256:${'a'.repeat(64)}`, + resource: { + sessionId: 'id-1', + ownership: { kind: 'local' }, + sourceTurnId: launchId, + sourceToolCallId: launchId, + result: { + ...connection.userCommandResource, + status: 'completed', + output: { ...connection.userCommandResource.output, stdout: 'done\n' }, + updatedAt: 2, + completedAt: 2, + exitCode: 0, + revision: 2, + }, + } satisfies ShellRunUpdate, + }; + subscription.push({ + kind: 'subscription.session_domain_changed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'id-1', + domain: 'runtime_resource', + resources: [{ sourceSessionId: 'id-1', ref: connection.userCommandResource.ref }], + }); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.query'), + ); + await delay(0); + }; + + const command = await driver.runUserCommand!('printf done'); + const raced = command.takeRacedUpdate(); + + assert.equal(raced?.status, 'completed'); + assert.equal(raced?.output?.mode, 'pipes'); + assert.equal(raced?.output?.mode === 'pipes' && raced.output.stdout, 'done\n'); + }); + + test('stops an already-running user command when the driver closes', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.stop(); + + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + }); + + test('stops a user command whose start races driver close', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const releaseStart = deferred(); + connection.onRuntimeResourceStart = () => releaseStart.promise; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + + const starting = driver.runUserCommand!('sleep 3600'); + await waitFor(() => + connection.requests.some((request) => request.operation === 'runtime.resource.start'), + ); + const stopping = driver.stop(); + releaseStart.resolve(); + const command = await starting; + command.takeRacedUpdate(); + await stopping; + + assert.equal( + connection.requests.filter((request) => request.operation === 'runtime.resource.stop').length, + 1, + ); + }); + + test('a rejecting user-command stop does not fail the turn interrupt (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: runningTurn('turn-1', 'run-1'), + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + // turn.stop succeeds while the user-command stop rejects: the interrupt + // itself must still report success. + await driver.stop(); + + assert.ok(connection.requests.some((request) => request.operation === 'turn.stop')); + assert.ok(connection.requests.some((request) => request.operation === 'runtime.resource.stop')); + }); + + test('stops a running user command before switching Sessions (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const switchSubscription = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription, switchSubscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.switchSession('session-1'); + + const stopIndex = connection.requests.findIndex( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.notEqual(stopIndex, -1); + assert.deepEqual(connection.requests[stopIndex]?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), 'session-1'); + }); + + test('a rejecting user-command stop aborts the switch before any durable relocation commits (#3210)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tui-switch-stop-failure-')); + const target = join(root, 'new-worktree'); + await mkdir(target); + try { + const oldCwd = join(root, 'old-worktree'); + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: oldCwd }, hostCwd: oldCwd }, + }), + ); + connection.runtimeResourceStopFailure = new Error('host_draining'); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: root, + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + inspectCwdChanges: async () => undefined, + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await assert.rejects( + driver.switchSession('session-1', { relocateCwd: './new-worktree' }), + /host_draining/, + ); + + // The switch aborted before anything durable: no relocation was + // committed and the driver still owns the original Session. + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.workspace.relocate'), + false, + ); + assert.equal(driver.getSessionId(), 'id-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('awaits the user-command stop before clearing identity on /new (#3210)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + await driver.startNewSession(); + + const stop = connection.requests.find( + (request) => request.operation === 'runtime.resource.stop', + ); + assert.deepEqual(stop?.input, { + sessionId: 'id-1', + ref: connection.userCommandResource.ref, + }); + assert.equal(driver.getSessionId(), null); + }); + + test('a rejected user-command stop aborts /new without clearing identity (#3210 review)', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ + rootTurn: null, + session: { + sessionId: 'id-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('id-1', 'id-2'), + }); + const command = await driver.runUserCommand!('sleep 3600'); + command.takeRacedUpdate(); + + connection.runtimeResourceStopFailure = new Error('host_draining'); + await assert.rejects(() => driver.startNewSession(), /host_draining/); + + // Nothing committed: the previous Session is still owned, so its card and + // Ctrl+C affordance remain live. + assert.equal(driver.getSessionId(), 'id-1'); + + // Once the Host recovers, /new proceeds normally. + connection.runtimeResourceStopFailure = undefined; + await driver.startNewSession(); + assert.equal(driver.getSessionId(), null); + }); + + test('submits a same-slug model recovery with the newly selected Connection id', async () => { + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-a', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-a', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + connection.sessionQueries.push( + sessionProjection({ revision: 1, llmConnectionId: 'connection-a' }), + sessionProjection({ revision: 2, llmConnectionId: 'connection-a' }), + ); + connection.configurationOutcomes.push( + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + { + kind: 'committed', + session: sessionProjection({ + revision: 3, + llmConnectionId: 'connection-b', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }), + }, + ); + await driver.setModel('gpt-5', 'openai-main', 'connection-b'); + + assert.deepEqual( + connection.requests + .filter(({ operation }) => operation === 'session.configuration.update') + .map(({ input }) => input), + [1, 2].map((expectedRevision) => ({ + sessionId: 'session-id', + expectedRevision, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: 'connection-b', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + thinkingLevel: null, + }, + })), + ); + }); + + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { + // The TUI flow behind /new: session A is elevated to bypass, then the + // driver is asked to start over. The next prompt lazily creates session B + // through preparePrompt. Session B must be created with the + // construction-time default — Full access is an explicit per-session + // opt-in, never inherited. + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + new FakeSubscription( + continuitySnapshot({ + session: { + sessionId: 'session-2', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + rootTurn: null, + }), + Promise.resolve([]), + ), + ]); + let nextId = 0; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + prospectivePermissionMode: 'ask', + newId: () => `session-${++nextId}`, + }); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + permissionMode: 'ask', + }); + connection.executionBoundary = { kind: 'bypass', revision: 2 }; + await driver.setPermissionMode('bypass'); + assert.equal(driver.getPermissionMode?.(), 'bypass'); + + await driver.startNewSession(); + assert.equal(driver.getPermissionMode?.(), 'ask'); + + // The fresh Session's boundary is managed again once it exists. + connection.executionBoundary = { kind: 'managed', access: 'writable', revision: 3 }; + await driver.preparePrompt('hello'); + + const creates = connection.requests.filter(({ operation }) => operation === 'session.create'); + assert.equal(creates.length, 2); + // The elevation does not leak, and the fresh Session carries no client + // claim at all: an omitted field is what leaves the starting mode to the + // Host's `chatDefaults`. Substituting the launch reading here would make + // the CLI a second authority over it. + assert.deepEqual(creates[1]!.input, { + sessionId: 'session-2', + workspace: { kind: 'host_path', path: '/repo' }, + name: 'New Chat', + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + }); + }); + + test('relocates a moved Session through Host authority before attaching', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tui-resume-moved-cwd-')); + const target = join(root, 'new-worktree'); + await mkdir(target); + try { + const oldCwd = join(root, 'old-worktree'); + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + connection.sessionQueries.push( + sessionProjection({ + workspace: { target: { kind: 'host_path', path: oldCwd }, hostCwd: oldCwd }, + }), + sessionProjection({ + workspace: { target: { kind: 'host_path', path: oldCwd }, hostCwd: oldCwd }, + }), + ); + const inspected: string[] = []; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: root, + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + inspectCwdChanges: async (cwd) => { + inspected.push(cwd); + return undefined; + }, + }); + + const switched = await driver.switchSession('session-1', { + relocateCwd: './new-worktree', + }); + const canonicalTarget = await realpath(target); + + assert.equal(switched.summary.cwd, canonicalTarget); + assert.deepEqual(switched.relocation, { + previousCwd: oldCwd, + cwd: canonicalTarget, + changed: true, + oldCwdDirty: undefined, + }); + assert.deepEqual(inspected, [oldCwd]); + assert.deepEqual( + connection.requests.map(({ operation }) => operation), + [ + 'session.catalog.query', + 'session.execution_boundary.query', + 'session.catalog.query', + 'session.workspace.relocate', + ], + ); + assert.deepEqual(connection.requests.at(-1)?.input, { + sessionId: 'session-1', + expectedRevision: 1, + workspace: { kind: 'host_path', path: canonicalTarget }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('does not relocate an externally isolated Session during resume', async () => { + const connection = new FakeConnection([]); + connection.executionBoundary = { kind: 'external', harness: 'harbor', revision: 1 }; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: process.cwd(), + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + + await assert.rejects( + driver.switchSession('session-1', { relocateCwd: process.cwd() }), + /Cannot resume externally isolated session/, + ); + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.workspace.relocate'), + false, + ); + }); + + test('atomically joins an active turn without losing output produced during transcript load', async () => { + const transcript = deferred(); + const subscription = new FakeSubscription(continuitySnapshot(), transcript.promise); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + + const switching = driver.switchSession('session-1'); + await waitFor(() => subscription.nextCalls > 0); + subscription.push(deltaFrame(1, 'turn-1', 5, ' world')); + transcript.resolve([assistantMessage('turn-1', 'Hello')]); + + const switched = await switching; + assert.deepEqual(switched.messages, [assistantMessage('turn-1', 'Hello')]); + assert.ok(switched.activeTurn); + const event = await nextEvent(switched.activeTurn.events); + assert.deepEqual(event, { + type: 'text_delta', + id: 'host-frame:host-1:subscription-1:1', + turnId: 'turn-1', + messageId: 'message-turn-1', + ts: 50, + startOffset: 5, + text: ' world', + }); + }); + + test('delivers completed thinking while a later step remains active', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + subscription.push(thinkingFrame(1, 'step-1', 0, 'first')); + subscription.push(thinkingFrame(2, 'step-1', 5, '', true)); + subscription.push(thinkingFrame(3, 'step-2', 0, 'second')); + + assert.deepEqual( + [ + await nextEvent(switched.activeTurn.events), + await nextEvent(switched.activeTurn.events), + await nextEvent(switched.activeTurn.events), + ].map((event) => ({ + type: event.type, + messageId: 'messageId' in event ? event.messageId : undefined, + })), + [ + { type: 'thinking_delta', messageId: 'step-1' }, + { type: 'thinking_complete', messageId: 'step-1' }, + { type: 'thinking_delta', messageId: 'step-2' }, + ], + ); + }); + + test('restarts initial hydration when the first connection closes during transcript load', async () => { + const transcript = deferred(); + const initial = new FakeSubscription(continuitySnapshot(), transcript.promise); + const replacementMessages = [assistantMessage('turn-1', 'Canonical replacement')]; + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve(replacementMessages), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + + const switching = driver.switchSession('session-1'); + await waitFor(() => initial.nextCalls > 0); + const disconnected = new RuntimeHostSubscriptionError( + 'connection_closed', + 'connection closed during initial hydration', + ); + initial.fail(disconnected); + transcript.reject(disconnected); + + const switched = await switching; + assert.deepEqual(switched.messages, replacementMessages); + assert.equal(connection.openedSubscriptions, 2); + }); + + test('drains the active cut when its turn completes during transcript load', async () => { + const transcript = deferred(); + const subscription = new FakeSubscription(continuitySnapshot(), transcript.promise); + const connection = new FakeConnection([ + subscription, + new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([assistantMessage('turn-1', 'Hello world')]), + 'subscription-2', + ), + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + + const switching = driver.switchSession('session-1'); + await waitFor(() => subscription.nextCalls > 0); + subscription.push(deltaFrame(1, 'turn-1', 5, ' world')); + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + }); + transcript.resolve([assistantMessage('turn-1', 'Hello')]); + + const switched = await switching; + assert.ok(switched.activeTurn); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'text_delta'); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'text_complete'); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + }); + + test('reattaches atomically when another client starts the successor turn', async () => { + const first = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Finished')]), + ); + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([assistantMessage('turn-1', 'Finished')]), + 'subscription-refresh', + ); + const second = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + Promise.resolve([userMessage('turn-2', 'Follow up'), assistantMessage('turn-2', 'New')]), + 'subscription-2', + ); + const connection = new FakeConnection([first, refresh, second]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 60, + }); + const initial = await driver.switchSession('session-1'); + assert.ok(initial.activeTurn); + const started = deferred(); + driver.subscribeStartedTurns!((turn) => started.resolve(turn)); + + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + }); + assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); + assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => refresh.nextCalls > 0); + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + }); + const attached = await Promise.race([ + started.promise, + delay(WAIT_BUDGET_MS).then(() => assert.fail('Timed out waiting for successor turn')), + ]); + assert.deepEqual(attached.messages, [ + userMessage('turn-2', 'Follow up'), + assistantMessage('turn-2', 'New'), + ]); + second.push(deltaFrame(1, 'turn-2', 3, ' text', 'subscription-2', 'run-2')); + assert.equal((await nextEvent(attached.events)).type, 'text_delta'); + }); + + test('hides the copied parent transcript when a Host starts the side successor turn', async () => { + const parentTranscript = [ + userMessage('turn-parent', 'Parent question'), + assistantMessage('turn-parent', 'Parent answer'), + ]; + const first = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([...parentTranscript]), + ); + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([...parentTranscript]), + 'subscription-refresh', + ); + const second = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + Promise.resolve([ + ...parentTranscript, + userMessage('turn-2', 'Side follow up'), + assistantMessage('turn-2', 'Side answer'), + ]), + 'subscription-2', + ); + const connection = new FakeConnection([first, refresh, second]); + // A side Session is a copy that carries the parent transcript; both the + // initial switch and the reattach's configuration load must see the side + // labels so the driver keeps hiding everything through `turn-parent`. + const sideProjection = () => + sessionProjection({ + labels: ['mode:side_conversation'], + parentSessionId: 'parent-1', + branchOfTurnId: 'turn-parent', + }); + connection.sessionQueries.push(sideProjection(), sideProjection()); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 60, + }); + const initial = await driver.switchSession('session-1'); + assert.ok(initial.activeTurn); + // The visible side transcript starts empty even though the copy carried + // the parent's messages. + assert.deepEqual(initial.messages, []); + const started = deferred(); + driver.subscribeStartedTurns!((turn) => started.resolve(turn)); + + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + }); + assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); + assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => refresh.nextCalls > 0); + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + }); + const attached = await Promise.race([ + started.promise, + delay(WAIT_BUDGET_MS).then(() => assert.fail('Timed out waiting for successor turn')), + ]); + // Without the filter this replaced the visible transcript with the copied + // parent conversation the user opened `/side` to leave (#3881). + assert.deepEqual(attached.messages, [ + userMessage('turn-2', 'Side follow up'), + assistantMessage('turn-2', 'Side answer'), + ]); + }); + + test('adopts a successor that finishes before its atomic reattach completes', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-refresh', + ); + const second = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 3, + rootTurn: completedTurn('turn-2', 'run-2'), + }), + Promise.resolve([ + userMessage('turn-2', 'Fast follow up'), + assistantMessage('turn-2', 'Done'), + ]), + 'subscription-2', + ); + const connection = new FakeConnection([first, refresh, second]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const started = deferred(); + driver.subscribeStartedTurns!((turn) => started.resolve(turn)); + + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + }); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => refresh.nextCalls > 0); + first.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + }); + + const attached = await started.promise; + assert.deepEqual(attached.messages, [ + userMessage('turn-2', 'Fast follow up'), + assistantMessage('turn-2', 'Done'), + ]); + const text = await nextEvent(attached.events); + assert.equal(text.type, 'text_complete'); + if (text.type !== 'text_complete') assert.fail('Expected the durable assistant answer'); + assert.equal(text.text, 'Done'); + assert.equal((await nextEvent(attached.events)).type, 'complete'); + }); + + test('serializes buffered successor reattach so transcript completion cannot reverse turn order', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const refreshFirst = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-refresh-1', + ); + const refreshSecond = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-2', 'run-2') }), + Promise.resolve([]), + 'subscription-refresh-2', + ); + const secondTranscript = deferred(); + const thirdTranscript = deferred(); + const second = new FakeSubscription( + continuitySnapshot({ projectionRevision: 4, rootTurn: runningTurn('turn-2', 'run-2') }), + secondTranscript.promise, + 'subscription-2', + ); + const third = new FakeSubscription( + continuitySnapshot({ projectionRevision: 5, rootTurn: runningTurn('turn-3', 'run-3') }), + thirdTranscript.promise, + 'subscription-3', + ); + const connection = new FakeConnection([first, refreshFirst, refreshSecond, second, third]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const started: MakaAttachedSessionTurn[] = []; + driver.subscribeStartedTurns!((turn) => started.push(turn)); + + first.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + first.push(projectionFrame(2, runningTurn('turn-2', 'run-2'), 3)); + first.push(projectionFrame(3, completedTurn('turn-2', 'run-2'), 4)); + first.push(projectionFrame(4, runningTurn('turn-3', 'run-3'), 5)); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + + await waitFor(() => connection.openedSubscriptions === 4); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(connection.openedSubscriptions, 4, 'the later successor must wait for reattach'); + secondTranscript.resolve([userMessage('turn-2', 'Second')]); + await waitFor(() => started.length === 1 && connection.openedSubscriptions === 5); + assert.equal(started[0]?.turnId, 'turn-2'); + + thirdTranscript.resolve([userMessage('turn-3', 'Third')]); + await waitFor(() => started.length === 2); + assert.deepEqual( + started.map((turn) => turn.turnId), + ['turn-2', 'turn-3'], + ); + }); + + test('a retired intermediate channel cannot republish its buffered successor', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const refreshFirst = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-refresh-1', + ); + const refreshSecondFromFirst = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-2', 'run-2') }), + Promise.resolve([]), + 'subscription-refresh-2-first', + ); + const secondTranscript = deferred(); + const second = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3, rootTurn: runningTurn('turn-2', 'run-2') }), + secondTranscript.promise, + 'subscription-2', + ); + const refreshSecondFromSecond = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-2', 'run-2') }), + Promise.resolve([]), + 'subscription-refresh-2-second', + ); + const third = new FakeSubscription( + continuitySnapshot({ projectionRevision: 5, rootTurn: runningTurn('turn-3', 'run-3') }), + Promise.resolve([userMessage('turn-3', 'Third')]), + 'subscription-3', + ); + const duplicateThird = new FakeSubscription( + continuitySnapshot({ projectionRevision: 5, rootTurn: runningTurn('turn-3', 'run-3') }), + Promise.resolve([userMessage('turn-3', 'Duplicate third')]), + 'subscription-3-duplicate', + ); + const connection = new FakeConnection([ + first, + refreshFirst, + refreshSecondFromFirst, + second, + refreshSecondFromSecond, + third, + duplicateThird, + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const started: MakaAttachedSessionTurn[] = []; + driver.subscribeStartedTurns!((turn) => started.push(turn)); + + first.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + first.push(projectionFrame(2, runningTurn('turn-2', 'run-2'), 3)); + first.push(projectionFrame(3, completedTurn('turn-2', 'run-2'), 4)); + first.push(projectionFrame(4, runningTurn('turn-3', 'run-3'), 5)); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => connection.openedSubscriptions === 4 && second.nextCalls > 0); + + second.push(projectionFrame(1, completedTurn('turn-2', 'run-2'), 4, 'subscription-2')); + second.push(projectionFrame(2, runningTurn('turn-3', 'run-3'), 5, 'subscription-2')); + secondTranscript.resolve([userMessage('turn-2', 'Second')]); + await waitFor( + () => + connection.openedSubscriptions === 6 && + started.some((turn) => turn.turnId === 'turn-2') && + started.some((turn) => turn.turnId === 'turn-3'), + ); + + const secondTurn = started.find((turn) => turn.turnId === 'turn-2'); + assert.ok(secondTurn); + for await (const _event of secondTurn.events) { + // Draining the retired channel must not publish its buffered successor. + } + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(connection.openedSubscriptions, 6); + assert.deepEqual( + started.map((turn) => turn.turnId), + ['turn-2', 'turn-3'], + ); + }); + + test('an explicit Session switch fences an older successor reattach already loading', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-refresh', + ); + const staleTranscript = deferred(); + const stale = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3, rootTurn: runningTurn('turn-2', 'run-2') }), + staleTranscript.promise, + 'subscription-stale', + ); + const switchedSubscription = new FakeSubscription( + continuitySnapshot({ projectionRevision: 4, rootTurn: runningTurn('turn-3', 'run-3') }), + Promise.resolve([userMessage('turn-3', 'Current')]), + 'subscription-current', + ); + const connection = new FakeConnection([first, refresh, stale, switchedSubscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const initial = await driver.switchSession('session-1'); + assert.ok(initial.activeTurn); + const started: string[] = []; + driver.subscribeStartedTurns!((turn) => started.push(turn.turnId)); + + first.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + first.push(projectionFrame(2, runningTurn('turn-2', 'run-2'), 3)); + assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); + assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => connection.openedSubscriptions === 3); + + const switched = await driver.switchSession('session-1'); + assert.equal(switched.activeTurn?.turnId, 'turn-3'); + staleTranscript.resolve([userMessage('turn-2', 'Stale')]); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(started, []); + }); + + test('a stale reattach cannot overwrite configuration adopted by an explicit switch', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-refresh', + ); + const stale = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3, rootTurn: runningTurn('turn-2', 'run-2') }), + Promise.resolve([userMessage('turn-2', 'Stale')]), + 'subscription-stale', + ); + const current = new FakeSubscription( + continuitySnapshot({ projectionRevision: 4, rootTurn: runningTurn('turn-3', 'run-3') }), + Promise.resolve([userMessage('turn-3', 'Current')]), + 'subscription-current', + ); + const staleConfiguration = deferred(); + const connection = new FakeConnection([first, refresh, stale, current]); + connection.sessionQueries.push( + sessionProjection(), + staleConfiguration.promise, + sessionProjection({ orchestrationMode: 'graph' }), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const initial = await driver.switchSession('session-1'); + assert.ok(initial.activeTurn); + + first.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + first.push(projectionFrame(2, runningTurn('turn-2', 'run-2'), 3)); + assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); + assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor( + () => + connection.requests.filter((request) => request.operation === 'session.catalog.query') + .length === 2, + ); + + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + assert.equal(driver.getOrchestrationMode!(), 'graph'); + staleConfiguration.resolve(sessionProjection({ orchestrationMode: 'default' })); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(driver.getOrchestrationMode!(), 'graph'); + }); + + test('a stale generation cannot block successor reattach after an explicit switch', async () => { + const first = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const oldRefresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve([]), + 'subscription-old-refresh', + ); + const staleTranscript = deferred(); + const stale = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3, rootTurn: runningTurn('turn-2', 'run-2') }), + staleTranscript.promise, + 'subscription-stale', + ); + const current = new FakeSubscription( + continuitySnapshot({ projectionRevision: 4, rootTurn: runningTurn('turn-3', 'run-3') }), + Promise.resolve([userMessage('turn-3', 'Current')]), + 'subscription-current', + ); + const currentRefresh = new FakeSubscription( + continuitySnapshot({ projectionRevision: 5, rootTurn: completedTurn('turn-3', 'run-3') }), + Promise.resolve([assistantMessage('turn-3', 'Done')]), + 'subscription-current-refresh', + ); + const successor = new FakeSubscription( + continuitySnapshot({ projectionRevision: 6, rootTurn: runningTurn('turn-4', 'run-4') }), + Promise.resolve([userMessage('turn-4', 'Next')]), + 'subscription-successor', + ); + const connection = new FakeConnection([ + first, + oldRefresh, + stale, + current, + currentRefresh, + successor, + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const initial = await driver.switchSession('session-1'); + assert.ok(initial.activeTurn); + const started: string[] = []; + driver.subscribeStartedTurns!((turn) => started.push(turn.turnId)); + + first.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + first.push(projectionFrame(2, runningTurn('turn-2', 'run-2'), 3)); + assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); + assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => connection.openedSubscriptions === 3); + + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + current.push(projectionFrame(1, completedTurn('turn-3', 'run-3'), 5, 'subscription-current')); + current.push(projectionFrame(2, runningTurn('turn-4', 'run-4'), 6, 'subscription-current')); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + await waitFor(() => connection.openedSubscriptions === 6 && started.includes('turn-4')); + assert.deepEqual(started, ['turn-4']); + }); + + test('routes queue and retract mutations through Host authority', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('retract-1'), + }); + await driver.switchSession('session-1'); + + await driver.submitMessage!('Later', { + messageId: 'message-1', + placement: 'next_turn', + }); + assert.deepEqual(await driver.retractQueued!(), { + text: 'Later', + messageIds: ['message-1'], + }); + assert.deepEqual( + connection.requests.filter( + (request) => + request.operation === 'turn.message.submit' || request.operation === 'queue.retract', + ), + [ + { + operation: 'turn.message.submit', + input: { + originHostEpoch: 'host-1', + sessionId: 'session-1', + messageId: 'message-1', + content: { text: 'Later' }, + placement: 'next_turn', + }, + }, + { + operation: 'queue.retract', + input: { + originHostEpoch: 'host-1', + sessionId: 'session-1', + retractId: 'retract-1', + }, + }, + ], + ); + }); + + test('submits an idle message under the caller-owned stable identity', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('unused-generated-id'), + }); + await driver.switchSession('session-1'); + + await driver.submitMessage!('Visible prompt', { + messageId: 'message-1', + placement: 'current_turn', + modelText: 'Expanded prompt', + }); + assert.deepEqual(connection.requests.at(-1), { + operation: 'turn.message.submit', + input: { + originHostEpoch: 'host-1', + sessionId: 'session-1', + messageId: 'message-1', + content: { text: 'Expanded prompt', displayText: 'Visible prompt' }, + placement: 'current_turn', + }, + }); + }); + + test('admits concurrent first messages into one Session in submission order', async () => { + // Two subscriptions so a driver that creates two Sessions fails on the + // claim rather than on missing fake infrastructure. + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + const create = deferred(); + connection.heldOperations.set('session.create', create.promise); + let nextId = 0; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => `session-${++nextId}`, + }); + + // Two Enters before the first round trip resolves. Nothing about the TUI + // holds the second one back, so the driver is what has to keep them from + // racing into two Sessions or reaching the Host out of order. + const first = driver.submitMessage!('first', { + messageId: 'message-1', + placement: 'current_turn', + }); + const second = driver.submitMessage!('second', { + messageId: 'message-2', + placement: 'current_turn', + }); + create.resolve(); + await Promise.all([first, second]); + + const creates = connection.requests.filter(({ operation }) => operation === 'session.create'); + assert.equal(creates.length, 1); + const submits = connection.requests.filter( + ({ operation }) => operation === 'turn.message.submit', + ); + assert.deepEqual( + submits.map(({ input }) => (input as OperationInput<'turn.message.submit'>).messageId), + ['message-1', 'message-2'], + ); + assert.deepEqual( + new Set( + submits.map(({ input }) => (input as OperationInput<'turn.message.submit'>).sessionId), + ), + new Set(['session-1']), + ); + }); + + test('keeps a configuration change from crossing a pending admission', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + const submit = deferred(); + connection.heldOperations.set('turn.message.submit', submit.promise); + const admitted = driver.submitMessage!('before the model change', { + messageId: 'message-1', + placement: 'current_turn', + }); + // `/model` typed while the Message is still in flight. The Host must see + // it after the Message it was typed after, or the Turn that Message opens + // runs under a model the user had not chosen yet. + const changed = driver.setModel('gpt-5-codex'); + submit.resolve(); + await Promise.all([admitted, changed]); + + const ordered = connection.requests + .map(({ operation }) => operation) + .filter( + (operation) => + operation === 'turn.message.submit' || operation === 'session.configuration.update', + ); + assert.deepEqual(ordered, ['turn.message.submit', 'session.configuration.update']); + }); + + test('keeps an unknown message admission available for transcript reconciliation', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.messageSubmitOutcomes.push( + new RuntimeHostOperationError( + 'turn.message.submit', + 'outcome_unknown', + 'Message disposition cannot be proven in this Host Epoch', + ), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { + messageId: 'message-unknown', + placement: 'current_turn', + }), + ); + }); + + test('keeps a dispatched interrupted admission available for transcript reconciliation', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.messageSubmitOutcomes.push( + new RuntimeHostRequestInterruptedError( + 'turn.message.submit', + 'command', + 'dispatched', + 'connection_lost', + ), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + await assert.doesNotReject(() => + driver.submitMessage!('Keep this visible', { + messageId: 'message-interrupted', + placement: 'current_turn', + }), + ); + }); + + test('projects the acknowledgement that releases a question answered through the Host', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 75, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'user_question_request'); + + await driver.respondToUserQuestion!({ requestId: 'question-1', answers: ['Yes'] }); + + assert.deepEqual(await nextEvent(switched.activeTurn.events), { + type: 'user_question_answer_ack', + id: 'host-interaction:question-1:2', + turnId: 'turn-1', + ts: 75, + requestId: 'question-1', + toolUseId: 'tool-question', + }); + }); + + test('answers and releases a Host-owned form through the generic Interaction operation', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingForm()] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 76, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'form_request'); + + await driver.respondToUserForm!({ + requestId: 'form-1', + action: 'accept', + values: { version: 'v2' }, + }); + + assert.deepEqual(connection.requests.at(-1), { + operation: 'interaction.answer', + input: { + sessionId: 'session-1', + interactionId: 'form-1', + answer: { kind: 'form', action: 'accept', values: { version: 'v2' } }, + }, + }); + assert.deepEqual(await nextEvent(switched.activeTurn.events), { + type: 'form_answer_ack', + id: 'host-interaction:form-1:2', + turnId: 'turn-1', + ts: 76, + requestId: 'form-1', + toolUseId: 'tool-form', + }); + }); + + test('publishes a pending permission that has no transcript event', async () => { + const permission = pendingPermission(); + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [permission] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const published = deferred(); + driver.subscribePendingInteractions((pending) => published.resolve(pending)); + + await driver.switchSession('session-1'); + + assert.deepEqual(await published.promise, permission); + }); + + test('keeps Host-triggered prompts out of rewind', async () => { + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const messages: StoredMessage[] = [ + userMessage('turn-new', 'Newest prompt'), + { + ...userMessage('turn-automation', 'Automated prompt'), + origin: { kind: 'legacy_automation', automationId: 'automation-1' }, + }, + { + ...userMessage('turn-automation', 'Steer the automated turn'), + id: 'user-turn-automation-steering', + steeringEventId: 'runtime-event-steering', + }, + ]; + const current = new FakeSubscription( + continuitySnapshot(), + Promise.resolve(messages), + 'subscription-2', + ); + const direct = new FakeSubscription( + continuitySnapshot(), + Promise.resolve(messages), + 'subscription-3', + ); + const connection = new FakeConnection([attached, current, direct]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + assert.deepEqual(await driver.listRewindTargets(), [ + { turnId: 'turn-new', label: 'Newest prompt' }, + ]); + await assert.rejects( + driver.rewindToTurn('turn-automation'), + /Host-triggered prompts are read-only/, + ); + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.revision.create'), + false, + ); + }); + + test('fails rewind closed when the selected turn carries structured content', async () => { + // A rewind that refills only the human-facing text would silently drop + // the selected turn's quotes/attachments from the replacement submit — + // fail closed with a precise notice instead until the TUI can carry + // them (#5109). + const attachment = { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 10, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'a.png' }, + } as const; + const messages: StoredMessage[] = [ + userMessage('turn-plain', 'Plain prompt'), + { + ...userMessage('turn-quoted', 'Quoted prompt'), + quotes: [{ text: 'a large pasted excerpt' }], + }, + { + ...userMessage('turn-attached', 'Attached prompt'), + attachments: [attachment], + }, + ]; + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve(messages)); + const connection = new FakeConnection([attached]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + await assert.rejects( + driver.rewindToTurn('turn-quoted'), + /carries quotes or attachments/, + ); + await assert.rejects( + driver.rewindToTurn('turn-attached'), + /carries quotes or attachments/, + ); + assert.equal( + connection.requests.some(({ operation }) => operation === 'session.revision.create'), + false, + 'no revision is created for content the TUI cannot carry', + ); + }); + + test('opens a hidden side copy at the latest completed Turn and removes it on close', async (t) => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'maka-tui-side-')); + t.after(() => rm(cleanupRoot, { recursive: true, force: true })); + const sourceMessages: StoredMessage[] = [ + userMessage('turn-completed', 'Settled question'), + assistantMessage('turn-completed', 'Settled answer'), + turnStateMessage('turn-completed', 'completed'), + userMessage('turn-failed', 'Failed question'), + turnStateMessage('turn-failed', 'failed'), + ]; + const subscriptions = [ + new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve(sourceMessages)), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-copy-source', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages.slice(0, 3)), + 'subscription-side', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([ + ...sourceMessages.slice(0, 3), + userMessage('turn-side', 'Side question'), + assistantMessage('turn-side', 'Side answer'), + ]), + 'subscription-side-read', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-parent-return', + ), + ]; + const connection = new FakeConnection(subscriptions); + connection.sessionQueries.push( + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'session-1', revision: 4 }), + sessionProjection({ + id: 'side-1', + labels: ['mode:side_conversation'], + parentSessionId: 'session-1', + branchOfTurnId: 'turn-completed', + }), + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'side-1', labels: ['mode:side_conversation'] }), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'side-1', + sessionCopyCleanupRoot: cleanupRoot, + }); + await driver.switchSession('session-1'); + + const opened = await driver.openSideConversation!(); + + assert.equal((await stat(join(cleanupRoot, 'a'.repeat(64), 'runtime.sqlite'))).isFile(), true); + + assert.equal(opened.parentSessionId, 'session-1'); + assert.equal(opened.sideSessionId, 'side-1'); + assert.deepEqual(opened.messages, []); + assert.deepEqual( + (await driver.readMessages()).map((message) => + 'turnId' in message ? `${message.type}:${message.turnId}` : message.type, + ), + ['user:turn-side', 'assistant:turn-side'], + ); + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.branch.create')?.input, + { + sourceSessionId: 'session-1', + targetSessionId: 'side-1', + sourceTurnId: 'turn-completed', + expectedSourceRevision: 4, + intent: 'side_conversation', + }, + ); + + const closed = await driver.closeSideConversation!('side-1', 'session-1'); + assert.equal(closed.summary.id, 'session-1'); + assert.equal(closed.cleanup, 'removed'); + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.remove')?.input, + { sessionId: 'side-1', expectedRevision: 1 }, + ); + }); + + test('opens an empty side copy when the parent has no completed Turn yet', async (t) => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'maka-tui-side-empty-')); + t.after(() => rm(cleanupRoot, { recursive: true, force: true })); + // The parent's first Turn is still running (an explicit running turn_state), + // so there is no completed Turn to branch through. The side conversation + // must still open, forking with an empty context instead of erroring. + const sourceMessages: StoredMessage[] = [ + userMessage('turn-running', 'In-flight question'), + { + type: 'turn_state', + id: 'state-turn-running', + turnId: 'turn-running', + ts: 80, + status: 'running', + }, + ]; + const subscriptions = [ + new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve(sourceMessages)), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-copy-source', + ), + // The empty copy carries no source transcript. + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + 'subscription-side', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + 'subscription-side-read', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-parent-return', + ), + ]; + const connection = new FakeConnection(subscriptions); + connection.sessionQueries.push( + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'session-1', revision: 4 }), + // The empty copy records provenance (parentSessionId) but fabricates no + // branchOfTurnId. + sessionProjection({ + id: 'side-1', + labels: ['mode:side_conversation'], + parentSessionId: 'session-1', + }), + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'side-1', labels: ['mode:side_conversation'] }), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'side-1', + sessionCopyCleanupRoot: cleanupRoot, + }); + await driver.switchSession('session-1'); + + const opened = await driver.openSideConversation!(); + + assert.equal((await stat(join(cleanupRoot, 'a'.repeat(64), 'runtime.sqlite'))).isFile(), true); + + assert.equal(opened.parentSessionId, 'session-1'); + assert.equal(opened.sideSessionId, 'side-1'); + assert.deepEqual(opened.messages, []); + assert.deepEqual(await driver.readMessages(), []); + // The branch omits sourceTurnId entirely (empty copy) while still carrying + // the side_conversation intent the Host requires for an empty copy. + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.branch.create')?.input, + { + sourceSessionId: 'session-1', + targetSessionId: 'side-1', + expectedSourceRevision: 4, + intent: 'side_conversation', + }, + ); + + const closed = await driver.closeSideConversation!('side-1', 'session-1'); + assert.equal(closed.summary.id, 'session-1'); + assert.equal(closed.cleanup, 'removed'); + }); + + test('observes actionable and terminal parent status from the Host projection', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingPermission()] } }), + Promise.resolve([]), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: new FakeConnection([subscription]).value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const statuses: Array = []; + + const stop = await driver.observeSideConversationParent!('session-1', (status) => { + statuses.push(status); + }); + assert.equal(statuses.at(-1), 'needs_approval'); + + subscription.push(projectionFrame(1, runningTurn('turn-2', 'run-2'), 2)); + await waitFor(() => statuses.at(-1) === undefined); + subscription.push(projectionFrame(2, completedTurn('turn-2', 'run-2'), 3)); + await waitFor(() => statuses.at(-1) === 'finished'); + + await stop(); + }); + + test('clears parent status when observer recovery is exhausted', async () => { + const snapshot = continuitySnapshot({ interactions: { pending: [pendingPermission()] } }); + const initial = new FakeSubscription(snapshot, Promise.resolve([])); + const ended = Array.from({ length: 8 }, (_, index) => { + const subscription = new FakeSubscription( + { ...snapshot, projectionRevision: index + 2 }, + Promise.resolve([]), + `subscription-${index + 2}`, + ); + void subscription.close(); + return subscription; + }); + const connection = new FakeConnection([initial, ...ended], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + const statuses: Array = []; + const cleared = deferred(); + + await driver.observeSideConversationParent!('session-1', (status) => { + statuses.push(status); + if (status === undefined) cleared.resolve(); + }); + assert.equal(statuses.at(-1), 'needs_approval'); + + await initial.close(); + await Promise.race([ + cleared.promise, + delay(3_000).then(() => assert.fail('Timed out waiting for observer recovery exhaustion')), + ]); + assert.equal(connection.openedSubscriptions, 9); + assert.equal(statuses.at(-1), undefined); + }); + + test('reopens a failed Session channel before starting the next turn', async () => { + const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([])); + const second = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + 'subscription-2', + ); + const connection = new FakeConnection([first, second]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('turn-2'), + }); + await driver.switchSession('session-1'); + + first.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', + }); + await new Promise((resolve) => setImmediate(resolve)); + + const turn = await driver.preparePrompt('Continue'); + second.push(deltaFrame(1, 'turn-2', 0, 'Recovered', 'subscription-2', 'run-2')); + assert.equal((await nextEvent(turn.events)).text, 'Recovered'); + }); + + test('starts explicit Skills through the Host command and preserves its typed feedback', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: sequenceIds('turn-skill'), + }); + await driver.switchSession('session-1'); + + const turn = await driver.preparePrompt('/skill:alpha Help'); + assert.deepEqual(turn.skillInvocation?.loaded, [{ id: 'alpha', name: 'Alpha' }]); + assert.equal(connection.requests.at(-1)?.operation, 'turn.start'); + + connection.skillStartBlocked = true; + // The failure names what could not be resolved: headless `maka run` reports + // this message and nothing reads a structured payload off it. + await assert.rejects(driver.preparePrompt('/skill:missing', { turnId: 'turn-blocked' }), { + message: /Could not resolve the Skill this Turn asked for: \/skill:missing \(not found\)/, + }); + }); + + test('retires a pending question when another client answers it', async () => { + const subscription = new FakeSubscription( + continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), + Promise.resolve([]), + ); + const connection = new FakeConnection([subscription]); + connection.interactionQuery = { + ...pendingQuestion(), + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 80 }, + }; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const resolved = deferred(); + driver.subscribeResolvedInteractions!((_sessionId, requestId) => resolved.resolve(requestId)); + + subscription.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ projectionRevision: 2, interactions: { pending: [] } }), + }); + + assert.equal(await resolved.promise, 'question-1'); + }); + + test('reconciles the durable transcript after a turn reaches its terminal boundary', async () => { + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const durableMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')]; + const refresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve(durableMessages), + 'subscription-2', + ); + const connection = new FakeConnection([attached, refresh]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const replacement = deferred(); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages) => + replacement.resolve(messages), + ); + + attached.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + }); + + assert.deepEqual(await replacement.promise, durableMessages); + }); + + test('publishes only the newest live tool-result transcript refresh', async () => { + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const firstRefresh = new FakeSubscription( + continuitySnapshot(), + new Promise(() => undefined), + 'subscription-2', + ); + const secondMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')]; + const secondRefresh = new FakeSubscription( + continuitySnapshot(), + Promise.resolve(secondMessages), + 'subscription-3', + ); + const connection = new FakeConnection([attached, firstRefresh, secondRefresh]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const replacements: Array = []; + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { + assert.equal(reason, 'reconcile'); + replacements.push(messages); + }); + + attached.push(toolResultFrame(1)); + attached.push(toolResultFrame(2)); + await waitFor(() => connection.openedSubscriptions === 3); + await waitFor(() => replacements.length === 1); + assert.deepEqual(replacements, [secondMessages]); + }); + + test('does not publish an older tool-result transcript after the terminal transcript', async () => { + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const liveTranscript = deferred(); + const liveRefresh = new FakeSubscription( + continuitySnapshot(), + liveTranscript.promise, + 'subscription-2', + ); + const terminalMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')]; + const terminalRefresh = new FakeSubscription( + continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), + Promise.resolve(terminalMessages), + 'subscription-3', + ); + const connection = new FakeConnection([attached, liveRefresh, terminalRefresh]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const replacements: Array<{ messages: readonly StoredMessage[]; reason: string }> = []; + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { + replacements.push({ messages, reason }); + }); + + attached.push(toolResultFrame(1)); + await waitFor(() => connection.openedSubscriptions === 2); + attached.push(projectionFrame(2, completedTurn('turn-1', 'run-1'), 2)); + await waitFor(() => replacements.length === 1); + assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); + + liveTranscript.resolve([userMessage('turn-1', 'Run it')]); + await delay(0); + assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); + }); + + test('does not publish an older tool-result transcript after reconnect recovery', async () => { + const initial = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const liveTranscript = deferred(); + const liveRefresh = new FakeSubscription( + continuitySnapshot(), + liveTranscript.promise, + 'subscription-2', + ); + const recoveredMessages = [ + userMessage('turn-1', 'Run it'), + assistantMessage('turn-1', 'Recovered'), + ]; + const recovered = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve(recoveredMessages), + 'subscription-3', + ); + const connection = new FakeConnection([initial, liveRefresh, recovered], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const replacements: Array<{ messages: readonly StoredMessage[]; reason: string }> = []; + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { + replacements.push({ messages, reason }); + }); + + initial.push(toolResultFrame(1)); + await waitFor(() => connection.openedSubscriptions === 2); + initial.fail( + new RuntimeHostSubscriptionError('connection_closed', 'connection lost during active Turn'), + ); + await waitFor(() => replacements.length === 1); + assert.deepEqual(replacements, [{ messages: recoveredMessages, reason: 'reconnect' }]); + + liveTranscript.resolve([userMessage('turn-1', 'Run it')]); + await delay(0); + assert.deepEqual(replacements, [{ messages: recoveredMessages, reason: 'reconnect' }]); + }); + + test('resnapshots an active Session after reconnect and continues its live stream', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello world')]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const transcript = deferred(); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { + assert.equal(reason, 'reconnect'); + transcript.resolve(messages); + }); + + initial.fail( + new RuntimeHostSubscriptionError('connection_closed', 'connection lost during active Turn'), + ); + assert.deepEqual(await transcript.promise, [assistantMessage('turn-1', 'Hello world')]); + assert.equal(connection.openedSubscriptions, 2); + replacement.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-2')); + assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); + }); + + test('recovers the complete terminal answer when a Turn finishes during reconnect', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const replacement = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 2, + rootTurn: completedTurn('turn-1', 'run-1'), + }), + Promise.resolve([ + assistantMessage('turn-1', 'Hello world'), + turnStateMessage('turn-1', 'completed'), + ]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + initial.fail(new RuntimeHostSubscriptionError('connection_closed', 'connection lost')); + const text = await nextEvent(switched.activeTurn.events); + assert.equal(text.type, 'text_complete'); + assert.equal(text.text, 'Hello world'); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + }); + + test('settles an attached Turn before publishing its reconnect-gap successor', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Working')]), + ); + const replacementMessages = [ + assistantMessage('turn-1', 'Finished'), + turnStateMessage('turn-1', 'completed'), + userMessage('turn-2', 'Continue'), + assistantMessage('turn-2', 'Continuing'), + ]; + const replacement = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + Promise.resolve(replacementMessages), + 'subscription-2', + ); + const successor = new FakeSubscription( + continuitySnapshot({ + projectionRevision: 3, + rootTurn: runningTurn('turn-2', 'run-2'), + }), + Promise.resolve(replacementMessages), + 'subscription-3', + ); + const connection = new FakeConnection([initial, replacement, successor], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const started = deferred(); + driver.subscribeStartedTurns!((turn) => started.resolve(turn)); + + initial.fail(new RuntimeHostSubscriptionError('connection_closed', 'connection lost')); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'text_complete'); + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + assert.equal((await started.promise).turnId, 'turn-2'); + }); +}); + +class FakeConnection { + readonly requests: Array<{ operation: string; input: unknown }> = []; + readonly sessionQueries: Array> = []; + openedSubscriptions = 0; + interactionQuery: unknown; + runtimeResourceQuery: unknown; + todoQuery: OperationOutput<'session.todo.query'> | undefined; + onRuntimeResourceStart: (() => Promise) | undefined; + executionBoundary: unknown = { kind: 'managed', access: 'read_write', revision: 1 }; + skillStartBlocked = false; + /** When set, runtime.resource.stop rejects with this error (e.g. a draining Host). */ + runtimeResourceStopFailure: Error | undefined; + /** Scripted outcomes for goal.control: return the result goal, or throw (e.g. operation_conflict). */ + readonly goalControlOutcomes: Array = []; + /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ + readonly goalQueryResults: Array = []; + readonly messageSubmitOutcomes: Array | Error> = []; + /** + * Operations held open by a test. The request is recorded on entry and then + * waits, so a test can hold one round trip and observe what the driver does + * with a second call while the first is still in flight. + */ + readonly heldOperations = new Map>(); + readonly userCommandResource = { + kind: 'shell_run' as const, + ref: 'maka://runtime/background-tasks/user-command', + mode: 'pipes' as const, + status: 'running' as const, + cwd: '/repo', + cmd: 'pwd', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: { + mode: 'pipes' as const, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }; + readonly configurationOutcomes: SessionUpdateResult[] = []; + readonly value: RuntimeHostMakaSessionDriverInput['connection']; + + constructor( + private readonly subscriptions: FakeSubscription[], + reconnecting = false, + ) { + this.value = { + ...(reconnecting ? { reconnecting: true as const } : {}), + rootId: 'a'.repeat(64), + hostEpoch: 'host-1', + request: (operation: K, input: OperationInput) => + this.request(operation, input), + openSessionSubscription: async () => { + const subscription = this.subscriptions[this.openedSubscriptions]; + this.openedSubscriptions += 1; + if (!subscription) throw new Error('No fake subscription available'); + return subscription; + }, + } satisfies RuntimeHostMakaSessionDriverInput['connection']; + } + + async request( + operation: K, + input: OperationInput, + ): Promise> { + this.requests.push({ operation, input }); + const held = this.heldOperations.get(operation); + if (held) await held; + if (operation === 'session.workspace.relocate') { + const workspace = (input as OperationInput<'session.workspace.relocate'>).workspace; + if (workspace.kind !== 'host_path') throw new Error('Expected Host-path workspace'); + return { + kind: 'committed', + session: sessionProjection({ + revision: 2, + workspace: { target: workspace, hostCwd: workspace.path }, + }), + } as OperationOutput; + } + if (operation === 'session.create') { + const create = input as OperationInput<'session.create'>; + return sessionProjection({ + id: create.sessionId, + workspace: { + target: create.workspace, + hostCwd: create.workspace.kind === 'host_path' ? create.workspace.path : '/project', + }, + }) as OperationOutput; + } + if (operation === 'session.branch.create') { + const copy = input as OperationInput<'session.branch.create'>; + return { + kind: 'committed', + session: sessionProjection({ + id: copy.targetSessionId, + labels: copy.intent === 'side_conversation' ? ['mode:side_conversation'] : [], + parentSessionId: copy.sourceSessionId, + branchOfTurnId: copy.sourceTurnId, + }), + } as OperationOutput; + } + if (operation === 'session.remove') { + return { + kind: 'removed', + sessionId: (input as OperationInput<'session.remove'>).sessionId, + } as OperationOutput; + } + if (operation === 'goal.control') { + const outcome = this.goalControlOutcomes.shift(); + if (outcome === undefined) throw new Error('Unexpected goal.control request'); + if (outcome instanceof Error) throw outcome; + return { + sessionId: (input as OperationInput<'goal.control'>).sessionId, + goal: outcome, + } as OperationOutput; + } + if (operation === 'goal.query') { + return { + sessionId: (input as OperationInput<'goal.query'>).sessionId, + goal: this.goalQueryResults.shift() ?? null, + } as OperationOutput; + } + if (operation === 'session.configuration.update') { + const update = input as OperationInput<'session.configuration.update'>; + const outcome = this.configurationOutcomes.shift(); + if (outcome) return outcome as OperationOutput; + return { + kind: 'committed', + session: sessionProjection({ + revision: update.expectedRevision + 1, + permissionMode: update.patch.permissionMode ?? 'ask', + }), + } as OperationOutput; + } + if (operation === 'runtime.resource.start') { + await this.onRuntimeResourceStart?.(); + return { resource: this.userCommandResource } as OperationOutput; + } + if (operation === 'runtime.resource.stop') { + if (this.runtimeResourceStopFailure) throw this.runtimeResourceStopFailure; + return { + resource: { + ...this.userCommandResource, + status: 'cancelled', + updatedAt: 2, + completedAt: 2, + revision: 2, + }, + } as OperationOutput; + } + if (operation === 'runtime.resource.query') { + if (this.runtimeResourceQuery === undefined) { + throw new Error('Unexpected Runtime Resource query'); + } + return this.runtimeResourceQuery as OperationOutput; + } + if (operation === 'session.todo.query') { + if (this.todoQuery === undefined) throw new Error('Unexpected Session Todo query'); + return this.todoQuery as OperationOutput; + } + if (operation === 'turn.stop') { + return {} as OperationOutput; + } + const turnInput = input as { + sessionId?: string; + turnId?: string; + content: { text: string }; + }; + const result: unknown = + operation === 'session.catalog.query' + ? { + kind: 'session', + session: await (this.sessionQueries.shift() ?? sessionProjection()), + } + : operation === 'session.execution_boundary.query' + ? this.executionBoundary + : operation === 'turn.message.submit' + ? (() => { + const outcome = this.messageSubmitOutcomes.shift(); + if (outcome instanceof Error) throw outcome; + return ( + outcome ?? { + disposition: + (input as OperationInput<'turn.message.submit'>).placement === 'next_turn' + ? 'followup' + : 'steering', + queueRevision: 2, + } + ); + })() + : operation === 'queue.retract' + ? { + hostEpoch: 'host-1', + queueRevision: 3, + retracted: [ + { + entryId: 'entry-1', + messageId: 'message-1', + content: { text: 'Later' }, + placement: 'next_turn', + }, + ], + } + : operation === 'interaction.answer' + ? (input as OperationInput<'interaction.answer'>).answer.kind === 'form' + ? { + ...pendingForm(), + revision: 2, + status: 'answered', + outcome: { + kind: 'form_answer', + action: 'accept', + values: { version: 'v2' }, + committedAt: 76, + }, + } + : { + ...pendingQuestion(), + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, + } + : operation === 'interaction.query' + ? this.interactionQuery + : operation === 'turn.start' + ? this.skillStartBlocked + ? { + kind: 'blocked', + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + } + : { + kind: 'started', + turn: { + sessionId: turnInput.sessionId, + turnId: turnInput.turnId, + runId: 'run-1', + status: 'running', + }, + skillInvocation: turnInput.content.text.includes('/skill:') + ? { + loaded: [{ id: 'alpha', name: 'Alpha' }], + failed: [], + receipts: [], + } + : { loaded: [], failed: [], receipts: [] }, + } + : undefined; + if (result === undefined) throw new Error(`Unexpected fake operation: ${operation}`); + return result as OperationOutput; + } +} + +class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator { + subscribePtyData(): () => void { + return () => undefined; + } + readonly #sessionDomainListeners = new Set< + (frame: Extract) => void + >(); + subscribeSessionDomainChanges( + listener: ( + frame: Extract, + ) => void, + ): () => void { + this.#sessionDomainListeners.add(listener); + return () => this.#sessionDomainListeners.delete(listener); + } + readonly hostEpoch = 'host-1'; + readonly activeAssistantStreams = []; + readonly transcriptBootstrap = null; + readonly subscriptionId: string; + readonly #frames: SubscriptionFrame[] = []; + readonly #waiters: Array<{ + resolve(result: IteratorResult): void; + reject(error: Error): void; + }> = []; + nextCalls = 0; + #closed = false; + #failure: Error | undefined; + + constructor( + readonly snapshot: SessionContinuitySnapshot, + private readonly transcript: Promise, + subscriptionId = 'subscription-1', + ) { + this.subscriptionId = subscriptionId; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + next(): Promise> { + this.nextCalls += 1; + const frame = this.#frames.shift(); + if (frame) return Promise.resolve({ done: false, value: frame }); + if (this.#failure) return Promise.reject(this.#failure); + if (this.#closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject })); + } + + push(frame: SubscriptionFrame): void { + if (frame.kind === 'subscription.session_domain_changed') { + for (const listener of this.#sessionDomainListeners) listener(frame); + } + const waiter = this.#waiters.shift(); + if (waiter) waiter.resolve({ done: false, value: frame }); + else this.#frames.push(frame); + } + + fail(error: Error): void { + this.#failure = error; + for (const waiter of this.#waiters.splice(0)) waiter.reject(error); + } + + async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + return (await this.transcript).map(decodeMessage); + } + + async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { + return []; + } + + async decodeTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async loadTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + + async close(): Promise { + this.#closed = true; + for (const waiter of this.#waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } +} + +function continuitySnapshot( + overrides: Partial = {}, +): SessionContinuitySnapshot { + return { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'session-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: runningTurn('turn-1', 'run-1'), + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + ...overrides, + }; +} + +function goalProjection(overrides: Partial = {}): GoalProjection { + return { + goalId: 'goal-1', + revision: 1, + sessionId: 'session-id', + condition: 'Ship the feature', + status: 'active', + setAt: 1, + iterations: 2, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: 100_000, + tokensSpent: 12_000, + lastReason: null, + achievedAt: null, + pausedAt: null, + ...overrides, + }; +} + +function runningTurn(turnId: string, runId: string) { + return { sessionId: 'session-1', turnId, runId, status: 'running' as const }; +} + +function completedTurn(turnId: string, runId: string) { + return { + sessionId: 'session-1', + turnId, + runId, + status: 'completed' as const, + completedAt: 80, + terminalEventId: `terminal-${turnId}`, + }; +} + +function sessionProjection( + overrides: Partial = {}, +): SessionCatalogProjection { + return { + id: 'session-1', + revision: 1, + workspace: { + target: { kind: 'host_path', path: '/tmp' }, + hostCwd: '/tmp', + }, + createdAt: 1, + activityAt: 2, + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + labelsTruncated: false, + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + connectionLocked: true, + model: 'gpt-5', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + ...overrides, + }; +} + +function assistantMessage(turnId: string, text: string): StoredMessage { + return { + type: 'assistant', + id: `message-${turnId}`, + turnId, + ts: 10, + text, + modelId: 'gpt-5', + }; +} + +function userMessage(turnId: string, text: string): Extract { + return { type: 'user', id: `user-${turnId}`, turnId, ts: 9, text }; +} + +function turnStateMessage( + turnId: string, + status: 'completed' | 'failed' | 'aborted', +): StoredMessage { + return { + type: 'turn_state', + id: `state-${turnId}`, + turnId, + ts: 80, + status, + }; +} + +function deltaFrame( + sequence: number, + turnId: string, + startOffset: number, + text: string, + subscriptionId = 'subscription-1', + runId = 'run-1', +): SubscriptionFrame { + return { + kind: 'subscription.session_delta', + hostEpoch: 'host-1', + subscriptionId, + sequence, + sessionId: 'session-1', + delta: { + kind: 'text', + turnId, + runId, + messageId: `message-${turnId}`, + startOffset, + text, + }, + }; +} + +function textCompleteFrame( + sequence: number, + turnId: string, + startOffset: number, + text: string, + subscriptionId = 'subscription-1', +): SubscriptionFrame { + return { + kind: 'subscription.session_delta', + hostEpoch: 'host-1', + subscriptionId, + sequence, + sessionId: 'session-1', + delta: { + kind: 'text', + turnId, + runId: 'run-1', + messageId: `message-${turnId}`, + startOffset, + text, + complete: true, + }, + }; +} + +function thinkingFrame( + sequence: number, + messageId: string, + startOffset: number, + text: string, + complete = false, +): SubscriptionFrame { + return { + kind: 'subscription.session_delta', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence, + sessionId: 'session-1', + delta: { + kind: 'thinking', + turnId: 'turn-1', + runId: 'run-1', + messageId, + startOffset, + text, + ...(complete ? { complete: true } : {}), + }, + }; +} + +function projectionFrame( + sequence: number, + rootTurn: NonNullable, + projectionRevision: number, + subscriptionId = 'subscription-1', +): SubscriptionFrame { + return { + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId, + sequence, + snapshot: continuitySnapshot({ projectionRevision, rootTurn }), + }; +} + +function pendingQuestion() { + return { + schemaVersion: 1 as const, + interactionId: 'question-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + status: 'pending' as const, + outcome: null, + request: { + kind: 'question' as const, + toolUseId: 'tool-question', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }, + }; +} + +function pendingForm() { + return { + schemaVersion: 1 as const, + interactionId: 'form-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + status: 'pending' as const, + outcome: null, + request: { + kind: 'form' as const, + toolUseId: 'tool-form', + message: 'Configure deployment', + requester: { name: 'deploy', source: 'Acme MCP' }, + fields: [{ kind: 'string' as const, name: 'version', label: 'Version', required: true }], + }, + }; +} + +function pendingPermission() { + return { + schemaVersion: 1 as const, + interactionId: 'permission-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1 as const, + status: 'pending' as const, + outcome: null, + request: { + kind: 'permission' as const, + toolUseId: 'tool-permission', + prompt: { + kind: 'tool_permission' as const, + toolName: 'Bash', + category: 'shell_unsafe' as const, + reason: 'shell_dangerous' as const, + review: { kind: 'command' as const, command: 'echo protected', cwd: '/tmp' }, + rememberForTurnAllowed: true, + }, + }, + }; +} + +async function nextEvent(events: AsyncIterable): Promise { + const iterator = events[Symbol.asyncIterator](); + const result = await Promise.race([ + iterator.next(), + delay(WAIT_BUDGET_MS).then(() => assert.fail('Timed out waiting for Session event')), + ]); + assert.equal(result.done, false); + return result.value; +} + +function sequenceIds(...ids: string[]): () => string { + let index = 0; + return () => ids[index++] ?? `id-${index}`; +} +async function waitFor(predicate: () => boolean): Promise { + await pollFor(predicate, { + timeoutMs: WAIT_BUDGET_MS, + message: 'Timed out waiting for fake Host state', + }); +} + +describe('turn consumer lag recovery (#3180)', () => { + async function floodTurnStream( + subscription: InstanceType, + count: number, + startOffset: number, + subscriptionId = 'subscription-1', + ): Promise { + let offset = startOffset; + for (let index = 0; index < count; index += 1) { + const text = `x${String(index).padStart(4, '0')}`; + subscription.push(deltaFrame(index + 1, 'turn-1', offset, text, subscriptionId)); + offset += text.length; + if (index % 64 === 63) await delay(0); + } + await delay(0); + } + + async function floodToolStream( + subscription: InstanceType, + count: number, + subscriptionId = 'subscription-1', + startSequence = 1, + ): Promise { + for (let index = 0; index < count; index += 1) { + subscription.push( + toolStartFrame(startSequence + index, startSequence + index, subscriptionId), + ); + if (index % 64 === 63) await delay(0); + } + await delay(0); + } + + async function floodToolOutput( + subscription: InstanceType, + count: number, + subscriptionId = 'subscription-1', + startSequence = 1, + ): Promise { + for (let index = 0; index < count; index += 1) { + subscription.push( + toolOutputDeltaFrame(startSequence + index, startSequence + index, subscriptionId), + ); + if (index % 64 === 63) await delay(0); + } + await delay(0); + } + + async function waitForSubscriptions(connection: FakeConnection, count: number): Promise { + const deadline = Date.now() + WAIT_BUDGET_MS; + while (connection.openedSubscriptions !== count && Date.now() < deadline) await delay(5); + assert.equal(connection.openedSubscriptions, count); + } + + function lagRecoveryFixture() { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const resynced = deferred(); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, _messages, reason) => { + if (reason === 'reconnect') resynced.resolve(); + }); + return { initial, replacement, connection, driver, resynced }; + } + + async function drainUntilDone(events: AsyncIterable): Promise { + const iterator = events[Symbol.asyncIterator](); + let completed = false; + for (let index = 0; index < 1_200; index += 1) { + const result = await iterator.next(); + if (result.done) return completed; + if ((result.value as { type?: string }).type === 'complete') completed = true; + } + return false; + } + + test('resubscribes instead of failing when a turn event consumer falls behind', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // Flood the unconsumed turn stream past its 1024-event bound. + await floodTurnStream(initial, 1_100, 5); + + // The channel retires the lagged subscription, resubscribes, and compacts + // the sheddable backlog the canonical resync supersedes. + await waitForSubscriptions(connection, 2); + await resynced.promise; + + // The stream never rejected, and live events land right away. + replacement.push(deltaFrame(1, 'turn-1', 5, ' world', 'subscription-2')); + assert.equal((await nextEvent(switched.activeTurn.events)).text, ' world'); + }); + + test('lands terminal events while shedding deltas from a lagging consumer', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + await floodTurnStream(initial, 1_100, 5); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + await floodTurnStream(replacement, 1_024, 5, 'subscription-2'); + replacement.push(projectionFrame(1_025, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + assert.ok( + await drainUntilDone(switched.activeTurn.events), + 'terminal complete event survived the lagged delta backlog', + ); + }); + + test('admits a terminal outcome when the lagged backlog holds no deltas', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // Fill the bound with non-delta events: nothing sheddable to evict. + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + await floodToolStream(replacement, 1_024, 'subscription-2'); + // The terminal outcome must land even though no delta can be evicted; + // process the frame before draining so the backlog is still full. + replacement.push(projectionFrame(1_025, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + assert.ok( + await drainUntilDone(switched.activeTurn.events), + 'terminal complete event was admitted over a non-delta backlog', + ); + }); + + test('admits assistant completion before the terminal outcome over a non-delta backlog', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + await floodToolStream(replacement, 1_024, 'subscription-2'); + replacement.push(textCompleteFrame(1_025, 'turn-1', 5, ' final answer', 'subscription-2')); + replacement.push(projectionFrame(1_026, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + + let finalOutput: string | undefined; + let completed = false; + for await (const event of switched.activeTurn.events) { + if (event.type === 'text_complete') finalOutput = event.text; + if (event.type === 'complete') completed = true; + } + assert.equal(finalOutput, 'Hello final answer'); + assert.equal(completed, true); + }); + + test('drops the entire pre-resync tool backlog at the canonical cut', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + replacement.push(toolStartFrame(1, 9_000, 'subscription-2')); + replacement.push(projectionFrame(2, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + + const iterator = switched.activeTurn.events[Symbol.asyncIterator](); + const first = await iterator.next(); + assert.equal(first.done, false); + assert.equal(first.value.type, 'tool_start'); + if (first.value.type === 'tool_start') assert.equal(first.value.toolUseId, 'tool-9000'); + }); + + test('admits a tool result when the lagged backlog holds no deltas', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // Fill the bound with non-delta events: nothing sheddable to evict. + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + await floodToolStream(replacement, 1_024, 'subscription-2'); + // The tool result is the authoritative terminal outcome for its tool and + // must land even though no delta can be evicted; otherwise the live tool + // card stays running until the durable transcript heals it. + replacement.push(toolResultFrame(1_025, 'subscription-2')); + replacement.push(projectionFrame(1_026, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + + let sawToolResult = false; + const iterator = switched.activeTurn.events[Symbol.asyncIterator](); + for (let index = 0; index < 1_200; index += 1) { + const result = await iterator.next(); + if (result.done) break; + if ((result.value as { type?: string }).type === 'tool_result') sawToolResult = true; + } + assert.ok(sawToolResult, 'tool_result was admitted over a non-delta backlog'); + }); + + test('sheds lagged tool output deltas so the tool result and terminal outcome land', async () => { + const { initial, replacement, connection, driver, resynced } = lagRecoveryFixture(); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // A noisy tool floods the unconsumed stream with seq-ordered output + // deltas, the realistic way a consumer falls behind. + await floodToolOutput(initial, 1_100); + await waitForSubscriptions(connection, 2); + await resynced.promise; + + await floodToolOutput(replacement, 1_024, 'subscription-2'); + // The canonical resync compacts the unseen tool deltas, so the tool + // result lands instead of being dropped behind a full non-delta backlog + // (which would leave the live card stuck at "running" until the durable + // transcript heals it). + replacement.push(toolResultFrame(1_025, 'subscription-2')); + replacement.push(projectionFrame(1_026, completedTurn('turn-1', 'run-1'), 2, 'subscription-2')); + await delay(0); + + let sawToolResult = false; + const iterator = switched.activeTurn.events[Symbol.asyncIterator](); + for (let index = 0; index < 1_200; index += 1) { + const result = await iterator.next(); + if (result.done) break; + if ((result.value as { type?: string }).type === 'tool_result') sawToolResult = true; + if ((result.value as { type?: string }).type === 'complete') { + assert.ok(sawToolResult, 'tool_result landed ahead of the terminal outcome'); + return; + } + } + assert.fail('stream ended without the terminal complete event'); + }); + + test('resubscribes when the live stream ends without a terminal close', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello world')]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const transcript = deferred(); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { + assert.equal(reason, 'reconnect'); + transcript.resolve(messages); + }); + + // A clean iterator end with no subscription.closed frame — e.g. the Host + // evicted the subscription as a slow consumer while the channel was still + // buffering the catch-up transcript — used to fail the channel + // permanently. It must resubscribe and continue the live stream instead. + await initial.close(); + assert.deepEqual(await transcript.promise, [assistantMessage('turn-1', 'Hello world')]); + assert.equal(connection.openedSubscriptions, 2); + replacement.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-2')); + assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); + }); + + test('recovers when slow-consumer closure is buffered during initial hydration', async () => { + const transcript = deferred(); + const initial = new FakeSubscription(continuitySnapshot(), transcript.promise); + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello world')]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + + const switching = driver.switchSession('session-1'); + await waitFor(() => initial.nextCalls === 1); + initial.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', + }); + await waitFor(() => initial.nextCalls === 2); + transcript.resolve([assistantMessage('turn-1', 'Hello')]); + + const switched = await switching; + assert.ok(switched.activeTurn); + assert.equal(connection.openedSubscriptions, 2); + replacement.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-2')); + assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); + }); + + test('backs off several immediate clean-EOF replacements before recovering', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const ended = [2, 3, 4].map( + (index) => + new FakeSubscription( + continuitySnapshot({ projectionRevision: index }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + `subscription-${index}`, + ), + ); + for (const subscription of ended) await subscription.close(); + const stable = new FakeSubscription( + continuitySnapshot({ projectionRevision: 5 }), + Promise.resolve([assistantMessage('turn-1', 'Hello world')]), + 'subscription-5', + ); + const connection = new FakeConnection([initial, ...ended, stable], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + const resynced = deferred(); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, _messages, reason) => { + if (reason === 'reconnect') resynced.resolve(); + }); + + await initial.close(); + await waitForSubscriptions(connection, 2); + await delay(5); + assert.equal(connection.openedSubscriptions, 2, 'the first repeated EOF is backoff-gated'); + + await resynced.promise; + assert.equal(connection.openedSubscriptions, 5); + stable.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-5')); + assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); + }); + + for (const [name, replacementRoot] of [ + ['the same terminal turn', completedTurn('turn-1', 'run-1')], + ['a successor turn', runningTurn('turn-2', 'run-2')], + ] as const) { + test(`preserves an unconsumed terminal event across a replacement with ${name}`, async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const replacement = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3, rootTurn: replacementRoot }), + Promise.resolve([ + assistantMessage('turn-1', 'Hello'), + turnStateMessage('turn-1', 'completed'), + ...(replacementRoot.turnId === 'turn-2' ? [userMessage('turn-2', 'Continue')] : []), + ]), + 'subscription-2', + ); + const connection = new FakeConnection([initial, replacement], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + initial.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 2)); + await delay(0); + initial.fail(new RuntimeHostSubscriptionError('connection_closed', 'connection lost')); + await waitForSubscriptions(connection, 2); + + assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); + assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); + }); + } + + test('exhausts recovery after repeated one-frame clean-EOF replacements', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const ended = Array.from({ length: 8 }, (_, index) => { + const subscription = new FakeSubscription( + continuitySnapshot({ projectionRevision: index + 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + `subscription-${index + 2}`, + ); + subscription.push(deltaFrame(1, 'turn-1', 5, String(index), `subscription-${index + 2}`)); + return subscription; + }); + for (const subscription of ended) await subscription.close(); + const connection = new FakeConnection([initial, ...ended], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + await initial.close(); + await assert.rejects(async () => { + for await (const _event of switched.activeTurn!.events) { + // Drain each replacement's single live frame until recovery fails. + } + }, /recovery exhausted its retry budget/u); + assert.equal(connection.openedSubscriptions, 9); + }); + + test('does not reset recovery after a silent replacement outlives the stability window', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const silent = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-2', + ); + const ended = Array.from({ length: 7 }, (_, index) => { + const subscription = new FakeSubscription( + continuitySnapshot({ projectionRevision: index + 3 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + `subscription-${index + 3}`, + ); + void subscription.close(); + return subscription; + }); + const connection = new FakeConnection([initial, silent, ...ended], true); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + await initial.close(); + await waitForSubscriptions(connection, 2); + await delay(1_100); + await silent.close(); + + await assert.rejects(async () => { + for await (const _event of switched.activeTurn!.events) { + // A silent hydrated subscription is not evidence of live stability. + } + }, /recovery exhausted its retry budget/u); + assert.equal(connection.openedSubscriptions, 9); + }); + + test('re-arms lag detection exactly at the hysteresis watermark', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const second = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-2', + ); + const third = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-3', + ); + const connection = new FakeConnection([initial, second, third], true); + let resyncs = 0; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, _messages, reason) => { + if (reason === 'reconnect') resyncs += 1; + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // Latch the lag flag with a non-delta backlog. The canonical cut clears + // every pre-cut event, then a still-wedged consumer fills again without + // triggering a resubscribe loop. + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await waitFor(() => resyncs === 1); + await floodToolStream(second, 1_100, 'subscription-2', 1); + await delay(20); + assert.equal(connection.openedSubscriptions, 2, 'the post-cut lag latch stayed armed'); + + // Draining to one event above the watermark (513 pending) must NOT + // re-arm: a fresh overflow on the still-latched queue is the same lag + // episode and triggers no new recovery. The flood refills the backlog + // to the bound. + const iterator = switched.activeTurn.events[Symbol.asyncIterator](); + for (let index = 0; index < 511; index += 1) { + assert.equal((await iterator.next()).done, false); + } + await floodToolStream(second, 600, 'subscription-2', 1_101); + await delay(20); + assert.equal(connection.openedSubscriptions, 2, 'lag latch held above the watermark'); + + // Draining the refilled backlog down to the watermark (512 pending) + // re-arms: the next overflow is a new lag episode and resubscribes again. + for (let index = 0; index < 512; index += 1) { + assert.equal((await iterator.next()).done, false); + } + await floodToolStream(second, 600, 'subscription-2', 1_701); + await waitForSubscriptions(connection, 3); + await waitFor(() => resyncs === 2); + }); + + test('recovers again when the consumer lags again after making progress', async () => { + const initial = new FakeSubscription( + continuitySnapshot(), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + ); + const second = new FakeSubscription( + continuitySnapshot({ projectionRevision: 2 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-2', + ); + const third = new FakeSubscription( + continuitySnapshot({ projectionRevision: 3 }), + Promise.resolve([assistantMessage('turn-1', 'Hello')]), + 'subscription-3', + ); + const connection = new FakeConnection([initial, second, third], true); + let resyncs = 0; + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + now: () => 50, + }); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, _messages, reason) => { + if (reason === 'reconnect') resyncs += 1; + }); + const switched = await driver.switchSession('session-1'); + assert.ok(switched.activeTurn); + + // First lag episode over a non-delta backlog. The canonical cut clears the + // retired subscription's events; a still-wedged consumer can fill again + // without immediately looping recovery. + await floodToolStream(initial, 1_100); + await waitForSubscriptions(connection, 2); + await waitFor(() => resyncs === 1); + await floodToolStream(second, 1_100, 'subscription-2', 1); + + // The consumer drains past the hysteresis watermark, re-arming lag + // detection, and fresh output flows again. One hundred events stay queued + // behind the delta, so the backlog never empties. + const iterator = switched.activeTurn.events[Symbol.asyncIterator](); + for (let index = 0; index < 600; index += 1) { + const result = await iterator.next(); + assert.equal(result.done, false); + } + second.push(deltaFrame(1_101, 'turn-1', 5, ' world', 'subscription-2')); + for (let index = 0; index < 100; index += 1) { + second.push(toolStartFrame(1_102 + index, 2_000 + index, 'subscription-2')); + } + await delay(0); + let fresh = ''; + for (let index = 0; index < 425; index += 1) { + const result = await iterator.next(); + assert.equal(result.done, false); + fresh = (result.value as { text?: string }).text ?? ''; + } + assert.equal(fresh, ' world'); + + // A second lag episode is a new episode, not a dead latch: it triggers a + // fresh canonical resync. The stream stays contiguous on `second`. + await floodToolStream(second, 1_100, 'subscription-2', 1_202); + await waitForSubscriptions(connection, 3); + await waitFor(() => resyncs === 2); + + third.push(projectionFrame(1, completedTurn('turn-1', 'run-1'), 3, 'subscription-3')); + await delay(0); + assert.ok( + await drainUntilDone(switched.activeTurn.events), + 'stream still completes after repeated lag recoveries', + ); + }); +}); + +function toolStartFrame( + sequence: number, + index: number, + subscriptionId = 'subscription-1', +): SubscriptionFrame { + return { + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId, + sequence, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_start', + id: `tool-${index}`, + turnId: 'turn-1', + ts: 10, + toolUseId: `tool-${index}`, + toolName: 'Bash', + }, + }; +} + +function toolOutputDeltaFrame( + sequence: number, + seq: number, + subscriptionId = 'subscription-1', +): SubscriptionFrame { + return { + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId, + sequence, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_output_delta', + id: `output-${seq}`, + turnId: 'turn-1', + ts: 10, + toolUseId: 'tool-1', + seq, + stream: 'stdout', + chunk: `chunk-${seq}`, + redacted: false, + createdAt: 10, + }, + }; +} + +function toolResultFrame(sequence: number, subscriptionId = 'subscription-1'): SubscriptionFrame { + return { + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId, + sequence, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_result', + id: 'result-tool-1', + turnId: 'turn-1', + ts: 11, + toolUseId: 'tool-1', + status: 'completed', + }, + }; +} diff --git a/packages/cli/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json b/packages/cli/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json new file mode 100644 index 0000000000..ba2a1b004a --- /dev/null +++ b/packages/cli/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-11T13:05:40.121Z", + "sessionId": "sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d", + "event": "PostToolUse", + "toolName": "Edit", + "file": "E:\\guahub\\gh\\fork\\maka\\packages\\runtime\\src\\__tests__\\ai-sdk-backend.test.ts", + "outcome": "inconclusive", + "coverage": "partial", + "findingCount": 0, + "durationMs": 8, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..5e6c90d826 --- /dev/null +++ b/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":["E:\\guahub\\gh\\fork\\maka\\packages\\core\\src\\events.ts"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtvk47wa-19000-9570adbbb7","createdAt":"2026-09-10T13:22:18.490Z","files":{"src/events.ts":{"existed":true,"snapshot":"7dd2fefe172bde28c172c523c27b1520acf1409f9160cceb6d736056fc402626.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-10T13:23:39.745Z"} \ No newline at end of file diff --git a/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk47wa-19000-9570adbbb7.baseline/7dd2fefe172bde28c172c523c27b1520acf1409f9160cceb6d736056fc402626.source b/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk47wa-19000-9570adbbb7.baseline/7dd2fefe172bde28c172c523c27b1520acf1409f9160cceb6d736056fc402626.source new file mode 100644 index 0000000000..9037c0863b --- /dev/null +++ b/packages/core/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk47wa-19000-9570adbbb7.baseline/7dd2fefe172bde28c172c523c27b1520acf1409f9160cceb6d736056fc402626.source @@ -0,0 +1,1357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Backend → UI unified event stream. + * + * Runtime backends normalize their provider-native streams to + * this `SessionEvent` union. The UI never imports SDK types directly. + * + * Connection-setup events live in ./connections.ts (separate channel). + */ + +import * as nodeCrypto from 'node:crypto'; +import type { ModelRetryDecision } from './model-failure.js'; +import { CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS, type SessionContextRef } from './context-offload.js'; +import type { + AdditionalPermissionRequest, + PermissionMode, + PermissionRequest, + PermissionResponse, + SandboxEscalationRequest, +} from './permission.js'; +import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js'; +import type { InteractionFormField, InteractionRequesterProjection } from './interaction.js'; +import type { UserQuestionRequest } from './user-question.js'; +import type { + ClientCapabilityGrantCapability, + ClientCapabilityGrantScope, +} from './client-capability-grant.js'; +import type { + PipeShellOutput, + PtyShellOutput, + ShellOutput, + ShellRunOperation, + ShellRunStatus, + ShellRunTerminalStatus, +} from './shell-run.js'; +export { SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES } from './shell-run.js'; +import { type TokenUsageFields } from './usage-record-schema.js'; +import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js'; +import type { DurableToolResultProjection } from './durable-tool-result-projection.js'; + +export const TOOL_OUTPUT_STREAMS = ['stdout', 'stderr'] as const; +export const TOOL_OUTPUT_DELTA_MAX_CHARS = 8192; +export const TOOL_ACTIVITY_KINDS = [ + // Driving the user's own machine is not "a tool call". It has its own risk, + // its own approval classes and its own place in a transcript, and reading it + // under the same gear icon as everything else hides the one activity a person + // most wants to pick out at a glance. + 'computer', + 'read', + 'search', + 'websearch', + 'webfetch', + 'edit', + 'command', + 'explore', + 'browser', + 'tool', +] as const; +export type ToolActivityKind = (typeof TOOL_ACTIVITY_KINDS)[number]; +type TerminalToolResultStatus = Exclude; + +// ============================================================================ +// Storage refs (shared by attachments, image tool results, etc.) +// ============================================================================ + +export type StorageRef = + | SessionContextRef + | { kind: 'session_file'; sessionId: string; relativePath: string } + | { kind: 'workspace_file'; relativePath: string } + | { kind: 'external_file'; absolutePath: string }; + +export interface AttachmentRef { + kind: 'image' | 'pdf' | 'doc' | 'code' | 'other'; + name: string; + mimeType: string; + bytes: number; + ref: StorageRef; +} + +/** A live directory on the originating Host, not a saved file or an access grant. */ +export interface DirectoryReference { + hostId: string; + path: string; +} + +export const DIRECTORY_REFERENCE_MAX_COUNT = 4; + +export function isDirectoryReference(value: unknown): value is DirectoryReference { + return ( + isRecord(value) && + Object.keys(value).length === 2 && + typeof value.hostId === 'string' && + /^[A-Za-z0-9_-]{1,128}$/.test(value.hostId) && + typeof value.path === 'string' && + value.path.length <= 4096 && + isCanonicalAbsolutePath(value.path) + ); +} + +/** + * An inline quoted excerpt attached to a user message — e.g. text selected in + * the transcript and carried into a follow-up. Unlike {@link AttachmentRef} + * (file-backed), the quoted text lives inline: it renders as a chip on the user + * bubble (never as raw body text) and is folded into the model-facing content. + */ +export interface QuoteRef { + text: string; + /** Optional label shown on the chip (e.g. the source turn's role/preview). */ + label?: string; + /** Provenance: the transcript turn the excerpt was selected from. */ + sourceTurnId?: string; +} + +/** + * Frozen display metadata for one token embedded in a sent message's visible + * text. The model-facing authority remains {@link MessageContent.text}; this + * record only lets clients replay the token the user actually sent without + * consulting a mutable Skill catalog or guessing file-path boundaries. + */ +export interface InlineReference { + kind: 'skill' | 'workspace_file'; + /** Exact serialized token value present in `displayText ?? text`. */ + value: string; + /** Display label captured when the message was accepted. */ + label: string; + /** UTF-16 offset of this exact occurrence in `displayText ?? text`. */ + start: number; +} + +/** Canonical user-authored content shared by storage, runtime, and Host wire. */ +export interface MessageContent { + /** + * Authoritative model-facing input. This may be a composed envelope when a + * client injects context such as explicit skill instructions. + */ + text: string; + /** Human-facing text when it differs from `text`; omit when equal. */ + displayText?: string; + /** Ordered attachment references; omit when empty. Attachment bytes never travel here. */ + attachments?: AttachmentRef[]; + directoryReferences?: DirectoryReference[]; + /** Ordered inline excerpts; omit when empty. Provenance remains part of content identity. */ + quotes?: QuoteRef[]; + /** Sent inline tokens; an empty array marks a current-format plain message. Never model-visible. */ + inlineReferences?: InlineReference[]; +} + +const MESSAGE_CONTENT_SHAPE = defineObjectShape()( + ['text'], + ['displayText', 'attachments', 'directoryReferences', 'quotes', 'inlineReferences'], +); + +/** + * A Turn message is meaningful when at least one of its three content carriers + * is present: inline text, an inline excerpt, or an attachment reference. + * Admission, compaction estimates, and recap projection must share this one + * predicate (#4804) — restating it per layer is how a quote-only message ends + * up admitted by one boundary and silently dropped by the next. + */ +export function hasMeaningfulMessageContent(content: MessageContent): boolean { + return ( + content.text.length > 0 || + (content.quotes?.length ?? 0) > 0 || + (content.attachments?.length ?? 0) > 0 + ); +} +const ATTACHMENT_REF_SHAPE = defineObjectShape()( + ['kind', 'name', 'mimeType', 'bytes', 'ref'], + [], +); +const QUOTE_REF_SHAPE = defineObjectShape()(['text'], ['label', 'sourceTurnId']); +const INLINE_REFERENCE_SHAPE = defineObjectShape()( + ['kind', 'value', 'label', 'start'], + [], +); +const INLINE_SKILL_REFERENCE_VALUE = /^\/skill:[A-Za-z0-9._-]+$/; +export const INLINE_REFERENCE_MAX_COUNT = 32; +const MAX_INLINE_REFERENCE_VALUE_LENGTH = 4_096; +export const INLINE_REFERENCE_LABEL_MAX_LENGTH = 200; +const SESSION_FILE_REF_SHAPE = defineObjectShape>()( + ['kind', 'sessionId', 'relativePath'], + [], +); +const SESSION_CONTEXT_REF_SHAPE = defineObjectShape< + Extract +>()(['kind', 'sessionId', 'refId'], []); +const WORKSPACE_FILE_REF_SHAPE = defineObjectShape< + Extract +>()(['kind', 'relativePath'], []); +const EXTERNAL_FILE_REF_SHAPE = defineObjectShape>()( + ['kind', 'absolutePath'], + [], +); + +export function normalizeMessageContent(content: MessageContent): MessageContent { + return { + text: content.text, + ...(content.directoryReferences?.length + ? { directoryReferences: content.directoryReferences.map((ref) => ({ ...ref })) } + : {}), + ...(content.displayText !== undefined && content.displayText !== content.text + ? { displayText: content.displayText } + : {}), + ...(content.attachments !== undefined && content.attachments.length > 0 + ? { + attachments: content.attachments.map((attachment) => ({ + ...attachment, + bytes: Object.is(attachment.bytes, -0) ? 0 : attachment.bytes, + ref: { ...attachment.ref }, + })), + } + : {}), + ...(content.quotes !== undefined && content.quotes.length > 0 + ? { + quotes: content.quotes.map((quote) => ({ + text: quote.text, + ...(quote.label !== undefined ? { label: quote.label } : {}), + ...(quote.sourceTurnId !== undefined ? { sourceTurnId: quote.sourceTurnId } : {}), + })), + } + : {}), + ...(content.inlineReferences !== undefined + ? { + inlineReferences: content.inlineReferences.map((reference) => ({ ...reference })), + } + : {}), + }; +} + +export function aggregateMessageContents(contents: readonly MessageContent[]): MessageContent { + const text = contents.map((content) => content.text).join('\n\n'); + const displayText = contents.map((content) => content.displayText ?? content.text).join('\n\n'); + const attachments = contents.flatMap((content) => content.attachments ?? []); + const directoryReferences = contents.flatMap((content) => content.directoryReferences ?? []); + const quotes = contents.flatMap((content) => content.quotes ?? []); + const inlineReferences: InlineReference[] = []; + const hasInlineReferenceMarker = contents.some( + (content) => content.inlineReferences !== undefined, + ); + let displayOffset = 0; + for (const content of contents) { + for (const reference of content.inlineReferences ?? []) { + if (inlineReferences.length === INLINE_REFERENCE_MAX_COUNT) break; + inlineReferences.push({ ...reference, start: displayOffset + reference.start }); + } + displayOffset += (content.displayText ?? content.text).length + 2; + } + return normalizeMessageContent({ + text, + ...(displayText !== text ? { displayText } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + ...(directoryReferences.length > 0 ? { directoryReferences } : {}), + ...(quotes.length > 0 ? { quotes } : {}), + ...(hasInlineReferenceMarker ? { inlineReferences } : {}), + }); +} + +export function decodeMessageContent(value: unknown): MessageContent { + if (!isMessageContent(value)) throw new TypeError('Invalid MessageContent'); + return normalizeMessageContent(value); +} + +export function isMessageContent(value: unknown): value is MessageContent { + return ( + isRecord(value) && + hasExactShape(value, MESSAGE_CONTENT_SHAPE) && + typeof value.text === 'string' && + (value.directoryReferences === undefined || + (Array.isArray(value.directoryReferences) && + value.directoryReferences.every(isDirectoryReference))) && + (value.displayText === undefined || typeof value.displayText === 'string') && + (value.attachments === undefined || + (Array.isArray(value.attachments) && value.attachments.every(isAttachmentRef))) && + (value.quotes === undefined || + (Array.isArray(value.quotes) && value.quotes.every(isQuoteRef))) && + (value.inlineReferences === undefined || + (Array.isArray(value.inlineReferences) && + value.inlineReferences.length <= INLINE_REFERENCE_MAX_COUNT && + value.inlineReferences.every(isInlineReference) && + inlineReferencesMatchText(value.inlineReferences, value.displayText ?? value.text))) + ); +} + +function inlineReferencesMatchText(references: readonly InlineReference[], text: string): boolean { + let previousEnd = 0; + for (const reference of references) { + if ( + reference.start < previousEnd || + text.slice(reference.start, reference.start + reference.value.length) !== reference.value + ) { + return false; + } + previousEnd = reference.start + reference.value.length; + } + return true; +} + +export function isInlineReference(value: unknown): value is InlineReference { + return ( + isRecord(value) && + hasExactShape(value, INLINE_REFERENCE_SHAPE) && + (value.kind === 'skill' || value.kind === 'workspace_file') && + typeof value.value === 'string' && + value.value.length > 0 && + value.value.length <= MAX_INLINE_REFERENCE_VALUE_LENGTH && + typeof value.label === 'string' && + value.label.length > 0 && + value.label.length <= INLINE_REFERENCE_LABEL_MAX_LENGTH && + typeof value.start === 'number' && + Number.isSafeInteger(value.start) && + value.start >= 0 && + (value.kind === 'skill' + ? INLINE_SKILL_REFERENCE_VALUE.test(value.value) + : value.value.startsWith('@') && + isCanonicalStorageRef({ + kind: 'workspace_file', + relativePath: value.value.slice(1), + })) + ); +} + +export function isQuoteRef(value: unknown): value is QuoteRef { + return ( + isRecord(value) && + hasExactShape(value, QUOTE_REF_SHAPE) && + typeof value.text === 'string' && + (value.label === undefined || typeof value.label === 'string') && + (value.sourceTurnId === undefined || typeof value.sourceTurnId === 'string') + ); +} + +export function isAttachmentRef(value: unknown): value is AttachmentRef { + return ( + isRecord(value) && + hasExactShape(value, ATTACHMENT_REF_SHAPE) && + (value.kind === 'image' || + value.kind === 'pdf' || + value.kind === 'doc' || + value.kind === 'code' || + value.kind === 'other') && + typeof value.name === 'string' && + typeof value.mimeType === 'string' && + typeof value.bytes === 'number' && + Number.isSafeInteger(value.bytes) && + value.bytes >= 0 && + isStorageRef(value.ref) + ); +} + +/** A structurally valid attachment whose metadata and locator are canonical at durable boundaries. */ +export function isCanonicalAttachmentRef(value: unknown): value is AttachmentRef { + return ( + isAttachmentRef(value) && + value.name.length > 0 && + value.mimeType.length > 0 && + isCanonicalStorageRef(value.ref) + ); +} + +export function isStorageRef(value: unknown): value is StorageRef { + if (!isRecord(value)) return false; + if (value.kind === 'session_file') { + return ( + hasExactShape(value, SESSION_FILE_REF_SHAPE) && + typeof value.sessionId === 'string' && + typeof value.relativePath === 'string' + ); + } + if (value.kind === 'session_context') { + return ( + hasExactShape(value, SESSION_CONTEXT_REF_SHAPE) && + typeof value.sessionId === 'string' && + typeof value.refId === 'string' + ); + } + if (value.kind === 'workspace_file') { + return hasExactShape(value, WORKSPACE_FILE_REF_SHAPE) && typeof value.relativePath === 'string'; + } + return ( + value.kind === 'external_file' && + hasExactShape(value, EXTERNAL_FILE_REF_SHAPE) && + typeof value.absolutePath === 'string' + ); +} + +export function isCanonicalStorageRef(value: unknown): value is StorageRef { + if (!isStorageRef(value)) return false; + if (value.kind === 'external_file') return isCanonicalAbsolutePath(value.absolutePath); + if ( + (value.kind === 'session_file' || value.kind === 'session_context') && + !/^[A-Za-z0-9_-]{1,128}$/.test(value.sessionId) + ) { + return false; + } + if (value.kind === 'session_context') { + return value.refId.length > 0 && [...value.refId].length <= CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS; + } + return isCanonicalRelativePath(value.relativePath); +} + +function isCanonicalRelativePath(path: string): boolean { + return ( + path.length > 0 && + !path.includes('\0') && + !path.includes('\\') && + !path.startsWith('/') && + !/^[A-Za-z]:/.test(path) && + path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') + ); +} + +function isCanonicalAbsolutePath(path: string): boolean { + if (path.length === 0 || path.includes('\0')) return false; + if (path.startsWith('/')) return true; + if (/^[A-Za-z]:[\\/]/.test(path)) return true; + return /^\\\\[^\\/]+[\\/][^\\/]+/.test(path); +} + +export function messageContentsEqual(left: MessageContent, right: MessageContent): boolean { + const leftDisplayText = left.displayText === left.text ? undefined : left.displayText; + const rightDisplayText = right.displayText === right.text ? undefined : right.displayText; + const leftAttachments = left.attachments?.length ? left.attachments : undefined; + const rightAttachments = right.attachments?.length ? right.attachments : undefined; + const leftQuotes = left.quotes?.length ? left.quotes : undefined; + const rightQuotes = right.quotes?.length ? right.quotes : undefined; + const leftInlineReferences = left.inlineReferences; + const rightInlineReferences = right.inlineReferences; + return ( + left.text === right.text && + leftDisplayText === rightDisplayText && + (left.directoryReferences?.length ?? 0) === (right.directoryReferences?.length ?? 0) && + (left.directoryReferences ?? []).every( + (ref, index) => + ref.hostId === right.directoryReferences?.[index]?.hostId && + ref.path === right.directoryReferences?.[index]?.path, + ) && + ((leftAttachments === undefined && rightAttachments === undefined) || + (leftAttachments !== undefined && + rightAttachments !== undefined && + leftAttachments.length === rightAttachments.length && + leftAttachments.every((attachment, index) => + attachmentRefsEqual(attachment, rightAttachments[index]!), + ))) && + ((leftQuotes === undefined && rightQuotes === undefined) || + (leftQuotes !== undefined && + rightQuotes !== undefined && + leftQuotes.length === rightQuotes.length && + leftQuotes.every((quote, index) => quoteRefsEqual(quote, rightQuotes[index]!)))) && + ((leftInlineReferences === undefined && rightInlineReferences === undefined) || + (leftInlineReferences !== undefined && + rightInlineReferences !== undefined && + leftInlineReferences.length === rightInlineReferences.length && + leftInlineReferences.every((reference, index) => + inlineReferencesEqual(reference, rightInlineReferences[index]!), + ))) + ); +} + +export function messageContentDigest(content: MessageContent): `sha256:${string}` { + return `sha256:${nodeCrypto + .createHash('sha256') + .update(JSON.stringify(canonicalizeMessageContent(normalizeMessageContent(content)))) + .digest('hex')}`; +} + +function canonicalizeMessageContent(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalizeMessageContent); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalizeMessageContent(entry)]), + ); +} + +function inlineReferencesEqual(left: InlineReference, right: InlineReference): boolean { + return ( + left.kind === right.kind && + left.value === right.value && + left.label === right.label && + left.start === right.start + ); +} + +function quoteRefsEqual(left: QuoteRef, right: QuoteRef): boolean { + return ( + left.text === right.text && + left.label === right.label && + left.sourceTurnId === right.sourceTurnId + ); +} + +function attachmentRefsEqual(left: AttachmentRef, right: AttachmentRef): boolean { + if ( + left.kind !== right.kind || + left.name !== right.name || + left.mimeType !== right.mimeType || + left.bytes !== right.bytes || + left.ref.kind !== right.ref.kind + ) { + return false; + } + switch (left.ref.kind) { + case 'session_context': + return ( + right.ref.kind === 'session_context' && + left.ref.sessionId === right.ref.sessionId && + left.ref.refId === right.ref.refId + ); + case 'session_file': + return ( + right.ref.kind === 'session_file' && + left.ref.sessionId === right.ref.sessionId && + left.ref.relativePath === right.ref.relativePath + ); + case 'workspace_file': + return ( + right.ref.kind === 'workspace_file' && left.ref.relativePath === right.ref.relativePath + ); + case 'external_file': + return right.ref.kind === 'external_file' && left.ref.absolutePath === right.ref.absolutePath; + } +} + +// ============================================================================ +// Event union +// ============================================================================ + +interface BaseEvent { + /** Event uuid — used for dedup on reconnect/replay. */ + id: string; + /** Groups all events from one agent turn. */ + turnId: string; + /** Unix ms timestamp. */ + ts: number; +} + +interface ToolActivityIdentity { + /** Execution surface that produced this tool activity. */ + origin?: 'provider' | 'code_mode'; + /** Provider-history projection policy for this activity. */ + modelVisibility?: 'visible' | 'hidden'; + /** Enclosing exec provider call, for nested CodeMode activity. */ + parentToolCallId?: string; + /** Enclosing exec durable operation, for nested CodeMode activity. */ + parentOperationId?: string; +} + +export type SessionEvent = + | TextDeltaEvent + | TextCompleteEvent + | ThinkingDeltaEvent + | ThinkingCompleteEvent + | ToolStartEvent + | ToolOutputDeltaEvent + | ToolProgressEvent + | ToolResultPreviewEvent + | ToolResultEvent + | AnyPermissionRequestEvent + | SandboxBoundaryRequestEvent + | SandboxBoundaryDecisionAckEvent + | ClientCapabilityRequestEvent + | ClientCapabilityDecisionAckEvent + | PermissionAnswerAckEvent + | PermissionClosureAckEvent + | PermissionDecisionAckEvent + | UserQuestionRequestEvent + | UserQuestionAnswerAckEvent + | FormRequestEvent + | FormAnswerAckEvent + | PlanSubmittedEvent + | TokenUsageEvent + | SteeringMessageEvent + | MessageAdmissionEvent + | QueueUpdateEvent + | ProviderRetryEvent + | ErrorEvent + | CompleteEvent + | AbortEvent + | ContextCompactionStartedEvent; + +export interface TextDeltaEvent extends BaseEvent { + type: 'text_delta'; + messageId: string; + /** Absolute UTF-16 offset for replay-safe streams; absent for append-only backends. */ + startOffset?: number; + text: string; +} + +export interface TextCompleteEvent extends BaseEvent { + type: 'text_complete'; + messageId: string; + text: string; + /** Provider-owned text metadata such as Responses URL citations. */ + providerOptions?: Record; +} + +export interface ThinkingDeltaEvent extends BaseEvent { + type: 'thinking_delta'; + messageId: string; + /** Absolute UTF-16 offset for replay-safe streams; absent for append-only backends. */ + startOffset?: number; + text: string; +} + +export interface ThinkingCompleteEvent extends BaseEvent { + type: 'thinking_complete'; + messageId: string; + text: string; + /** Anthropic signed thinking — MUST be re-sent on replay. */ + signature?: string; + /** Provider-owned replay metadata that must survive backend recreation. */ + providerOptions?: Record; +} + +export interface ToolStartEvent extends BaseEvent, ToolActivityIdentity { + type: 'tool_start'; + toolUseId: string; + toolName: string; + /** Bounded correlation for a shell-run observation without transporting full tool args. */ + shellRunRef?: string; + /** Runtime-owned durable tool-operation identity (Phase 2). */ + operationId?: string; + /** Stable semantic category for presentation; absent on legacy events. */ + activityKind?: ToolActivityKind; + args: unknown; + /** Provider-owned opaque call metadata that must survive model replay. */ + providerOptions?: Record; + /** True when the provider executed the tool inside the model request. */ + providerExecuted?: boolean; + displayName?: string; + intent?: string; + /** + * Transient, never persisted: a bounded/redacted args subset synthesized at + * the Runtime Host client seam (live `tool_start` frames omit full args). + * Display formatters read `args ?? argsPreview`; durable replay never has it. + */ + argsPreview?: unknown; + /** + * Id of the assistant step this tool call belongs to (equals the step's + * AssistantMessage id / the step's text+thinking messageId). Lets model + * replay group a step's reasoning + text + tool calls into one provider + * assistant message. Absent on legacy events; consumers treat a missing + * stepId as un-pairable (degraded, per-turn) history. + */ + stepId?: string; +} + +export type ToolOutputStream = (typeof TOOL_OUTPUT_STREAMS)[number]; + +/** + * Live output side-channel for long-running tools. + * + * This is intentionally separate from ToolResultEvent: deltas are transient UI + * updates, while tool_result remains the terminal persisted result. `seq` is + * monotonic per toolCallId/toolUseId so renderers can de-dupe and repair + * event/result races without relying on arrival order. + */ +export interface ToolOutputDeltaEvent extends BaseEvent, ToolActivityIdentity { + type: 'tool_output_delta'; + sessionId: string; + toolCallId: string; + /** Existing UI/runtime name for the same identifier. */ + toolUseId: string; + seq: number; + stream: ToolOutputStream; + chunk: string; + redacted: boolean; + createdAt: number; +} + +export interface ToolProgressEvent extends BaseEvent, ToolActivityIdentity { + type: 'tool_progress'; + toolUseId: string; + chunk: string | { kind: 'stdout' | 'stderr'; text: string }; +} + +export interface ToolStepProgress { + current: number; + total: number; +} + +const TOOL_STEP_PROGRESS_PATTERN = /^steps:(\d+)\/(\d+)$/; + +export function encodeToolStepProgress(progress: ToolStepProgress): string | undefined { + return isValidToolStepProgress(progress) + ? `steps:${progress.current}/${progress.total}` + : undefined; +} + +export function decodeToolStepProgress( + chunk: ToolProgressEvent['chunk'], +): ToolStepProgress | undefined { + if (typeof chunk !== 'string') return undefined; + const match = TOOL_STEP_PROGRESS_PATTERN.exec(chunk); + if (!match) return undefined; + const progress = { current: Number(match[1]), total: Number(match[2]) }; + return isValidToolStepProgress(progress) ? progress : undefined; +} + +function isValidToolStepProgress(progress: ToolStepProgress): boolean { + return ( + Number.isSafeInteger(progress.current) && + Number.isSafeInteger(progress.total) && + progress.current >= 0 && + progress.total >= 1 && + progress.current <= progress.total + ); +} + +/** + * Live-only open-facts for a tool that is still running (e.g. agent_spawn child ready). + * Not a durable transcript commit and not model-visible function_response. + * Terminal outcome remains a later tool_result. + */ +export type ToolResultPreviewContent = { + kind: 'subagent'; + /** Required: the sole purpose of this preview is mid-flight Open. */ + childSessionId: string; + agentId?: string; + agentName: string; + turnId: string; + runId?: string; + status: 'running'; + permissionMode: PermissionMode; +}; + +export interface ToolResultPreviewEvent extends BaseEvent, ToolActivityIdentity { + type: 'tool_result_preview'; + toolUseId: string; + isError: boolean; + content: ToolResultPreviewContent; +} + +export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity { + type: 'tool_result'; + toolUseId: string; + /** Runtime-owned durable tool-operation identity (Phase 2). */ + operationId?: string; + /** True when the provider executed the tool inside the model request. */ + providerExecuted?: boolean; + /** Raw provider result retained for provider-native replay; never rendered directly. */ + providerOutput?: unknown; + /** Provider-neutral model-visible output computed before durable publication. */ + modelProjection?: DurableToolResultProjection; + /** The transport omitted durable result content; consumers must not treat the placeholder as authoritative. */ + contentOmitted?: true; + isError: boolean; + content: ToolResultContent; + durationMs?: number; +} + +type ShellRunResultMetadata = { + kind: 'shell_run'; + ref: string; + status: ShellRunStatus; + cwd: string; + cmd: string; + startedAt: number; + updatedAt: number; + completedAt?: number; + exitCode?: number; + failureMessage?: string; + revision: number; + timeoutMs?: number; + sandboxDenial?: SandboxDenialSignal | SandboxDenialRecovery; +}; + +export interface SandboxDenialSignal { + likely: true; + backend?: 'macos-seatbelt' | 'linux' | 'windows'; +} + +export interface SandboxDenialRecovery extends SandboxDenialSignal { + recovery: 'require_escalated'; +} + +export interface SandboxBoundaryFailureSignal { + reason: 'sandbox_boundary_required' | 'requires_bypass'; + requiredExpansion?: SandboxBoundaryExpansion; + source?: 'client_capability'; +} + +export interface ToolUncertainOutcomeSignal { + code: 'outcome_unknown'; + retrySafe: false; +} + +export class ToolOutcomeUnknownError extends Error { + readonly code = 'outcome_unknown'; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ToolOutcomeUnknownError'; + } +} + +export type ShellRunCompactResult = ShellRunResultMetadata & + ({ mode: 'pipes'; output?: never } | { mode: 'pty'; output?: never }); + +export type ShellRunSnapshotResult = ShellRunResultMetadata & + ({ mode: 'pipes'; output: PipeShellOutput } | { mode: 'pty'; output: PtyShellOutput }); + +export type ShellRunStateResult = ShellRunCompactResult | ShellRunSnapshotResult; + +type ShellRunStopOperation = Extract; +type ShellRunPtyControlOperation = Extract; +type ShellRunToolResultContent = + | (ShellRunCompactResult & { operation?: never }) + | (ShellRunSnapshotResult & + ( + | { operation?: never } + | { operation: ShellRunStopOperation } + | { mode: 'pty'; output: PtyShellOutput; operation: ShellRunPtyControlOperation } + )); + +export type ToolResultContent = + | { + kind: 'text'; + text: string; + sandboxDenial?: SandboxDenialSignal; + sandboxFailure?: SandboxBoundaryFailureSignal; + uncertainOutcome?: ToolUncertainOutcomeSignal; + } + | { kind: 'json'; value: unknown } + | { kind: 'file_diff'; paths: string[]; diff: string } + | { kind: 'file_write'; path: string; bytes: number } + | { + kind: 'archived_tool_result'; + status: 'not_loaded' | 'missing' | 'corrupt'; + runtimeEventId: string; + toolCallId: string; + toolName: string; + artifactId?: string; + resourceRef?: string; + bodySha256?: string; + originalEstimatedTokens: number; + originalBytes: number; + rewriteVersion: number; + /** + * Both prune paths now record the same durable projection transition + * (#4283), so the archived-result read model spans both reasons. + */ + reason: + | 'stale_tool_result_pruned_before_compact' + | 'active_current_turn_tool_result_pruned_before_next_step'; + } + | { + kind: 'terminal'; + cwd: string; + cmd: string; + status: TerminalToolResultStatus; + exitCode?: number; + failureMessage?: string; + output: ShellOutput; + sandboxDenial?: SandboxDenialSignal | SandboxDenialRecovery; + } + | ShellRunToolResultContent + | { kind: 'image'; mimeType: string; ref: StorageRef } + | { kind: 'summary'; original: string; summarized: string; reason: 'too_large' } + /** + * PR-CHAT-WEB-SEARCH-RENDER-0: structured tool-result for the gated + * WebSearch agent tool. The chat renderer surfaces these as plain + * text cards (title + url + snippet + source); never markdown, never + * HTML, matching the Settings → 联网搜索 live-query verification surface. + * + * Rows are an opaque `unknown[]` here so the storage layer does not + * need to import the `@maka/core/web-search` row type; the renderer + * narrows each row at render time. + */ + | { + kind: 'web_search'; + provider: string; + query: string; + rows: ReadonlyArray<{ + title: string; + url: string; + snippet: string; + source: string; + }>; + } + | { + kind: 'web_search_error'; + ok: false; + provider: string; + query?: string; + reason: string; + message: string; + credentialSource?: string; + } + | { + kind: 'subagent'; + childSessionId?: string; + agentId?: string; + agentName: string; + turnId: string; + runId?: string; + status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user'; + permissionMode: PermissionMode; + summary: string; + artifactIds: readonly string[]; + startedAt?: number; + completedAt?: number; + durationMs?: number; + eventCount?: number; + failureClass?: string; + } + | { + kind: 'agent_swarm'; + status: 'completed' | 'partial' | 'failed' | 'cancelled'; + items: ReadonlyArray<{ + itemId: string; + index: number; + profile: string; + started: boolean; + childSessionId?: string; + agentId?: string; + agentName?: string; + turnId?: string; + runId?: string; + resumedFromRunId?: string; + status: 'completed' | 'failed' | 'cancelled'; + summary: string; + artifactIds: readonly string[]; + startedAt?: number; + completedAt?: number; + durationMs?: number; + failureClass?: string; + }>; + startedAt: number; + completedAt: number; + durationMs: number; + } + | { + kind: 'rive_workflow'; + ok: boolean; + action: string; + command: string[]; + state?: string; + ids: { + workflowRunId?: string; + schedulerRunId?: string; + rootWorkNodeId?: string; + }; + summary: string; + projection?: { + templateId?: string; + version?: number; + templateHash?: string; + idempotencyStatus?: string; + workflowRunId?: string; + schedulerRunId?: string; + rootWorkNodeId?: string; + state?: string; + schedulerState?: string; + rootState?: string; + }; + nodes?: ReadonlyArray<{ + id?: string; + templateId?: string; + title?: string; + state?: string; + runner?: string; + worker?: string; + }>; + stdoutTail?: string; + stderrTail?: string; + error?: { + reason: string; + message: string; + code?: string; + suggestedAction?: string; + }; + }; + +/** Durable ShellRun state updates use a separate observer channel from model turns. */ +export type ShellRunUpdateOwnership = + | { kind: 'local' } + | { kind: 'source_owned'; sourceSessionId: string; ownerSessionId: string } + | { kind: 'source_unavailable'; sourceSessionId: string }; + +export interface ShellRunUpdate { + /** Session whose conversation view should consume this projection. */ + sessionId: string; + /** Whether the process is local or inherited, and whether its real owner is still resolvable. */ + ownership: ShellRunUpdateOwnership; + sourceTurnId: string; + sourceToolCallId: string; + result: ShellRunStateResult; +} + +export interface PermissionRequestEvent extends BaseEvent, PermissionRequest { + type: 'permission_request'; +} + +export interface AdditionalPermissionRequestEvent extends BaseEvent, AdditionalPermissionRequest { + type: 'permission_request'; + /** Additional-permission prompts deliberately do not expose raw tool arguments. */ + args: undefined; + rememberForTurnAllowed?: false; +} + +export interface SandboxEscalationRequestEvent extends BaseEvent, SandboxEscalationRequest { + type: 'permission_request'; + /** Escalation prompts expose only bounded command and justification fields. */ + args: undefined; + rememberForTurnAllowed?: false; +} + +export type AnyPermissionRequestEvent = + | PermissionRequestEvent + | AdditionalPermissionRequestEvent + | SandboxEscalationRequestEvent; + +export interface UserQuestionRequestEvent extends BaseEvent, UserQuestionRequest { + type: 'user_question_request'; +} + +export interface FormRequestEvent extends BaseEvent { + type: 'form_request'; + requestId: string; + toolUseId: string; + message: string; + requester: InteractionRequesterProjection; + fields: readonly InteractionFormField[]; +} + +export interface SandboxBoundaryRequestEvent extends BaseEvent { + type: 'sandbox_boundary_request'; + requestId: string; + toolUseId: string; + justification: string; + expansion: SandboxBoundaryExpansion; +} + +export interface ClientCapabilityRequestEvent extends BaseEvent { + type: 'client_capability_request'; + requestId: string; + toolUseId: string; + capability: ClientCapabilityGrantCapability; + scope: ClientCapabilityGrantScope; +} + +/** + * The requests a session can park on while it waits for the user. Both are + * registered by RuntimeKernel while unanswered, so a surface that missed the + * live event can rehydrate the prompt instead of stranding the run. + */ +export type ActiveInteractionRequestEvent = + | SandboxBoundaryRequestEvent + | UserQuestionRequestEvent + | FormRequestEvent + | ClientCapabilityRequestEvent; + +export interface SandboxBoundaryDecisionAckEvent extends BaseEvent { + type: 'sandbox_boundary_decision_ack'; + requestId: string; + toolUseId: string; + decision: 'allow' | 'deny'; + status: Exclude; + revision: number; +} + +export interface ClientCapabilityDecisionAckEvent extends BaseEvent { + type: 'client_capability_decision_ack'; + requestId: string; + toolUseId: string; + decision: 'allow' | 'deny'; +} + +/** + * Echo that the backend accepted a user-question answer. + * The canonical answer remains owned by InteractionStore. + */ +export interface UserQuestionAnswerAckEvent extends BaseEvent { + type: 'user_question_answer_ack'; + requestId: string; + toolUseId: string; +} + +/** Echo that the hosted runtime accepted a form answer. */ +export interface FormAnswerAckEvent extends BaseEvent { + type: 'form_answer_ack'; + requestId: string; + toolUseId: string; +} + +/** + * Echo that the hosted runtime accepted a permission answer. + * The canonical decision remains owned by the Interaction outcome. + */ +export interface PermissionAnswerAckEvent extends BaseEvent { + type: 'permission_answer_ack'; + requestId: string; + toolUseId: string; +} + +export type PermissionClosureReason = 'timed_out'; + +/** + * Echo that the hosted runtime durably closed an unanswered permission request. + * This acknowledgement carries identity and closure reason only. + */ +export interface PermissionClosureAckEvent extends BaseEvent { + type: 'permission_closure_ack'; + requestId: string; + toolUseId: string; + reason: PermissionClosureReason; +} + +/** + * Embedded/legacy echo of a permission decision. Hosted execution uses the + * identity-only PermissionAnswerAckEvent instead. + */ +export interface PermissionDecisionAckEvent extends BaseEvent { + type: 'permission_decision_ack'; + requestId: string; + toolUseId: string; + decision: 'allow' | 'deny'; + rememberForTurn?: boolean; + reviewer?: import('./permission.js').ApprovalsReviewer; + rationale?: string; + riskLevel?: import('./permission.js').ApprovalRiskLevel; +} + +export interface PlanSubmittedEvent extends BaseEvent { + type: 'plan_submitted'; + planId: string; + proposalId?: string; + revision?: number; + title: string; + overview?: string; + risks?: string[]; + /** Legacy file-backed proposal representation. */ + markdownPath?: string; + steps?: PlanStep[]; +} + +export interface PlanStep { + id: string; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed' | 'skipped'; + files?: string[]; + complexity?: 'low' | 'medium' | 'high'; +} + +export interface TokenUsageEvent extends BaseEvent, TokenUsageFields { + type: 'token_usage'; +} + +/** + * A user message injected into a running turn at a step boundary (steering). + * The runtime persists it as a user event in the ledger and echoes it through + * the stream so the transcript renders the interjection in place. `text` is the + * raw user text; the backend wraps it in a steering envelope for the model. + */ +export interface SteeringMessageEvent extends BaseEvent { + type: 'steering_message'; + messageId: string; + content: MessageContent; + submittedContentDigest?: `sha256:${string}`; +} + +/** + * Transient Host projection fact: a submitted message now belongs to this + * Turn. It is emitted by the session projector, not by a backend or durable + * event ledger, so a client can bind a queued admission without guessing from + * timing or Turn ids returned by a stale command response. + */ +export interface MessageAdmissionEvent extends BaseEvent { + type: 'message_admission'; + messageId: string; + outcome: 'admitted' | 'retracted'; +} + +/** Host-owned placement for a submitted message projected through `queue_update`. */ +export type MessageQueuePlacement = 'current_turn' | 'next_turn'; +export type MessageQueueEntryState = 'queued' | 'in_flight'; +export type FollowUpMode = 'queue' | 'steer'; + +export interface MessageQueueEntryProjection { + entryId: string; + messageId: string; + content: MessageContent; + placement: MessageQueuePlacement; + state: MessageQueueEntryState; +} + +/** + * Authoritative queue snapshot pushed into the active turn's event stream + * whenever either pending queue changes (enqueue, step-boundary consumption, or + * interrupt clear). UI observers mirror it; the runtime owns the source of truth. + */ +export interface QueueUpdateEvent extends BaseEvent { + type: 'queue_update'; + queueRevision?: number; + steering: string[]; + followup: string[]; + steeringEntries?: MessageQueueEntryProjection[]; + followupEntries?: MessageQueueEntryProjection[]; +} + +export type ProviderRetryReason = + | 'stream_truncated' + | 'network' + | 'provider_capacity' + | 'provider_unavailable' + | 'rate_limit' + | 'timeout' + | 'unknown'; + +/** + * Transient progress for a provider request that Runtime will retry. + * + * This event is intentionally not a durable conversation fact. `attempt` + * names the next/current physical request (2–10), while `maxAttempts` + * includes the first request. + */ +export type ProviderRetryEvent = ProviderRetryScheduledEvent | ProviderRetryStartedEvent; + +export interface ProviderRetryScheduledEvent extends BaseEvent { + type: 'provider_retry'; + phase: 'scheduled'; + attempt: number; + maxAttempts: number; + delayMs: number; + /** + * Authoritative remaining wait at emission, as a DURATION — unlike `ts`, + * it carries no clock domain, so a client on another machine (remote + * Runtime Host) can count it down from its own receipt time without being + * skewed against the host clock. Runtime sets it to `delayMs` at + * scheduling; a host re-projection mid-wait recomputes it from the stored + * schedule time. Absent from older emitters; clients fall back to + * `delayMs`. + */ + remainingMs?: number; + reason: ProviderRetryReason; +} + +export interface ProviderRetryStartedEvent extends BaseEvent { + type: 'provider_retry'; + phase: 'started'; + attempt: number; + maxAttempts: number; + reason: ProviderRetryReason; +} + +export interface ErrorEvent extends BaseEvent { + type: 'error'; + retry?: ModelRetryDecision; + recoverable: boolean; + code?: string; + /** Stable machine-readable reason for UI / telemetry routing. */ + reason?: string; + message: string; + /** Adapter MUST scrub secrets before populating this field. */ + details?: string[] | Record; +} + +export interface CompleteEvent extends BaseEvent { + type: 'complete'; + stopReason: + | 'end_turn' + | 'user_stop' + | 'error' + | 'plan_handoff' + | 'graph_yield' + | 'permission_handoff' + | 'step_limit' + | 'max_tokens'; + /** Durable result of an explicit context-compaction execution. */ + contextCompactionOutcome?: ContextCompactionOutcome; +} + +export type ContextCompactionOutcome = + | { kind: 'compacted'; checkpointId: string } + | { kind: 'unchanged'; reason: string } + | { kind: 'failed'; reason: string }; + +export type CompleteStopReason = CompleteEvent['stopReason']; + +/** Stable failure taxonomy for complete events that did not finish the turn. */ +export function failureClassFromCompleteStopReason( + reason: CompleteStopReason, +): 'runtime_error' | 'tool_step_cap_reached' | undefined { + if (reason === 'error') return 'runtime_error'; + if (reason === 'step_limit') return 'tool_step_cap_reached'; + return undefined; +} + +export interface AbortEvent extends BaseEvent { + type: 'abort'; + reason: 'user_stop' | 'redirect' | 'timeout' | 'crash'; +} + +/** + * A host-owned explicit context-compaction Turn has started. Synthesized by the + * Runtime Host session projector (not the kernel) purely so a client can render + * a "compacting" transcript row while the Turn is in flight; it carries no + * durable state and is excluded from `BackendSessionEvent` like `queue_update`. + */ +export interface ContextCompactionStartedEvent extends BaseEvent { + type: 'context_compaction_started'; +} + +// ============================================================================ +// UI → Backend commands +// ============================================================================ + +/** + * SessionCommand: commands that target a specific session. + * + * Connection-management commands live in ConnectionCommand (./connections.ts). + * + * `permission_response` composes PermissionResponse rather than flattening + * its fields, so there is exactly ONE shape for a permission decision in + * the codebase. + */ +export type AttachmentIngestItem = + | { approvalId: string; name: string; mimeType?: string } + | { name: string; mimeType?: string; base64: string }; + +export type SessionCommand = + | { + type: 'send'; + turnId: string; + text: string; + attachmentItems?: AttachmentIngestItem[]; + } + | { type: 'stop' } + | { type: 'permission_response'; response: PermissionResponse } + | { + type: 'plan_response'; + planId: string; + action: 'approve' | 'refine'; + feedback?: string; + }; diff --git a/packages/core/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json b/packages/core/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json new file mode 100644 index 0000000000..e3e3c89f47 --- /dev/null +++ b/packages/core/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-10T13:23:24.288Z", + "sessionId": "sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d", + "event": "PostToolUse", + "toolName": "Edit", + "file": "src/events.ts", + "outcome": "clear", + "coverage": "complete", + "findingCount": 0, + "durationMs": 5, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 3762431167..d96da31e88 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -887,17 +887,35 @@ describe('runtimeEventHasModelVisibleContent', () => { ); }); - test('treats whitespace-only inline text as contentless everywhere (#4815 review)', () => { - // The desktop guard trims before judging; the shared predicate must trim - // too, or a whitespace-only message is admitted by the Host and then - // dropped by the desktop path — the same one-layer-accepts split this - // predicate exists to prevent. + test('keeps whitespace-only persisted text model-visible, without trimming (#4815 review)', () => { + // Replay visibility must stay compatible with everything admission has + // ever accepted. Trimming here would re-read stored whitespace-only + // events as invisible and block replay on them — #4804's own failure. + // Surfaces that want the trimmed judgement trim at their own boundary. assert.strictEqual( runtimeEventHasModelVisibleContent(baseEvent({ content: { kind: 'text', text: ' ' } })), - false, + true, + ); + assert.strictEqual(hasMeaningfulMessageContent({ text: ' ' }), true); + assert.strictEqual(hasMeaningfulMessageContent({ text: '' }), false); + assert.strictEqual(hasMeaningfulMessageContent({ text: '', quotes: [{ text: 'q' }] }), true); + }); + + test('counts directory references as a content carrier (#4815 review)', () => { + assert.strictEqual( + runtimeEventHasModelVisibleContent( + baseEvent({ + role: 'user', + content: { + kind: 'text', + text: '', + directoryReferences: [{ hostId: 'host-a', path: '/workspace/source' }], + }, + }), + ), + true, ); - assert.strictEqual(hasMeaningfulMessageContent({ text: ' ' }), false); - assert.strictEqual(hasMeaningfulMessageContent({ text: ' ', quotes: [{ text: 'q' }] }), true); + assert.strictEqual(hasMeaningfulMessageContent({ text: '', directoryReferences: [{ hostId: 'host-a', path: '/workspace/source' }] }), true); }); }); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index f3816feac2..b9c25e817d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -169,21 +169,26 @@ const MESSAGE_CONTENT_SHAPE = defineObjectShape()( ); /** - * A Turn message is meaningful when at least one of its three content carriers - * is present: inline text, an inline excerpt, or an attachment reference. - * Admission, compaction estimates, and recap projection must share this one - * predicate (#4804) — restating it per layer is how a quote-only message ends - * up admitted by one boundary and silently dropped by the next. The inline - * text is trimmed here so a whitespace-only message is judged contentless by - * every layer at once: the desktop guard already trims, and a predicate that - * did not would re-create the one-layer-accepts split on `" "` (#4815 - * review). + * A Turn message is meaningful when at least one of its four content carriers + * is present: inline text, an inline excerpt, an attachment reference, or a + * directory reference. Admission, compaction estimates, replay visibility, + * and recap projection must share this one predicate (#4804) — restating it + * per layer is how a quote-only message ends up admitted by one boundary and + * silently dropped by the next. + * + * The inline text is deliberately NOT trimmed. Admission asks "is this frame + * legal"; replay visibility asks "will the model see this already-persisted + * event", and that answer must stay compatible with everything admission has + * ever accepted — trimming here retroactively re-reads stored history as + * invisible and blocks replay on it (#4815 review). Surfaces that want the + * trimmed judgement (the desktop guard) trim at their own boundary. */ export function hasMeaningfulMessageContent(content: MessageContent): boolean { return ( - content.text.trim().length > 0 || + content.text.length > 0 || (content.quotes?.length ?? 0) > 0 || - (content.attachments?.length ?? 0) > 0 + (content.attachments?.length ?? 0) > 0 || + (content.directoryReferences?.length ?? 0) > 0 ); } const ATTACHMENT_REF_SHAPE = defineObjectShape()( diff --git a/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..cfc58ad9ad --- /dev/null +++ b/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":["E:\\guahub\\gh\\fork\\maka\\packages\\runtime-host\\src\\__tests__\\protocol.test.ts"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtvk9fb2-9208-c1160a1156","createdAt":"2026-09-10T13:26:21.374Z","files":{"src/__tests__/protocol.test.ts":{"existed":true,"snapshot":"b2494949d06c246ebd46b3b681a4b0bd984cd41970b10816f4ed1c8b172ceeb2.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"},{"stage":"baseline-snapshot","target":"../ui/src/__tests__/composer-send-toggle.test.tsx","reason":"file is outside project"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-11T13:07:11.658Z"} \ No newline at end of file diff --git a/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk9fb2-9208-c1160a1156.baseline/b2494949d06c246ebd46b3b681a4b0bd984cd41970b10816f4ed1c8b172ceeb2.source b/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk9fb2-9208-c1160a1156.baseline/b2494949d06c246ebd46b3b681a4b0bd984cd41970b10816f4ed1c8b172ceeb2.source new file mode 100644 index 0000000000..93a7409b24 --- /dev/null +++ b/packages/runtime-host/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvk9fb2-9208-c1160a1156.baseline/b2494949d06c246ebd46b3b681a4b0bd984cd41970b10816f4ed1c8b172ceeb2.source @@ -0,0 +1,2625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RuntimeHostProtocolError } from '../protocol/errors.js'; +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events'; +import { CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS } from '@maka/core/runtime-policy'; +import { + decodeClientCapabilityReplaceInput, + decodeClientFrame, + decodeHostFrame, + decodeHostRegistration, + decodeSessionMessageQueueProjection, + decodeSessionContinuitySnapshot, + encodeProtocolMessage, + HOST_OPERATION_SPECS, + MESSAGE_OPERATION_RESULT_MAX_BYTES, + MESSAGE_QUEUE_MAX_ENTRIES, + negotiateProtocol, + RUNTIME_HOST_COMPATIBILITY_EPOCH, + RUNTIME_HOST_MAX_MESSAGE_BYTES, + RUNTIME_HOST_PROTOCOL_VERSION, + SESSION_CONTINUITY_SCHEMA_VERSION, + SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES, + SESSION_LIVE_DELTA_MAX_BYTES, + SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES, + SESSION_TOOL_NAME_MAX_BYTES, + SUBSCRIPTION_OPEN_RESULT_MAX_BYTES, + TURN_MESSAGE_CONTENT_MAX_BYTES, + TURN_MESSAGE_TEXT_MAX_BYTES, + RUNTIME_POLICY_OPERATION_SPECS, +} from '../protocol/index.js'; +import { HOST_BOOTSTRAP_OPERATION_SPECS } from '../protocol/host-status.js'; +import { composeOperationSpecMaps } from '../protocol/operation-spec.js'; +import { + RUNTIME_HOST_DIAGNOSTIC_LOG_MAX_BYTES, + runtimeHostLogBuffer, +} from '../process-diagnostics.js'; +import { + TURN_MESSAGE_QUOTE_LABEL_MAX_LENGTH, + TURN_MESSAGE_QUOTE_MAX_COUNT, + TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH, + TURN_FAILURE_MESSAGE_MAX_BYTES, + decodeMessageContent, + TURN_SKILL_ID_MAX_COUNT, + TURN_SKILL_ID_MAX_LENGTH, +} from '../protocol/turn.js'; + +describe('Runtime Host bootstrap protocol', () => { + test('accepts only authenticated-listener registration endpoints on IPv4 loopback', () => { + const registration = { + kind: 'maka-runtime-host', + schemaVersion: 1, + rootId: 'a'.repeat(64), + hostEpoch: 'host-epoch', + endpoint: '/tmp/runtime-host.sock', + websocketEndpoints: ['ws://127.0.0.1:43210/runtime-host'], + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: 'maka.interactive', + compositionRevision: 'revision', + lifecycleMode: 'ephemeral', + state: 'ready', + pid: 1234, + createdAt: new Date(0).toISOString(), + } as const; + assert.deepEqual(decodeHostRegistration(registration).websocketEndpoints, [ + 'ws://127.0.0.1:43210/runtime-host', + ]); + assert.throws(() => + decodeHostRegistration({ + ...registration, + websocketEndpoints: ['ws://0.0.0.0:43210/runtime-host'], + }), + ); + assert.throws(() => + decodeHostRegistration({ + ...registration, + websocketEndpoints: ['ws://127.0.0.1:43210/runtime-host?credential=secret'], + }), + ); + }); + + test('decodes a Client hello without a surface identity', () => { + const hello = { + kind: 'hello', + clientInstanceId: 'client-without-surface', + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: 'maka.interactive', + } as const; + + assert.deepEqual(decodeClientFrame(hello), hello); + }); + + test('ignores a legacy surface identity while decoding a Client hello', () => { + const hello = { + kind: 'hello', + clientInstanceId: 'legacy-surface-client', + surface: 'tui', + protocolMin: RUNTIME_HOST_PROTOCOL_VERSION, + protocolMax: RUNTIME_HOST_PROTOCOL_VERSION, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, + compositionId: 'maka.interactive', + } as const; + + const { surface: _legacySurface, ...expected } = hello; + assert.deepEqual(decodeClientFrame(hello), expected); + }); + + test('publishes a new compatibility epoch for Session catalog live-run state', () => { + // Epoch 22 predates the live-run projection and rejects its added catalog + // field, so mixed-version peers must fail during the handshake instead. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22); + }); + + test('publishes a new compatibility epoch for mandatory submit Skill outcomes', () => { + // Submit Skill outcomes and explicit OAuth Connection targets independently + // claimed epoch 78, so their merge requires a distinct compatibility boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 78); + }); + + test('publishes a new compatibility epoch for Read image Session context refs', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 95); + }); + + test('rejects the legacy connection update result in the current compatibility epoch', () => { + assert.throws( + () => + decodeHostFrame({ + requestId: 'connection-update-legacy', + operation: 'connection.catalog.update', + ok: true, + result: { + kind: 'invalid_default_target', + target: { connectionId: '2a42da77-afac-4fb1-bff1-e7d6e6e55e9f', modelId: 'gpt-5' }, + }, + }), + isInvalidFrame, + ); + }); + + test('publishes a new compatibility epoch for external Session import state', () => { + // Epoch 25 added authoritative live run state. Requiring importState on + // external catalog items is another closed wire-schema change, so Clients + // and Hosts from epoch 25 must fail the handshake instead of decoding each + // other's catalog responses asymmetrically. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 25); + }); + + test('publishes a new compatibility epoch for sandbox failure results', () => { + // Epoch 32 rejects the bounded sandbox failure reason on live tool results, + // so mixed-version peers must fail the handshake. Asserted as a floor, like + // the epochs above: pinning an exact value breaks on every later bump. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 32); + }); + + test('publishes a new compatibility epoch for backend-free ScheduledTask templates', () => { + // Epoch 33 Clients require the `backend` field these templates no longer + // emit. Also a floor, for the same reason as above. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 33); + }); + + test('publishes a new compatibility epoch for Session trace pagination', () => { + // Epoch 34 peers cannot exchange the paged trace and usage frames. Also a + // floor, for the same reason as above. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 34); + }); + + test('publishes a new compatibility epoch for TraceTotals removal', () => { + // Epoch 35 peers still transport aggregate TraceTotals. Also a floor, for + // the same reason as above. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 35); + }); + + test('publishes a new compatibility epoch for the catalog search term', () => { + // Epoch 36 cannot carry the search term. Also a floor, for the same reason + // as above. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 36); + }); + + test('publishes a new compatibility epoch for the retired execute permission mode', () => { + // Epoch 37 still speaks `execute`. Frame decoders now reject it, so such a + // peer would fail mid-Session rather than at connect. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 37); + }); + + test('publishes a new compatibility epoch for Client Capability progress', () => { + // Epoch 38 peers reject the additional tool descriptor field and progress + // frame, so the capability must be negotiated at a newer epoch. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); + }); + + test('publishes a new compatibility epoch for nested Client Capability interactions', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 81); + }); + + test('publishes a new compatibility epoch for onboarding endpoint overrides', () => { + // Epoch 44 peers reject the required `baseUrl` and `connectionId` on + // onboarding inputs, and the `base_url_not_configured` / + // `connection_not_found` rejections on their results. Both landed in one + // epoch because neither shape was ever published separately. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 44); + }); + + test('publishes a new compatibility epoch for explicit onboarding targets', () => { + // Epoch 51 peers require nullable connectionId targeting and decode a + // successful save without its committed Connection identity. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 52); + }); + + test('publishes a new compatibility epoch for explicit OAuth Connection targets', () => { + // Epoch 53 peers still send connectionId directly and receive provider plus + // connectionId fields instead of one canonical Connection identity. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 53); + }); + + test('publishes a new compatibility epoch for queued message editing', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); + }); + + test('publishes a new compatibility epoch for the project registration preference', () => { + // Epoch 46 Hosts reject the optional preference field on the closed register + // input, so mixed-version peers must fail during the handshake instead. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 46); + }); + + test('publishes a new compatibility epoch for Side Conversation copy intent', () => { + // Epoch 47 belongs to project registration preferences on current main. + // Side Conversation adds another closed branch-copy input and therefore + // needs its own later handshake boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47); + }); + + test('publishes a new compatibility epoch for GitHub Copilot logins', () => { + // Main is at 101 and open PRs already claim 102. The new OAuth provider, + // enrollment query, and onboarding credential shape change the closed wire + // vocabulary, so this branch re-derives the first unclaimed epoch. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 102); + }); + + test('publishes a new compatibility epoch for context-budget failure detail', () => { + // Epoch 50 is already used by WorkHub coordination summaries on main. + // The context-budget detail therefore needs its own strictly newer + // handshake boundary so peers cannot accept the wrong closed shape. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + + test('publishes a new compatibility epoch for compound proxy updates', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 83); + }); + + test('publishes a new compatibility epoch for bound configuration credentials', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 84); + }); + + test('publishes a new compatibility epoch for explicit proxy credential updates', () => { + // Epoch 87 predates the Host-owned proxy credential mutation and its + // target-bound transfer result. Older peers cannot safely exchange these + // shapes, so the merged PR must advance the handshake boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 87); + }); + + test('publishes a new compatibility epoch for the removed execution.inspect.resolve operation', () => { + // Epoch 63 peers still know execution.inspect.resolve and would send it + // only to fail mid-connection now that it is gone, so its removal must + // fail the handshake instead. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 63); + assert.equal(Object.hasOwn(HOST_OPERATION_SPECS, 'execution.inspect.resolve'), false); + assert.equal(Object.hasOwn(HOST_OPERATION_SPECS, 'execution.inspect.query'), true); + }); + + test('adds credential rotation without changing existing credential inputs', () => { + const issueInput = { + principalKind: 'remote_owner', + principalId: 'desktop:test', + operationGrants: ['host.status'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }; + assert.deepEqual( + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput(issueInput), + issueInput, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({ + ...issueInput, + bindClientInstance: true, + }), + { ...issueInput, bindClientInstance: true }, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['access.credential.finalize'].decodeOutput({ + reconnectRequired: true, + }), + { reconnectRequired: true }, + ); + assert.throws(() => + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({ + replacementOfCredentialId: 'credential-current', + }), + ); + assert.throws(() => + HOST_OPERATION_SPECS['access.credential.revoke'].decodeInput({ + credentialId: 'credential-target', + requiredActiveCredentialId: 'credential-current', + }), + ); + assert.deepEqual( + HOST_OPERATION_SPECS['access.credential.rotation.prepare'].decodeInput({ + replacementOfCredentialId: 'credential-current', + }), + { replacementOfCredentialId: 'credential-current' }, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['access.credential.rotation.revoke'].decodeInput({ + credentialId: 'credential-target', + requiredActiveCredentialId: 'credential-current', + }), + { + credentialId: 'credential-target', + requiredActiveCredentialId: 'credential-current', + }, + ); + }); + + test('decodes Host-bound capability-provider ownership at a new compatibility boundary', () => { + const input = { + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: 'terminal-owner-credential', + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeInput(input), input); + assert.throws(() => + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({ + ...input, + bindClientInstance: true, + }), + ); + const output = { + credentialId: 'provider-credential', + deliveryId: 'provider-delivery', + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwner: { + principalId: 'terminal-owner', + clientInstanceId: 'terminal-client', + }, + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeOutput(output), output); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 67); + }); + + test('decodes atomic principal revocation and publishes its compatibility boundary', () => { + assert.deepEqual( + HOST_OPERATION_SPECS['access.principal.revoke'].decodeInput({ + principalKind: 'remote_owner', + principalId: 'desktop-owner:local-sharing', + }), + { + principalKind: 'remote_owner', + principalId: 'desktop-owner:local-sharing', + }, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['access.principal.revoke'].decodeOutput({ revoked: true }), + { revoked: true }, + ); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 54); + }); + + test('publishes a new compatibility epoch for Client-bound pairing claims', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 53); + }); + + test('publishes a new compatibility epoch for provider capacity retry progress', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 41); + }); + + test('publishes a new compatibility epoch for shell-run poll correlation', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 42); + }); + + test('publishes a new compatibility epoch for the retired Session timestamp', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); + }); + + test('publishes a new compatibility epoch for durable Message lifecycle queries', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + + test('publishes a new compatibility epoch for Message execution ownership', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 61); + }); + + test('publishes a new compatibility epoch for exact Session Connection identity', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); + }); + + test('publishes a new compatibility epoch for Host-bound directory references', () => { + // Epoch 80 belongs to catalog model-facts provenance on main. Directory + // references widen closed message inputs and need a later boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 80); + }); + + test('publishes a new compatibility epoch for catalog model-facts provenance', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 79); + }); + + test('publishes a new compatibility epoch for the optional conversation-copy sourceTurnId', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 99); + }); + + test('publishes a new compatibility epoch for external Session import failure reasons', () => { + // model_unavailable / source_unreadable let the shell classify import + // failures by stable code; older peers cannot decode the new codes. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 117); + }); + + test('publishes a new compatibility epoch for event-addressed transcript cursors', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 118); + }); + + test('publishes a new compatibility epoch for context-compaction transcript state', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 124); + }); + + test('selects the highest mutually supported protocol and rejects a gap', () => { + assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); + assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); + assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 1, max: 1 }), undefined); + assert.throws(() => negotiateProtocol({ min: -1, max: 0 }, { min: 0, max: 0 }), isInvalidFrame); + }); + + test('keeps the subscription queue Epoch correlated', () => { + assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 5); + const opened = { + requestId: 'open-1', + operation: 'subscription.open', + ok: true, + result: { + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + nextSequence: 1, + activeAssistantStreams: [{ kind: 'thinking', turnId: 'turn-1', messageId: 'message-1' }], + transcript: null, + snapshot: continuitySnapshot('epoch-1'), + }, + }; + assert.deepEqual(decodeHostFrame(opened), opened); + assert.throws( + () => + decodeHostFrame({ + ...opened, + result: { + ...opened.result, + activeAssistantStreams: [ + ...opened.result.activeAssistantStreams, + ...opened.result.activeAssistantStreams, + ], + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...opened, + result: { ...opened.result, snapshot: continuitySnapshot('epoch-2') }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeSessionContinuitySnapshot({ + ...continuitySnapshot('epoch-1'), + interactions: [], + }), + isInvalidFrame, + ); + const waiting = { + ...continuitySnapshot('epoch-1'), + rootTurn: { + ...continuitySnapshot('epoch-1').rootTurn, + status: 'waiting_for_user', + }, + }; + assert.deepEqual(decodeSessionContinuitySnapshot(waiting), waiting); + const retrying = { + ...continuitySnapshot('epoch-1'), + rootTurn: { + ...continuitySnapshot('epoch-1').rootTurn, + providerRetry: { + phase: 'scheduled' as const, + attempt: 8, + maxAttempts: 10, + delayMs: 40_000, + reason: 'rate_limit' as const, + }, + }, + }; + assert.deepEqual(decodeSessionContinuitySnapshot(retrying), retrying); + // Snapshots written after #3393 carry the host-clock schedule time so a + // re-projection can recompute the remaining wait; the field is optional + // for older snapshots. + const retryingWithTs = { + ...continuitySnapshot('epoch-1'), + rootTurn: { + ...continuitySnapshot('epoch-1').rootTurn, + providerRetry: { + phase: 'scheduled' as const, + attempt: 8, + maxAttempts: 10, + delayMs: 40_000, + ts: 1_700_000_000_000, + reason: 'rate_limit' as const, + }, + }, + }; + assert.deepEqual(decodeSessionContinuitySnapshot(retryingWithTs), retryingWithTs); + assert.throws( + () => + decodeSessionContinuitySnapshot({ + ...waiting, + rootTurn: { ...waiting.rootTurn, status: 'waiting_permission' }, + }), + isInvalidFrame, + ); + const oversized = { + ...opened, + result: { + ...opened.result, + activeAssistantStreams: Array.from({ length: 1_000 }, (_, index) => ({ + kind: 'text' as const, + turnId: 'turn-1', + messageId: `message-${index}-${'x'.repeat(96)}`, + })), + }, + }; + assert.ok( + Buffer.byteLength(JSON.stringify(oversized.result), 'utf8') > + SUBSCRIPTION_OPEN_RESULT_MAX_BYTES, + ); + assert.throws(() => decodeHostFrame(oversized), isInvalidFrame); + }); + + test('normalizes legacy Session statuses in continuity snapshots', () => { + for (const status of ['review', 'done']) { + const decoded = decodeSessionContinuitySnapshot({ + ...continuitySnapshot('epoch-1'), + session: { ...continuitySnapshot('epoch-1').session, status }, + }); + assert.equal(decoded.session.status, 'active'); + } + }); + + test('rejects unknown Session statuses in continuity snapshots', () => { + assert.throws( + () => + decodeSessionContinuitySnapshot({ + ...continuitySnapshot('epoch-1'), + session: { ...continuitySnapshot('epoch-1').session, status: 'unknown' }, + }), + isInvalidSessionStatus, + ); + }); + + test('decodes only privacy-normalized bounded subscription live frames', () => { + const envelope = { + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + }; + const identity = { + id: 'event-1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + }; + for (const event of [ + { + ...identity, + type: 'tool_start', + toolName: 'read', + displayName: 'Read file', + }, + { + ...identity, + type: 'tool_start', + toolName: 'Bash', + intent: '只读探索:定位渲染入口', + argsPreview: { command: 'git status --porcelain' }, + }, + { + ...identity, + type: 'tool_output_delta', + seq: 0, + stream: 'stdout', + chunk: 'visible output', + redacted: false, + createdAt: 2, + }, + { ...identity, type: 'tool_progress', chunk: 'working' }, + { ...identity, type: 'tool_result', status: 'completed', durationMs: 3 }, + { + ...identity, + type: 'tool_result', + status: 'errored', + sandboxFailureReason: 'sandbox_boundary_required', + }, + { + ...identity, + type: 'tool_result_preview', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Local Read', + turnId: 'turn-child', + status: 'running', + permissionMode: 'explore', + }, + }, + ]) { + assert.doesNotThrow(() => decodeHostFrame({ ...envelope, event })); + } + for (const event of [ + { + ...identity, + type: 'tool_start', + toolName: 'read', + args: { path: '/private' }, + }, + { + ...identity, + type: 'tool_start', + toolName: 'read', + argsPreview: { command: 'x'.repeat(9 * 1024) }, + }, + { + ...identity, + type: 'tool_start', + toolName: 'read', + intent: 42, + }, + { + ...identity, + type: 'tool_result', + status: 'errored', + result: { secret: true }, + }, + { + ...identity, + type: 'tool_result', + status: 'errored', + error: 'raw provider error', + }, + { + ...identity, + type: 'tool_result', + status: 'errored', + sandboxFailureReason: 'raw provider error', + }, + { + ...identity, + type: 'tool_result', + status: 'completed', + sandboxFailureReason: 'requires_bypass', + }, + { + ...identity, + type: 'tool_result_preview', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Local Read', + turnId: 'turn-child', + status: 'running', + permissionMode: 'explore', + summary: 'bulk is not open-facts', + }, + }, + ]) { + assert.throws(() => decodeHostFrame({ ...envelope, event }), isInvalidFrame); + } + + // The durable steering echo shares the session-event frame without a + // toolUseId; unknown keys stay rejected. + const steering = { + type: 'steering_message' as const, + id: 'steering-event-1', + turnId: 'turn-1', + ts: 7, + messageId: 'steering-message-1', + content: { text: 'steer the turn' }, + }; + const decodedSteering = decodeHostFrame({ ...envelope, event: steering }); + assert.ok('kind' in decodedSteering); + if ('kind' in decodedSteering) { + assert.equal(decodedSteering.kind, 'subscription.session_event'); + if (decodedSteering.kind === 'subscription.session_event') { + assert.deepEqual(decodedSteering.event, steering); + } + } + assert.throws( + () => decodeHostFrame({ ...envelope, event: { ...steering, toolUseId: 'tool-1' } }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...envelope, + event: { ...steering, content: { text: 'x'.repeat(49 * 1024) } }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + kind: 'subscription.session_delta', + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + delta: { + kind: 'thinking', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + text: 'private reasoning', + signature: 'provider-signature', + }, + }), + isInvalidFrame, + ); + const completion = { + kind: 'subscription.session_delta' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + delta: { + kind: 'thinking' as const, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + startOffset: 7, + text: '', + complete: true as const, + }, + }; + assert.deepEqual(decodeHostFrame(completion), completion); + const replacement = { + ...completion, + delta: { + kind: completion.delta.kind, + turnId: completion.delta.turnId, + runId: completion.delta.runId, + messageId: completion.delta.messageId, + startOffset: 0, + text: 'final', + reset: true as const, + }, + }; + assert.deepEqual(decodeHostFrame(replacement), replacement); + assert.throws( + () => + decodeHostFrame({ + ...replacement, + delta: { ...replacement.delta, startOffset: 1 }, + }), + isInvalidFrame, + ); + }); + + test('validates tool activity kinds at the wire boundary', () => { + const envelope = { + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + }; + const start = { + id: 'event-1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + type: 'tool_start' as const, + toolName: 'maka_computer', + }; + assert.doesNotThrow(() => + decodeHostFrame({ ...envelope, event: { ...start, activityKind: 'computer' } }), + ); + assert.throws( + () => decodeHostFrame({ ...envelope, event: { ...start, activityKind: 'desktop' } }), + isInvalidFrame, + ); + assert.throws( + () => decodeHostFrame({ ...envelope, event: { ...start, activityKind: 7 } }), + isInvalidFrame, + ); + }); + + test('enforces UTF-8 snapshot, live field, and whole-message byte bounds', () => { + const snapshot = continuitySnapshot('epoch-1'); + assert.ok(Buffer.byteLength(JSON.stringify(snapshot)) < SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES); + assert.throws( + () => + decodeSessionContinuitySnapshot({ + ...snapshot, + padding: 'x'.repeat(SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES), + }), + isInvalidFrame, + ); + const frame = { + kind: 'subscription.session_delta' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + delta: { + kind: 'text' as const, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + text: '界'.repeat(Math.floor(SESSION_LIVE_DELTA_MAX_BYTES / 3) + 1), + }, + }; + assert.throws(() => decodeHostFrame(frame), isInvalidFrame); + const eventEnvelope = { + kind: 'subscription.session_event', + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + }; + const eventIdentity = { id: 'event-1', turnId: 'turn-1', ts: 1, toolUseId: 'tool-1' }; + assert.throws( + () => + decodeHostFrame({ + ...eventEnvelope, + event: { + ...eventIdentity, + type: 'tool_start', + toolName: '界'.repeat(Math.floor(SESSION_TOOL_NAME_MAX_BYTES / 3) + 1), + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...eventEnvelope, + event: { + ...eventIdentity, + type: 'tool_progress', + chunk: '界'.repeat(Math.floor(SESSION_LIVE_DELTA_MAX_BYTES / 3) + 1), + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...frame, + privatePadding: 'x'.repeat(RUNTIME_HOST_MAX_MESSAGE_BYTES), + }), + isInvalidFrame, + ); + }); + + test('allows larger credential frames only for validated custom request headers', () => { + const secret = JSON.stringify( + Object.fromEntries( + Array.from({ length: 3 }, (_, index) => [`X-${index}`, '"'.repeat(8_192)]), + ), + ); + const secretBase64 = Buffer.from(secret, 'utf8').toString('base64'); + const requestHeadersLocator = { + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'request_headers', + } as const; + const apiKeyLocator = { ...requestHeadersLocator, kind: 'api_key' as const }; + const setCredential = RUNTIME_POLICY_OPERATION_SPECS['credential.vault.set']; + const exportCredentials = HOST_OPERATION_SPECS['configuration.credentials.export']; + + assert.doesNotThrow(() => + setCredential.decodeInput({ locator: requestHeadersLocator, expected: null, secret }), + ); + assert.throws( + () => setCredential.decodeInput({ locator: apiKeyLocator, expected: null, secret }), + isInvalidFrame, + ); + assert.doesNotThrow(() => + exportCredentials.decodeOutput({ + credential: { locator: requestHeadersLocator, secretBase64 }, + }), + ); + assert.doesNotThrow(() => + encodeProtocolMessage({ + requestId: 'credential-export', + operation: 'configuration.credentials.export', + ok: true, + result: { credential: { locator: requestHeadersLocator, secretBase64 } }, + }), + ); + assert.throws( + () => + exportCredentials.decodeOutput({ + credential: { locator: apiKeyLocator, secretBase64 }, + }), + isInvalidFrame, + ); + }); + + test('keeps connection credential transfer bound to an exact Host target', () => { + const locator = { + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'api_key', + } as const; + const expectedConnection = { + connectionId: locator.connectionId, + revision: 4, + slug: 'deepseek-main', + providerType: 'deepseek' as const, + effectiveBaseUrl: 'https://api.deepseek.com/', + }; + const setInput = { + locator, + expected: null, + expectedConnection, + secret: 'bound-secret', + }; + assert.deepEqual( + RUNTIME_POLICY_OPERATION_SPECS['credential.vault.set'].decodeInput(setInput), + setInput, + ); + const exportInput = { locator, expectedConnection }; + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeInput(exportInput), + exportInput, + ); + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput({ + credential: null, + connectionStale: { + expected: { connectionId: locator.connectionId, revision: 4 }, + actual: { connectionId: locator.connectionId, revision: 5 }, + }, + }), + { + credential: null, + connectionStale: { + expected: { connectionId: locator.connectionId, revision: 4 }, + actual: { connectionId: locator.connectionId, revision: 5 }, + }, + }, + ); + assert.throws( + () => + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeInput({ + locator: { scope: 'network_proxy', kind: 'password' }, + expectedConnection, + }), + isInvalidFrame, + ); + }); + + test('exports a proxy credential with its Host-read target binding', () => { + const locator = { scope: 'network_proxy', kind: 'password' } as const; + const result = { + credential: { + locator, + secretBase64: Buffer.from('proxy-secret').toString('base64'), + proxyTarget: { + protocol: 'https' as const, + host: 'proxy.example', + port: 8443, + username: 'proxy-user', + }, + }, + }; + + assert.deepEqual( + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput(result), + result, + ); + assert.throws( + () => + HOST_OPERATION_SPECS['configuration.credentials.export'].decodeOutput({ + credential: { + locator: { + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'api_key', + }, + secretBase64: Buffer.from('connection-secret').toString('base64'), + proxyTarget: result.credential.proxyTarget, + }, + }), + isInvalidFrame, + ); + }); + + test('keeps the connection update model limit aligned with the catalog', () => { + const updateConnection = RUNTIME_POLICY_OPERATION_SPECS['connection.catalog.update']; + const enabledModelIds = Array.from( + { length: CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS }, + (_, index) => `model-${index}`, + ); + const input = { + expected: { connectionId: '00000000-0000-4000-8000-000000000001', revision: 1 }, + changes: { + name: 'OpenRouter', + enabled: true, + enabledModelIds, + }, + }; + + assert.doesNotThrow(() => updateConnection.decodeInput(input)); + assert.throws( + () => + updateConnection.decodeInput({ + ...input, + changes: { + ...input.changes, + enabledModelIds: [...enabledModelIds, 'model-too-many'], + }, + }), + isInvalidFrame, + ); + }); + + test('keeps Runtime Policy request and response codecs exact', () => { + assert.deepEqual( + decodeClientFrame({ + requestId: 'policy-query', + operation: 'runtime.policy.query', + input: {}, + }), + { + requestId: 'policy-query', + operation: 'runtime.policy.query', + input: {}, + }, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'policy-query-extra', + operation: 'runtime.policy.query', + input: { secret: 'must-not-cross-wire' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'credential-status-secret', + operation: 'credential.vault.query', + ok: true, + result: { + kind: 'status', + status: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: false, + credentialId: null, + revision: null, + updatedAt: null, + secret: 'must-not-cross-wire', + }, + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'undeclared-error', + operation: 'runtime.policy.query', + ok: false, + error: { code: 'commit_outcome_unknown', message: 'not declared for query' }, + }), + isInvalidFrame, + ); + }); + + test('rejects password overrides on network proxy tests', () => { + assert.throws( + () => + HOST_OPERATION_SPECS['network-proxy.test'].decodeInput({ + password: 'must-not-cross-wire', + }), + isInvalidFrame, + ); + }); + + test('keeps compound proxy credentials write-only on the protocol', () => { + const operation = RUNTIME_POLICY_OPERATION_SPECS['runtime.policy.network-proxy.update']; + const input = { + expectedPolicyRevision: 4, + expectedCredential: null, + networkProxy: { + enabled: true, + protocol: 'http' as const, + host: '127.0.0.1', + port: 7897, + authEnabled: true, + username: 'proxy-user', + bypassList: ['localhost'], + autoBypassDomains: ['127.0.0.1'], + }, + credential: { + kind: 'replace' as const, + secret: 'write-only-secret', + expectedTarget: { + protocol: 'http' as const, + host: '127.0.0.1', + port: 7897, + username: 'proxy-user', + }, + }, + }; + assert.deepEqual(operation.decodeInput(input), input); + assert.deepEqual( + operation.decodeOutput({ + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + }, + }), + { + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + }, + }, + ); + assert.deepEqual( + operation.decodeOutput({ + kind: 'proxy_target_mismatch', + expected: input.credential.expectedTarget, + actual: { + protocol: 'https', + host: 'proxy.example', + port: 8443, + username: 'other-user', + }, + }), + { + kind: 'proxy_target_mismatch', + expected: input.credential.expectedTarget, + actual: { + protocol: 'https', + host: 'proxy.example', + port: 8443, + username: 'other-user', + }, + }, + ); + assert.throws( + () => + operation.decodeOutput({ + kind: 'committed', + revision: 5, + credentialStatus: { + locator: { scope: 'network_proxy', kind: 'password' }, + configured: true, + credentialId: '00000000-0000-4000-8000-000000000001', + revision: 2, + updatedAt: 1, + secret: 'must-not-cross-wire', + }, + }), + isInvalidFrame, + ); + }); + + test('encodes maximum legal tool output as one bounded frame without identity loss', () => { + const chunks = [ + ['CJK', '界'.repeat(TOOL_OUTPUT_DELTA_MAX_CHARS)], + ['NUL', '\0'.repeat(TOOL_OUTPUT_DELTA_MAX_CHARS)], + ['lone surrogate', '\ud800'.repeat(TOOL_OUTPUT_DELTA_MAX_CHARS)], + ] as const; + for (const [label, chunk] of chunks) { + assert.ok( + Buffer.byteLength(chunk, 'utf8') <= SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES, + `${label} exceeds the tool output raw-byte bound`, + ); + const frame = { + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_output_delta' as const, + id: `event-${label}`, + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + seq: 23, + stream: 'stdout' as const, + chunk, + redacted: false, + createdAt: 2, + }, + }; + + const encoded = encodeProtocolMessage(frame); + assert.ok( + encoded.byteLength <= RUNTIME_HOST_MAX_MESSAGE_BYTES, + `${label} envelope exceeds the protocol message limit`, + ); + const decoded = decodeHostFrame(JSON.parse(encoded.toString('utf8'))); + assert.ok('kind' in decoded); + if (!('kind' in decoded)) continue; + assert.equal(decoded.kind, 'subscription.session_event'); + if (decoded.kind !== 'subscription.session_event') continue; + assert.equal(decoded.event.type, 'tool_output_delta'); + if (decoded.event.type !== 'tool_output_delta') continue; + assert.equal(decoded.event.id, `event-${label}`); + assert.equal(decoded.event.seq, 23); + assert.equal(decoded.event.chunk, chunk); + } + }); + + test('encodes a legal large sandbox boundary Interaction without disconnecting the client', () => { + const identity = 'i'.repeat(128); + const frame = { + requestId: 'q'.repeat(128), + operation: 'interaction.query' as const, + ok: true as const, + result: { + schemaVersion: 1 as const, + interactionId: identity, + sessionId: identity, + turnId: identity, + runId: identity, + revision: 2 as const, + request: { + kind: 'sandbox_boundary' as const, + expansion: { + filesystem: { + entries: Array.from({ length: 32 }, (_, index) => ({ + path: `/opt/service-${index}/${'x'.repeat(1_980)}`, + access: 'read' as const, + scope: 'exact' as const, + })), + }, + }, + justification: '\u0001'.repeat(2_000), + }, + status: 'answered' as const, + outcome: { + kind: 'sandbox_boundary_decision' as const, + decision: 'allow' as const, + status: 'approved' as const, + committedAt: Number.MAX_SAFE_INTEGER, + }, + }, + }; + + const canonical = decodeHostFrame(frame); + assert.ok(Buffer.byteLength(`${JSON.stringify(canonical)}\n`, 'utf8') > 64 * 1024); + const encoded = encodeProtocolMessage(canonical); + assert.ok(encoded.byteLength <= RUNTIME_HOST_MAX_MESSAGE_BYTES); + assert.deepEqual(decodeHostFrame(JSON.parse(encoded.toString('utf8'))), canonical); + }); + + test('keeps the operation registry closed at request and response boundaries', () => { + assert.throws( + () => decodeClientFrame({ requestId: 'request-1', operation: 'store.read', input: {} }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-2', + operation: 'turn.query', + input: { sessionId: 'session-1', turnId: 'turn-1', path: '/tmp/private' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-3', + operation: 'turn.query', + ok: false, + error: { code: 'session_busy', message: 'busy' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-unknown-field', + operation: 'host.status', + ok: false, + error: { code: 'host_draining', message: 'draining' }, + trace: 'private', + }), + isInvalidFrame, + ); + }); + + test('keeps safe-boundary continuation plans closed and bounded', () => { + const query = { + requestId: 'resume-query-1', + operation: 'turn.resume.query' as const, + input: { + sessionId: 'session-1', + sourceRunId: 'run-source-1', + expectedRuntimeEventHighWater: 2, + }, + }; + assert.deepEqual(decodeClientFrame(query), query); + assert.throws( + () => + decodeClientFrame({ + ...query, + input: { sessionId: 'session-1', expectedRuntimeEventHighWater: 2 }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...query, + input: { ...query.input, expectedRuntimeEventHighWater: 0 }, + }), + isInvalidFrame, + ); + + const ready = { + requestId: query.requestId, + operation: query.operation, + ok: true as const, + result: { + sessionId: 'session-1', + disposition: 'ready' as const, + sourceRunId: 'run-source-1', + sourceTurnId: 'turn-source-1', + sourceRuntimeEventHighWater: 2, + }, + }; + assert.deepEqual(decodeHostFrame(ready), ready); + assert.throws( + () => + HOST_OPERATION_SPECS['turn.resume.query'].assertOutputForInput?.(query.input, { + ...ready.result, + sourceRuntimeEventHighWater: 3, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...ready, + result: { ...ready.result, diagnostics: ['private runtime detail'] }, + }), + isInvalidFrame, + ); + + const parked = { + requestId: query.requestId, + operation: query.operation, + ok: true as const, + result: { + sessionId: 'session-1', + disposition: 'parked' as const, + reason: 'safety_check_failed' as const, + }, + }; + assert.deepEqual(decodeHostFrame(parked), parked); + for (const reason of [ + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + ] as const) { + const unavailable = { + ...parked, + result: { ...parked.result, reason }, + }; + assert.deepEqual(decodeHostFrame(unavailable), unavailable); + } + assert.throws( + () => + decodeHostFrame({ + ...parked, + result: { ...parked.result, reason: 'continuation_unavailable' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...parked, + result: { ...parked.result, reason: 'workspace_identity_mismatch' }, + }), + isInvalidFrame, + ); + + const start = { + requestId: 'resume-start-1', + operation: 'turn.resume.start' as const, + input: { + sessionId: 'session-1', + turnId: 'turn-resume-1', + sourceRunId: 'run-source-1', + sourceRuntimeEventHighWater: 2, + }, + }; + assert.deepEqual(decodeClientFrame(start), start); + const started = { + requestId: start.requestId, + operation: start.operation, + ok: true as const, + result: { + kind: 'started' as const, + turn: { + sessionId: 'session-1', + turnId: 'turn-resume-1', + runId: 'run-resume-1', + status: 'running' as const, + }, + }, + }; + assert.deepEqual(decodeHostFrame(started), started); + assert.throws( + () => + decodeHostFrame({ + ...started, + result: { kind: 'parked', plan: ready.result }, + }), + isInvalidFrame, + ); + }); + + test('requires stable Message command identities, origin Host Epoch, and exact inputs', () => { + const query = { + requestId: 'query-request-1', + operation: 'turn.message.query' as const, + input: { + sessionId: 'session-1', + messageIds: ['message-1', 'message-2', 'message-3'], + }, + }; + const executionQuery = { + requestId: 'execution-query-request-1', + operation: 'turn.message.execution.query' as const, + input: query.input, + }; + const submit = { + requestId: 'submit-request-1', + operation: 'turn.message.submit' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content: { text: 'adjust the active turn' }, + placement: 'current_turn' as const, + }, + }; + const retract = { + requestId: 'retract-request-1', + operation: 'queue.retract' as const, + input: { originHostEpoch: 'epoch-1', sessionId: 'session-1', retractId: 'retract-1' }, + }; + const interrupt = { + requestId: 'interrupt-request-1', + operation: 'turn.interrupt' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + interruptId: 'interrupt-1', + turnId: 'turn-1', + runId: 'run-1', + }, + }; + assert.deepEqual(decodeClientFrame(query), query); + assert.deepEqual(decodeClientFrame(executionQuery), executionQuery); + const queried = { + requestId: executionQuery.requestId, + operation: executionQuery.operation, + ok: true as const, + result: { + resolutions: [ + { messageId: 'message-1', state: 'pending' as const }, + { + messageId: 'message-2', + state: 'owned' as const, + turnId: 'turn-2', + runId: 'run-2', + }, + { messageId: 'message-3', state: 'cancelled' as const }, + ], + }, + }; + assert.deepEqual(decodeHostFrame(queried), queried); + assert.throws( + () => + decodeHostFrame({ + ...queried, + result: { + ...queried.result, + resolutions: [...queried.result.resolutions, ...queried.result.resolutions], + }, + }), + isInvalidFrame, + ); + assert.deepEqual(decodeClientFrame(submit), submit); + assert.deepEqual(decodeClientFrame(retract), retract); + assert.deepEqual(decodeClientFrame(interrupt), interrupt); + const entryRetract = { + requestId: 'entry-retract-request-1', + operation: 'queue.entry.retract' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + entryId: 'entry-1', + retractId: 'retract-2', + }, + }; + const entryPromote = { + requestId: 'entry-promote-request-1', + operation: 'queue.entry.promote' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + entryId: 'entry-1', + promoteId: 'promote-1', + }, + }; + const entryUpdate = { + requestId: 'entry-update-request-1', + operation: 'queue.entry.update' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + entryId: 'entry-1', + updateId: 'update-1', + expectedQueueRevision: 7, + text: 'updated message', + }, + }; + const entriesReorder = { + requestId: 'entries-reorder-request-1', + operation: 'queue.entries.reorder' as const, + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + reorderId: 'reorder-1', + entryIds: ['entry-2', 'entry-1'], + }, + }; + assert.deepEqual(decodeClientFrame(entryRetract), entryRetract); + assert.deepEqual(decodeClientFrame(entryPromote), entryPromote); + assert.deepEqual(decodeClientFrame(entryUpdate), entryUpdate); + assert.deepEqual(decodeClientFrame(entriesReorder), entriesReorder); + assert.throws( + () => + decodeClientFrame({ + ...entryUpdate, + input: { ...entryUpdate.input, text: ' ' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...entriesReorder, + input: { ...entriesReorder.input, entryIds: ['entry-1', 'entry-1'] }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...entriesReorder, + input: { ...entriesReorder.input, entryIds: ['not/a/semantic/id'] }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...entryRetract, + input: { ...entryRetract.input, generation: 1 }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...entryPromote, + input: { ...entryPromote.input, entryId: 'not/a/semantic/id' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ ...submit, input: { ...submit.input, originHostEpoch: undefined } }), + isInvalidFrame, + ); + assert.throws( + () => decodeClientFrame({ ...retract, input: { ...retract.input, generation: 1 } }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + ...interrupt, + input: { ...interrupt.input, interruptId: 'not/a/semantic/id' }, + }), + isInvalidFrame, + ); + }); + + test('decodes old-Epoch ambiguity only for operations that declare outcome_unknown', () => { + const response = { + requestId: 'submit-old-epoch', + operation: 'turn.message.submit' as const, + ok: false as const, + error: { + code: 'outcome_unknown' as const, + message: 'Message disposition cannot be proven in this Host Epoch', + }, + }; + assert.deepEqual(decodeHostFrame(response), response); + assert.throws(() => decodeHostFrame({ ...response, operation: 'turn.query' }), isInvalidFrame); + }); + + test('accepts bounded explicit Skill identities on turn.start', () => { + const start = (skillIds: unknown, text = '') => + decodeClientFrame({ + requestId: 'skill-start', + operation: 'turn.start', + input: { + sessionId: 'session-1', + turnId: 'turn-skill-1', + content: { text }, + skillIds, + }, + }); + assert.deepEqual(start(['writer', 'project:maka:reviewer']), { + requestId: 'skill-start', + operation: 'turn.start', + input: { + sessionId: 'session-1', + turnId: 'turn-skill-1', + content: { text: '' }, + skillIds: ['writer', 'project:maka:reviewer'], + }, + }); + assert.doesNotThrow(() => + start(Array.from({ length: TURN_SKILL_ID_MAX_COUNT }, (_, index) => `skill-${index}`)), + ); + for (const skillIds of [ + Array.from({ length: TURN_SKILL_ID_MAX_COUNT + 1 }, (_, index) => `skill-${index}`), + ['bad/id'], + ['bad id'], + ['x'.repeat(TURN_SKILL_ID_MAX_LENGTH + 1)], + [1], + ]) { + assert.throws(() => start(skillIds), isInvalidFrame); + } + assert.deepEqual(start(undefined, 'plain'), { + requestId: 'skill-start', + operation: 'turn.start', + input: { + sessionId: 'session-1', + turnId: 'turn-skill-1', + content: { text: 'plain' }, + }, + }); + assert.deepEqual(start([], 'plain'), { + requestId: 'skill-start', + operation: 'turn.start', + input: { + sessionId: 'session-1', + turnId: 'turn-skill-1', + content: { text: 'plain' }, + }, + }); + }); + + test('bounds turn.start feedback as one transport-safe result', () => { + const receipt = { + invocation: 'explicit' as const, + request: 'writer', + success: true as const, + ref: 'workspace:legacy:writer', + id: 'writer', + name: 'Writer', + scope: 'workspace' as const, + source: 'legacy' as const, + truncated: false, + }; + const response = { + requestId: 'skill-start-response', + operation: 'turn.start' as const, + ok: true as const, + result: { + kind: 'started' as const, + turn: { + sessionId: 'session-1', + turnId: 'turn-skill-1', + runId: 'run-skill-1', + status: 'running' as const, + }, + skillInvocation: { + loaded: [{ id: receipt.id, name: receipt.name }], + failed: [], + receipts: [receipt], + }, + }, + }; + assert.deepEqual(decodeHostFrame(response), response); + assert.ok(encodeProtocolMessage(response).byteLength < RUNTIME_HOST_MAX_MESSAGE_BYTES); + + const request = 'r'.repeat(TURN_SKILL_ID_MAX_LENGTH); + const id = 'i'.repeat(81); + const name = '"'.repeat(256); + const oversized = { + ...response, + result: { + ...response.result, + skillInvocation: { + loaded: Array.from({ length: TURN_SKILL_ID_MAX_COUNT }, () => ({ id, name })), + failed: [], + receipts: Array.from({ length: TURN_SKILL_ID_MAX_COUNT }, () => ({ + ...receipt, + request, + ref: `workspace:legacy:${id}`, + id, + name, + })), + }, + }, + }; + assert.throws(() => decodeHostFrame(oversized), isInvalidFrame); + }); + + test('decodes a closed regenerate identity without accepting replacement content', () => { + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-regenerate', + operation: 'turn.regenerate', + input: { + sessionId: 'session-1', + sourceTurnId: 'turn-source', + turnId: 'turn-regenerated', + }, + }), + { + requestId: 'request-regenerate', + operation: 'turn.regenerate', + input: { + sessionId: 'session-1', + sourceTurnId: 'turn-source', + turnId: 'turn-regenerated', + }, + }, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-regenerate', + operation: 'turn.regenerate', + input: { + sessionId: 'session-1', + sourceTurnId: 'turn-source', + turnId: 'turn-regenerated', + content: { text: 'replacement' }, + }, + }), + isInvalidFrame, + ); + }); + + test('bounds canonical MessageContent attachments, directory references and quotes', () => { + const submit = (content: unknown) => + decodeClientFrame({ + requestId: 'submit-bounds', + operation: 'turn.message.submit', + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content, + placement: 'next_turn', + }, + }); + const directory = { hostId: 'host-a', path: '/workspace/source' }; + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); + assert.doesNotThrow(() => submit({ text: 'valid', directoryReferences: [directory] })); + for (const directoryReferences of [ + Array.from({ length: 5 }, () => directory), + [{ ...directory, path: '../outside' }], + [{ ...directory, hostId: '' }], + [{ ...directory, permissions: 'read' }], + ]) { + assert.throws(() => submit({ text: 'valid', directoryReferences }), isInvalidFrame); + } + assert.doesNotThrow(() => + submit({ + text: 'valid', + attachments: Array.from({ length: MAX_ATTACHMENT_COUNT }, (_, index) => + attachmentRef({ kind: 'workspace_file', relativePath: `${index}.ts` }), + ), + }), + ); + const contextContent = { + text: 'valid context ref', + attachments: [ + attachmentRef({ + kind: 'session_context' as const, + sessionId: 'session-1', + refId: 'read-image:owner-1', + }), + ], + }; + assert.throws(() => submit(contextContent), isInvalidFrame); + assert.deepEqual(decodeMessageContent(contextContent), contextContent); + assert.throws( + () => + submit({ + text: 'valid', + attachments: Array.from({ length: MAX_ATTACHMENT_COUNT + 1 }, (_, index) => + attachmentRef({ kind: 'workspace_file', relativePath: `${index}.ts` }), + ), + }), + isInvalidFrame, + ); + for (const attachment of [ + { ...attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' }), bytes: -1 }, + { + ...attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' }), + bytes: MAX_ATTACHMENT_BYTES + 1, + }, + { ...attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' }), name: '' }, + { ...attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' }), mimeType: '' }, + attachmentRef({ kind: 'workspace_file', relativePath: 'a'.repeat(4097) }), + attachmentRef({ kind: 'session_file', sessionId: 'bad/id', relativePath: 'a.ts' }), + attachmentRef({ kind: 'session_context', sessionId: 'session-1', refId: '' }), + attachmentRef({ kind: 'session_context', sessionId: 'session-1', refId: 'a'.repeat(513) }), + attachmentRef({ kind: 'workspace_file', relativePath: '../secret' }), + attachmentRef({ kind: 'workspace_file', relativePath: 'src//a.ts' }), + attachmentRef({ kind: 'external_file', absolutePath: 'relative/a.ts' }), + ]) { + assert.throws(() => submit({ text: 'valid', attachments: [attachment] }), isInvalidFrame); + } + assert.doesNotThrow(() => + submit({ + text: 'valid', + quotes: Array.from({ length: TURN_MESSAGE_QUOTE_MAX_COUNT }, (_, index) => ({ + text: `excerpt-${index}`, + label: 'Assistant', + sourceTurnId: `turn-${index}`, + })), + }), + ); + for (const quotes of [ + Array.from({ length: TURN_MESSAGE_QUOTE_MAX_COUNT + 1 }, () => ({ text: 'excerpt' })), + [{ text: '' }], + [{ text: 'x'.repeat(TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH + 1) }], + [{ text: 'excerpt', label: '' }], + [{ text: 'excerpt', label: 'x'.repeat(TURN_MESSAGE_QUOTE_LABEL_MAX_LENGTH + 1) }], + [{ text: 'excerpt', sourceTurnId: 'bad/id' }], + [{ text: 'excerpt', sourceTurnId: 'x'.repeat(129) }], + [{ text: 'excerpt', extra: true }], + ]) { + assert.throws(() => submit({ text: 'valid', quotes }), isInvalidFrame); + } + assert.throws( + () => submit({ text: 'a'.repeat(TURN_MESSAGE_CONTENT_MAX_BYTES), displayText: 'also large' }), + isInvalidFrame, + ); + }); + + test('admits structured-only Messages: empty inline text with quotes or attachments (#4804)', () => { + const submit = (content: unknown) => + decodeClientFrame({ + requestId: 'submit-structured-only', + operation: 'turn.message.submit', + input: { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content, + placement: 'next_turn', + }, + }); + // A quote or an attachment carries the turn by itself: empty inline text + // is admissible when either is present. + assert.doesNotThrow(() => + submit({ text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }), + ); + assert.doesNotThrow(() => + submit({ + text: '', + attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' })], + }), + ); + // A Message with nothing but empty text is still an invalid frame. + assert.throws(() => submit({ text: '' }), isInvalidFrame); + }); + + test('admitted structured-only Messages survive queue and steering read-back (#4804)', () => { + const admitted = { text: '', quotes: [{ text: 'pasted reference-sized excerpt' }] }; + // A queued next_turn entry carries content admission already accepted at + // submit; the read-back decoders must apply the same rule or the whole + // snapshot frame breaks around one admitted entry. + const projectionWire = { + hostEpoch: 'epoch-1', + queueRevision: 7, + steering: [], + followup: [ + { + ...queuedMessage('later', 'next_turn'), + entryId: 'entry-9', + messageId: 'm-9', + content: admitted, + }, + ], + }; + assert.deepEqual( + decodeSessionMessageQueueProjection(JSON.parse(JSON.stringify(projectionWire))), + projectionWire, + ); + // The durable steering echo reads back through the session-event frame. + assert.doesNotThrow(() => + decodeHostFrame({ + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'steering_message' as const, + id: 'steering-event-9', + turnId: 'turn-1', + ts: 7, + messageId: 'steering-message-9', + content: admitted, + }, + }), + ); + }); + + test('bounds Message text in UTF-8 bytes while preserving frame headroom', () => { + const input = { + originHostEpoch: 'epoch-1', + sessionId: 'session-1', + messageId: 'message-1', + content: { text: 'a'.repeat(TURN_MESSAGE_TEXT_MAX_BYTES) }, + placement: 'next_turn' as const, + }; + const frame = decodeClientFrame({ + requestId: 'submit-request-1', + operation: 'turn.message.submit', + input, + }); + assert.ok(encodeProtocolMessage(frame).byteLength < RUNTIME_HOST_MAX_MESSAGE_BYTES); + assert.throws( + () => + decodeClientFrame({ + requestId: 'submit-request-2', + operation: 'turn.message.submit', + input: { + ...input, + content: { text: '界'.repeat(Math.floor(TURN_MESSAGE_TEXT_MAX_BYTES / 3) + 1) }, + }, + }), + isInvalidFrame, + ); + }); + + test('decodes exact submit dispositions and bounded retract and interrupt results', () => { + const skillInvocation = { loaded: [], failed: [], receipts: [] }; + for (const result of [ + { disposition: 'steering', queueRevision: 2, skillInvocation }, + { disposition: 'followup', queueRevision: 3, skillInvocation }, + { disposition: 'steering', skillInvocation }, + { disposition: 'followup', skillInvocation }, + { disposition: 'turn_started', turnId: 'turn-2', skillInvocation }, + { + disposition: 'blocked', + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], + receipts: [], + }, + }, + ]) { + assert.doesNotThrow(() => + decodeHostFrame({ + requestId: 'submit-response', + operation: 'turn.message.submit', + ok: true, + result, + }), + ); + } + for (const result of [ + { disposition: 'steering', queueRevision: 2 }, + { disposition: 'followup', queueRevision: 3 }, + { disposition: 'turn_started', turnId: 'turn-2' }, + { disposition: 'blocked' }, + ]) { + assert.throws( + () => + decodeHostFrame({ + requestId: 'submit-response', + operation: 'turn.message.submit', + ok: true, + result, + }), + isInvalidFrame, + ); + } + assert.throws( + () => + decodeHostFrame({ + requestId: 'submit-response', + operation: 'turn.message.submit', + ok: true, + result: { + disposition: 'turn_started', + turnId: 'turn-2', + queueRevision: 4, + skillInvocation, + }, + }), + isInvalidFrame, + ); + for (const skillInvocation of [ + { loaded: 'invalid', failed: [], receipts: [] }, + { loaded: [{ id: 'writer', name: 'Writer' }], failed: [], receipts: [] }, + { loaded: [], failed: [], receipts: [] }, + ]) { + assert.throws( + () => + decodeHostFrame({ + requestId: 'submit-response', + operation: 'turn.message.submit', + ok: true, + result: { disposition: 'blocked', skillInvocation }, + }), + isInvalidFrame, + ); + } + for (const [operation, requestId] of [ + ['queue.entry.retract', 'entry-retract-response'], + ['queue.entry.promote', 'entry-promote-response'], + ['queue.entry.update', 'entry-update-response'], + ['queue.entries.reorder', 'entries-reorder-response'], + ] as const) { + assert.doesNotThrow(() => + decodeHostFrame({ + requestId, + operation, + ok: true, + result: { queueRevision: 8 }, + }), + ); + assert.throws( + () => + decodeHostFrame({ + requestId, + operation, + ok: true, + result: { queueRevision: 8, retracted: [] }, + }), + isInvalidFrame, + ); + } + const retracted = [retractedMessage()]; + assert.doesNotThrow(() => + decodeHostFrame({ + requestId: 'interrupt-response', + operation: 'turn.interrupt', + ok: true, + result: { + queueRevision: 5, + retracted, + turn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'cancelled', + terminalEventId: 'event-1', + abortSource: 'user_interrupt', + }, + }, + }), + ); + const oversized = Array.from({ length: MESSAGE_QUEUE_MAX_ENTRIES }, (_, index) => ({ + ...retractedMessage('a'.repeat(900)), + entryId: `entry-${index}`, + messageId: `message-${index}`, + })); + assert.ok(Buffer.byteLength(JSON.stringify(oversized)) > MESSAGE_OPERATION_RESULT_MAX_BYTES); + assert.throws( + () => + decodeHostFrame({ + requestId: 'retract-response', + operation: 'queue.retract', + ok: true, + result: { queueRevision: 6, retracted: oversized }, + }), + isInvalidFrame, + ); + }); + + test('validates queued, in-flight, and retracted snapshots as closed bounded unions', () => { + const projectedQuotes = [ + { text: 'one', sourceTurnId: 'turn-1' }, + { text: 'two', label: 'User', sourceTurnId: 'turn-2' }, + ]; + const followup = { + ...queuedMessage('later', 'next_turn'), + entryId: 'entry-3', + messageId: 'm-3', + content: { text: 'later', quotes: projectedQuotes }, + }; + const projectionWire = { + hostEpoch: 'epoch-1', + queueRevision: 7, + steering: [queuedMessage(), inFlightMessage()], + followup: [followup], + }; + assert.deepEqual( + decodeSessionMessageQueueProjection(JSON.parse(JSON.stringify(projectionWire))), + projectionWire, + ); + for (const projection of [ + { + hostEpoch: 'epoch-1', + queueRevision: 1, + steering: [queuedMessage('wrong lane', 'next_turn')], + followup: [], + }, + { + hostEpoch: 'epoch-1', + queueRevision: 1, + steering: [], + followup: [{ ...inFlightMessage(), placement: 'next_turn' }], + }, + { + hostEpoch: 'epoch-1', + queueRevision: 1, + steering: [], + followup: [queuedMessage('wrong followup lane', 'current_turn')], + }, + { + hostEpoch: 'epoch-1', + queueRevision: 1, + steering: [queuedMessage(), { ...queuedMessage(), entryId: 'other-entry' }], + followup: [], + }, + { + hostEpoch: 'epoch-1', + queueRevision: 1, + steering: Array.from({ length: MESSAGE_QUEUE_MAX_ENTRIES + 1 }, (_, index) => ({ + ...queuedMessage(), + entryId: `entry-${index}`, + messageId: `message-${index}`, + })), + followup: [], + }, + ]) { + assert.throws(() => decodeSessionMessageQueueProjection(projection), isInvalidFrame); + } + }); + + test('rejects duplicate operation keys while composing domain registries', () => { + const composeUnchecked = composeOperationSpecMaps as ( + left: typeof HOST_BOOTSTRAP_OPERATION_SPECS, + right: typeof HOST_BOOTSTRAP_OPERATION_SPECS, + ) => unknown; + assert.throws( + () => composeUnchecked(HOST_BOOTSTRAP_OPERATION_SPECS, HOST_BOOTSTRAP_OPERATION_SPECS), + /Duplicate Runtime Host operation key: host\.status/, + ); + }); + + test('publishes a bounded live Direct peer endpoint through Host status', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 94); + const status = { + hostEpoch: 'epoch-1', + compositionId: 'maka.interactive', + compositionRevision: '1', + state: 'ready', + connections: 1, + activeOperations: 0, + activeResidencies: 0, + peerEndpoint: { + lease: { + version: 1, + peerId: '12D3KooWhost', + revision: 1, + issuedAt: 1, + expiresAt: 2, + directRoutes: ['/ip4/192.0.2.1/udp/41000/quic-v1'], + coordinationRoutes: ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'], + }, + publicKey: 'AA', + signature: 'AA', + }, + }; + assert.deepEqual(HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput(status), status); + assert.throws(() => + HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput({ + ...status, + peerEndpoint: { + ...status.peerEndpoint, + lease: { + ...status.peerEndpoint.lease, + coordinationRoutes: [ + status.peerEndpoint.lease.coordinationRoutes[0], + status.peerEndpoint.lease.coordinationRoutes[0], + ], + }, + }, + }), + ); + }); + + test('keeps Runtime Host logs within the diagnostics operation contract', () => { + for (let index = 0; index < 257; index += 1) { + runtimeHostLogBuffer.append('info', `entry ${index}`); + } + runtimeHostLogBuffer.append('error', '🚀'.repeat(3_000)); + const entryBoundedLogs = runtimeHostLogBuffer.snapshot(); + + assert.equal(entryBoundedLogs.length, 256); + + for (let index = 0; index < 256; index += 1) { + runtimeHostLogBuffer.append('info', `retained detail ${index} ${'x'.repeat(256)}`); + } + const logs = runtimeHostLogBuffer.snapshot(); + const encodedLogBytes = Buffer.byteLength(JSON.stringify(logs)); + + assert.ok(encodedLogBytes > 48 * 1024); + assert.ok(encodedLogBytes <= RUNTIME_HOST_DIAGNOSTIC_LOG_MAX_BYTES); + assert.doesNotThrow(() => + HOST_BOOTSTRAP_OPERATION_SPECS['host.diagnostics.query'].decodeOutput({ + hostEpoch: 'epoch-1', + compositionId: 'maka.interactive', + compositionRevision: '1', + compositionModules: ['interactive'], + residencies: [{ label: 'hosted-execution', count: 1 }], + state: 'ready', + connections: 1, + activeOperations: 0, + activeResidencies: 0, + upgradeBlockingActivity: true, + protocolVersion: 0, + compatibilityEpoch: 9, + pid: 42, + processUptimeSeconds: 1, + nodeVersion: '22.0.0', + platform: 'linux', + arch: 'x64', + osRelease: '6.6.0', + logs, + }), + ); + }); + + test('decodes the required upgrade blocking activity fact in diagnostics', () => { + const base = { + hostEpoch: 'epoch-1', + compositionId: 'maka.interactive', + compositionRevision: '1', + compositionModules: ['interactive'], + residencies: [], + state: 'ready', + connections: 1, + activeOperations: 0, + activeResidencies: 0, + upgradeBlockingActivity: false, + protocolVersion: 0, + compatibilityEpoch: 9, + pid: 42, + processUptimeSeconds: 1, + nodeVersion: '22.0.0', + platform: 'linux', + arch: 'x64', + osRelease: '6.6.0', + logs: [], + }; + const spec = HOST_BOOTSTRAP_OPERATION_SPECS['host.diagnostics.query']; + + assert.deepEqual(spec.decodeOutput(base), { ...base }); + assert.deepEqual(spec.decodeOutput({ ...base, upgradeBlockingActivity: true }), { + ...base, + upgradeBlockingActivity: true, + }); + assert.throws( + () => spec.decodeOutput({ ...base, upgradeBlockingActivity: 'yes' }), + isInvalidFrame, + ); + const missing = { ...base } as Record; + delete missing.upgradeBlockingActivity; + assert.throws(() => spec.decodeOutput(missing), isInvalidFrame); + }); + + test('rejects terminal snapshots with fields from another terminal variant', () => { + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-4', + operation: 'turn.query', + ok: true, + result: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'event-1', + abortSource: 'user', + }, + }), + isInvalidFrame, + ); + }); + + test('carries a bounded failed Turn message without opening the snapshot shape', () => { + const response = { + requestId: 'request-failed-turn', + operation: 'turn.query' as const, + ok: true as const, + result: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed' as const, + terminalEventId: 'event-1', + failureClass: 'unknown', + failureMessage: 'Provider request failed', + }, + }; + + assert.deepEqual(decodeHostFrame(response), response); + assert.throws( + () => + decodeHostFrame({ + ...response, + result: { + ...response.result, + failureMessage: '界'.repeat(TURN_FAILURE_MESSAGE_MAX_BYTES), + }, + }), + isInvalidFrame, + ); + }); + + test('bounds encoded protocol messages', () => { + const empty = { + kind: 'draining', + hostEpoch: '', + compositionId: 'maka.interactive', + compositionRevision: '1', + } as const; + const overhead = Buffer.byteLength(JSON.stringify(empty), 'utf8'); + const value = { + ...empty, + hostEpoch: 'x'.repeat(RUNTIME_HOST_MAX_MESSAGE_BYTES - overhead), + }; + const message = encodeProtocolMessage(value); + + assert.equal(message.byteLength, RUNTIME_HOST_MAX_MESSAGE_BYTES); + assert.notEqual(message.at(-1), 0x0a); + assert.throws( + () => encodeProtocolMessage({ ...value, hostEpoch: `${value.hostEpoch}x` }), + (error: unknown) => + error instanceof RuntimeHostProtocolError && error.code === 'frame_too_large', + ); + }); +}); + +test('Client Capability tool descriptors preserve only known activity kinds', () => { + const input = { + registrationId: 'registration-1', + offers: [ + { + offerId: 'desktop_computer_use', + version: '0', + affinity: 'session', + hostPathAccess: 'cwd', + label: 'Computer Use', + tools: [ + { + serverId: 'desktop_computer_use', + name: 'maka_computer', + inputSchema: { type: 'object' }, + activityKind: 'computer', + }, + ], + }, + ], + }; + + assert.equal( + decodeClientCapabilityReplaceInput(input).offers[0]?.tools[0]?.activityKind, + 'computer', + ); + assert.throws( + () => + decodeClientCapabilityReplaceInput({ + ...input, + offers: [ + { + ...input.offers[0], + tools: [{ ...input.offers[0]!.tools[0], activityKind: 'desktop' }], + }, + ], + }), + isInvalidFrame, + ); +}); + +test('Client Capability tuple schemas accept only boolean or schema additionalItems', () => { + const input = (additionalItems: unknown) => ({ + registrationId: 'registration-1', + offers: [ + { + offerId: 'desktop_computer_use', + version: '0', + affinity: 'session', + hostPathAccess: 'cwd', + label: 'Computer Use', + tools: [ + { + serverId: 'desktop_computer_use', + name: 'maka_computer', + inputSchema: { + type: 'object', + properties: { + position: { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }], + additionalItems, + }, + }, + }, + }, + ], + }, + ], + }); + + assert.deepEqual(decodeClientCapabilityReplaceInput(input(false)), input(false)); + assert.deepEqual( + decodeClientCapabilityReplaceInput(input({ type: 'number' })), + input({ type: 'number' }), + ); + assert.throws(() => decodeClientCapabilityReplaceInput(input('no')), isInvalidFrame); +}); + +test('Client Capability progress frames require bounded monotonic coordinates', () => { + assert.deepEqual( + decodeClientFrame({ + kind: 'client.capability.progress', + invocationId: 'invocation-1', + current: 7, + total: 11, + }), + { + kind: 'client.capability.progress', + invocationId: 'invocation-1', + current: 7, + total: 11, + }, + ); + assert.throws( + () => + decodeClientFrame({ + kind: 'client.capability.progress', + invocationId: 'invocation-1', + current: 12, + total: 11, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + kind: 'client.capability.progress', + invocationId: 'invocation-1', + current: 1, + total: 1_025, + }), + isInvalidFrame, + ); +}); + +function isInvalidFrame(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame'; +} + +function isInvalidSessionStatus(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.message === 'Invalid Session status'; +} + +function queuedMessage( + text = 'adjust this turn', + placement: 'current_turn' | 'next_turn' = 'current_turn', +) { + return { + entryId: 'entry-1', + messageId: 'message-1', + content: { text }, + placement, + state: 'queued' as const, + }; +} + +function inFlightMessage() { + return { + ...queuedMessage('already pulled'), + entryId: 'entry-2', + messageId: 'message-2', + state: 'in_flight' as const, + }; +} + +function retractedMessage(text = 'do this next') { + return { + entryId: 'entry-retracted', + messageId: 'message-retracted', + content: { text }, + placement: 'next_turn' as const, + state: 'retracted' as const, + }; +} + +function attachmentRef( + ref: + | { kind: 'session_file'; sessionId: string; relativePath: string } + | { kind: 'session_context'; sessionId: string; refId: string } + | { kind: 'workspace_file'; relativePath: string } + | { kind: 'external_file'; absolutePath: string }, +) { + return { kind: 'code' as const, name: 'a.ts', mimeType: 'text/typescript', bytes: 10, ref }; +} + +function continuitySnapshot(hostEpoch: string) { + return { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'session-1', + metadataRevision: 1, + status: 'running' as const, + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running' as const, + }, + goal: null, + queue: { + hostEpoch, + queueRevision: 1, + steering: [], + followup: [], + }, + interactions: { pending: [] }, + }; +} diff --git a/packages/runtime-host/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json b/packages/runtime-host/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json new file mode 100644 index 0000000000..ac476c52cc --- /dev/null +++ b/packages/runtime-host/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-10T13:28:15.843Z", + "sessionId": "sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d", + "event": "PostToolUse", + "toolName": "Edit", + "file": "E:\\guahub\\gh\\fork\\maka\\packages\\ui\\src\\__tests__\\composer-send-toggle.test.tsx", + "outcome": "inconclusive", + "coverage": "partial", + "findingCount": 0, + "durationMs": 4, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index d29269c482..a683c8110a 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1952,13 +1952,12 @@ describe('Runtime Host bootstrap protocol', () => { attachments: [attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' })], }), ); - // A Message with nothing but empty text is still an invalid frame — and - // whitespace-only text judges the same way: the shared meaningful-content - // predicate trims, matching the desktop guard, so a whitespace-only - // submit cannot be admitted here and dropped one layer down (#4815 - // review). + // A Message with nothing but empty text is still an invalid frame. + // Whitespace-only text stays admissible: replay visibility must remain + // compatible with everything admission has ever accepted, so the + // predicate does not trim (#4815 review). assert.throws(() => submit({ text: '' }), isInvalidFrame); - assert.throws(() => submit({ text: ' ' }), isInvalidFrame); + assert.doesNotThrow(() => submit({ text: ' ' })); }); test('admitted structured-only Messages survive queue and steering read-back (#4804)', () => { diff --git a/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..df146d9c73 --- /dev/null +++ b/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":["E:\\guahub\\gh\\fork\\maka\\packages\\runtime\\src\\__tests__\\ai-sdk-backend.test.ts"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtwz034o-27400-b48dc3ad61","createdAt":"2026-09-11T13:06:46.104Z","files":{"src/__tests__/ai-sdk-backend.test.ts":{"existed":true,"snapshot":"dcced2d6789ab4a15878031fe17bc727d5810850f9d19da6d1f5e8be2e57c697.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":1,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-11T13:06:47.248Z"} \ No newline at end of file diff --git a/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtwz034o-27400-b48dc3ad61.baseline/dcced2d6789ab4a15878031fe17bc727d5810850f9d19da6d1f5e8be2e57c697.source b/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtwz034o-27400-b48dc3ad61.baseline/dcced2d6789ab4a15878031fe17bc727d5810850f9d19da6d1f5e8be2e57c697.source new file mode 100644 index 0000000000..0f9cec47b5 --- /dev/null +++ b/packages/runtime/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtwz034o-27400-b48dc3ad61.baseline/dcced2d6789ab4a15878031fe17bc727d5810850f9d19da6d1f5e8be2e57c697.source @@ -0,0 +1,16777 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { RunHandoffGate } from '../run-handoff-gate.js'; +import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import type { ModelMessage, ModelStreamResult } from '../model-protocol.js'; +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; +import { APICallError, type LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import type { RuntimeInvocationRootAuthority } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import type { AttachmentByteReader } from '@maka/core/attachments'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { SessionHeader } from '@maka/core/session'; +import type { StorageRef } from '@maka/core/events'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import type { SessionEvent } from '@maka/core/events'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RequestCompositionSnapshotInput } from '@maka/core/run-composition'; +import { + createSessionEventMapMemory, + mapSessionEventToRuntimeEvent, +} from '../session-event-runtime-mapper.js'; +import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; +import { sectionedSummary } from './history-compact-test-fixtures.js'; +import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; +import type { AssistantMessage, StoredMessage, ToolResultMessage } from '@maka/core/session'; +import { z } from 'zod'; +import { + AiSdkBackend, + INVALID_TOOL_NAME, + MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, + TOOL_ERROR_RESULT_MAX_CHARS, + formatSyntheticToolErrorText, + normalizeAiSdkUsage, + repairMakaToolCall, + type AiSdkBackendInput, + type RunTraceEvent, +} from '../ai-sdk-backend.js'; +import type { DurableSessionEventSink, MakaTool, ToolRuntime } from '../tool-runtime.js'; +import { TOOL_SEARCH_NAME } from '../tool-availability.js'; +import { buildNativeWebSearchTool } from '../native-web-search-tool.js'; +import { canonicalizeToolSet } from '../request-shape.js'; +import { + ARCHIVED_TOOL_RESULT_PLACEHOLDER_KIND, + ARCHIVED_TOOL_RESULT_REWRITE_VERSION, + applyRuntimeEventContextBudget, +} from '../context-budget.js'; +import { + buildHistoryCompactCheckpoint, + type HistoryCompactCheckpoint, +} from '../history-compact-checkpoint.js'; +import { buildDefaultContextBudgetPolicy } from '../context-budget-policy.js'; +import { buildRuntimeEventModelReplayPlan, buildSteeringEnvelope } from '../model-history.js'; +import { HistoryCompactSummarizerError } from '../history-compact-summarizer.js'; +import { SandboxCommandError } from '../sandbox/errors.js'; +import { buildRequestSandboxBoundaryTool } from '../sandbox-boundary-tool.js'; +import { + preflightDeclaredSandboxBoundary, + sandboxBoundaryExpansionSchema, +} from '../sandbox-boundary-declaration.js'; +import { FilesystemWorkerClientError } from '../filesystem-worker/client.js'; +import { RunTrace } from '../run-trace.js'; +import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; +import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; +import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; +import { + createTestAiSdkBackend, + projectedTranscriptOf, + readExternalExecutionBoundary, + testToolResultArchive, +} from './execution-boundary-test-helpers.js'; +import type { MemoryExtractionSourceSnapshot } from '../memory-extraction.js'; +import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-continuation.js'; +import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; +import { getAIModel } from '../model-factory.js'; +import { deferred, waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { Context } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { PluginToolService } from '../plugin-tool-service.js'; +import { testInvocationOpening } from './invocation-fixture.js'; + +describe('AiSdkBackend ApplyPatch routing', () => { + test('advertises apply_patch only to supported native OpenAI models', async () => { + for (const [providerType, modelId, expected] of [ + ['openai', 'gpt-5.4', true], + ['openai', 'gpt-5', false], + ['anthropic', connection().defaultModel, false], + ] as const) { + const model = completionModel(); + const backend = createBackend({ + connection: + providerType === 'openai' + ? { ...connection(), slug: 'openai', providerType } + : connection(), + modelId, + modelFactory: () => model, + tools: [ + nativeApplyPatchTool(), + testTool('Write', z.object({})), + testTool('Edit', z.object({})), + ], + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); + const names = modelToolNames(model); + assert.equal(names.includes('apply_patch'), expected); + assert.equal(names.includes('Write'), !expected); + assert.equal(names.includes('Edit'), !expected); + } + }); + + test('keeps Write and Edit when DeepSeek cannot carry custom apply_patch', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + ...connection(), + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [ + nativeApplyPatchTool(), + testTool('Write', z.object({})), + testTool('Edit', z.object({})), + ], + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); + + const names = modelToolNames(model); + assert.equal(names.includes('apply_patch'), false); + assert.equal(names.includes('Write'), true); + assert.equal(names.includes('Edit'), true); + }); + + test('replays a durable apply_patch failure as native provider JSON', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-previous', + role: 'user', + author: 'user', + text: 'patch it', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-1', + name: 'apply_patch', + args: { + callId: 'call-1', + operation: { type: 'update_file', path: 'file.txt', diff: '@@' }, + }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { status: 'failed', output: 'diff rejected' }, + isError: true, + }, + }), + ], + }), + ); + + const toolResult = (compactPrompt(model) as Array<{ role: string; content: any[] }>) + .find((message) => message.role === 'tool') + ?.content.find((part) => part.type === 'tool-result'); + assert.deepEqual(toolResult?.output, { + type: 'json', + value: { status: 'failed', output: 'diff rejected' }, + }); + }); + + const assertApplyPatchHistoryDowngraded = async ( + targetConnection: LlmConnection, + modelId: string, + ) => { + const model = completionModel(); + const backend = createBackend({ + connection: targetConnection, + modelId, + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-previous', + role: 'user', + author: 'user', + text: 'patch it', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-1', + name: 'apply_patch', + args: [ + '*** Begin Patch', + '*** Update File: file.txt', + '@@', + '-before', + '+after', + '*** End Patch', + ].join('\n'), + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { status: 'completed', output: 'Applied 1 file operation.' }, + }, + }), + ], + }), + ); + + const replay = compactPrompt(model) as Array<{ role: string; content: any[] }>; + assert.equal( + replay.some((message) => + message.content.some( + (part) => part.type === 'tool-call' && part.toolName === 'apply_patch', + ), + ), + false, + ); + assert.equal( + replay.some((message) => message.role === 'tool'), + false, + ); + assert.match( + replay + .flatMap((message) => message.content) + .find((part) => part.type === 'text' && /ApplyPatch completed/.test(part.text))?.text ?? '', + /ApplyPatch completed 1 file operation: update_file file\.txt/, + ); + }; + + test('downgrades durable DeepSeek freeform apply_patch history to a fact', async () => { + await assertApplyPatchHistoryDowngraded( + { + ...connection(), + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + 'deepseek-v4-flash', + ); + }); + + test('downgrades apply_patch history when a non-Responses target does not advertise it', async () => { + const targetConnection = connection(); + await assertApplyPatchHistoryDowngraded(targetConnection, targetConnection.defaultModel!); + }); + + test('preserves a durable projection failure when apply_patch history is downgraded', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-1', + name: 'apply_patch', + args: [ + '*** Begin Patch', + '*** Update File: file.txt', + '@@', + '-before', + '+after', + '*** End Patch', + ].join('\n'), + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { status: 'completed', output: 'Applied 1 file operation.' }, + modelProjection: { + version: 1, + kind: 'failure', + reason: 'projection_failed', + message: + 'The tool completed, but its model-visible result could not be projected safely.', + }, + }, + }), + ], + }), + ); + + const replayText = (compactPrompt(model) as Array<{ content: any[] }>) + .flatMap((message) => message.content) + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); + assert.match(replayText, /could not be projected safely/); + assert.doesNotMatch(replayText, /ApplyPatch completed/); + }); + + test('preserves a multi-file ApplyPatch fact when structured replay cannot represent it', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + }); + const patch = [ + '*** Begin Patch', + '*** Add File: added.txt', + '+hello', + '*** Delete File: removed.txt', + '*** End Patch', + ].join('\n'); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-previous', + role: 'user', + author: 'user', + text: 'patch both files', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'call-1', name: 'apply_patch', args: patch }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-1', + name: 'apply_patch', + result: { + status: 'completed', + applied: [ + { type: 'create_file', path: 'added.txt' }, + { type: 'delete_file', path: 'removed.txt' }, + ], + output: 'Applied 2 file operations.', + }, + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const replayText = prompt + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.content) + .find((part) => part.type === 'text' && part.text.includes('added.txt')); + assert.equal( + replayText?.text, + 'ApplyPatch completed 2 file operations: create_file added.txt, delete_file removed.txt.', + ); + assert.equal( + prompt.some((message) => + message.content.some( + (part) => part.type === 'tool-call' && part.toolName === 'apply_patch', + ), + ), + false, + ); + }); + + test('preserves every multi-file ApplyPatch fact from one provider step', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [nativeApplyPatchTool()], + }); + const firstPatch = [ + '*** Begin Patch', + '*** Add File: first.txt', + '+first', + '*** Delete File: old-first.txt', + '*** End Patch', + ].join('\n'); + const secondPatch = [ + '*** Begin Patch', + '*** Add File: second.txt', + '+second', + '*** Delete File: old-second.txt', + '*** End Patch', + ].join('\n'); + const call = (id: string, args: string) => + runtimeEvent({ + id: `rt-${id}`, + turnId: 'turn-previous', + role: 'model', + author: 'agent', + refs: { stepId: 'patch-step' }, + content: { kind: 'function_call', id, name: 'apply_patch', args }, + }); + const result = (id: string, applied: Array<{ type: string; path: string }>) => + runtimeEvent({ + id: `rt-${id}-result`, + turnId: 'turn-previous', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id, + name: 'apply_patch', + result: { status: 'completed', applied, output: 'Applied 2 file operations.' }, + }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-previous', + role: 'user', + author: 'user', + text: 'patch both pairs', + }), + call('call-1', firstPatch), + call('call-2', secondPatch), + result('call-1', [ + { type: 'create_file', path: 'first.txt' }, + { type: 'delete_file', path: 'old-first.txt' }, + ]), + result('call-2', [ + { type: 'create_file', path: 'second.txt' }, + { type: 'delete_file', path: 'old-second.txt' }, + ]), + runtimeEvent({ + id: 'rt-step-text', + turnId: 'turn-previous', + role: 'model', + author: 'agent', + refs: { providerEventId: 'patch-step' }, + content: { kind: 'text', text: 'Both patches finished.' }, + }), + ], + }), + ); + + const replayFacts = (compactPrompt(model) as Array<{ role: string; content: any[] }>) + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.content) + .filter((part) => part.type === 'text' && part.text.startsWith('ApplyPatch completed')) + .map((part) => part.text); + assert.deepEqual(replayFacts, [ + 'ApplyPatch completed 2 file operations: create_file first.txt, delete_file old-first.txt.', + 'ApplyPatch completed 2 file operations: create_file second.txt, delete_file old-second.txt.', + ]); + }); +}); + +/** Deferred memory triggers need one tool_search step before the model may call them. */ +function memorySearchChunks(searchToolName: string): LanguageModelV4StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'memory-search', + toolName: searchToolName, + input: JSON.stringify({ query: 'memory' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ]; +} + +function memoryFinishTextChunks(delta: string): LanguageModelV4StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; +} + +describe('AiSdkBackend Memory Extraction triggers', () => { + test('terminates cleanly when the dynamic system prompt rejects before Compaction', async () => { + const model = completionModel(); + const recorded: HistoryCompactCheckpoint[] = []; + let dispatches = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + systemPrompt: async () => { + throw new Error('dynamic system prompt failed'); + }, + tools: [], + contextBudget: { + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => structuredSummary('must not summarize'), + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + memoryExtraction: { + gate: async () => ({ allowed: true }), + automaticGate: () => ({ allowed: true }), + remember: async () => ({ status: 'unavailable', requestedItems: [] }), + extract: () => { + dispatches += 1; + }, + }, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'prompt-failure-turn', + runId: 'prompt-failure-run', + text: 'continue', + context: [], + runtimeContext: [], + })) { + events.push(event); + } + + assert.equal(model.doStreamCalls.length, 0); + assert.equal(recorded.length, 0); + assert.equal(dispatches, 0); + assert.equal( + events.some((event) => event.type === 'error'), + true, + ); + assert.equal( + events.some((event) => event.type === 'complete' && event.stopReason === 'error'), + true, + ); + }); + + test('exposes explicitly unsupported Memory triggers on the native OpenAI Responses lane', async () => { + let modelCalls = 0; + let memoryCalled = false; + const model = new MockLanguageModelV4({ + doStream: async () => { + modelCalls += 1; + return { + stream: simulateReadableStream({ + chunks: (modelCalls === 1 + ? memorySearchChunks('maka_tool_search') + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'hello'); + const backend = createBackend({ + connection: { ...connection(), providerType: 'openai' }, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + toolAvailability: {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + memoryExtraction: { + gate: async () => ({ allowed: true }), + remember: async () => { + memoryCalled = true; + return { status: 'unavailable', requestedItems: [] }; + }, + extract: () => { + memoryCalled = true; + }, + }, + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + + const stepZeroToolNames = model.doStreamCalls[0]?.tools?.map((tool) => tool.name) ?? []; + assert.equal( + stepZeroToolNames.some((name) => name === 'memory_remember' || name === 'memory_extract'), + false, + ); + assert.ok(stepZeroToolNames.includes('maka_tool_search')); + const searchedToolNames = model.doStreamCalls[1]?.tools?.map((tool) => tool.name) ?? []; + assert.ok(searchedToolNames.includes('memory_remember')); + assert.ok(searchedToolNames.includes('memory_extract')); + assert.equal(memoryCalled, false); + }); + + test('runs memory_remember synchronously and returns the persisted requested Item to the next step', async () => { + let modelCalls = 0; + let snapshot: MemoryExtractionSourceSnapshot | undefined; + const model = new MockLanguageModelV4({ + doStream: async () => { + modelCalls += 1; + return { + stream: simulateReadableStream({ + chunks: (modelCalls === 1 + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'remember-call', + toolName: 'memory_remember', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Remembered.')) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-memory', 'Remember that I prefer concise Chinese.'); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + toolAvailability: {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + memoryExtraction: { + gate: async () => ({ allowed: true }), + remember: async (value) => { + snapshot = value; + return { + status: 'remembered', + requestedItems: [{ itemId: 'memory-1', content: 'User prefers concise Chinese.' }], + }; + }, + extract: () => {}, + }, + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + + assert.equal(snapshot?.trigger, 'remember'); + assert.equal(snapshot?.toolCallId, 'remember-call'); + const sourceUserEvent = durable.ledger.find( + (event) => event.role === 'user' && event.content?.kind === 'text', + ); + assert.ok(sourceUserEvent); + assert.deepEqual(snapshot?.sourceEventMessagePositions?.[sourceUserEvent.id], [0]); + assert.match(JSON.stringify(model.doStreamCalls[2]?.prompt), /User prefers concise Chinese/); + }); + + test('keeps the complete frozen provider context while evidence authority remains user-only', async () => { + let modelCalls = 0; + let snapshot: MemoryExtractionSourceSnapshot | undefined; + const model = new MockLanguageModelV4({ + doStream: async () => { + modelCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + modelCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-call', + toolName: 'Read', + input: JSON.stringify({ path: 'volatile.json' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : modelCalls === 2 + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 3 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'remember-call', + toolName: 'memory_remember', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Remembered.'); + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-memory-tool', 'Remember only what I explicitly said.'); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'read volatile data', + parameters: z.object({ path: z.string() }), + impl: async () => ({ value: 'TOOL-ONLY-SECRET' }), + }, + ], + toolAvailability: {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + memoryExtraction: { + gate: async () => ({ allowed: true }), + remember: async (value) => { + snapshot = value; + return { status: 'not_applicable', requestedItems: [] }; + }, + extract: () => {}, + }, + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + + assert.ok(snapshot); + const messagesJson = JSON.stringify(snapshot.sourceMessages); + assert.match(messagesJson, /TOOL-ONLY-SECRET/); + assert.match(messagesJson, /read-call/); + assert.match(messagesJson, /volatile\.json/); + assert.ok(snapshot.sourceMessages.some((message) => message.role === 'assistant')); + assert.ok(snapshot.sourceMessages.some((message) => message.role === 'tool')); + const sourceUserEvent = durable.ledger.find( + (event) => event.role === 'user' && event.content?.kind === 'text', + ); + assert.ok(sourceUserEvent); + assert.deepEqual(snapshot.sourceEventMessagePositions?.[sourceUserEvent.id], [0]); + assert.ok(snapshot.sourceTools.Read, 'Tool schemas remain available for provider-prefix reuse'); + }); + + test('dispatches memory_extract only after the terminal Event is durably consumed', async () => { + let modelCalls = 0; + let extractionSnapshot: MemoryExtractionSourceSnapshot | undefined; + const model = new MockLanguageModelV4({ + doStream: async () => { + modelCalls += 1; + return { + stream: simulateReadableStream({ + chunks: (modelCalls === 1 + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'extract-call', + toolName: 'memory_extract', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Done.')) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-memory', 'This is durable project context.'); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + toolAvailability: {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + memoryExtraction: { + gate: async () => ({ allowed: true }), + remember: async () => ({ status: 'unavailable', requestedItems: [] }), + extract: (snapshot) => { + extractionSnapshot = snapshot; + }, + }, + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(modelCalls, 3); + assert.ok( + durable.ledger.some( + (event) => + event.content?.kind === 'function_response' && + event.content.name === 'memory_extract' && + JSON.stringify(event.content.result).includes('accepted'), + ), + ); + assert.equal(extractionSnapshot?.trigger, 'extract'); + assert.ok(extractionSnapshot?.terminalEventId); + assert.ok(durable.ledger.some(({ id }) => id === extractionSnapshot?.terminalEventId)); + }); +}); + +describe('AiSdkBackend sandbox boundary convergence', () => { + test('bounds an expansion retry after denial with one tool-free final step', async () => { + const cwd = process.cwd(); + const calls = [ + { + toolCallId: 'boundary-request', + toolName: 'request_sandbox_boundary', + input: { + expansion: { network: { enabled: true } }, + justification: 'Use the network.', + }, + }, + { + toolCallId: 'approved-boundary-use', + toolName: 'Bash', + input: { + command: 'read an already allowed workspace file', + boundary_intent: 'expand', + required_boundary: { + filesystem: { + entries: [{ path: cwd, access: 'read', scope: 'subtree' }], + }, + }, + }, + }, + { + toolCallId: 'boundary-retry', + toolName: 'Bash', + input: { + command: 'read outside the workspace', + boundary_intent: 'expand', + required_boundary: { + filesystem: { + entries: [{ path: resolve(cwd, '..'), access: 'read', scope: 'subtree' }], + }, + }, + }, + }, + { + toolCallId: 'forbidden-final-tool', + toolName: 'Bash', + input: { command: 'echo should-not-run', boundary_intent: 'current' }, + }, + ] as const; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const call = calls[streamCalls - 1]; + assert.ok(call); + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { ...call, type: 'tool-call', input: JSON.stringify(call.input) }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-denial-bound', 'Request access only if required.'); + const managed = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); + let pendingRequest: + | Awaited>> + | undefined; + let createCalls = 0; + let bashImplCalls = 0; + const backend = createBackend({ + header: { ...header(), cwd, workspaceRoot: cwd }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + buildRequestSandboxBoundaryTool(), + { + name: 'Bash', + description: 'Run one command.', + parameters: z.object({ + command: z.string(), + boundary_intent: z.enum(['current', 'expand']), + required_boundary: sandboxBoundaryExpansionSchema.optional(), + }), + impl: async (input, context) => { + await preflightDeclaredSandboxBoundary(input.required_boundary, context); + bashImplCalls += 1; + return 'used existing authority'; + }, + }, + ], + readExecutionBoundary: async () => managed, + createSandboxBoundaryRequest: async (input) => { + createCalls += 1; + pendingRequest = { + ...input, + status: 'pending', + baseRevision: 0, + createdAt: 1, + }; + return pendingRequest; + }, + settleSandboxBoundaryRequest: async () => { + assert.ok(pendingRequest); + pendingRequest = { ...pendingRequest, status: 'denied', settledAt: 2 }; + return { request: pendingRequest, boundary: managed, changed: false }; + }, + maxSteps: 5, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const events: SessionEvent[] = []; + const consuming = collectEvents(backend.send(durable.input()), events, durable.record); + + await waitFor(() => events.some((event) => event.type === 'sandbox_boundary_request')); + const request = events.find((event) => event.type === 'sandbox_boundary_request'); + assert.ok(request?.type === 'sandbox_boundary_request'); + await backend.respondToSandboxBoundary({ requestId: request.requestId, decision: 'deny' }); + await consuming; + + assert.equal(streamCalls, 4); + assert.equal(createCalls, 1); + assert.equal(bashImplCalls, 1); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 1); + assert.doesNotMatch( + JSON.stringify(model.doStreamCalls[1]?.tools ?? []), + /request_sandbox_boundary/u, + ); + assert.match(JSON.stringify(model.doStreamCalls[1]?.tools ?? []), /Bash/u); + assert.deepEqual(model.doStreamCalls[3]?.tools ?? [], []); + assert.deepEqual(model.doStreamCalls[3]?.toolChoice, { type: 'none' }); + assert.match(JSON.stringify(model.doStreamCalls[3]?.prompt), /sandbox_boundary_finalization/u); + assert.equal( + events.find((event) => event.type === 'complete')?.stopReason, + 'permission_handoff', + ); + await backend.dispose(); + }); + + for (const inheritedDenial of [false, true]) { + test(`routes a ${inheritedDenial ? 'continued' : 'fresh'} Code Mode denial through the same finalization latch`, async () => { + let streamCalls = inheritedDenial ? 1 : 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'code-boundary-request', + toolName: 'request_sandbox_boundary', + input: JSON.stringify({ + expansion: { network: { enabled: true } }, + justification: 'Use the network.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : streamCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'code-boundary-retry', + toolName: 'exec', + input: JSON.stringify({ + code: [ + 'return await tools.request_sandbox_boundary({', + ' expansion: { network: { enabled: true } },', + ' justification: "Try another expansion."', + '})', + ].join('\n'), + }), + }, + { + type: 'finish', + finishReason: { + unified: 'tool-calls', + raw: 'tool_calls', + }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'code-boundary-final' }, + { + type: 'text-delta', + id: 'code-boundary-final', + delta: 'The denied boundary remains unchanged.', + }, + { type: 'text-end', id: 'code-boundary-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-code-boundary-denial', 'Use Code Mode safely.'); + const managed = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); + let pendingRequest: + | Awaited>> + | undefined; + let createCalls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => managed, + createSandboxBoundaryRequest: async (input) => { + createCalls += 1; + pendingRequest = { + ...input, + status: 'pending', + baseRevision: 0, + createdAt: 1, + }; + return pendingRequest; + }, + settleSandboxBoundaryRequest: async () => { + assert.ok(pendingRequest); + pendingRequest = { + ...pendingRequest, + status: 'denied', + settledAt: 2, + }; + return { request: pendingRequest, boundary: managed, changed: false }; + }, + maxSteps: 5, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + const consuming = collectEvents( + backend.send( + durable.input({ + toolMode: 'code_mode', + ...(inheritedDenial + ? { + runtimeContext: [durable.anchor], + continuation: { + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 1, + sandboxBoundaryDenied: true, + }, + } + : {}), + }), + ), + events, + durable.record, + ); + + if (!inheritedDenial) { + await waitFor(() => events.some((event) => event.type === 'sandbox_boundary_request')); + const request = events.find((event) => event.type === 'sandbox_boundary_request'); + assert.ok(request?.type === 'sandbox_boundary_request'); + await backend.respondToSandboxBoundary({ + requestId: request.requestId, + decision: 'deny', + }); + } + await consuming; + + const inheritedOffset = inheritedDenial ? 1 : 0; + assert.equal(streamCalls, 3); + assert.equal(createCalls, 1 - inheritedOffset); + assert.equal( + events.filter((event) => event.type === 'sandbox_boundary_request').length, + 1 - inheritedOffset, + ); + assert.equal( + events.filter( + (event) => event.type === 'tool_start' && event.toolName === 'request_sandbox_boundary', + ).length, + 2 - inheritedOffset, + ); + assert.doesNotMatch( + JSON.stringify(model.doStreamCalls[1 - inheritedOffset]?.tools ?? []), + /request_sandbox_boundary/u, + ); + assert.match(JSON.stringify(model.doStreamCalls[1 - inheritedOffset]?.tools ?? []), /exec/u); + assert.deepEqual(model.doStreamCalls[2 - inheritedOffset]?.tools ?? [], []); + assert.match( + JSON.stringify(model.doStreamCalls[2 - inheritedOffset]?.prompt), + /sandbox_boundary_finalization/u, + ); + assert.equal( + events.find((event) => event.type === 'complete')?.stopReason, + 'permission_handoff', + ); + await backend.dispose(); + }); + } + + test('bounds varied invalid declarations before creating a boundary request', async () => { + const invalidCalls = [ + { expansion: {}, justification: 'Missing permission.' }, + { + expansion: { + filesystem: { + entries: [{ path: '.', access: 'read', scope: 'exact' }], + }, + }, + justification: 'Read this path.', + }, + { expansion: { network: { enabled: true } }, justification: ' ' }, + ] as const; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + const input = invalidCalls[streamCalls]; + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = input + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `invalid-boundary-${streamCalls}`, + toolName: 'request_sandbox_boundary', + input: JSON.stringify(input), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'boundary-final' }, + { + type: 'text-delta', + id: 'boundary-final', + delta: 'The boundary declaration could not be corrected in this turn.', + }, + { type: 'text-end', id: 'boundary-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-invalid-boundary', 'Use the current boundary.'); + let createCalls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => + createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + createSandboxBoundaryRequest: async () => { + createCalls += 1; + throw new Error('invalid calls must not create a boundary request'); + }, + settleSandboxBoundaryRequest: async () => { + throw new Error('invalid calls must not settle a boundary request'); + }, + maxSteps: 4, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const events: SessionEvent[] = []; + await collectEvents(backend.send(durable.input()), events, durable.record); + + assert.equal(streamCalls, 4); + assert.equal(createCalls, 0); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 0); + assert.deepEqual(model.doStreamCalls[3]?.tools ?? [], []); + assert.deepEqual(model.doStreamCalls[3]?.toolChoice, { type: 'none' }); + assert.match(JSON.stringify(model.doStreamCalls[3]?.prompt), /sandbox_boundary_finalization/u); + assert.equal( + events.find((event) => event.type === 'complete')?.stopReason, + 'permission_handoff', + ); + await backend.dispose(); + }); +}); + +describe('AiSdkBackend model history', () => { + test('records structured sandbox failure metadata on tool failure traces', async () => { + const traces: RunTraceEvent[] = []; + const messages: ToolResultMessage[] = []; + const backend = createBackend({ + header: header('bypass'), + appendMessage: async (message) => { + if (message.type === 'tool_result') messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => ({}), + tools: [], + }); + turnScope(backend, 'turn-1').runTrace = new RunTrace({ + sessionId: 'session-1', + turnId: 'turn-1', + connectionSlug: 'anthropic-main', + providerId: 'anthropic', + modelId: 'mock-model-id', + newId: idGenerator(), + now: monotonicClock(), + record: (event) => traces.push(event), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'shell', + parameters: {}, + impl: async () => { + throw new SandboxCommandError({ + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + backend: 'macos-seatbelt', + recoverable: false, + profileName: 'workspace-write', + message: 'contains /private/workspace/path', + }); + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { push: () => {} }); + + await execute( + { command: 'true' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + const failure = traces.find((event) => event.type === 'tool_failed'); + assert.deepEqual(failure?.data?.sandbox, { + domain: 'command', + stage: 'transform', + reason: 'backend_not_available', + recoverable: false, + backend: 'macos-seatbelt', + profileName: 'workspace-write', + }); + assert.equal(JSON.stringify(failure).includes('/private/workspace/path'), false); + assert.equal( + messages[0]?.content.kind === 'text' ? messages[0].content.sandboxDenial : undefined, + undefined, + ); + }); + + test('persists a sandbox denial signal for explicit filesystem worker sandbox denials', async () => { + const messages: ToolResultMessage[] = []; + const events: SessionEvent[] = []; + const backend = createBackend({ + header: header('bypass'), + appendMessage: async (message) => { + if (message.type === 'tool_result') messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => ({}), + tools: [], + }); + const tool: MakaTool = { + name: 'Grep', + description: 'search', + parameters: {}, + impl: async () => { + throw new FilesystemWorkerClientError({ + reason: 'sandbox_denied', + stage: 'operation', + backend: 'macos-seatbelt', + recoverable: false, + message: 'Filesystem access was denied.', + }); + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + }); + + await execute( + { pattern: 'needle', path: '/workspace' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + const expected = { likely: true, backend: 'macos-seatbelt' } as const; + assert.deepEqual( + messages[0]?.content.kind === 'text' ? messages[0].content.sandboxDenial : undefined, + expected, + ); + const event = events.find( + (candidate): candidate is Extract => + candidate.type === 'tool_result', + ); + assert.deepEqual( + event?.content.kind === 'text' ? event.content.sandboxDenial : undefined, + expected, + ); + }); + + test('does not label ordinary filesystem permission errors as sandbox denials', async () => { + const messages: ToolResultMessage[] = []; + const backend = createBackend({ + header: header('bypass'), + appendMessage: async (message) => { + if (message.type === 'tool_result') messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => ({}), + tools: [], + }); + const tool: MakaTool = { + name: 'Read', + description: 'read', + parameters: {}, + impl: async () => { + throw new FilesystemWorkerClientError({ + reason: 'filesystem_denied', + stage: 'operation', + backend: 'macos-seatbelt', + recoverable: false, + message: 'Filesystem access was denied.', + }); + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { push: () => {} }); + + await execute( + { path: '/workspace/private.txt' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + assert.equal( + messages[0]?.content.kind === 'text' ? messages[0].content.sandboxDenial : undefined, + undefined, + ); + }); + + test('prefers the connection-advertised Kimi output limit over catalog metadata', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'kimi-coding-plan', + providerType: 'kimi-coding-plan', + defaultModel: 'k3', + models: [{ id: 'k3', maxOutputTokens: 65_536 }], + }, + modelId: 'k3', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + }), + ); + + assert.equal(model.doStreamCalls[0]?.maxOutputTokens, 65_536); + }); + + test('reserves Kimi fixed thinking inside the provider wire output limit', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'kimi-coding-plan', + providerType: 'kimi-coding-plan', + defaultModel: 'kimi-for-coding', + }, + modelId: 'kimi-for-coding', + providerOptions: { + anthropic: { + thinking: { type: 'enabled', budgetTokens: 1_024 }, + effort: 'max', + }, + }, + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + }), + ); + + // Anthropic's adapter adds budgetTokens to maxOutputTokens on the wire. + assert.equal(model.doStreamCalls[0]?.maxOutputTokens, 32_768 - 1_024); + }); + + test('leaves OpenAI-compatible output limits to their provider adapter', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'mistral', + providerType: 'mistral', + defaultModel: 'mistral-large-latest', + }, + modelId: 'mistral-large-latest', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + }), + ); + + assert.equal(model.doStreamCalls[0]?.maxOutputTokens, undefined); + }); + + test('prefers RuntimeEvent prior messages and appends current user once', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'runtime user', + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'runtime assistant', + }), + runtimeTextEvent({ + id: 'rt-current', + turnId: 'turn-current', + role: 'user', + author: 'user', + text: 'current from runtime', + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'runtime user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'runtime assistant' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('safe-boundary continuation does not append a duplicate current user message', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 1, + }, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + assert.deepEqual(prompt, [ + { role: 'user', content: [{ type: 'text', text: 'original user' }] }, + ]); + assert.equal(JSON.stringify(prompt).match(/original user/gu)?.length, 1); + }); + + test('continuation replays the original user after diagnostic terminal errors with no StoredMessage context', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + runtimeEvent({ + id: 'rt-failed', + turnId: 'turn-source', + role: 'system', + author: 'system', + status: 'failed', + content: { kind: 'error', reason: 'runtime_error', message: 'previous attempt failed' }, + actions: { endInvocation: true }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 2, + }, + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original user' }] }, + ]); + }); + + test('continuation fails before the provider when replay materializes no messages', async () => { + const trace: RunTraceEvent[] = []; + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordRunTrace: (event) => trace.push(event), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-failed', + turnId: 'turn-source', + role: 'system', + author: 'system', + status: 'failed', + content: { kind: 'error', reason: 'runtime_error', message: 'previous attempt failed' }, + actions: { endInvocation: true }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 1, + }, + })) { + events.push(event); + } + + assert.equal(model.doStreamCalls.length, 0); + assert.deepEqual( + events.map((event) => event.type), + ['error', 'complete'], + ); + const error = events.find( + (event): event is Extract => event.type === 'error', + ); + assert.equal(error?.code, 'continuation_replay_empty'); + const failure = trace.find((event) => event.type === 'model_stream_failed'); + assert.equal(failure?.data?.errorClass, 'continuation_replay_empty'); + assert.equal(failure?.data?.priorReplayGate, 'runtime_replay_text_only'); + assert.deepEqual(failure?.data?.priorReplayDiagnosticCodes, [ + 'terminal_fact_diagnostic_only', + 'error_content_diagnostic_only', + ]); + }); + + test('continuation materializes validated RuntimeEvents when provider-native replay is unavailable', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { ...connection(), providerType: 'openai' }, + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-source', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'private reasoning', signature: 'sig-1' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 2, + }, + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original user' }] }, + ]); + }); + + test('continuation never substitutes StoredMessages when RuntimeEvent replay has blocking diagnostics', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [ + { + type: 'user', + id: 'projection-u', + turnId: 'turn-source', + ts: 1, + text: 'must not replay projection', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-source', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Bash', + args: { command: 'printf ok' }, + }, + }), + runtimeEvent({ + id: 'rt-invalid-result', + turnId: 'turn-source', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Bash', + result: { + kind: 'terminal', + cwd: '/workspace', + cmd: 'printf ok', + status: 'completed', + exitCode: 0, + stdout: 'ok', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + output: { + mode: 'pipes', + stdout: 'ok', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 3, + }, + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original user' }] }, + ]); + }); + + test('continuation replay may end with an assistant message without an active user head anchor', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-source', + role: 'model', + author: 'agent', + text: 'partial answer', + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 2, + }, + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'partial answer' }] }, + ]); + }); + + test('continuation replay may end at a paired tool boundary without an active user head anchor', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'run the check', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-source', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'README.md' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-source', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: 'contents' }, + }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 3, + }, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string }>; + assert.equal(prompt[0]?.role, 'user'); + assert.equal(prompt.at(-1)?.role, 'tool'); + }); + + test('does not recover provider history from StoredMessages when RuntimeEvent replay is empty', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + { + id: 'rt-terminal', + invocationId: 'inv-1', + runId: 'run-prev', + sessionId: 'session-1', + turnId: 'turn-prev', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true }, + }, + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('RuntimeEvent replay describes an attachment that is not safely addressable', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'see the attached chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'sess-1', + relativePath: 'attachments/chart.png', + }, + }, + ], + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'projection assistant', + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const historicalUser = prompt[0]; + const parts = historicalUser.content as Array<{ type: string; text: string }>; + const text = parts[0]?.text ?? ''; + assert.ok(text.includes('see the attached chart'), `expected user text in: ${text}`); + assert.ok( + text.includes( + '\nThe attachment content is unavailable to Read.\nname: "chart.png"\nmime_type: "image/png"\n', + ), + `expected unavailable attachment context in RuntimeEvent replay, got: ${text}`, + ); + }); + + test('current and replayed directory references expose paths without eager listings', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + const currentReference = { hostId: 'host-a', path: '/workspace/current-source' }; + const historicalReference = { hostId: 'host-a', path: '/workspace/prior-source' }; + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'inspect current', + directoryReferences: [currentReference], + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'inspect prior', + directoryReferences: [historicalReference], + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'projection assistant', + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + const historicalText = prompt[0]?.content[0]?.text ?? ''; + const currentText = prompt.at(-1)?.content[0]?.text ?? ''; + assert.match(historicalText, /inspect prior/); + assert.match(historicalText, /\/workspace\/prior-source/); + assert.match(currentText, /inspect current/); + assert.match(currentText, /\/workspace\/current-source/); + for (const text of [historicalText, currentText]) { + assert.match(text, //); + assert.equal(text.includes('"entries"'), false); + assert.equal(text.includes('"status"'), false); + } + }); + + test('RuntimeEvent replay renders image attachments as image parts when a reader is wired', async () => { + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 4, 5, 6]); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + supportsVision: true, + } as never); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'see the attached chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'sess-1', + relativePath: 'attachments/chart.png', + }, + }, + ], + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'projection assistant', + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const historicalUser = prompt[0]; + const parts = historicalUser.content as Array<{ type: string; mediaType?: string }>; + const imageLike = parts.find((p) => p.type !== 'text' && p.mediaType === 'image/png'); + assert.ok( + imageLike, + `expected a historical image/png part in RuntimeEvent replay, got: ${JSON.stringify(parts)}`, + ); + }); + + test('a persisted quote-only user event replays its excerpt into the provider prompt (#4804)', async () => { + // The headline behaviour of #4804 measured at the production seam: a + // stored user event whose text is empty but whose quotes carry the turn + // must reach the provider prompt as the excerpt itself, not be skipped + // as invisible or summarized as a count. + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + } as never); + await drain( + backend.send({ + turnId: 'turn-current', + text: 'and the current ask', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-quote', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: '', + quotes: [{ text: 'the deploy failed at step three' }], + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const historical = prompt[0]?.content as Array<{ type: string; text?: string }>; + const joined = JSON.stringify(historical); + assert.match(joined, /the deploy failed at step three/, 'the excerpt reaches the prompt'); + assert.doesNotMatch(joined, /quoted_excerpt/, 'no envelope double-wrap in replay'); + }); + + test('current-turn image attachment keeps its Read reference unless vision support is explicit', async () => { + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + } as never); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'describe this chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: pngBytes.length, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + ], + context: [], + runtimeContext: [], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const currentUser = prompt[prompt.length - 1]; + const parts = currentUser.content as Array<{ type: string; mediaType?: string; text?: string }>; + const imageLike = parts.find((p) => p.type !== 'text' && p.mediaType === 'image/png'); + assert.equal( + imageLike, + undefined, + `expected no image/png part without explicit vision support, got: ${JSON.stringify(parts)}`, + ); + const text = parts.map((p) => p.text ?? '').join('\n'); + assert.ok(text.includes('describe this chart'), `expected original text in: ${text}`); + assert.ok( + text.includes('\nRead argument: {"ref":"maka://runtime/attachments/artifact-1"}'), + `expected attachment Read reference in: ${text}`, + ); + assert.doesNotMatch(text, /does not support image input/); + assert.doesNotMatch(text, /switch to a vision-capable model/); + }); + + test('reports unavailable attachment reads without consuming image budget', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 15, + readAttachmentBytes: async (ref: StorageRef) => + ref.kind === 'session_file' && ref.relativePath === 'missing' + ? { ok: false, reason: 'not_found' } + : { ok: true, bytes: new Uint8Array(10) }, + } as never); + const attachment = (relativePath: string) => ({ + kind: 'image' as const, + name: `${relativePath}.png`, + mimeType: 'image/png', + bytes: 10, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'describe these charts', + attachments: [attachment('missing'), attachment('available')], + context: [], + runtimeContext: [], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const parts = prompt.at(-1)?.content as Array<{ + type: string; + mediaType?: string; + text?: string; + }>; + assert.equal( + parts.filter((part) => part.type !== 'text' && part.mediaType === 'image/png').length, + 1, + ); + assert.match(parts.map((part) => part.text ?? '').join('\n'), /missing\.png.*not_found/); + }); + + test('charges attachment image budget from the bytes actually read', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 15, + readAttachmentBytes: async () => ({ ok: true, bytes: new Uint8Array(10) }), + } as never); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'describe this chart', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 20, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'chart' }, + }, + ], + context: [], + runtimeContext: [], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const parts = prompt.at(-1)?.content as Array<{ type: string; mediaType?: string }>; + assert.equal( + parts.filter((part) => part.type !== 'text' && part.mediaType === 'image/png').length, + 1, + ); + }); + + test('degrades excess current-turn image attachments once the per-request budget is exceeded', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 25, + readAttachmentBytes: async () => ({ ok: true, bytes: new Uint8Array(10) }), + } as never); + + const attachment = (relativePath: string) => ({ + kind: 'image' as const, + name: relativePath, + mimeType: 'image/png', + bytes: 10, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'describe these charts', + attachments: [attachment('img-1'), attachment('img-2'), attachment('img-3')], + context: [], + runtimeContext: [], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const currentUser = prompt[prompt.length - 1]; + const parts = currentUser.content as Array<{ type: string; mediaType?: string; text?: string }>; + const imageParts = parts.filter((p) => p.type !== 'text' && p.mediaType === 'image/png'); + assert.equal(imageParts.length, 2, `expected two image parts, got: ${JSON.stringify(parts)}`); + const text = parts.map((p) => p.text ?? '').join('\n'); + assert.match( + text, + /1 image attachment\(s\) omitted.*image budget/, + `expected budget-omitted notice in: ${text}`, + ); + }); + + test('counts the same attachment ref separately in replay and the current turn', async () => { + const bytes = new Uint8Array(10); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 15, + readAttachmentBytes: async () => ({ ok: true, bytes }), + }); + const attachment = { + kind: 'image' as const, + name: 'chart.png', + mimeType: 'image/png', + bytes: bytes.length, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'artifact-1' }, + }; + + await drain( + backend.send({ + turnId: 'turn-regenerated', + text: 'describe this chart', + attachments: [attachment], + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-original', + turnId: 'turn-original', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'describe this chart', attachments: [attachment] }, + }), + runtimeTextEvent({ + id: 'rt-answer', + turnId: 'turn-original', + role: 'model', + author: 'agent', + text: 'original answer', + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const imageParts = prompt + .flatMap((message) => (Array.isArray(message.content) ? message.content : [])) + .filter((part: any) => part.type !== 'text' && part.mediaType === 'image/png'); + assert.equal( + imageParts.length, + 1, + `expected the repeated ref to consume budget twice: ${JSON.stringify(prompt)}`, + ); + const currentUser = prompt[prompt.length - 1]; + const currentText = (currentUser.content as Array<{ text?: string }>) + .map((part) => part.text ?? '') + .join('\n'); + assert.match( + currentText, + /1 image attachment\(s\) omitted.*image budget/, + `expected current attachment omission: ${currentText}`, + ); + }); + + test('charges a durable current-turn image once when the first request reloads the ledger', async () => { + const bytes = new Uint8Array(10); + const model = completionModel(); + const attachment = { + kind: 'image' as const, + name: 'chart.png', + mimeType: 'image/png', + bytes: bytes.length, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'chart' }, + }; + const anchor = runtimeEvent({ + id: 'rt-current', + turnId: 'turn-current', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'describe this chart', + attachments: [attachment], + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: async () => [anchor], + supportsVision: true, + maxProviderImageRequestBytes: 15, + readAttachmentBytes: async () => ({ ok: true, bytes }), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'describe this chart', + attachments: [attachment], + context: [], + headAnchorRuntimeEvent: anchor, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const parts = prompt.at(-1)?.content as Array<{ type: string; mediaType?: string }>; + assert.equal( + parts.filter((part) => part.type !== 'text' && part.mediaType === 'image/png').length, + 1, + ); + }); + + test('degrades excess replayed image tool results once the per-request budget is exceeded', async () => { + const bytes = new Uint8Array(10); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 25, + readAttachmentBytes: async () => ({ ok: true, bytes }), + }); + + const imageResult = (callId: string, relativePath: string) => + runtimeEvent({ + id: `rt-result-${callId}`, + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: callId, + name: 'Read', + isError: false, + result: { + kind: 'image', + mimeType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath }, + }, + }, + }); + const call = (callId: string, path: string) => + runtimeEvent({ + id: `rt-call-${callId}`, + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: callId, name: 'Read', args: { path } }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read them', + }), + call('tool-1', 'a.png'), + imageResult('tool-1', 'artifact-1'), + call('tool-2', 'b.png'), + imageResult('tool-2', 'artifact-2'), + call('tool-3', 'c.png'), + imageResult('tool-3', 'artifact-3'), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const toolOutputs = prompt + .filter((message) => message.role === 'tool') + .flatMap((message) => message.content as any[]) + .map((entry) => entry?.output) + .filter((output) => output?.type === 'content'); + const imageData = toolOutputs.filter((output) => + output.value.some((part: any) => part.type === 'file' && part.mediaType === 'image/png'), + ); + const degraded = toolOutputs.filter((output) => + output.value.some((part: any) => part.type === 'text' && /image budget/.test(part.text)), + ); + assert.equal( + imageData.length, + 2, + `expected two hydrated image tool results, got: ${JSON.stringify(toolOutputs)}`, + ); + assert.equal( + degraded.length, + 1, + `expected one budget-degraded tool result, got: ${JSON.stringify(toolOutputs)}`, + ); + }); + + test('budgets replayed image tool results by durable occurrence instead of reused tool-call ids', async () => { + const bytes = new Uint8Array(10); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + maxProviderImageRequestBytes: 15, + readAttachmentBytes: async () => ({ ok: true, bytes }), + }); + const call = (eventId: string, turnId: string) => + runtimeEvent({ + id: eventId, + turnId, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'reused-tool-id', + name: 'Read', + args: { path: `${turnId}.png` }, + }, + }); + const result = (eventId: string, turnId: string) => + runtimeEvent({ + id: eventId, + turnId, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'reused-tool-id', + name: 'Read', + isError: false, + result: { + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: `${turnId}.png`, + }, + }, + }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'user-a', + turnId: 'turn-a', + role: 'user', + author: 'user', + text: 'read a', + }), + call('call-a', 'turn-a'), + result('result-a', 'turn-a'), + runtimeTextEvent({ + id: 'user-b', + turnId: 'turn-b', + role: 'user', + author: 'user', + text: 'read b', + }), + call('call-b', 'turn-b'), + result('result-b', 'turn-b'), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const outputs = prompt + .filter((message) => message.role === 'tool') + .flatMap((message) => message.content) + .map((entry) => entry?.output) + .filter((output) => output?.type === 'content'); + assert.equal( + outputs.filter((output) => + output.value.some((part: any) => part.type === 'file' && part.mediaType === 'image/png'), + ).length, + 1, + ); + assert.equal( + outputs.filter((output) => + output.value.some((part: any) => part.type === 'text' && /image budget/.test(part.text)), + ).length, + 1, + ); + }); + + test('RuntimeEvent replay renders historical image attachments as image parts', async () => { + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 8, 7]); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + supportsVision: true, + } as never); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'follow-up question', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-img', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'look at this chart', + attachments: [ + { + kind: 'image', + name: 'pic.png', + mimeType: 'image/png', + bytes: 11, + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'fake/pic.png', + }, + }, + ], + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'noted', + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const historicalUser = prompt[0]; + const parts = historicalUser.content as Array<{ type: string; mediaType?: string }>; + const imageLike = parts.find((p) => p.type !== 'text' && p.mediaType === 'image/png'); + assert.ok( + imageLike, + `expected a historical image/png part in replay, got: ${JSON.stringify(parts)}`, + ); + }); + + test('preserves RuntimeEvent tool calls and results as structured AI SDK parts', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'projection user', + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'projection assistant', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'contents', + isError: false, + }, + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'projection user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'projection assistant' }] }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: { path: 'package.json' }, + providerExecuted: undefined, + providerOptions: undefined, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'tool-1', + toolName: 'Read', + output: { type: 'text', value: 'contents' }, + providerOptions: undefined, + }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('replays provider-executed CC web search with encrypted result content intact', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'anthropic-messages' })], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result', + }, + ], + providerExecuted: true, + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ + role: string; + content: Array>; + }>; + const assistant = prompt.find((message) => message.role === 'assistant'); + const call = assistant?.content.find((part) => part.type === 'tool-call'); + const result = assistant?.content.find((part) => part.type === 'tool-result'); + assert.equal(call?.providerExecuted, true, JSON.stringify(prompt)); + assert.deepEqual(call?.input, { query: 'latest Maka' }); + assert.match(JSON.stringify(result?.output), /encrypted-result/); + assert.equal( + prompt.some((message) => message.role === 'tool'), + false, + JSON.stringify(prompt), + ); + }); + + test('replays provider-executed web search before its grounded assistant text', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'openai-responses' })], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerOutput: { type: 'web_search_result', id: 'ws_123' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const assistant = prompt.find( + (message) => + message.role === 'assistant' && message.content.some((part) => part.type === 'tool-call'), + ); + assert.deepEqual( + assistant?.content.map((part) => part.type), + ['tool-call', 'tool-result', 'text'], + ); + assert.match(JSON.stringify(assistant), /ws_123/); + assert.match(JSON.stringify(assistant), /Maka shipped the feature/); + }); + + test('preserves pending assistant steps before a following client tool step', async () => { + const prompt = await replayPrompt([ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'inspect the workspace', + }), + runtimeEvent({ + id: 'rt-progress-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'progress-step-a' }, + content: { kind: 'text', text: 'I found the relevant package.' }, + }), + runtimeEvent({ + id: 'rt-progress-b', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'progress-step-b' }, + content: { kind: 'text', text: 'I will inspect its configuration.' }, + }), + clientToolCallEvent('rt-read-call', 'tool-step'), + clientToolResultEvent('rt-read-result'), + ]); + + assert.deepEqual( + prompt.slice(0, 5).map((message) => ({ + role: message.role, + types: message.content.map((part) => part.type), + text: message.content.find((part) => part.type === 'text')?.text, + })), + [ + { role: 'user', types: ['text'], text: 'inspect the workspace' }, + { + role: 'assistant', + types: ['text'], + text: 'I found the relevant package.', + }, + { + role: 'assistant', + types: ['text'], + text: 'I will inspect its configuration.', + }, + { role: 'assistant', types: ['tool-call'], text: undefined }, + { role: 'tool', types: ['tool-result'], text: undefined }, + ], + ); + }); + + test('groups a client tool only with the immediately pending matching step', async () => { + const contiguousPrompt = await replayPrompt([ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read the file', + }), + runtimeEvent({ + id: 'rt-progress', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'shared-step' }, + content: { kind: 'text', text: 'I will read the file now.' }, + }), + clientToolCallEvent('rt-read-call', 'shared-step'), + clientToolResultEvent('rt-read-result'), + ]); + assert.deepEqual( + contiguousPrompt.slice(0, 3).map((message) => ({ + role: message.role, + types: message.content.map((part) => part.type), + })), + [ + { role: 'user', types: ['text'] }, + { role: 'assistant', types: ['text', 'tool-call'] }, + { role: 'tool', types: ['tool-result'] }, + ], + ); + + const interruptedPrompt = await replayPrompt([ + runtimeTextEvent({ + id: 'rt-user', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read the file', + }), + runtimeEvent({ + id: 'rt-progress-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'shared-step' }, + content: { kind: 'text', text: 'I will read the file now.' }, + }), + runtimeEvent({ + id: 'rt-progress-b', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'intervening-step' }, + content: { kind: 'text', text: 'Another step was persisted.' }, + }), + clientToolCallEvent('rt-read-call', 'shared-step'), + clientToolResultEvent('rt-read-result'), + ]); + assert.deepEqual( + interruptedPrompt.slice(0, 5).map((message) => ({ + role: message.role, + types: message.content.map((part) => part.type), + })), + [ + { role: 'user', types: ['text'] }, + { role: 'assistant', types: ['text'] }, + { role: 'assistant', types: ['text'] }, + { role: 'assistant', types: ['tool-call'] }, + { role: 'tool', types: ['tool-result'] }, + ], + ); + }); + + test('falls back to grounded text when Open Responses cannot replay a hosted tool pair', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 4, + }, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + assert.match(JSON.stringify(prompt), /Maka shipped the feature/); + assert.equal(JSON.stringify(prompt).includes('tool-call'), false); + assert.equal(JSON.stringify(prompt).includes('tool-result'), false); + }); + + test('keeps unrelated client tool history when degrading a hosted tool pair', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: '[redacted]', + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-mixed', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read then search', + }), + runtimeEvent({ + id: 'rt-read-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'client-step' }, + content: { + kind: 'function_call', + id: 'read-1', + name: 'Read', + args: { path: '/tmp/sentinel.ts' }, + }, + }), + runtimeEvent({ + id: 'rt-read-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'read-1', + name: 'Read', + result: [{ type: 'text', text: 'CLIENT_READ_SENTINEL_CONTENT' }], + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-mixed-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 6, + }, + }), + ); + + const wire = JSON.stringify(compactPrompt(model)); + // The unsupported provider-executed pair degrades away… + assert.equal(wire.includes('latest Maka'), false, wire); + // …but the unrelated client Read call and its result survive (#2972). + assert.match(wire, /CLIENT_READ_SENTINEL_CONTENT/); + assert.match(wire, /"toolName":"Read"|\\"toolName\\":\\"Read\\"/); + assert.match(wire, /Maka shipped the feature/); + }); + + test('replays an image tool result as provider image data', async () => { + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read it', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'chart.png' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + isError: false, + result: { + kind: 'image', + mimeType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const result = prompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.equal(result.type, 'content'); + assert.ok( + result.value.some((part: any) => part.type === 'file' && part.mediaType === 'image/png'), + ); + }); + + test('sends a live image tool result to the next provider step', async () => { + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', + ); + let calls = 0; + let artifactReads = 0; + const anchor = runtimeTextEvent({ + id: 'runtime-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'read chart.png', + }); + const ledger: RuntimeEvent[] = [anchor]; + const mappingMemory = createSessionEventMapMemory(); + const mappingContext: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + now: monotonicClock(), + }; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: (calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'chart.png' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'read', + parameters: z.object({ path: z.string() }), + impl: async () => ({ + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_file' as const, + sessionId: 'session-1', + relativePath: 'artifact-1', + }, + }), + }, + ], + supportsVision: true, + maxProviderImageRequestBytes: pngBytes.byteLength, + readAttachmentBytes: async () => { + artifactReads += 1; + return { ok: true, bytes: pngBytes }; + }, + loadTurnRuntimeEvents: async () => ledger, + }); + + for await (const event of backend.send({ + turnId: 'turn-1', + text: 'read chart.png', + context: [], + headAnchorRuntimeEvent: anchor, + })) { + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + } + + const nextPrompt = model.doStreamCalls[1]?.prompt as Array<{ role: string; content: any[] }>; + const result = nextPrompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.ok( + result.value.some((part: any) => part.type === 'file' && part.mediaType === 'image/png'), + ); + assert.equal(artifactReads, 1); + }); + + test('a live Plugin can enable, execute, and disable a Tool within one Turn', async () => { + const durable = durableTurnHarness('turn-dynamic-tools', 'check inventory then disable access'); + const root = new Context(); + const pluginTools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + let disposeInventory: (() => Promise) | undefined; + const inventoryTool: MakaTool = { + name: 'lookup_inventory', + description: 'look up current inventory', + parameters: z.object({ sku: z.string() }), + impl: async ({ sku }) => ({ sku, available: 7 }), + }; + await loader.install({ + packageId: 'inventory-plugin', + host: (ctx) => { + ctx.tools.register({ + name: 'enable_inventory', + description: 'enable inventory access', + parameters: z.object({}), + impl: async () => { + disposeInventory ??= ctx.tools.register(inventoryTool); + return { enabled: inventoryTool.name }; + }, + }); + ctx.tools.register({ + name: 'disable_inventory', + description: 'disable inventory access', + parameters: z.object({}), + impl: async () => { + await disposeInventory?.(); + disposeInventory = undefined; + return { disabled: inventoryTool.name }; + }, + }); + }, + }); + await loader.create('profile', { + id: 'inventory-entry', + packageId: 'inventory-plugin', + }); + let calls = 0; + const requestCompositions: RequestCompositionSnapshotInput[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const toolCall = + calls === 1 + ? { + id: 'search-enable-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'enable inventory', limit: 1 }), + } + : calls === 2 + ? { id: 'enable-call', name: 'enable_inventory', input: '{}' } + : calls === 3 + ? { + id: 'search-inventory-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'look up current inventory', limit: 1 }), + } + : calls === 4 + ? { + id: 'inventory-call', + name: inventoryTool.name, + input: JSON.stringify({ sku: 'SKU-42' }), + } + : calls === 5 + ? { + id: 'search-disable-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'disable inventory', limit: 1 }), + } + : calls === 6 + ? { id: 'disable-call', name: 'disable_inventory', input: '{}' } + : undefined; + return { + stream: simulateReadableStream({ + chunks: (toolCall + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: toolCall.id, + toolName: toolCall.name, + input: toolCall.input, + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [...pluginTools.resolve('session-1', []).tools], + resolveTools: () => pluginTools.resolve('session-1', []).tools, + toolAvailability: { + groups: [ + { + id: 'plugins', + toolNames: ['enable_inventory', 'lookup_inventory', 'disable_inventory'], + }, + ], + }, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + recordRequestComposition: async (_runId, snapshot) => { + requestCompositions.push(snapshot); + return snapshot.compositionId; + }, + newId: idGenerator(), + now: monotonicClock(), + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + + const namesForRequest = (index: number): string[] => { + const tools = model.doStreamCalls[index]?.tools ?? []; + return Array.isArray(tools) + ? tools.flatMap((tool) => + tool && typeof tool === 'object' && 'name' in tool ? [String(tool.name)] : [], + ) + : Object.keys(tools); + }; + assert.equal(namesForRequest(0).includes('enable_inventory'), false); + assert.equal(namesForRequest(1).includes('enable_inventory'), true); + assert.equal(namesForRequest(2).includes(inventoryTool.name), false); + assert.equal(namesForRequest(3).includes(inventoryTool.name), true); + assert.equal(namesForRequest(4).includes('disable_inventory'), false); + assert.equal(namesForRequest(5).includes('disable_inventory'), true); + assert.equal(namesForRequest(6).includes(inventoryTool.name), false); + assert.equal(requestCompositions.length, 7); + assert.equal(requestCompositions[2]?.toolNames.includes(inventoryTool.name), false); + assert.equal(requestCompositions[3]?.toolNames.includes(inventoryTool.name), true); + assert.equal(requestCompositions[6]?.toolNames.includes(inventoryTool.name), false); + const finalPrompt = model.doStreamCalls[6]?.prompt as Array<{ + role: string; + content: Array<{ output?: { value?: unknown } }>; + }>; + assert.equal( + JSON.stringify(finalPrompt).includes('SKU-42') && + JSON.stringify(finalPrompt).includes('available'), + true, + ); + await loader.close(); + }); + + test('installs, invokes, and removes a live weather plugin within one model turn', async () => { + const root = new Context(); + const pluginTools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + const invocations: Array<{ city: string }> = []; + await loader.install({ + packageId: 'weather-package', + host: (ctx) => { + ctx.tools.register({ + name: 'weather_forecast', + description: 'Get the current weather forecast for a city', + parameters: z.object({ city: z.string() }), + impl: async (input) => { + const { city } = input as { city: string }; + invocations.push({ city }); + return { city, condition: 'sunny', temperatureCelsius: 28 }; + }, + }); + }, + }); + + const installPlugin: MakaTool = { + name: 'install_weather_plugin', + description: 'Install the weather plugin for the current profile', + parameters: z.object({}), + impl: async () => { + await loader.create('profile', { + id: 'weather-entry', + packageId: 'weather-package', + }); + return { installed: true }; + }, + }; + const removePlugin: MakaTool = { + name: 'remove_weather_plugin', + description: 'Remove the installed weather plugin', + parameters: z.object({}), + impl: async () => { + await loader.remove('weather-entry'); + return { removed: true }; + }, + }; + const resolveTools = (): readonly MakaTool[] => + pluginTools.resolve('session-1', [installPlugin, removePlugin]).tools; + const durable = durableTurnHarness( + 'turn-live-weather-plugin', + 'Install a weather plugin, check Shanghai, then remove the plugin.', + ); + const scriptedCalls = [ + { toolCallId: 'install-call', toolName: installPlugin.name, input: '{}' }, + { + toolCallId: 'forecast-call', + toolName: 'weather_forecast', + input: JSON.stringify({ city: 'Shanghai' }), + }, + { toolCallId: 'remove-call', toolName: removePlugin.name, input: '{}' }, + ] as const; + let step = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + const call = scriptedCalls[step++]; + return { + stream: simulateReadableStream({ + chunks: (call + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'tool-call', ...call }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [...resolveTools()], + resolveTools, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + try { + const events = await drainDurably(backend.send(durable.input()), durable); + const namesForStep = (index: number): string[] => { + const tools = model.doStreamCalls[index]?.tools ?? []; + return Array.isArray(tools) + ? tools.flatMap((tool) => + tool && typeof tool === 'object' && 'name' in tool ? [String(tool.name)] : [], + ) + : Object.keys(tools); + }; + + assert.equal(namesForStep(0).includes('weather_forecast'), false); + assert.equal(namesForStep(1).includes('weather_forecast'), true); + assert.equal(namesForStep(2).includes('weather_forecast'), true); + assert.equal(namesForStep(3).includes('weather_forecast'), false); + assert.deepEqual(invocations, [{ city: 'Shanghai' }]); + assert.equal(events.filter((event) => event.type === 'tool_result').length, 3); + assert.deepEqual(pluginTools.inspect(), []); + } finally { + await loader.close(); + } + }); + + test('reloads durable multi-tool settlement before terminal continuation', async () => { + const anchor = runtimeTextEvent({ + id: 'runtime-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'run both tools', + }); + const ledger: RuntimeEvent[] = [anchor]; + const mappingMemory = createSessionEventMapMemory(); + const mappingContext: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + now: monotonicClock(), + }; + const executions: string[] = []; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls === 2) { + assert.equal( + ledger.filter((event) => event.content?.kind === 'function_response').length, + 2, + ); + } + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'inspect first' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-step-1' } }, + }, + { type: 'reasoning-end', id: 'reasoning-1' }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Running tools.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'tool-call', + toolCallId: 'call-success', + toolName: 'Read', + input: JSON.stringify({ path: 'ok.md' }), + providerMetadata: { + google: { thoughtSignature: 'thought-signature-step-1' }, + }, + }, + { + type: 'tool-call', + toolCallId: 'call-failure', + toolName: 'Fail', + input: JSON.stringify({ path: 'bad.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'Done.' }, + { type: 'text-end', id: 'text-2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'read', + parameters: z.object({ path: z.string() }), + impl: async ({ path }: { path: string }) => { + executions.push(`Read:${path}`); + return { body: 'ok' }; + }, + }, + { + name: 'Fail', + description: 'fail', + parameters: z.object({ path: z.string() }), + impl: async ({ path }: { path: string }) => { + executions.push(`Fail:${path}`); + throw new Error('tool failed'); + }, + }, + ], + loadTurnRuntimeEvents: async () => ledger, + }); + + const emitted: SessionEvent[] = []; + for await (const event of backend.send({ + runId: 'run-1', + turnId: 'turn-1', + text: 'run both tools', + context: [], + headAnchorRuntimeEvent: anchor, + })) { + emitted.push(event); + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + } + + assert.equal(calls, 2); + assert.deepEqual(executions, ['Read:ok.md', 'Fail:bad.md']); + const nextPrompt = model.doStreamCalls[1]?.prompt as unknown as Array<{ + role: string; + content: Array>; + }>; + const assistantStep = nextPrompt.find( + (message) => + message.role === 'assistant' && message.content.some((part) => part.type === 'tool-call'), + ); + assert.deepEqual( + assistantStep?.content.map((part) => part.type), + ['reasoning', 'text', 'tool-call', 'tool-call'], + ); + assert.match(JSON.stringify(assistantStep), /sig-step-1/); + assert.deepEqual(assistantStep?.content[2]?.providerOptions, { + google: { thoughtSignature: 'thought-signature-step-1' }, + }); + assert.match(JSON.stringify(assistantStep), /Running tools\./); + assert.deepEqual( + nextPrompt + .filter((message) => message.role === 'tool') + .flatMap((message) => message.content.map((part) => part.toolCallId)), + ['call-success', 'call-failure'], + ); + const toolResults = JSON.stringify(nextPrompt.filter((message) => message.role === 'tool')); + assert.match(toolResults, /"ok"/); + assert.match(toolResults, /tool failed/i); + assert.deepEqual( + ledger + .filter((event) => event.content?.kind === 'function_response') + .map((event) => { + assert.equal(event.content?.kind, 'function_response'); + return { + id: event.content.id, + isError: event.content.isError === true, + }; + }), + [ + { id: 'call-success', isError: false }, + { id: 'call-failure', isError: true }, + ], + ); + assert.ok( + emitted.some((event) => event.type === 'text_complete' && event.text === 'Done.'), + 'the terminal provider step emits its final assistant text', + ); + assert.ok( + ledger.some( + (event) => + event.partial !== true && + event.role === 'model' && + event.content?.kind === 'text' && + event.content.text === 'Done.', + ), + 'the terminal assistant text becomes a durable RuntimeEvent fact', + ); + const complete = emitted.find( + (event): event is Extract => event.type === 'complete', + ); + assert.equal(complete?.stopReason, 'end_turn'); + }); + + test('does not read image bytes for a non-vision model', async () => { + let reads = 0; + const model = completionModel(); + const backend = imageReplayBackend(model, { + supportsVision: false, + readAttachmentBytes: async () => { + reads += 1; + return { ok: true, bytes: new Uint8Array([1]) }; + }, + }); + + await drain(backend.send(imageReplayInput())); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const output = prompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.equal(reads, 0); + assert.match(output.value[0].text, /does not support image input/); + }); + + test('explains when a replayed image artifact is missing', async () => { + const model = completionModel(); + const backend = imageReplayBackend(model, { + supportsVision: true, + readAttachmentBytes: async () => ({ ok: false, reason: 'not_found' }), + }); + + await drain(backend.send(imageReplayInput())); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const output = prompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.match(output.value[0].text, /not_found/); + }); + + test('explains when replayed image storage throws', async () => { + const model = completionModel(); + const backend = imageReplayBackend(model, { + supportsVision: true, + readAttachmentBytes: async () => { + throw new Error('private disk detail'); + }, + }); + + await drain(backend.send(imageReplayInput())); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const output = prompt.find((message) => message.role === 'tool')?.content[0]?.output; + assert.match(output.value[0].text, /read_failed/); + assert.doesNotMatch(JSON.stringify(prompt), /private disk detail/); + }); + + test('replays interleaved parallel RuntimeEvent tool calls as one provider tool-call block', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'inspect files', + }), + runtimeEvent({ + id: 'rt-call-0', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-0', + name: 'Read', + args: { path: 'main.cpp' }, + }, + }), + runtimeEvent({ + id: 'rt-call-1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'user.cpp' }, + }, + }), + runtimeEvent({ + id: 'rt-result-0', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-0', + name: 'Read', + result: 'main', + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-call-2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-2', name: 'Glob', args: { pattern: '*' } }, + }), + runtimeEvent({ + id: 'rt-result-1', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'user', + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-result-2', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-2', + name: 'Glob', + result: ['main.cpp', 'user.cpp'], + isError: false, + }, + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'inspect files' }] }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tool-0', + toolName: 'Read', + input: { path: 'main.cpp' }, + providerExecuted: undefined, + providerOptions: undefined, + }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: { path: 'user.cpp' }, + providerExecuted: undefined, + providerOptions: undefined, + }, + { + type: 'tool-call', + toolCallId: 'tool-2', + toolName: 'Glob', + input: { pattern: '*' }, + providerExecuted: undefined, + providerOptions: undefined, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'tool-0', + toolName: 'Read', + output: { type: 'text', value: 'main' }, + providerOptions: undefined, + }, + { + type: 'tool-result', + toolCallId: 'tool-1', + toolName: 'Read', + output: { type: 'text', value: 'user' }, + providerOptions: undefined, + }, + { + type: 'tool-result', + toolCallId: 'tool-2', + toolName: 'Glob', + output: { type: 'json', value: ['main.cpp', 'user.cpp'] }, + providerOptions: undefined, + }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('replays durable Bash results without duplicating commands in provider output', async () => { + const model = completionModel(); + const durableResults = [ + { + kind: 'terminal' as const, + cwd: '/workspace', + cmd: 'printf completed-marker', + status: 'completed' as const, + exitCode: 0, + output: { + mode: 'pipes' as const, + stdout: 'completed', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + { + kind: 'terminal' as const, + cwd: '/workspace', + cmd: 'printf failed-marker', + status: 'failed' as const, + exitCode: 2, + output: { + mode: 'pipes' as const, + stdout: '', + stderr: 'failed', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + sandboxDenial: { + likely: true as const, + backend: 'macos-seatbelt' as const, + recovery: 'require_escalated' as const, + }, + }, + { + kind: 'terminal' as const, + cwd: '/workspace', + cmd: 'printf timeout-marker', + status: 'timed_out' as const, + exitCode: 124, + output: { + mode: 'pipes' as const, + stdout: 'partial timeout', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + { + kind: 'terminal' as const, + cwd: '/workspace', + cmd: 'printf cancelled-marker', + status: 'cancelled' as const, + exitCode: 130, + output: { + mode: 'pipes' as const, + stdout: '', + stderr: 'cancelled', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + { + kind: 'terminal' as const, + cwd: '/workspace', + cmd: 'printf truncated-marker', + status: 'completed' as const, + exitCode: 0, + output: { + mode: 'pipes' as const, + stdout: 'tail', + stderr: 'error tail', + stdoutTruncated: true, + stderrTruncated: true, + redacted: true, + }, + }, + ]; + const runtimeContext: RuntimeEvent[] = [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'run all commands', + }), + ...durableResults.map((result, index) => + runtimeEvent({ + id: `rt-call-${index}`, + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: `tool-${index}`, + name: 'Bash', + args: { command: result.cmd }, + }, + }), + ), + ...durableResults.map((result, index) => + runtimeEvent({ + id: `rt-result-${index}`, + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: `tool-${index}`, + name: 'Bash', + result, + isError: result.status !== 'completed', + }, + }), + ), + ]; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const serializedPrompt = JSON.stringify(prompt); + const toolResults = prompt.find((message) => message.role === 'tool')?.content ?? []; + assert.equal(toolResults.length, durableResults.length); + for (const [index, durable] of durableResults.entries()) { + assert.equal( + serializedPrompt.split(durable.cmd).length - 1, + 1, + `command ${index} should remain only in its paired Bash call`, + ); + const output = toolResults[index]?.output; + assert.equal(output?.type, durable.status === 'completed' ? 'json' : 'error-json'); + assert.equal(Object.hasOwn(output?.value ?? {}, 'cmd'), false); + const { cmd: _cmd, ...expected } = durable; + assert.deepEqual(output?.value, expected); + assert.equal(durableResults[index]?.cmd, durable.cmd); + } + }); + + test('archives stale RuntimeEvent tool results before replay placeholder rewrite', async () => { + const model = completionModel(); + const archiveRequests: Array<{ + runtimeEventId: string; + serializedResult: string; + bodySha256: string; + }> = []; + const oldResult = { body: 'x'.repeat(500) }; + const transitions: ModelProjectionTransition[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'archive-test', + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + charsPerToken: 1, + }, + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (event) => { + archiveRequests.push({ + runtimeEventId: event.runtimeEventId, + serializedResult: event.serializedResult, + bodySha256: event.bodySha256, + }); + return { artifactId: `artifact-${event.runtimeEventId}` }; + }, + }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: oldResult, + isError: false, + }, + }), + ], + }), + ); + + assert.equal(archiveRequests.length, 1); + assert.equal(archiveRequests[0]?.runtimeEventId, 'rt-result'); + assert.equal(archiveRequests[0]?.serializedResult, JSON.stringify(oldResult)); + assert.match(archiveRequests[0]?.bodySha256 ?? '', /^[a-f0-9]{64}$/); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /"kind":"maka\.archived_tool_result"/); + assert.match(prompt, /"artifactId":"artifact-rt-result"/); + assert.match(prompt, /"runtimeEventId":"rt-result"/); + assert.equal(prompt.includes(oldResult.body), false); + }); + + test('manual compactHistory retreats to a span this route has accepted', async () => { + // The retreat needs the run headers and the route to find the newest reply + // this model produced. The planner tests hand those in directly, so they + // would stay green if the call site stopped passing them; this drives the + // entry `/compact` actually uses. + const attemptedCoverage: string[][] = []; + const backend = createBackend({ + header: { ...header(), llmConnectionId: 'test-connection-id', model: 'mock-model-id' }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { name: 'standalone-retreat-test', charsPerToken: 1 }, + summarizeHistoryCompact: async ({ source }) => { + attemptedCoverage.push(source.foldedRuntimeEvents.map((event) => event.id)); + if (attemptedCoverage.length === 1) { + throw new HistoryCompactSummarizerError('input_too_large'); + } + return structuredSummary('STANDALONE_RETREAT_SENTINEL'); + }, + recordHistoryCompactCheckpoint: () => {}, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'test-connection-id', modelId: 'mock-model-id' }), + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'old alpha '.repeat(100), + }), + runtimeTextEvent({ + id: 'old-model', + turnId: 'turn-old', + role: 'model', + author: 'agent', + text: 'old beta '.repeat(100), + }), + runtimeTextEvent({ + id: 'recent-user', + turnId: 'turn-recent', + role: 'user', + author: 'user', + text: 'recent alpha '.repeat(100), + }), + runtimeTextEvent({ + id: 'recent-model', + turnId: 'turn-recent', + role: 'model', + author: 'agent', + text: 'recent beta '.repeat(100), + }), + ], + }); + + assert.equal(result.outcome.kind, 'compacted'); + // The first attempt covers everything; the retreat stops where the newest + // reply this route produced begins. Without the route reaching the planner + // there is no second attempt at all. + assert.deepEqual(attemptedCoverage, [ + ['old-user', 'old-model', 'recent-user', 'recent-model'], + ['old-user', 'old-model', 'recent-user'], + ]); + }); + + test('manual compactHistory writes a V2 checkpoint without the legacy artifact writer', async () => { + const recorded: HistoryCompactCheckpoint[] = []; + let memoryDispatches = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-v2-compact-test', + charsPerToken: 1, + }, + summarizeHistoryCompact: async () => structuredSummary('MANUAL_V2_HISTORY_COMPACT_SENTINEL'), + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + memoryExtraction: { + gate: async () => ({ allowed: true }), + remember: async () => ({ status: 'unavailable', requestedItems: [] }), + extract: () => { + memoryDispatches += 1; + }, + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'manual-v2-old-1', + turnId: 'turn-old-1', + role: 'user', + author: 'user', + text: 'manual v2 old alpha '.repeat(100), + }), + runtimeTextEvent({ + id: 'manual-v2-old-2', + turnId: 'turn-old-2', + role: 'model', + author: 'agent', + text: 'manual v2 old beta '.repeat(100), + }), + runtimeTextEvent({ + id: 'manual-v2-recent', + turnId: 'turn-recent', + role: 'user', + author: 'user', + text: 'manual v2 recent retained context', + }), + ], + }); + + assert.equal(recorded.length, 1); + assert.equal( + recorded[0]?.version === 2 ? recorded[0].summary : undefined, + structuredSummary('MANUAL_V2_HISTORY_COMPACT_SENTINEL'), + ); + assert.deepEqual(recorded[0]?.coverage.eventCount, 3); + assert.equal(recorded[0]?.memoryExtractionBoundary, undefined); + assert.equal(memoryDispatches, 0); + assert.equal(result.outcome.kind, 'compacted'); + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.decision, 'replaced'); + }); + + test('manual compactHistory compacts one completed turn with multiple agent steps', async () => { + const recorded: HistoryCompactCheckpoint[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-single-turn-compact-test', + charsPerToken: 1, + }, + summarizeHistoryCompact: async () => structuredSummary('MANUAL_SINGLE_TURN_SENTINEL'), + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'single-turn-user', + turnId: 'turn-work', + role: 'user', + author: 'user', + text: 'Implement the requested change.', + }), + runtimeTextEvent({ + id: 'single-turn-agent-step-1', + turnId: 'turn-work', + role: 'model', + author: 'agent', + text: 'Inspected the current implementation. '.repeat(40), + }), + runtimeTextEvent({ + id: 'single-turn-agent-step-2', + turnId: 'turn-work', + role: 'model', + author: 'agent', + text: 'Completed and verified the change. '.repeat(40), + }), + ], + }); + + assert.equal(recorded.length, 1); + assert.equal(recorded[0]?.coverage.turnCount, 1); + assert.equal(recorded[0]?.coverage.eventCount, 3); + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.decision, 'replaced'); + }); + + test('manual compactHistory rolls forward from the previous V2 checkpoint', async () => { + const oldEvents = [ + runtimeTextEvent({ + id: 'manual-v2-roll-old-1', + turnId: 'manual-v2-roll-turn-1', + role: 'user', + author: 'user', + text: 'manual v2 roll old alpha '.repeat(12), + }), + runtimeTextEvent({ + id: 'manual-v2-roll-old-2', + turnId: 'manual-v2-roll-turn-2', + role: 'model', + author: 'agent', + text: 'manual v2 roll old beta '.repeat(12), + }), + ]; + const previous = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: oldEvents.slice(0, 1), + summary: sectionedSummary('MANUAL_V2_PREVIOUS_SUMMARY'), + charsPerToken: 1, + }); + const summaryInputs: Array<{ previous?: string; newlyFoldedIds: string[] }> = []; + const recorded: HistoryCompactCheckpoint[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-v2-roll-test', + charsPerToken: 1, + }, + loadHistoryCompactCheckpoint: () => previous, + summarizeHistoryCompact: async (input) => { + summaryInputs.push({ + previous: + input.previousCheckpoint?.version === 2 ? input.previousCheckpoint.summary : undefined, + newlyFoldedIds: (input.newlyFoldedRuntimeEvents ?? []).map((event) => event.id), + }); + return structuredSummary('MANUAL_V2_ROLLED_SUMMARY'); + }, + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + }); + + await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + ...oldEvents, + runtimeTextEvent({ + id: 'manual-v2-roll-recent', + turnId: 'manual-v2-roll-recent-turn', + role: 'user', + author: 'user', + text: 'manual v2 roll retained context', + }), + ], + }); + + assert.deepEqual(summaryInputs, [ + { + previous: previous.summary, + newlyFoldedIds: ['manual-v2-roll-old-2', 'manual-v2-roll-recent'], + }, + ]); + assert.equal(recorded[0]?.previousCheckpointId, previous.checkpointId); + assert.equal(recorded[0]?.coverage.eventCount, 3); + }); + + test('manual compactHistory reuses a checkpoint that already covers the full fold', async () => { + const oldEvents = [ + runtimeTextEvent({ + id: 'manual-v2-reuse-old-1', + turnId: 'manual-v2-reuse-turn-1', + role: 'user', + author: 'user', + text: 'manual v2 reuse old alpha '.repeat(12), + }), + runtimeTextEvent({ + id: 'manual-v2-reuse-old-2', + turnId: 'manual-v2-reuse-turn-2', + role: 'model', + author: 'agent', + text: 'manual v2 reuse old beta '.repeat(12), + }), + ]; + const recentEvent = runtimeTextEvent({ + id: 'manual-v2-reuse-recent', + turnId: 'manual-v2-reuse-recent-turn', + role: 'user', + author: 'user', + text: 'manual v2 reuse retained context', + }); + const previous = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [...oldEvents, recentEvent], + summary: sectionedSummary('MANUAL_V2_REUSED_SUMMARY'), + charsPerToken: 1, + }); + let summarizeCalls = 0; + let recordCalls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-v2-reuse-test', + charsPerToken: 1, + }, + loadHistoryCompactCheckpoint: () => previous, + summarizeHistoryCompact: async () => { + summarizeCalls += 1; + return structuredSummary('must not resummarize an already covered fold'); + }, + recordHistoryCompactCheckpoint: () => { + recordCalls += 1; + throw new Error('equal coverage must not reach the recorder'); + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [...oldEvents, recentEvent], + }); + + assert.equal(summarizeCalls, 0); + assert.equal(recordCalls, 0); + assert.deepEqual(result.outcome, { kind: 'unchanged', reason: 'already_compacted' }); + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.decision, 'unchanged'); + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.reason, 'already_compacted'); + }); + + test('manual compactHistory reports output-length exhaustion instead of empty_summary', async () => { + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-v2-output-length-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => { + throw new HistoryCompactSummarizerError('output_length'); + }, + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'output-length-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'output-length-recent', + turnId: 'recent', + role: 'user', + author: 'user', + text: 'recent', + }), + ], + }); + + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.failOpenReason, 'output_length'); + assert.deepEqual(result.outcome, { kind: 'failed', reason: 'output_length' }); + }); + + test('the checkpoint write gate rejects a malformed summary from any producer', async () => { + // #3029: the summarizer validates its own completions, but the WRITE gate + // must enforce the invariant even for a producer that skipped that path — + // a malformed summary never replaces folded history. + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-v2-write-gate-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + // Returns (not throws) a section-less fragment, bypassing the + // summarizer's own generate-time validation. + summarizeHistoryCompact: async () => '这次会话主要讨论了以下内容,然后:', + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'write-gate-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'write-gate-recent', + turnId: 'recent', + role: 'user', + author: 'user', + text: 'recent', + }), + ], + }); + + assert.equal( + result.contextBudget?.compactionDecisions?.[0]?.failOpenReason, + 'malformed_summary_missing_section', + ); + assert.deepEqual(result.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + }); + + test('does not redispatch unchanged compaction content for unrelated run provenance', async () => { + let calls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'malformed-summary-circuit-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => { + calls += 1; + throw new HistoryCompactSummarizerError('malformed_summary_missing_section'); + }, + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + const history = [ + runtimeTextEvent({ + id: 'circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + const sourceRunHeader = priorModelInvocation({ + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + }); + const priorCompactionRunHeader = priorModelInvocation({ + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + runId: 'run-1', + turnId: 'turn-compact-1', + root: { kind: 'context_compact' }, + }); + + const first = await backend.compactHistory({ + turnId: 'turn-compact-1', + runId: 'run-1', + runtimeContext: history, + runtimeContextInvocations: [sourceRunHeader], + }); + const repeated = await backend.compactHistory({ + turnId: 'turn-compact-2', + runId: 'run-2', + runtimeContext: history, + runtimeContextInvocations: [sourceRunHeader, priorCompactionRunHeader], + }); + + assert.equal(calls, 1); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + + await backend.compactHistory({ + turnId: 'turn-compact-3', + runId: 'run-3', + runtimeContext: [ + ...history, + runtimeTextEvent({ + id: 'circuit-changed', + turnId: 'changed', + role: 'user', + author: 'user', + text: 'new source history', + }), + ], + runtimeContextInvocations: [sourceRunHeader, priorCompactionRunHeader], + }); + assert.equal(calls, 2, 'changed source fingerprint is eligible again'); + }); + + test('does not redispatch when malformed-summary repair fails with another reason', async () => { + let providerCalls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async () => { + providerCalls += 1; + return providerCalls % 2 === 1 + ? { text: 'free-form incomplete summary', finishReason: 'stop' } + : { text: '## Goal\npartial summary', finishReason: 'length' }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'malformed-summary-repair-circuit-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: (input) => summarize(input), + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }); + const history = [ + runtimeTextEvent({ + id: 'repair-circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'repair-circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + + const first = await backend.compactHistory({ + turnId: 'turn-repair-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + const repeated = await backend.compactHistory({ + turnId: 'turn-repair-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(providerCalls, 2); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + }); + + test('a cancelled malformed-summary repair does not arm the Session circuit', async () => { + let repairStarted!: () => void; + const started = new Promise((resolve) => { + repairStarted = resolve; + }); + let providerCalls = 0; + let recordCalls = 0; + const summarize = buildLlmHistorySummarizer({ + resolveModel: () => 'fake-model', + generateText: async ({ abortSignal }) => { + providerCalls += 1; + if (providerCalls === 1) { + return { text: 'free-form incomplete summary', finishReason: 'stop' }; + } + if (providerCalls > 2) { + return { text: structuredSummary('RECOVERED_AFTER_CANCEL'), finishReason: 'stop' }; + } + repairStarted(); + return await new Promise((_resolve, reject) => { + const rejectAbort = () => + reject( + abortSignal?.reason ?? Object.assign(new Error('stopped'), { name: 'AbortError' }), + ); + if (abortSignal?.aborted) rejectAbort(); + else abortSignal?.addEventListener('abort', rejectAbort, { once: true }); + }); + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'malformed-summary-cancel-circuit-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: (input) => summarize(input), + recordHistoryCompactCheckpoint: () => { + recordCalls += 1; + }, + }); + const history = [ + runtimeTextEvent({ + id: 'cancel-circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'cancel-circuit-older', + turnId: 'older', + role: 'model', + author: 'agent', + text: 'older '.repeat(100), + }), + runtimeTextEvent({ + id: 'cancel-circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + + const cancelled = backend.compactHistory({ + turnId: 'turn-cancel-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + await started; + await backend.stop('user_stop'); + + assert.deepEqual(await cancelled, { outcome: { kind: 'failed', reason: 'aborted' } }); + const retried = await backend.compactHistory({ + turnId: 'turn-cancel-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(providerCalls, 3); + assert.equal(recordCalls, 1); + assert.equal(retried.outcome.kind, 'compacted'); + }); + + test('invalidates the malformed compaction circuit when configuration changes', async (t) => { + type FingerprintCase = { + name: string; + expectedCalls: number; + change?: (input: AiSdkBackendInput) => void; + }; + const cases = [ + { + name: 'unchanged input stays blocked', + expectedCalls: 1, + }, + { + name: 'model change retries', + expectedCalls: 2, + change: (input) => { + input.modelId = 'changed-model-id'; + }, + }, + { + name: 'connection change retries', + expectedCalls: 2, + change: (input) => { + input.connection = { ...input.connection, slug: 'anthropic-secondary' }; + }, + }, + { + name: 'context-window budget change retries', + expectedCalls: 2, + change: (input) => { + input.contextBudget = { + ...input.contextBudget, + name: 'malformed-summary-config-circuit-changed', + }; + }, + }, + { + name: 'compaction route change retries', + expectedCalls: 2, + change: (input) => { + input.historyCompactRoute = 'text_summary'; + }, + }, + ] satisfies readonly FingerprintCase[]; + + for (const fingerprintCase of cases) { + await t.test(fingerprintCase.name, async () => { + let calls = 0; + const backendInput: AiSdkBackendInput = { + sessionId: 'session-1', + header: header(), + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + readExecutionBoundary: readExternalExecutionBoundary, + contextBudget: { + name: 'malformed-summary-config-circuit-test', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => { + calls += 1; + throw new HistoryCompactSummarizerError('malformed_summary_missing_section'); + }, + recordHistoryCompactCheckpoint: () => { + throw new Error('must not persist'); + }, + }; + const backend = new AiSdkBackend(backendInput); + const history = [ + runtimeTextEvent({ + id: 'config-circuit-old', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + runtimeTextEvent({ + id: 'config-circuit-recent', + turnId: 'recent', + role: 'model', + author: 'agent', + text: 'recent', + }), + ]; + const first = await backend.compactHistory({ + turnId: 'turn-config-compact-1', + runId: 'run-1', + runtimeContext: history, + }); + fingerprintCase.change?.(backendInput); + const repeated = await backend.compactHistory({ + turnId: 'turn-config-compact-2', + runId: 'run-2', + runtimeContext: history, + }); + + assert.equal(calls, fingerprintCase.expectedCalls); + assert.deepEqual(first.outcome, { + kind: 'failed', + reason: 'malformed_summary_missing_section', + }); + assert.deepEqual(repeated.outcome, first.outcome); + }); + } + }); + + test('manual compactHistory is a no-op when context budget is disabled', async () => { + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'old-1', + turnId: 'turn-old-1', + role: 'user', + author: 'user', + text: 'old alpha '.repeat(20), + }), + runtimeTextEvent({ + id: 'old-2', + turnId: 'turn-old-2', + role: 'model', + author: 'agent', + text: 'old beta '.repeat(20), + }), + ], + }); + + assert.deepEqual(result.outcome, { kind: 'unchanged', reason: 'operation_unavailable' }); + }); + + test('manual compactHistory is a no-op when no durable writer is configured', async () => { + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-compact-test', + charsPerToken: 1, + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'old-1', + turnId: 'turn-old-1', + role: 'user', + author: 'user', + text: 'old alpha '.repeat(20), + }), + runtimeTextEvent({ + id: 'old-2', + turnId: 'turn-old-2', + role: 'model', + author: 'agent', + text: 'old beta '.repeat(20), + }), + ], + }); + + assert.deepEqual(result.outcome, { kind: 'unchanged', reason: 'operation_unavailable' }); + }); + + test('manual compactHistory does not report replaced when durable write fails', async () => { + const oldEvents = [ + runtimeTextEvent({ + id: 'manual-compact-old-1', + turnId: 'turn-old-1', + role: 'user', + author: 'user', + text: 'manual alpha compact source '.repeat(12), + }), + runtimeTextEvent({ + id: 'manual-compact-old-2', + turnId: 'turn-old-2', + role: 'model', + author: 'agent', + text: 'manual beta compact source '.repeat(12), + }), + runtimeTextEvent({ + id: 'manual-compact-recent', + turnId: 'turn-recent', + role: 'user', + author: 'user', + text: 'manual recent retained context', + }), + ]; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + contextBudget: { + name: 'manual-compact-test', + charsPerToken: 1, + }, + summarizeHistoryCompact: async () => structuredSummary('WRITE_FAILURE_SUMMARY'), + recordHistoryCompactCheckpoint: async () => { + throw new Error('artifact write failed'); + }, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: oldEvents, + }); + + assert.deepEqual(result.outcome, { kind: 'failed', reason: 'write_failed' }); + assert.deepEqual( + result.contextBudget?.compactionDecisions?.map((decision) => decision.decision), + ['failedOpen'], + ); + assert.equal(result.contextBudget?.compactionDecisions?.[0]?.failOpenReason, 'write_failed'); + }); + + test('stopping manual compactHistory aborts the transaction without poisoning the next turn', async () => { + let summarizeStarted!: () => void; + const started = new Promise((resolve) => { + summarizeStarted = resolve; + }); + let recordCalls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => textCompletionModel('NEXT_OK'), + tools: [], + contextBudget: { + name: 'manual-compact-abort-test', + charsPerToken: 1, + }, + summarizeHistoryCompact: ({ abortSignal }) => + new Promise((resolve) => { + summarizeStarted(); + abortSignal?.addEventListener( + 'abort', + () => resolve(structuredSummary('ABORTED_SUMMARY')), + { once: true }, + ); + }), + recordHistoryCompactCheckpoint: () => { + recordCalls += 1; + }, + }); + + const compact = backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContext: [ + runtimeTextEvent({ + id: 'abort-old', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'old '.repeat(100), + }), + ], + }); + await started; + await backend.stop('user_stop'); + + assert.deepEqual(await compact, { outcome: { kind: 'failed', reason: 'aborted' } }); + assert.equal(recordCalls, 0); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-next', text: 'next', context: [] })) { + events.push(event); + } + assert.equal( + events.some((event) => event.type === 'text_delta' && event.text === 'NEXT_OK'), + true, + ); + }); + + test('aborting the model stream mid-flight routes to the abort path instead of false success', async () => { + const gate = makeGate(); + let streamReachedGate = false; + const model = new MockLanguageModelV4({ + doStream: { + stream: new ReadableStream({ + async start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'PARTIAL' }); + controller.enqueue({ type: 'text-end', id: 'text-1' }); + streamReachedGate = true; + // Hold the stream open so stop() can flip this.aborted before the + // finish chunk arrives. The mock ignores the abort signal on + // purpose, simulating a provider that keeps yielding after abort. + await gate.promise; + controller.enqueue({ + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }); + controller.close(); + }, + }), + }, + }); + const appended: string[] = []; + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + appended.push(message.type); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + const events: SessionEvent[] = []; + const sendPromise = (async () => { + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + })(); + await waitFor(() => streamReachedGate); + await backend.stop('user_stop'); + gate.release(); + await sendPromise; + + // No partial assistant turn or usage should be persisted after a stop. + assert.equal(appended.includes('assistant'), false); + assert.equal(appended.includes('token_usage'), false); + // The turn must close as a user_stop, not a false end_turn success. + assert.equal( + events.some((event) => event.type === 'abort' && event.reason === 'user_stop'), + true, + ); + const completes = events.filter((event) => event.type === 'complete'); + assert.equal(completes.length > 0, true); + assert.equal( + completes.every((event) => (event as { stopReason?: string }).stopReason === 'user_stop'), + true, + ); + }); + + test('persists the compaction fail-open note at decision time, before any settlement (#4850)', async () => { + // The replay fail-open decision is known at turn start; a stop before + // settlement skips usage persistence entirely, so a settlement-time note + // would never reach the transcript. + const gate = makeGate(); + const model = new MockLanguageModelV4({ + doStream: { + stream: new ReadableStream({ + async start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'PARTIAL' }); + // Hold the finish back so the send never reaches settlement until + // the test releases the gate. + await gate.promise; + controller.enqueue({ type: 'text-end', id: 'text-1' }); + controller.enqueue({ + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }); + controller.close(); + }, + }), + }, + }); + // A checkpoint whose covered prefix does not match the replayed events: + // the pre-turn replay fails open with a coverage miss. + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [ + runtimeTextEvent({ + id: 'unrelated-covered', + turnId: 'turn-unrelated', + role: 'user', + author: 'user', + text: 'UNRELATED_COVERED '.repeat(50), + }), + ], + summary: structuredSummary('STALE_CHECKPOINT_SENTINEL'), + }); + const appended: Array<{ type: string; kind?: string; data?: unknown }> = []; + const isFailOpenNote = (message: { type: string; kind?: string }): boolean => + message.type === 'system_note' && message.kind === 'context_compaction_failed_open'; + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + appended.push(message as unknown as { type: string; kind?: string; data?: unknown }); + }, + recordSystemNote: async (kind, _turnId, data) => { + appended.push({ type: 'system_note', kind, data }); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { historyCompact: { enabled: true } }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + const sendPromise = drain( + backend.send({ + turnId: 'turn-1', + text: 'hi', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-real-history', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'REAL_HISTORY '.repeat(60), + }), + ], + }), + ); + // The decision-time write precedes the provider stream's finish: wait for + // the note itself, not for any stream signal. On a settlement-only + // implementation this wait can only time out, which is the regression. + try { + await pollFor(() => appended.some(isFailOpenNote), { + timeoutMs: 10_000, + message: 'fail-open note was not written before settlement', + }); + } finally { + await backend.stop('user_stop'); + gate.release(); + } + await sendPromise; + + const note = appended.find(isFailOpenNote); + assert.ok(note, 'the fail-open note must be persisted even though the turn never settled'); + assert.equal( + (note?.data as { failOpenReason?: string } | undefined)?.failOpenReason, + 'coverage_miss', + ); + // Settlement never ran: no usage was persisted, and the note did not wait + // for it. + assert.equal( + appended.some((message) => message.type === 'token_usage'), + false, + ); + }); + + test('writes the compaction fail-open note exactly once when the send settles (#4850)', async () => { + const model = completionModel(); + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [ + runtimeTextEvent({ + id: 'settle-unrelated-covered', + turnId: 'turn-unrelated', + role: 'user', + author: 'user', + text: 'SETTLE_UNRELATED_COVERED '.repeat(50), + }), + ], + summary: structuredSummary('SETTLE_STALE_CHECKPOINT_SENTINEL'), + }); + const appended: Array<{ type: string; kind?: string }> = []; + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + appended.push(message as unknown as { type: string; kind?: string }); + }, + recordSystemNote: async (kind) => { + appended.push({ type: 'system_note', kind }); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { historyCompact: { enabled: true } }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'turn-1', + text: 'hi', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-settle-history', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'SETTLE_REAL_HISTORY '.repeat(60), + }), + ], + }), + ); + + const notes = appended.filter( + (message) => + message.type === 'system_note' && message.kind === 'context_compaction_failed_open', + ); + assert.equal(notes.length, 1, 'the settlement fallback must not duplicate the early note'); + }); + + test('a failed decision-time note write still leaves the settlement fallback armed (#4850)', async () => { + // The per-send flag must rise only after the append lands: a failed + // decision-time write falls through to settlement instead of losing the + // note for the whole send. + const model = completionModel(); + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: [ + runtimeTextEvent({ + id: 'fallback-unrelated-covered', + turnId: 'turn-unrelated', + role: 'user', + author: 'user', + text: 'FALLBACK_UNRELATED_COVERED '.repeat(50), + }), + ], + summary: structuredSummary('FALLBACK_STALE_CHECKPOINT_SENTINEL'), + }); + const isFailOpenNote = (message: { type: string; kind?: string }): boolean => + message.type === 'system_note' && message.kind === 'context_compaction_failed_open'; + const persisted: Array<{ type: string; kind?: string }> = []; + let noteWriteAttempts = 0; + let failNextNoteWrite = true; + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + persisted.push(message as unknown as { type: string; kind?: string }); + }, + recordSystemNote: async (kind) => { + const candidate = { type: 'system_note', kind }; + if (isFailOpenNote(candidate)) { + noteWriteAttempts += 1; + if (failNextNoteWrite) { + failNextNoteWrite = false; + throw new Error('storage hiccup'); + } + } + persisted.push(candidate); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { historyCompact: { enabled: true } }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'turn-1', + text: 'hi', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-fallback-history', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'FALLBACK_REAL_HISTORY '.repeat(60), + }), + ], + }), + ); + + assert.equal(noteWriteAttempts, 2, 'the failed early write must be retried at settlement'); + assert.equal(persisted.filter(isFailOpenNote).length, 1); + }); + + test('after-step stop preserves the current provider step usage and prevents another step', async () => { + const loop = countingToolLoopModel(); + const durable = durableTurnHarness('turn-1', 'hi'); + let backend!: AiSdkBackend; + let stopRequested = false; + const stoppingTool: MakaTool = { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => { + stopRequested = true; + await ( + backend.stop as unknown as (reason: 'user_stop', mode: 'after_step') => Promise + )('user_stop', 'after_step'); + return { ok: true }; + }, + }; + backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [stoppingTool], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const events: SessionEvent[] = []; + + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + } + + assert.equal(stopRequested, true); + assert.equal(loop.callCount(), 1); + assert.equal( + events.some((event) => event.type === 'abort'), + false, + ); + const usage = events.find((event) => event.type === 'token_usage'); + assert.equal(usage?.type === 'token_usage' ? usage.total : undefined, 2); + }); + + for (const decision of ['cancel', 'commit', 'stop'] as const) { + test(`cooperative handoff ${decision} waits for the settled tool and gates the next request`, { + timeout: 5_000, + }, async () => { + const loop = countingToolLoopModel(); + const durable = durableTurnHarness('turn-1', 'hi'); + const toolEntered = makeGate(); + const finishTool = makeGate(); + const gate = new RunHandoffGate(); + const request = gate.request(new AbortController().signal); + let reached = false; + void request.ready.then((ready) => { + reached = ready; + }); + let effects = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [ + { + name: 'Read', + description: 'count effects', + parameters: z.object({ path: z.string() }), + impl: async () => { + toolEntered.release(); + await finishTool.promise; + effects += 1; + return { ok: true }; + }, + }, + ], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const events: SessionEvent[] = []; + const running = (async () => { + for await (const event of backend.send( + durable.input({ + maxSteps: 2, + handoffBoundary: (signal, remainingSteps) => { + assert.equal(remainingSteps, 1); + assert.equal( + events.some((event) => event.type === 'tool_result'), + true, + ); + return gate.reachBoundary(signal); + }, + }), + )) { + durable.record(event); + events.push(event); + } + })(); + await toolEntered.promise; + assert.equal(reached, false); + assert.equal(effects, 0); + finishTool.release(); + assert.equal(await request.ready, true); + assert.equal(loop.callCount(), 1); + assert.equal(effects, 1); + if (decision === 'commit') assert.equal(request.commit(), true); + else if (decision === 'cancel') request.cancel(); + else await backend.stop('user_stop'); + await running; + assert.equal(loop.callCount(), decision === 'cancel' ? 2 : 1); + assert.equal(effects, decision === 'cancel' ? 2 : 1); + assert.equal( + events.some((event) => event.type === 'complete'), + decision !== 'commit', + ); + assert.equal( + events.some((event) => event.type === 'abort'), + decision === 'stop', + ); + if (decision === 'commit') + assert.equal( + events.some((event) => event.type === 'token_usage'), + true, + ); + }); + } + + test('a natural final answer does not enter the handoff gate', async () => { + let boundaries = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => textCompletionModel('done'), + tools: [], + }); + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'turn-1', + text: 'hi', + handoffBoundary: async () => { + boundaries += 1; + return 'pause'; + }, + })) + events.push(event); + assert.equal(boundaries, 0); + assert.equal( + events.some((event) => event.type === 'complete'), + true, + ); + }); + + test('aborting during post-stream persistence wins over step-limit completion', async () => { + const loop = countingToolLoopModel(); + const gate = makeGate(); + let usagePersistenceStarted = false; + const backend = createBackend({ + // The usage checkpoint is the persistence this turn awaits at its step + // boundary, so holding it here is the window the stop has to win. + recordUsageCheckpoint: async () => { + usagePersistenceStarted = true; + await gate.promise; + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + maxSteps: 1, + }); + const events: SessionEvent[] = []; + const sendPromise = (async () => { + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + })(); + + await waitFor(() => usagePersistenceStarted); + await backend.stop('user_stop'); + gate.release(); + await sendPromise; + + assert.equal( + events.some((event) => event.type === 'abort' && event.reason === 'user_stop'), + true, + ); + assert.equal( + events.some((event) => event.type === 'complete' && event.stopReason === 'step_limit'), + false, + ); + }); + + test('provider error mid-step persists partial text and its safe provider summary', async () => { + // The user already saw the streamed text, so it belongs in the ledger even + // when the provider fails. The gate makes consumption-before-error deterministic. + const gate = makeGate(); + const model = new MockLanguageModelV4({ + doStream: { + stream: new ReadableStream({ + async start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'partial answer' }); + await gate.promise; + controller.error({ + error: { code: 'provider_error', message: 'provider exploded mid-step' }, + request_id: 'req-mid-step', + }); + }, + }), + }, + }); + const assistants: AssistantMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'text_delta' && event.text === 'partial answer') gate.release(); + } + + // The streamed partial persists as this step's AssistantMessage. + assert.equal(assistants.length, 1); + assert.equal(assistants[0]!.text, 'partial answer'); + // And the turn still closes as an error, not a false success. + const failure = events.find((event) => event.type === 'error'); + assert.equal( + failure?.message, + 'provider exploded mid-step (code=provider_error, requestId=req-mid-step)', + ); + const completes = events.filter((event) => event.type === 'complete'); + assert.equal(completes.length > 0, true); + assert.equal( + completes.every((event) => (event as { stopReason?: string }).stopReason === 'error'), + true, + ); + }); + + test('a blank summary preserves history and never records a checkpoint', async () => { + const model = completionModel(); + const storedMessages: StoredMessage[] = []; + const events: SessionEvent[] = []; + let recordCalls = 0; + const backend = createBackend({ + appendMessage: async (message) => { + storedMessages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + charsPerToken: 1, + historyCompact: { + enabled: true, + }, + }, + summarizeHistoryCompact: async () => ' ', + recordHistoryCompactCheckpoint: () => { + recordCalls += 1; + }, + }); + for await (const event of backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'blank-old-1', + turnId: 'blank-turn-1', + role: 'user', + author: 'user', + text: 'blank old source one '.repeat(30), + }), + runtimeTextEvent({ + id: 'blank-old-2', + turnId: 'blank-turn-2', + role: 'model', + author: 'agent', + text: 'blank old source two '.repeat(50), + }), + runtimeTextEvent({ + id: 'blank-recent', + turnId: 'blank-recent-turn', + role: 'user', + author: 'user', + text: 'BLANK_RETAINED_TAIL', + }), + ], + })) { + events.push(event); + } + + // A blank summary is not a checkpoint; the raw history goes out unchanged. + assert.equal(recordCalls, 0); + assert.equal(model.doStreamCalls.length, 1); + assert.match(JSON.stringify(model.doStreamCalls[0]?.prompt), /BLANK_RETAINED_TAIL/); + const terminal = events.find( + (event): event is Extract => event.type === 'complete', + ); + assert.equal(terminal?.stopReason, 'end_turn'); + }); + + test('replays a matching Codex V3 checkpoint as native provider state', async () => { + const model = completionModel(); + const codexConnection = { + ...connection(), + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }; + const covered = [ + runtimeTextEvent({ + id: 'codex-covered-1', + turnId: 'codex-old-1', + role: 'user', + author: 'user', + text: 'CODEX_RAW_OLD_ONE '.repeat(100), + }), + runtimeTextEvent({ + id: 'codex-covered-2', + turnId: 'codex-old-2', + role: 'model', + author: 'agent', + text: 'CODEX_RAW_OLD_TWO '.repeat(100), + }), + ]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + providerState: { + kind: 'openai_codex_remote_v2', + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + itemId: 'cmp_replay', + encryptedContent: 'CODEX_ENCRYPTED_REPLAY_STATE', + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: codexConnection, + apiKey: 'codex-token', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'codex-current', + text: 'continue', + context: [], + runtimeContext: [ + ...covered, + runtimeTextEvent({ + id: 'codex-tail', + turnId: 'codex-tail-turn', + role: 'user', + author: 'user', + text: 'CODEX_RAW_TAIL', + }), + ], + }), + ); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /CODEX_ENCRYPTED_REPLAY_STATE/); + assert.match(prompt, /cmp_replay/); + assert.match(prompt, /CODEX_RAW_TAIL/); + assert.doesNotMatch(prompt, /Provider-native OpenAI Codex compaction checkpoint/); + assert.doesNotMatch(prompt, /CODEX_RAW_OLD_(ONE|TWO)/); + }); + + test('replays a matching Codex V3 checkpoint when it covers the entire prior history', async () => { + const model = completionModel(); + const codexConnection = { + ...connection(), + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }; + const covered = [ + runtimeTextEvent({ + id: 'codex-full-covered', + turnId: 'codex-full-old', + role: 'user', + author: 'user', + text: 'CODEX_FULL_RAW_OLD '.repeat(100), + }), + ]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + providerState: { + kind: 'openai_codex_remote_v2', + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + itemId: 'cmp_full_replay', + encryptedContent: 'CODEX_FULL_ENCRYPTED_STATE', + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: codexConnection, + apiKey: 'codex-token', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'codex-full-current', + text: 'continue after full coverage', + context: [], + runtimeContext: covered, + }), + ); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /CODEX_FULL_ENCRYPTED_STATE/); + assert.match(prompt, /cmp_full_replay/); + assert.match(prompt, /continue after full coverage/); + assert.doesNotMatch(prompt, /CODEX_FULL_RAW_OLD/); + }); + + test('keeps a Codex V3 checkpoint when a signed-thinking tail requires text-only replay', async () => { + const model = completionModel(); + const codexConnection = { + ...connection(), + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }; + const covered = [ + runtimeTextEvent({ + id: 'codex-switch-covered-user', + turnId: 'codex-switch-old-user', + role: 'user', + author: 'user', + text: 'CODEX_SWITCH_RAW_COVERED_USER '.repeat(100), + }), + runtimeTextEvent({ + id: 'codex-switch-covered-model', + turnId: 'codex-switch-old-model', + role: 'model', + author: 'agent', + text: 'CODEX_SWITCH_RAW_COVERED_MODEL '.repeat(100), + }), + ]; + const tail = [ + runtimeTextEvent({ + id: 'anthropic-tail-user', + turnId: 'anthropic-tail', + role: 'user', + author: 'user', + text: 'ANTHROPIC_VISIBLE_TAIL_USER', + }), + runtimeEvent({ + id: 'anthropic-tail-thinking', + turnId: 'anthropic-tail', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'ANTHROPIC_PRIVATE_SIGNED_THINKING', + signature: 'anthropic-signature', + }, + }), + runtimeTextEvent({ + id: 'anthropic-tail-model', + turnId: 'anthropic-tail', + role: 'model', + author: 'agent', + text: 'ANTHROPIC_VISIBLE_TAIL_MODEL', + }), + ]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + providerState: { + kind: 'openai_codex_remote_v2', + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + itemId: 'cmp_model_switch', + encryptedContent: 'CODEX_MODEL_SWITCH_ENCRYPTED_STATE', + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: codexConnection, + apiKey: 'codex-token', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'codex-switch-current', + text: 'continue after switching back to Codex', + context: [ + { + type: 'user', + id: 'codex-switch-covered-user', + turnId: 'codex-switch-old-user', + ts: 1, + text: 'CODEX_SWITCH_RAW_COVERED_USER '.repeat(100), + }, + { + type: 'assistant', + id: 'codex-switch-covered-model', + turnId: 'codex-switch-old-model', + ts: 2, + text: 'CODEX_SWITCH_RAW_COVERED_MODEL '.repeat(100), + modelId: 'mock-model-id', + }, + { + type: 'user', + id: 'anthropic-tail-user', + turnId: 'anthropic-tail', + ts: 3, + text: 'ANTHROPIC_VISIBLE_TAIL_USER', + }, + { + type: 'assistant', + id: 'anthropic-tail-model', + turnId: 'anthropic-tail', + ts: 4, + text: 'ANTHROPIC_VISIBLE_TAIL_MODEL', + modelId: 'claude-sonnet', + thinking: { + text: 'ANTHROPIC_PRIVATE_SIGNED_THINKING', + signature: 'anthropic-signature', + }, + }, + ], + runtimeContext: [...covered, ...tail], + }), + ); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /CODEX_MODEL_SWITCH_ENCRYPTED_STATE|cmp_model_switch/); + assert.match(prompt, /ANTHROPIC_VISIBLE_TAIL_(USER|MODEL)/); + assert.doesNotMatch(prompt, /CODEX_SWITCH_RAW_COVERED_(USER|MODEL)/); + assert.doesNotMatch(prompt, /ANTHROPIC_PRIVATE_SIGNED_THINKING/); + }); + + test('ignores a Codex V3 checkpoint bound to a different model and replays raw history', async () => { + const model = completionModel(); + const codexConnection = { + ...connection(), + slug: 'codex-subscription', + providerType: 'openai-codex' as const, + }; + const covered = [ + runtimeTextEvent({ + id: 'codex-mismatch-covered', + turnId: 'codex-mismatch-old', + role: 'user', + author: 'user', + text: 'CODEX_MISMATCH_RAW_HISTORY', + }), + ]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: covered, + providerState: { + kind: 'openai_codex_remote_v2', + connectionId: 'test-connection-id', + modelId: 'different-model', + itemId: 'cmp_wrong_model', + encryptedContent: 'CODEX_WRONG_MODEL_STATE', + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: codexConnection, + apiKey: 'codex-token', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => checkpoint, + }); + + await drain( + backend.send({ + turnId: 'codex-current', + text: 'continue', + context: [], + runtimeContext: covered, + }), + ); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /CODEX_MISMATCH_RAW_HISTORY/); + assert.doesNotMatch(prompt, /CODEX_WRONG_MODEL_STATE|cmp_wrong_model/); + }); + + test('replays a checkpoint whose covered prefix carries a stale tool-result transition (#4842)', async () => { + // The standalone compaction path pins the checkpoint's coverage digest on + // RAW RuntimeEvents, while pre-turn replay used to match it against the + // transition-folded view: any durable stale-result archive inside the + // covered prefix then failed the digest and the turn silently fell back to + // full-history replay. Replay now matches the raw view and folds the + // projected [block, tail] afterwards. + const model = completionModel(); + const transitions: ModelProjectionTransition[] = []; + const recorded: HistoryCompactCheckpoint[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'checkpoint-transition-replay-test', + charsPerToken: 1, + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async () => structuredSummary('FOLDED_PREFIX_COMPACT_SENTINEL'), + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (event) => ({ artifactId: `artifact-${event.runtimeEventId}` }), + }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, + }); + const priorEvents = [ + runtimeTextEvent({ + id: 'fold-old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'FOLD_OLD_USER_ALPHA '.repeat(60), + }), + runtimeEvent({ + id: 'fold-call', + turnId: 'turn-old', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-fold-1', + name: 'Read', + args: { path: 'a.ts' }, + }, + }), + runtimeEvent({ + id: 'fold-result', + turnId: 'turn-old', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-fold-1', + name: 'Read', + result: { body: 'y'.repeat(400) }, + isError: false, + }, + }), + runtimeTextEvent({ + id: 'fold-recent-user', + turnId: 'turn-recent', + role: 'user', + author: 'user', + text: 'FOLD_RECENT_RETAINED_CONTEXT', + }), + ]; + + // Turn 1 commits the durable archive transition for the stale result. Each + // phase gets fresh clones: production readers deserialize their own event + // objects from the ledger, so no in-memory mutation can alias across them. + await drain( + backend.send({ + turnId: 'turn-seed', + text: 'seed the archive transition', + context: [], + runtimeContext: structuredClone(priorEvents), + }), + ); + assert.equal(transitions.length, 1); + + // Standalone compaction creates the checkpoint over the raw prefix, exactly + // like the production path whose input is begin.runtimeContext. + const compact = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-compact', + runtimeContext: structuredClone(priorEvents), + }); + assert.equal(compact.outcome.kind, 'compacted'); + assert.equal(recorded.length, 1); + + // The next turn must replay through the checkpoint, not fail open. A fresh + // backend mirrors production: the compaction operation and the next send + // run as separate runs with separate backend instances. + const replayBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'checkpoint-transition-replay-test', + charsPerToken: 1, + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (event) => ({ artifactId: `artifact-${event.runtimeEventId}` }), + }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, + }); + await drain( + replayBackend.send({ + turnId: 'turn-after-compact', + text: 'after compact', + context: [], + runtimeContext: structuredClone(priorEvents), + }), + ); + + const lastCall = model.doStreamCalls.at(-1); + const prompt = JSON.stringify( + lastCall?.prompt.map((message) => ({ role: message.role, content: message.content })), + ); + assert.match(prompt, /FOLDED_PREFIX_COMPACT_SENTINEL/); + assert.doesNotMatch(prompt, /FOLD_OLD_USER_ALPHA/); + }); + + test('a checkpoint summary cannot echo a body a durable transition removed (#4845)', async () => { + // Coverage identity is pinned on raw events, but the summarizer must read + // the EFFECTIVE (transition-folded) prefix: an echoing summarizer fed raw + // events would quote the archived body into the checkpoint block and every + // later replay would restore what the transition removed. + const model = completionModel(); + const transitions: ModelProjectionTransition[] = []; + const recorded: HistoryCompactCheckpoint[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'checkpoint-effective-summary-test', + charsPerToken: 1, + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: async (input) => + // Echo the covered span verbatim into a structurally valid summary. + structuredSummary( + `ECHO ${input.source.foldedRuntimeEvents + .map((event) => JSON.stringify(event.content)) + .join(' ')}`, + ), + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (event) => ({ artifactId: `artifact-${event.runtimeEventId}` }), + }), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, + }); + const priorEvents = [ + runtimeTextEvent({ + id: 'echo-old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'ECHO_OLD_USER '.repeat(60), + }), + runtimeEvent({ + id: 'echo-call', + turnId: 'turn-old', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-echo-1', + name: 'Read', + args: { path: 'secret.ts' }, + }, + }), + runtimeEvent({ + id: 'echo-result', + turnId: 'turn-old', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-echo-1', + name: 'Read', + result: { body: 'RAW_TRANSITIONED_TOOL_BODY '.repeat(40) }, + isError: false, + }, + }), + ]; + + await drain( + backend.send({ + turnId: 'turn-seed', + text: 'seed the archive transition', + context: [], + runtimeContext: structuredClone(priorEvents), + }), + ); + assert.equal(transitions.length, 1); + + const compact = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-compact', + runtimeContext: structuredClone(priorEvents), + }); + assert.equal(compact.outcome.kind, 'compacted'); + assert.equal(recorded.length, 1); + + // The summary was written from the effective view: it carries the archive + // placeholder's identity, not the transitioned body. + const summary = recorded[0]?.version === 2 ? recorded[0].summary : ''; + assert.match(summary, /artifact-echo-result/); + assert.doesNotMatch(summary, /RAW_TRANSITIONED_TOOL_BODY/); + + const replayBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'checkpoint-effective-summary-test', + charsPerToken: 1, + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({}), + loadModelProjectionTransitions: async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }), + }); + await drain( + replayBackend.send({ + turnId: 'turn-after-compact', + text: 'after compact', + context: [], + runtimeContext: structuredClone(priorEvents), + }), + ); + + const lastCall = model.doStreamCalls.at(-1); + const prompt = JSON.stringify( + lastCall?.prompt.map((message) => ({ role: message.role, content: message.content })), + ); + assert.match(prompt, /ECHO /); + assert.doesNotMatch(prompt, /RAW_TRANSITIONED_TOOL_BODY/); + }); + + test('a transition committed after creation invalidates the checkpoint at pre-turn replay (#4845 review)', async () => { + // The checkpoint pins the EFFECTIVE digest of its covered prefix. A + // projection transition committed AFTER the fold (here: a later turn's + // stale-result prune) leaves the raw ledger untouched, so the identity + // match still passes — but the summary describes a view that no longer + // exists. Replay must reject the checkpoint and fail open to the + // effective history rather than restore the transitioned body. + const model = completionModel(); + const transitions: ModelProjectionTransition[] = []; + const recorded: HistoryCompactCheckpoint[] = []; + const echoSummarizer: Parameters[0]['summarizeHistoryCompact'] = + async (input) => + structuredSummary( + `ECHO ${input.source.foldedRuntimeEvents + .map((event) => JSON.stringify(event.content)) + .join(' ')}`, + ); + const loadTransitions = async () => ({ + transitions: [...transitions], + unreadableTargets: new Set(), + unscopedUnreadable: 0, + }); + const priorEvents = [ + runtimeTextEvent({ + id: 'echo-old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'ECHO_OLD_USER '.repeat(60), + }), + runtimeEvent({ + id: 'echo-call', + turnId: 'turn-old', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-echo-1', + name: 'Read', + args: { path: 'secret.ts' }, + }, + }), + runtimeEvent({ + id: 'echo-result', + turnId: 'turn-old', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-echo-1', + name: 'Read', + result: { body: 'RAW_TRANSITIONED_TOOL_BODY '.repeat(40) }, + isError: false, + }, + }), + ]; + + // 1. Creation: no transition exists yet, so the effective view IS the raw + // view and the echo summary legitimately quotes the body into the block. + const creationBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: '[redacted]', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { + name: 'checkpoint-effective-drift-test', + charsPerToken: 1, + staleToolResultPrune: { enabled: false }, + historyCompact: { enabled: true }, + }, + summarizeHistoryCompact: echoSummarizer, + recordHistoryCompactCheckpoint: (checkpoint) => { + recorded.push(checkpoint); + }, + loadModelProjectionTransitions: loadTransitions, + }); + const compact = await creationBackend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-compact', + runtimeContext: structuredClone(priorEvents), + }); + assert.equal(compact.outcome.kind, 'compacted'); + assert.equal(recorded.length, 1); + assert.ok(recorded[0]!.coverage.effectiveSourceDigest); + const summary = recorded[0]!.version === 2 ? recorded[0]!.summary : ''; + assert.match(summary, /RAW_TRANSITIONED_TOOL_BODY/); + + // 2. A later turn commits a stale-result transition over the covered span. + const pruneModel = completionModel(); + const pruneBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: '[redacted]', + modelId: 'mock-model-id', + modelFactory: () => pruneModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { + name: 'checkpoint-effective-drift-test', + charsPerToken: 1, + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + }, + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (event) => ({ artifactId: `artifact-${event.runtimeEventId}` }), + }), + loadModelProjectionTransitions: loadTransitions, + recordModelProjectionTransition: async (transition) => { + transitions.push(transition); + }, + }); + await drain( + pruneBackend.send({ + turnId: 'turn-seed', + text: 'seed the archive transition', + context: [], + runtimeContext: structuredClone(priorEvents), + }), + ); + assert.equal(transitions.length, 1); + + // 3. Replay: the raw identity still matches, the effective digest does + // not. The stale block must never reach the provider. + const replayModel = completionModel(); + const replayBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: '[redacted]', + modelId: 'mock-model-id', + modelFactory: () => replayModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { + name: 'checkpoint-effective-drift-test', + charsPerToken: 1, + staleToolResultPrune: { enabled: false }, + historyCompact: { enabled: true }, + }, + loadHistoryCompactCheckpoint: () => recorded.at(-1), + toolResultArchive: testToolResultArchive({}), + loadModelProjectionTransitions: loadTransitions, + }); + const events: unknown[] = []; + for await (const event of replayBackend.send({ + turnId: 'turn-after-transition', + text: 'after transition', + context: [], + runtimeContext: structuredClone(priorEvents), + })) { + events.push(event); + } + + const lastCall = replayModel.doStreamCalls.at(-1); + const prompt = JSON.stringify( + lastCall?.prompt.map((message) => ({ role: message.role, content: message.content })), + ); + // Fail-open replayed the effective history: the archive placeholder is + // visible, the stale summary and the transitioned body are not. + assert.match(prompt, /artifact-echo-result/); + assert.doesNotMatch(prompt, /ECHO /); + assert.doesNotMatch(prompt, /RAW_TRANSITIONED_TOOL_BODY/); + // The rejection is diagnosed so the fail-open note can name it. + const usageEvent = events.find( + (event) => (event as { type?: string }).type === 'token_usage', + ) as + | { + contextBudget?: { + compactionDecisions?: Array<{ decision?: string; failOpenReason?: string }>; + }; + } + | undefined; + const decisions = usageEvent?.contextBudget?.compactionDecisions ?? []; + assert.ok( + decisions.some( + (decision) => + decision.decision === 'failedOpen' && + decision.failOpenReason === 'effective_history_changed', + ), + ); + }); + + test('pre-turn replay withholds a tool result whose transition record is unreadable (#4845)', async () => { + // prepareContextBudgetPolicy folds with the unreadable-target set so an + // undecodable record withholds the body behind the failure sentinel; the + // post-match fold of the projected [block, tail] must do the same or it + // becomes the one consumer that replays the removed body. + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + contextBudget: { + name: 'unreadable-target-replay-test', + charsPerToken: 1, + staleToolResultPrune: { enabled: false }, + historyCompact: { enabled: true }, + }, + loadModelProjectionTransitions: async () => ({ + transitions: [], + unreadableTargets: new Set(['unreadable-result::tool_result']), + unscopedUnreadable: 0, + }), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'unreadable-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-unreadable-1', + name: 'Read', + args: { path: 'a.ts' }, + }, + }), + runtimeEvent({ + id: 'unreadable-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-unreadable-1', + name: 'Read', + result: { body: 'RAW_UNREADABLE_TARGET_BODY' }, + isError: false, + }, + }), + ], + }), + ); + + const prompt = JSON.stringify(compactPrompt(model)); + assert.match(prompt, /could not be projected safely/); + assert.doesNotMatch(prompt, /RAW_UNREADABLE_TARGET_BODY/); + }); + + test('keeps RuntimeEvent replay when a tool result is unmatched (orphan dropped, rest replayed)', async () => { + // `unmatched_tool_result` is a non-blocking diagnostic: the materializer + // drops the orphan itself (a standalone tool message is an Anthropic 400) + // while retaining the rest of canonical history. + const model = completionModel(); + let imageReads = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + supportsVision: true, + readAttachmentBytes: async () => { + imageReads += 1; + return { ok: true, bytes: new Uint8Array([1]) }; + }, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'runtime user', + }), + runtimeEvent({ + id: 'rt-unmatched-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'missing-call', + name: 'Read', + isError: false, + result: { + kind: 'image', + mimeType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'orphan' }, + }, + }, + }), + ], + }), + ); + + // The orphan is gone and the rest of RuntimeEvent replay remains. + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'runtime user' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + assert.equal(imageReads, 0); + }); + + test('keeps RuntimeEvent replay when a system error fact is diagnostic-only', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'projection user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'runtime user', + }), + runtimeEvent({ + id: 'rt-error', + turnId: 'turn-prev', + role: 'system', + author: 'system', + content: { + kind: 'error', + reason: 'tool_failed', + message: 'Tool failed', + }, + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'runtime user' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + }); + + test('drops unsupported thinking while preserving RuntimeEvent text', async () => { + const model = completionModel(); + const openAiConnection = { ...connection(), providerType: 'openai' as const }; + const backend = createBackend({ + connection: openAiConnection, + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { + type: 'user', + id: 'projection-u', + turnId: 'turn-prev', + ts: 1, + text: 'wrong projection', + }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'wrong projection assistant', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'projection user', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'private chain of thought', signature: 'sig-1' }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'projection assistant', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'projection user' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'projection assistant' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + assert.equal(promptJson.includes('private chain of thought'), false); + }); + + test('drops cross-model Anthropic reasoning while preserving text and tool history', async () => { + const model = completionModel(); + const backend = createBackend({ + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-b' }, + connection: connection(), + modelId: 'claude-b', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'connection-a', modelId: 'claude-a' }), + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'inspect the file', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'provider reasoning', signature: 'signature-a' }, + refs: { stepId: 'step-1' }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { stepId: 'step-1' }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'file contents', + }, + }), + runtimeEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'inspection complete' }, + refs: { stepId: 'step-1' }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('provider reasoning'), false); + assert.equal(promptJson.includes('signature-a'), false); + assert.match(promptJson, /inspection complete/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /file contents/); + }); + + test('drops Anthropic reasoning after provider state changes under the same route id', async () => { + const model = completionModel(); + const backend = createBackend({ + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, + connection: connection(), + modelId: 'claude-a', + modelFactory: () => model, + tools: [], + providerStateIdentity: `sha256:${'b'.repeat(64)}`, + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextInvocations: [ + priorModelInvocation({ + connectionId: 'connection-a', + modelId: 'claude-a', + providerStateIdentity: `sha256:${'a'.repeat(64)}`, + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'old account reasoning', signature: 'old-sig' }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'portable answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('old account reasoning'), false); + assert.equal(promptJson.includes('old-sig'), false); + assert.match(promptJson, /portable answer/); + }); + + test('fails closed for provider reasoning with no source run provenance', async () => { + const model = completionModel(); + const backend = createBackend({ + header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, + connection: connection(), + modelId: 'claude-a', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'legacy signed reasoning', + signature: 'legacy-signature', + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'legacy visible answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('legacy signed reasoning'), false); + assert.equal(promptJson.includes('legacy-signature'), false); + assert.match(promptJson, /legacy visible answer/); + }); + + test('drops cross-model Copilot reasoning while preserving text and tools', async () => { + const model = completionModel(); + const copilotConnection: LlmConnection = { + ...connection(), + slug: 'github-copilot', + providerType: 'github-copilot', + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', apiProtocol: 'openai-chat' }], + }; + const backend = createBackend({ + header: { + ...header(), + llmConnectionId: 'connection-copilot', + llmConnectionSlug: 'github-copilot', + model: 'gpt-5.4', + }, + connection: copilotConnection, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextInvocations: [ + priorModelInvocation({ + connectionId: 'connection-copilot', + connectionSlug: 'github-copilot', + modelId: 'gpt-5.5', + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'copilot provider reasoning', + providerOptions: { + maka: { openAiChatReasoningField: 'reasoning_content' }, + }, + }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'copilot file contents', + }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'copilot visible answer', + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('copilot provider reasoning'), false); + assert.match(promptJson, /copilot visible answer/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /copilot file contents/); + }); + + test('keeps same-route OpenAI Responses reasoning replay', async () => { + const model = completionModel(); + const openAiConnection: LlmConnection = { + ...connection(), + slug: 'openai-main', + providerType: 'openai', + defaultModel: 'gpt-5.4', + }; + const backend = createBackend({ + header: { + ...header(), + llmConnectionId: 'connection-openai', + llmConnectionSlug: 'openai-main', + model: 'gpt-5.4', + }, + connection: openAiConnection, + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContextInvocations: [ + priorModelInvocation({ + connectionId: 'connection-openai', + connectionSlug: 'openai-main', + modelId: 'gpt-5.4', + }), + ], + runtimeContext: [ + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'responses reasoning', + providerOptions: { + openai: { + itemId: 'reasoning-item-1', + reasoningEncryptedContent: 'encrypted-reasoning', + }, + }, + }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.match(promptJson, /reasoning-item-1/); + assert.match(promptJson, /encrypted-reasoning/); + }); + + test('skips unsupported unsigned thinking without dropping native tool replay', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { ...connection(), providerType: 'openai' }, + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read the file', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'private unsigned thought' }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'file contents', + isError: false, + }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /file contents/); + assert.equal(promptJson.includes('private unsigned thought'), false); + }); + + test('skips unmarked unsigned thinking when replaying Kimi OpenAI tool history', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + ...connection(), + slug: 'kimi-main', + providerType: 'kimi-coding-plan', + defaultModel: 'k3', + models: [{ id: 'k3', apiProtocol: 'openai-chat' }], + }, + modelId: 'k3', + modelFactory: () => model, + tools: [], + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read the file', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'private thinking from another provider' }, + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: 'file contents', + isError: false, + }, + }), + ], + }), + ); + + const promptJson = JSON.stringify(compactPrompt(model)); + assert.equal(promptJson.includes('private thinking from another provider'), false); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /file contents/); + }); + + test('rejects signed thinking from provider-native replay when the target cannot replay it', async () => { + const trace: RunTraceEvent[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + throw new Error('provider failed'); + }, + }); + const backend = createBackend({ + connection: { ...connection(), providerType: 'openai' }, + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordRunTrace: (event) => trace.push(event), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [ + { type: 'user', id: 'projection-u', turnId: 'turn-prev', ts: 1, text: 'prior user' }, + { + type: 'assistant', + id: 'projection-a', + turnId: 'turn-prev', + ts: 2, + text: 'prior answer', + modelId: 'm', + }, + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'prior user', + }), + runtimeEvent({ + id: 'rt-thinking', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'signed thought', signature: 'sig-1' }, + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'prior answer', + }), + ], + }), + ); + + const failure = trace.find((event) => event.type === 'model_stream_failed'); + assert.equal(failure?.data?.priorReplayGate, 'runtime_replay_unsupported_semantics'); + }); +}); + +describe('AiSdkBackend error surfaces', () => { + test('preserves model setup diagnostics in renderer events', async () => { + const backend = createBackend({ + connection: connection(), + apiKey: 'sk-live-secret-token-value', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => { + throw new Error('401 Authorization: Bearer sk-live-secret-token-value'); + }, + tools: [], + now: () => 1, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + const error = events.find( + (event): event is Extract => event.type === 'error', + ); + assert.equal(error?.message, '401 Authorization: Bearer sk-live-secret-token-value'); + }); + + test('stops after a T1 rejection only after sibling tool calls settle', async () => { + const durable = durableTurnHarness('turn-1', 'read notes'); + const messages: StoredMessage[] = []; + const events: SessionEvent[] = []; + const executions: string[] = []; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'tool-call', + toolCallId: 'tool-2', + toolName: 'Read', + input: JSON.stringify({ path: 'sibling.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + ...testTool('Read', z.object({ path: z.string() })), + impl: async ({ path }: { path: string }) => { + executions.push(path); + return { body: path }; + }, + }, + ], + runtimeCommitSink: { + commitToolPrepared: async ({ providerToolCallId }) => { + if (providerToolCallId === 'tool-1') throw new Error('T1 unavailable'); + await new Promise((resolve) => setTimeout(resolve, 10)); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + for await (const event of backend.send( + durable.input({ + runId: 'run-1', + invocationId: 'invocation-1', + }), + )) { + durable.record(event); + events.push(event); + } + + assert.equal(streamCalls, 1); + assert.deepEqual(executions, ['sibling.md']); + assert.equal(messages.filter((message) => message.type === 'tool_result').length, 1); + assert.equal(events.filter((event) => event.type === 'tool_result').length, 1); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal( + events.find((event) => event.type === 'error')?.message, + 'T1 runtime commit failed: T1 unavailable', + ); + }); + + test('redacts and caps synthetic tool error text before storage and model return', () => { + const raw = `provider exploded: Authorization: Bearer sk-live-secret-token-value ${'x'.repeat(5000)}`; + const text = formatSyntheticToolErrorText(new Error(raw)); + + assert.equal(text.includes('sk-live-secret-token-value'), false); + assert.ok(text.includes('[redacted]')); + assert.equal(text.length, TOOL_ERROR_RESULT_MAX_CHARS); + assert.equal(text.endsWith('…'), true); + }); + + test('tool settlement never persists raw secret-shaped synthetic errors', async () => { + const messages: ToolResultMessage[] = []; + const events: SessionEvent[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'tool_result') messages.push(message); + }, + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + }); + + const tool: MakaTool = { + name: 'FailingTool', + description: 'fails with a provider secret', + parameters: {}, + impl: async () => { + throw new Error('failed with api_key=sk-live-secret-token-value'); + }, + }; + + await runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + })({}, { toolCallId: 'tool-1', abortSignal: new AbortController().signal }); + + assert.equal(JSON.stringify(messages).includes('sk-live-secret-token-value'), false); + assert.equal(JSON.stringify(events).includes('sk-live-secret-token-value'), false); + assert.deepEqual( + messages[0]?.content, + events.find((event) => event.type === 'tool_result')?.content, + ); + }); + + test('failed Bash results preserve terminal stdout and stderr as an error card', async () => { + const messages: ToolResultMessage[] = []; + const events: SessionEvent[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'tool_result') messages.push(message); + }, + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + }); + const tool: MakaTool = { + name: 'Bash', + description: 'shell', + parameters: {}, + impl: async () => { + throw Object.assign(new Error('Command failed with exit code 2'), { + code: 2, + stdout: 'stdout before failure\nAuthorization: Bearer sk-live-secret-token-value', + stderr: 'stderr before failure', + }); + }, + }; + + const execute = runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + }); + + const result = await execute( + { command: 'printf out; printf err >&2; exit 2' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + // In-turn result now folds in a redacted, bounded tail of stderr/stdout so + // the model can see *why* the command failed (the full structured content + // still goes to session history, asserted below). + assert.deepEqual(result, { + error: [ + '命令退出码 2', + '--- stderr ---\nstderr before failure', + '--- stdout ---\nstdout before failure\nAuthorization: Bearer [redacted]', + ].join('\n\n'), + }); + assert.equal(messages[0]?.isError, true); + assert.deepEqual( + messages[0]?.content, + events.find((event) => event.type === 'tool_result')?.content, + ); + assert.deepEqual(messages[0]?.content, { + kind: 'terminal', + cwd: '/tmp/maka', + cmd: 'printf out; printf err >&2; exit 2', + status: 'failed', + exitCode: 2, + output: { + mode: 'pipes', + stdout: 'stdout before failure\nAuthorization: Bearer [redacted]', + stderr: 'stderr before failure', + stdoutTruncated: false, + stderrTruncated: false, + redacted: true, + }, + }); + }); +}); + +describe('AiSdkBackend Plan tool boundaries', () => { + test('continues to a final response after update_plan completes execution', async () => { + const { calls, events } = await runPlanToolBoundary({ + turnId: 'turn-plan-complete', + prompt: 'execute the approved plan', + toolName: 'update_plan', + toolInput: { steps: [{ id: 'change', status: 'completed' }] }, + toolResult: { + kind: 'plan_execution_completed', + execution: planExecution('completed'), + storeVersion: 2, + }, + finalText: 'Implementation complete.', + }); + + assert.equal(calls, 2); + assert.equal( + events.find((event) => event.type === 'text_complete')?.text, + 'Implementation complete.', + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('continues to an acknowledgement after cancel_plan cancels execution', async () => { + const { calls, events } = await runPlanToolBoundary({ + turnId: 'turn-plan-cancel', + prompt: 'cancel the approved plan', + toolName: 'cancel_plan', + toolInput: { reason: 'User cancelled the execution.' }, + toolResult: { + kind: 'plan_execution_cancelled', + execution: planExecution('cancelled'), + storeVersion: 2, + }, + finalText: 'Plan execution cancelled.', + }); + + assert.equal(calls, 2); + assert.equal( + events.find((event) => event.type === 'text_complete')?.text, + 'Plan execution cancelled.', + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('keeps SubmitPlan as a one-step plan handoff', async () => { + const { calls, events } = await runPlanToolBoundary({ + turnId: 'turn-plan-submit', + prompt: 'prepare an implementation plan', + toolName: 'SubmitPlan', + toolInput: { + title: 'Implementation plan', + steps: [ + { + id: 'change', + title: 'Change implementation', + description: 'Change code', + }, + ], + }, + toolResult: { + kind: 'plan_submitted', + proposal: { + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + turnId: 'turn-plan-submit', + revision: 1, + title: 'Implementation plan', + steps: [ + { + id: 'change', + title: 'Change implementation', + description: 'Change code', + }, + ], + status: 'pending_approval', + submittedAt: 2, + }, + storeVersion: 1, + }, + }); + + assert.equal(calls, 1); + assert.equal(events.filter((event) => event.type === 'plan_submitted').length, 1); + assert.equal( + events.some((event) => event.type === 'text_complete'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'plan_handoff'); + }); +}); + +describe('AiSdkBackend usage telemetry', () => { + test('records provider-reported usage for a content-filter terminal', async () => { + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'content-filter', raw: 'content_filter' }, + usage: { + inputTokens: { total: 7, noCache: 7, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const appended: StoredMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.equal(events.find((event) => event.type === 'token_usage')?.total, 10); + assert.equal(appended.find((message) => message.type === 'token_usage')?.total, 10); + }); + + test('lets an unconfigured turn continue past the former 50-step default', async () => { + const loop = countingToolLoopModel(51); + const durable = durableTurnHarness('turn-1', 'hi'); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(loop.callCount(), 52); + assert.equal(events.at(-1)?.type, 'complete'); + }); + + test('retries an output-free truncated provider stream once and recovers', async () => { + const durable = durableTurnHarness('turn-truncated-retry', 'analyse the image'); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'other', raw: undefined }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, attempt, maxAttempts, reason }) => ({ + phase, + attempt, + maxAttempts, + reason, + })), + [ + { phase: 'scheduled', attempt: 2, maxAttempts: 2, reason: 'stream_truncated' }, + { phase: 'started', attempt: 2, maxAttempts: 2, reason: 'stream_truncated' }, + ], + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('records exhaustion of output-free truncated stream recovery', async () => { + const durable = durableTurnHarness('turn-truncated-exhausted', 'analyse the image'); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'other', raw: undefined }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + const error = events.find( + (event): event is Extract => event.type === 'error', + ); + + assert.equal(calls, 2); + assert.equal(error?.reason, 'stream_truncated'); + assert.deepEqual(error?.retry, { decision: 'exhausted', attempts: 2 }); + assert.equal(error?.message, 'Provider stream ended without finishing (other)'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + for (const output of ['text', 'tool'] as const) { + // The upstream cut the SSE connection mid-answer: chunks arrived, no + // `finish` frame did. The stream then ends without yielding an error and + // without throwing, so every guard that watches for a thrown failure sees + // nothing. Reporting `end_turn` here tells the caller the model said its + // piece when the connection simply died — a benchmark cell recorded + // `status: completed` on exactly this shape while the agent was still + // mid-task. + test(`does not retry a truncated provider stream after ${output} activity`, async () => { + const durable = durableTurnHarness('turn-truncated', 'analyse the image'); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + ...(output === 'text' + ? [ + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Let me look at the top region' }, + ] + : [ + { + type: 'tool-input-start', + id: 'search-1', + toolName: 'web_search', + providerExecuted: true, + }, + ]), + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + const complete = events.find( + (event): event is Extract => event.type === 'complete', + ); + + // Not merely "some other stop reason": `max_tokens` would also satisfy that + // and still record the turn as completed downstream, which is the bug. + assert.equal( + complete?.stopReason, + 'error', + 'a stream that never delivered a finish frame did not end the turn', + ); + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + // And it must say so. A failed terminal whose only trace is the stop reason + // leaves the session's lastError empty and the request ledger reading + // `success` — the same silence that let the benchmark cell pass unnoticed. + assert.ok( + events.some((event) => event.type === 'error'), + 'a failed terminal must be accompanied by an error event', + ); + const error = events.find( + (event): event is Extract => event.type === 'error', + ); + assert.equal(error?.reason, 'stream_truncated', error?.message ?? 'error event missing'); + assert.deepEqual(error?.retry, { + decision: 'declined', + because: output === 'tool' ? 'side_effects' : 'observable_output', + }); + }); + } + + test('rejects continuation-capable tools before side effects without a durable reader', async () => { + const loop = countingToolLoopModel(1); + let executions = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [ + { + ...testTool('Read', z.object({ path: z.string() })), + impl: async () => { + executions += 1; + return { ok: true }; + }, + }, + ], + maxSteps: 2, + }); + const events: SessionEvent[] = []; + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.equal(loop.callCount(), 1); + assert.equal(executions, 0); + assert.ok(events.some((event) => event.type === 'error')); + }); + + test('checks that the durable ledger is readable before tool side effects', async () => { + const loop = countingToolLoopModel(1); + const durable = durableTurnHarness('turn-1', 'hi'); + let reads = 0; + let executions = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [ + { + ...testTool('Read', z.object({ path: z.string() })), + impl: async () => { + executions += 1; + return { ok: true }; + }, + }, + ], + maxSteps: 2, + loadTurnRuntimeEvents: async (turnId) => { + reads += 1; + if (reads === 2) throw new Error('runtime ledger unavailable'); + return await durable.loadTurnRuntimeEvents(turnId); + }, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(loop.callCount(), 1); + assert.equal(executions, 0); + assert.ok(events.some((event) => event.type === 'error')); + }); + + test('lets a trusted turn override the configured step limit', async () => { + const loop = countingToolLoopModel(); + const durable = durableTurnHarness('turn-1', 'hi'); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + maxSteps: 3, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + await drainDurably(backend.send({ ...durable.input(), maxSteps: 1 }), durable); + + assert.equal(loop.callCount(), 1); + }); + + test('reserves the final child-agent step for a tool-free evidence summary', async () => { + const durable = durableTurnHarness('turn-1', 'audit the repository'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { + type: 'text-delta', + id: 'text-final', + delta: 'Verified evidence summary with explicit gaps.', + }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + header: { ...header(), collaborationMode: 'agent' }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + maxSteps: 2, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 2); + assert.equal(model.doStreamCalls[1]?.tools?.length ?? 0, 0); + assert.deepEqual(model.doStreamCalls[1]?.toolChoice, { type: 'none' }); + assert.match(JSON.stringify(model.doStreamCalls[1]?.prompt), /final budgeted step/i); + assert.ok( + events.some( + (event) => + event.type === 'text_delta' && + event.text === 'Verified evidence summary with explicit gaps.', + ), + ); + assert.equal(events.at(-1)?.type, 'complete'); + assert.equal( + (events.at(-1) as Extract).stopReason, + 'end_turn', + ); + }); + + test('ends the tool loop cooperatively when the graph supervisor yields', async () => { + const durable = durableTurnHarness('turn-graph-yield', 'coordinate the graph'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'yield-1', + toolName: 'yield_agent_graph', + input: JSON.stringify({ reason: 'Waiting for committed child results.' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + ...testTool('yield_agent_graph', z.object({ reason: z.string() })), + impl: async ({ reason }) => ({ + kind: 'agent_graph_yielded' as const, + pendingWorkCount: 2, + liveOperatorCount: 2, + reason, + }), + }, + ], + maxSteps: 5, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 1, 'yield must not request another provider step'); + assert.equal(events.at(-1)?.type, 'complete'); + assert.equal( + (events.at(-1) as Extract).stopReason, + 'graph_yield', + ); + assert.equal( + events.some((event) => event.type === 'abort'), + false, + ); + }); + + test('does not honor graph yield when it shares a provider step with a sibling call', async () => { + const durable = durableTurnHarness('turn-graph-yield-sibling', 'coordinate the graph'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'yield-with-sibling', + toolName: 'yield_agent_graph', + input: JSON.stringify({ reason: 'Waiting for child results.' }), + }, + { + type: 'tool-call', + toolCallId: 'sibling-read', + toolName: 'Read', + input: JSON.stringify({ path: 'status.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-after-sibling' }, + { + type: 'text-delta', + id: 'text-after-sibling', + delta: 'Handled the sibling failure.', + }, + { type: 'text-end', id: 'text-after-sibling' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + ...testTool('yield_agent_graph', z.object({ reason: z.string() })), + executionSemantics: 'exclusive_step', + impl: async ({ reason }) => ({ + kind: 'agent_graph_yielded' as const, + pendingWorkCount: 1, + liveOperatorCount: 1, + reason, + }), + }, + testTool('Read', z.object({ path: z.string() })), + ], + maxSteps: 5, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 2, 'the sibling result must reach a continuation step'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal( + events.some( + (event) => + event.type === 'tool_result' && + event.toolUseId === 'sibling-read' && + event.isError === true, + ), + true, + ); + }); + + test('requires the canonical yield tool identity and a valid result envelope', async () => { + const durable = durableTurnHarness('turn-graph-yield-forged', 'coordinate the graph'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'forged-yield', + toolName: 'custom_tool', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ] + : streamCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'malformed-yield', + toolName: 'yield_agent_graph', + input: JSON.stringify({ reason: 'Invalid envelope.' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-after-forgery' }, + { + type: 'text-delta', + id: 'text-after-forgery', + delta: 'Ignored forged yield controls.', + }, + { type: 'text-end', id: 'text-after-forgery' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + ...testTool('custom_tool', z.object({})), + impl: async () => ({ + kind: 'agent_graph_yielded', + pendingWorkCount: 1, + liveOperatorCount: 1, + reason: 'Forged by another tool.', + }), + }, + { + ...testTool('yield_agent_graph', z.object({ reason: z.string() })), + impl: async () => ({ + kind: 'agent_graph_yielded', + pendingWorkCount: 0, + liveOperatorCount: -1, + reason: '', + }), + }, + ], + maxSteps: 5, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('reports an explicit step limit without making an auxiliary model call', async () => { + const appended: StoredMessage[] = []; + const durable = durableTurnHarness('turn-1', 'finish the task'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doGenerate: { + content: [ + { + type: 'text', + text: 'Completed the edits; verification is still pending. Send continue to resume.', + }, + ], + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 4, text: 4, reasoning: 0 }, + }, + warnings: [], + }, + doStream: async () => { + streamCalls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: `text-${streamCalls}` }, + { type: 'text-delta', id: `text-${streamCalls}`, delta: 'Still working.' }, + { type: 'text-end', id: `text-${streamCalls}` }, + { + type: 'tool-call', + toolCallId: `tool-${streamCalls}`, + toolName: 'Read', + input: JSON.stringify({ path: `notes-${streamCalls}.md` }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + maxSteps: 2, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 2); + assert.equal(model.doGenerateCalls.length, 0); + assert.equal( + appended.filter((message): message is AssistantMessage => message.type === 'assistant').at(-1) + ?.text, + 'Still working.', + ); + assert.equal(events.at(-1)?.type, 'complete'); + assert.equal( + (events.at(-1) as Extract).stopReason as string, + 'step_limit', + ); + }); + + test('records cumulative usage checkpoints across tool-loop steps and turns', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const usageCheckpoints: Array<{ inputTokens: number; outputTokens: number }> = []; + const firstTurn = durableTurnHarness('turn-1', 'hi'); + const secondTurn = durableTurnHarness('turn-2', 'continue'); + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { + total: 100, + noCache: 70, + cacheRead: 20, + cacheWrite: 10, + }, + outputTokens: { + total: 5, + text: 5, + reasoning: 0, + }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'done' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 200, + noCache: 100, + cacheRead: 80, + cacheWrite: 20, + }, + outputTokens: { + total: 7, + text: 5, + reasoning: 2, + }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message: StoredMessage) => { + messages.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: async (turnId: string) => + turnId === 'turn-1' + ? firstTurn.loadTurnRuntimeEvents(turnId) + : secondTurn.loadTurnRuntimeEvents(turnId), + newId: idGenerator(), + now: monotonicClock(), + recordUsageCheckpoint: async (usage: { inputTokens: number; outputTokens: number }) => { + usageCheckpoints.push(usage); + }, + } as never); + + for await (const event of backend.send(firstTurn.input())) { + firstTurn.record(event); + events.push(event); + } + await drainDurably(backend.send(secondTurn.input()), secondTurn); + + const usageMessage = messages.find( + (message) => (message as { type?: string }).type === 'token_usage', + ) as + | { + input?: number; + output?: number; + cacheHitInput?: number; + cacheMissInput?: number; + cacheMissInputSource?: string; + cacheWriteInput?: number; + cacheRead?: number; + cacheCreation?: number; + reasoning?: number; + total?: number; + rawFinishReason?: string; + } + | undefined; + const usageEvent = events.find((event) => event.type === 'token_usage') as + | Extract + | undefined; + + assert.equal(streamCalls, 3); + assert.equal(usageMessage?.input, 300); + assert.equal(usageMessage?.output, 12); + assert.equal(usageMessage?.cacheHitInput, 100); + assert.equal(usageMessage?.cacheMissInput, 170); + assert.equal(usageMessage?.cacheMissInputSource, 'explicit'); + assert.equal(usageMessage?.cacheWriteInput, 30); + assert.equal(usageMessage?.cacheRead, 100); + assert.equal(usageMessage?.cacheCreation, 30); + assert.equal(usageMessage?.reasoning, 2); + assert.equal(usageMessage?.total, 312); + assert.equal(usageMessage?.rawFinishReason, 'stop'); + assert.equal(usageEvent?.input, 300); + assert.deepEqual( + usageCheckpoints.map(({ inputTokens, outputTokens }) => ({ inputTokens, outputTokens })), + [ + { inputTokens: 100, outputTokens: 5 }, + { inputTokens: 300, outputTokens: 12 }, + { inputTokens: 500, outputTokens: 19 }, + ], + ); + }); + + test('does not record fabricated zero telemetry when provider usage is unavailable', async () => { + const events: SessionEvent[] = []; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + } as never, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + // No usable sample means no usage record at all — a zero would be + // indistinguishable from a call that genuinely consumed nothing (#972). + assert.deepEqual( + events.filter((event) => event.type === 'token_usage'), + [], + ); + }); + + test('keeps checkpoint cost unknown when model pricing is unavailable', async () => { + const usageCheckpoints: Array<{ costUsd?: number }> = []; + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 10, text: 10, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createBackend({ + connection: connection(), + modelId: 'unpriced-model', + modelFactory: () => model, + tools: [], + lookupPricing: () => null, + recordUsageCheckpoint: async (usage: { costUsd?: number }) => { + usageCheckpoints.push(usage); + }, + } as never); + + await drain(backend.send({ turnId: 'turn-1', runId: 'run-1', text: 'hi', context: [] })); + + assert.equal(usageCheckpoints.length, 1); + assert.equal(usageCheckpoints[0]?.costUsd, undefined); + }); + + test('a pruned tool result is readable again through the tool its placeholder names', async () => { + // The whole loop through real dispatch (#2026): the budget prunes an + // oversized result, the runtime mints a placeholder naming `ArchiveRead`, + // the model calls it with the ref that placeholder carried, and the body + // lands back in the conversation. Advertising the decoder is only half the + // invariant; the other half is that calling it works from inside the turn. + const durable = durableTurnHarness('turn-1', 'read the big file'); + const largeBody = 'ARCHIVED_BODY_SENTINEL'.repeat(200); + const store = new Map(); + const prompts: unknown[] = []; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async ({ prompt }) => { + streamCalls += 1; + prompts.push(prompt); + const call = (toolCallId: string, toolName: string, input: unknown) => + [ + { type: 'stream-start', warnings: [] }, + { type: 'tool-call', toolCallId, toolName, input: JSON.stringify(input) }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] as LanguageModelV4StreamPart[]; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? call('tool-1', 'Read', { path: 'big.md' }) + : // The newest completed step is never pruned, so a second call is + // what makes the Read result stale enough to be archived. + streamCalls === 2 + ? call('tool-2', 'Bash', { cmd: 'continue' }) + : streamCalls === 3 + ? call('tool-3', 'ArchiveRead', { + // Read the ref out of the placeholder the runtime just + // handed us, exactly as a model would. + ref: /maka:\/\/archive\/[^"\\]+/.exec(JSON.stringify(prompt))?.[0] ?? 'missing', + operation: 'read', + }) + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => ({ body: largeBody }), + }, + { + name: 'Bash', + description: 'Bash description', + parameters: z.object({ cmd: z.string() }), + impl: async () => ({ body: 'small' }), + }, + ], + contextBudget: { + activeToolResultPrune: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, + }, + // A real store, so the decoder has to reach what the writer actually wrote. + toolResultArchive: createToolResultArchiveCapability({ + archiveToolResult: async (event) => { + const artifactId = `artifact-${store.size + 1}`; + store.set(artifactId, event.serializedResult); + return { artifactId }; + }, + readToolResultArchive: async () => ({ ok: false, reason: 'not_found' }), + readArchivedToolResultResource: async (event) => { + const serializedResult = + event.storage === 'ledger' ? undefined : store.get(event.artifactId); + return serializedResult === undefined + ? { ok: false, reason: 'not_found' } + : { ok: true, serializedResult }; + }, + }), + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + for await (const event of backend.send(durable.input())) durable.record(event); + + assert.match( + store.get('artifact-1') ?? '', + /ARCHIVED_BODY_SENTINEL/, + 'the oversized Read result must have been archived', + ); + const thirdPrompt = JSON.stringify(prompts[2]); + assert.doesNotMatch(thirdPrompt, /ARCHIVED_BODY_SENTINEL/); + assert.match(thirdPrompt, /maka:\/\/archive\//); + assert.match( + JSON.stringify(prompts[3]), + /ARCHIVED_BODY_SENTINEL/, + 'the ArchiveRead result must carry the archived body back into the conversation', + ); + }); + + test('records active tool-result prune diagnostics in usage telemetry', async () => { + const durable = durableTurnHarness('turn-1', 'hi'); + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const largeBody = 'SECRET_PAYLOAD_SHOULD_BE_ARCHIVED'.repeat(200); + const archivedToolCallIds: string[] = []; + let streamCalls = 0; + const prompts: unknown[] = []; + const model = new MockLanguageModelV4({ + doStream: async ({ prompt }) => { + streamCalls += 1; + prompts.push(prompt); + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : streamCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-2', + toolName: 'Bash', + input: JSON.stringify({ cmd: 'continue' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : streamCalls === 3 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-3', + toolName: 'Bash', + input: JSON.stringify({ cmd: 'again' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => ({ body: largeBody }), + }, + { + name: 'Bash', + description: 'Bash description', + parameters: z.object({ cmd: z.string() }), + impl: async () => ({ body: 'NEWEST_RESULT_STAYS_VISIBLE' }), + }, + ], + contextBudget: { + activeToolResultPrune: { enabled: true, maxCurrentResultEstimatedTokens: 1 }, + }, + toolResultArchive: testToolResultArchive({ + archiveToolResult: async (candidate) => { + archivedToolCallIds.push(candidate.toolCallId); + return { artifactId: `artifact-${candidate.toolCallId}` }; + }, + }), + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + } + + const usageMessage = messages.find( + (message) => (message as { type?: string }).type === 'token_usage', + ) as { contextBudget?: Record } | undefined; + const usageEvent = events.find((event) => event.type === 'token_usage') as + | (Extract & { + contextBudget?: Record; + }) + | undefined; + assert.equal(streamCalls, 4); + const secondPrompt = JSON.stringify(prompts[1]); + assert.match(secondPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); + assert.doesNotMatch(secondPrompt, /maka\.active_archived_tool_result/); + const thirdPrompt = JSON.stringify(prompts[2]); + assert.doesNotMatch(thirdPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); + assert.match(thirdPrompt, /artifact-tool-1/); + assert.match(thirdPrompt, /NEWEST_RESULT_STAYS_VISIBLE/); + // Every later step rebuilds its prompt from the durable Turn ledger. The + // archive is durable, so the rebuild must fold it: a step that measured the + // raw body again would both resurrect it and archive it a second time. + const fourthPrompt = JSON.stringify(prompts[3]); + assert.doesNotMatch(fourthPrompt, /SECRET_PAYLOAD_SHOULD_BE_ARCHIVED/); + assert.match(fourthPrompt, /artifact-tool-1/); + // Each result is archived once, no matter how many later steps rebuild the + // Turn: the ledger, not a per-run memory, is what says it already happened. + assert.deepEqual(archivedToolCallIds, ['tool-1', 'tool-2']); + for (const contextBudget of [usageMessage?.contextBudget, usageEvent?.contextBudget]) { + assert.equal(contextBudget?.activePrunedToolResults, 2); + assert.equal(contextBudget?.activeArchiveFailures, undefined); + assert.ok(((contextBudget?.activeEstimatedTokensSaved as number | undefined) ?? 0) > 0); + } + }); + + test('projects superseded current-turn observations before the next provider step', async () => { + const durable = durableTurnHarness('turn-1', 'hi'); + const messages: unknown[] = []; + const prompts: unknown[] = []; + const oldBody = 'OLD_READ_RESULT'.repeat(200); + const newBody = 'NEW_READ_RESULT'.repeat(200); + let streamCalls = 0; + let readCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async ({ prompt }) => { + streamCalls += 1; + prompts.push(prompt); + const chunks: LanguageModelV4StreamPart[] = + streamCalls <= 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `read-${streamCalls}`, + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => ({ body: readCalls++ === 0 ? oldBody : newBody }), + }, + ], + contextBudget: { + charsPerToken: 1, + activeToolResultPrune: { + enabled: true, + maxCurrentResultEstimatedTokens: 10_000, + minSupersededResultEstimatedTokens: 1, + }, + }, + toolResultArchive: testToolResultArchive({ + archiveToolResult: async () => ({ artifactId: 'artifact-read-1' }), + }), + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + for await (const event of backend.send(durable.input())) durable.record(event); + + assert.equal(streamCalls, 3); + assert.match(JSON.stringify(prompts[1]), /OLD_READ_RESULT/); + const thirdPrompt = JSON.stringify(prompts[2]); + assert.doesNotMatch(thirdPrompt, /OLD_READ_RESULT/); + assert.match(thirdPrompt, /NEW_READ_RESULT/); + assert.match(thirdPrompt, /newer_read_covers_range/); + const usageMessage = messages.find( + (message) => (message as { type?: string }).type === 'token_usage', + ) as { contextBudget?: Record } | undefined; + assert.equal(usageMessage?.contextBudget?.activeSupersededToolResults, 1); + assert.equal(usageMessage?.contextBudget?.activeDuplicateToolResults, undefined); + }); + + test('normalizes cache and reasoning tokens to messages, events, and telemetry', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const runTraceEvents: Array<{ type: string; data?: Record }> = []; + let pricingLookupCalls = 0; + const pricing = { + modelKey: 'anthropic:mock-model-id', + inputUsdPer1M: 3, + outputUsdPer1M: 15, + cacheReadUsdPer1M: 0.3, + cacheWriteUsdPer1M: 3.75, + }; + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'hello' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 10, + noCache: 5, + cacheRead: 3, + cacheWrite: 2, + }, + outputTokens: { + total: 7, + text: 5, + reasoning: 2, + }, + }, + }, + ]; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + systemPrompt: 'durable system prompt', + lookupPricing: (modelKey) => { + pricingLookupCalls += 1; + return modelKey === pricing.modelKey ? pricing : null; + }, + recordRunTrace: (event) => { + runTraceEvents.push(event); + }, + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + const usageMessage = messages.find( + (message) => (message as { type?: string }).type === 'token_usage', + ) as + | { + input?: number; + output?: number; + cacheHitInput?: number; + cacheMissInput?: number; + cacheMissInputSource?: string; + cacheWriteInput?: number; + cacheRead?: number; + cacheCreation?: number; + reasoning?: number; + total?: number; + rawFinishReason?: string; + costUsd?: number; + systemPromptHash?: string; + prefixHash?: string; + prefixChangeReason?: string; + requestShapeHash?: string; + requestShapeChangeReason?: string; + promptSegments?: unknown[]; + } + | undefined; + const usageEvent = events.find((event) => event.type === 'token_usage') as + | (Extract & { systemPromptHash?: string }) + | undefined; + const expectedCostUsd = (5 * 3 + 3 * 0.3 + 2 * 3.75 + 7 * 15) / 1_000_000; + const startTrace = runTraceEvents.find((event) => event.type === 'model_stream_started'); + + assert.equal((usageMessage as { type?: string } | undefined)?.type, 'token_usage'); + assert.equal((usageMessage as { turnId?: string } | undefined)?.turnId, 'turn-1'); + assert.equal(usageMessage?.input, 10); + assert.equal(usageMessage?.output, 7); + assert.equal(usageMessage?.cacheHitInput, 3); + assert.equal(usageMessage?.cacheMissInput, 5); + assert.equal(usageMessage?.cacheMissInputSource, 'explicit'); + assert.equal(usageMessage?.cacheWriteInput, 2); + assert.equal(usageMessage?.cacheRead, 3); + assert.equal(usageMessage?.cacheCreation, 2); + assert.equal(usageMessage?.reasoning, 2); + assert.equal(usageMessage?.total, 17); + assert.equal(usageMessage?.rawFinishReason, 'stop'); + assert.equal(usageMessage?.costUsd, expectedCostUsd); + assert.equal(usageEvent?.input, 10); + assert.equal(usageEvent?.output, 7); + assert.equal(usageEvent?.cacheHitInput, 3); + assert.equal(usageEvent?.cacheMissInput, 5); + assert.equal(usageEvent?.cacheMissInputSource, 'explicit'); + assert.equal(usageEvent?.cacheWriteInput, 2); + assert.equal(usageEvent?.cacheRead, 3); + assert.equal(usageEvent?.cacheCreation, 2); + assert.equal(usageEvent?.reasoning, 2); + assert.equal(usageEvent?.total, 17); + assert.equal(usageEvent?.rawFinishReason, 'stop'); + assert.equal(usageEvent?.costUsd, expectedCostUsd); + for (const currentWriter of [ + usageMessage as Record, + usageEvent as unknown as Record, + startTrace?.data, + ]) { + assert.equal(currentWriter?.systemPromptHash, undefined); + assert.equal(currentWriter?.prefixHash, undefined); + assert.equal(currentWriter?.prefixChangeReason, undefined); + assert.equal(currentWriter?.requestShapeHash, undefined); + assert.equal(currentWriter?.requestShapeChangeReason, undefined); + assert.equal(currentWriter?.promptSegments, undefined); + } + assert.equal(pricingLookupCalls, 1); + }); +}); + +describe('AiSdkBackend tool availability diagnostics', () => { + test('tool canonicalization is independent of registration order and places invalid last', () => { + const invalid = testTool(INVALID_TOOL_NAME, z.object({ tool: z.string().optional() })); + const first = canonicalizeToolSet( + [ + testTool('Write', z.object({ path: z.string(), content: z.string() })), + testTool('Read', z.object({ path: z.string() })), + ], + invalid, + ); + const second = canonicalizeToolSet( + [ + testTool('Read', z.object({ path: z.string() })), + testTool('Write', z.object({ content: z.string(), path: z.string() })), + ], + invalid, + ); + + assert.deepEqual(first.activeTools, ['Read', 'Write']); + assert.deepEqual( + first.providerTools.map((tool) => tool.name), + ['Read', 'Write', INVALID_TOOL_NAME], + ); + assert.deepEqual( + second.providerTools.map((tool) => tool.name), + ['Read', 'Write', INVALID_TOOL_NAME], + ); + }); + + test('backend full mode keeps the complete tool surface and omits the connector', async () => { + const model = completionModel(); + const events: SessionEvent[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + // No toolAvailability ⇒ full surface: every tool visible, no connector. + tools: [ + testTool('Read', z.object({ path: z.string() })), + testTool('WebFetch', z.object({ url: z.string() })), + ], + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.deepEqual(modelToolNames(model), sortedModelToolNames(['Read', 'WebFetch'])); + assert.equal(modelToolNames(model).includes(TOOL_SEARCH_NAME), false); + const usageEvent = events.find( + (event): event is Extract => + event.type === 'token_usage', + ); + assert.equal(usageEvent?.promptSegments, undefined); + }); + + test('preserves the tool-call provider prefix across user turns', async () => { + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: `text-${streamCalls}` }, + { type: 'text-delta', id: `text-${streamCalls}`, delta: 'done' }, + { type: 'text-end', id: `text-${streamCalls}` }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const firstTurn = durableTurnHarness('turn-1', 'inspect notes'); + const secondTurn = durableTurnHarness('turn-2', 'continue'); + const legacyVolatilePromptInput = { + turnTailPrompt: ({ turnId }: { turnId: string }) => `VOLATILE_CONTEXT_${turnId}`, + } as unknown as Partial; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: async (turnId) => + turnId === 'turn-1' + ? firstTurn.loadTurnRuntimeEvents(turnId) + : secondTurn.loadTurnRuntimeEvents(turnId), + newId: idGenerator(), + now: monotonicClock(), + ...legacyVolatilePromptInput, + }); + + await drainDurably(backend.send(firstTurn.input()), firstTurn); + await drainDurably( + backend.send(secondTurn.input({ runtimeContext: firstTurn.ledger })), + secondTurn, + ); + + assert.equal(streamCalls, 3); + const firstRequest = model.doStreamCalls[0]?.prompt; + const toolResultRequest = model.doStreamCalls[1]?.prompt; + const nextTurnRequest = model.doStreamCalls[2]?.prompt; + assert.ok(firstRequest); + assert.ok(toolResultRequest); + assert.ok(nextTurnRequest); + assert.equal(toolResultRequest.at(-1)?.role, 'tool'); + const toolCallPrefix = toolResultRequest.slice(0, -1); + assert.equal(toolCallPrefix.at(-1)?.role, 'assistant'); + assert.ok(toolCallPrefix.length > firstRequest.length); + assert.ok(nextTurnRequest.length > toolCallPrefix.length); + assert.deepEqual(nextTurnRequest.slice(0, firstRequest.length), firstRequest); + assert.deepEqual(nextTurnRequest.slice(0, toolCallPrefix.length), toolCallPrefix); + }); +}); + +describe('AiSdkBackend context budget and prompt attribution', () => { + test('replay hands the model a bounded summary for an Edit file_diff result, not the diff', () => { + const diff = ['--- a/a.ts', '+++ b/a.ts', '@@ -1,2 +1,2 @@', ' keep', '-old', '+new'].join( + '\n', + ); + const events = [ + runtimeEvent({ + id: 'edit-call', + turnId: 't1', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-edit', + name: 'Edit', + args: { path: 'a.ts' }, + }, + }), + runtimeEvent({ + id: 'edit-result', + turnId: 't1', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-edit', + name: 'Edit', + result: { kind: 'file_diff', paths: ['a.ts'], diff }, + }, + }), + ]; + + const plan = buildRuntimeEventModelReplayPlan(events); + const result = plan.items.find( + (item) => item.kind === 'tool_result' && item.toolCallId === 'tool-edit', + ); + + assert.equal(result?.kind === 'tool_result' ? result.output : undefined, 'Edited a.ts (+1 -1)'); + // The durable event itself keeps the full diff — only the model-facing + // projection is bounded. + const durable = events[1].content; + assert.equal( + durable?.kind === 'function_response' ? (durable.result as { kind: string }).kind : undefined, + 'file_diff', + ); + }); + + test('usage events keep context budget diagnostics without live prompt estimates', async () => { + const model = completionModel(); + const events: SessionEvent[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + systemPrompt: 'durable system', + contextBudget: { + name: 'test-budget', + charsPerToken: 1, + historyCompact: { enabled: true }, + }, + }); + + for await (const event of backend.send({ + turnId: 'turn-current', + text: 'current user', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'old-u', + turnId: 'old', + role: 'user', + author: 'user', + text: 'old user text', + }), + runtimeTextEvent({ + id: 'old-a', + turnId: 'old', + role: 'model', + author: 'agent', + text: 'old assistant text', + }), + runtimeTextEvent({ + id: 'new-u', + turnId: 'new', + role: 'user', + author: 'user', + text: 'new user text', + }), + runtimeTextEvent({ + id: 'new-a', + turnId: 'new', + role: 'model', + author: 'agent', + text: 'new assistant text', + }), + ], + })) { + events.push(event); + } + + assert.deepEqual(compactPrompt(model), [ + { role: 'system', content: 'durable system' }, + { role: 'user', content: [{ type: 'text', text: 'old user text' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'old assistant text' }] }, + { role: 'user', content: [{ type: 'text', text: 'new user text' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'new assistant text' }] }, + { role: 'user', content: [{ type: 'text', text: 'current user' }] }, + ]); + const usage = events.find( + (event): event is Extract => + event.type === 'token_usage', + ); + assert.ok(usage); + assert.equal(usage.contextBudget?.policyName, 'test-budget'); + assert.equal(usage.contextBudget?.droppedTurns, 0); + assert.equal(usage.promptSegments, undefined); + }); +}); + +describe('AiSdkBackend RunTrace', () => { + for (const protocol of ['openai-compatible', 'anthropic-compatible'] as const) { + test(`records ${protocol} multi-step requests and reconciles complete attempt usage`, async () => { + const attempts: ModelCallAttempt[] = []; + const durable = durableTurnHarness('turn-1', 'hi'); + let calls = 0; + const usageFor = (step: number) => { + if (protocol === 'openai-compatible') { + const input = step === 0 ? 10 : 20; + const cached = step === 0 ? 4 : 5; + const output = step === 0 ? 2 : 3; + return { + inputTokens: { + total: input, + noCache: input - cached, + cacheRead: cached, + cacheWrite: undefined, + }, + outputTokens: { + total: output, + text: output - (step === 0 ? 0 : 1), + reasoning: step === 0 ? 0 : 1, + }, + raw: { + prompt_tokens: input, + completion_tokens: output, + prompt_tokens_details: { cached_tokens: cached }, + completion_tokens_details: { reasoning_tokens: step === 0 ? 0 : 1 }, + }, + }; + } + const noCache = step === 0 ? 6 : 12; + const cacheRead = step === 0 ? 3 : 6; + const cacheWrite = step === 0 ? 1 : 2; + const output = step === 0 ? 2 : 3; + return { + inputTokens: { + total: noCache + cacheRead + cacheWrite, + noCache, + cacheRead, + cacheWrite, + }, + outputTokens: { total: output, text: undefined, reasoning: undefined }, + raw: { + input_tokens: noCache, + output_tokens: output, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheWrite, + }, + }; + }; + const model = new MockLanguageModelV4({ + doStream: async () => { + const step = calls++; + const chunks: LanguageModelV4StreamPart[] = + step === 0 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: usageFor(step), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'done' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: usageFor(step), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + recordModelCallAttempt: ({ attempt }) => { + attempts.push(attempt); + }, + }); + + const events = await drainDurably(backend.send(durable.input({ runId: 'run-1' })), durable); + + assert.deepEqual( + attempts.map(({ step, attempt, status }) => ({ step, attempt, status })), + [ + { step: 0, attempt: 0, status: 'completed' }, + { step: 1, attempt: 0, status: 'completed' }, + ], + ); + const aggregate = events.find( + (event): event is Extract => + event.type === 'token_usage', + ); + assert.ok(aggregate); + const sum = (field: keyof ModelCallAttempt) => + attempts.reduce( + (total, attempt) => total + ((attempt[field] as number | undefined) ?? 0), + 0, + ); + assert.equal(sum('inputTokens'), aggregate.input); + assert.equal(sum('outputTokens'), aggregate.output); + assert.equal(sum('cacheReadInputTokens'), aggregate.cacheHitInput); + assert.equal(sum('cacheMissInputTokens'), aggregate.cacheMissInput); + assert.equal(sum('cacheWriteInputTokens'), aggregate.cacheWriteInput); + }); + } + + test('observes the prepared request at dispatch and records its canonical attempt', async () => { + const attempts: ModelCallAttempt[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 4, noCache: 4, cacheRead: 0, cacheWrite: undefined }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + raw: { + prompt_tokens: 4, + completion_tokens: 2, + prompt_tokens_details: { cached_tokens: 0 }, + }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: { + ...connection(), + models: [{ id: 'mock-model-id', contextWindow: 200_000 }], + }, + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordModelCallAttempt: async ({ attempt }) => { + attempts.push(attempt); + }, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'turn-1', + runId: 'run-1', + text: 'hi', + context: [], + })) { + events.push(event); + } + + assert.equal(attempts.length, 1); + assert.equal(attempts[0]?.step, 0); + assert.equal(attempts[0]?.attempt, 0); + assert.equal(attempts[0]?.status, 'completed'); + assert.equal(attempts[0]?.contextWindow, 200_000); + assert.equal(attempts[0]?.cacheMissInputTokens, 4); + assert.equal( + events.find((event) => event.type === 'token_usage')?.providerRequestTraceId, + attempts[0]?.traceId, + ); + }); + + test('persists contextRemaining on the stored token_usage message (#4019)', async () => { + const messages: StoredMessage[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 4, noCache: 4, cacheRead: 0, cacheWrite: undefined }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + messages.push(message); + }, + connection: { + ...connection(), + models: [{ id: 'mock-model-id', contextWindow: 200_000 }], + }, + apiKey: '[redacted]', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + // The single step consumed 4 prompt tokens of the 200k window. + const expected = 200_000 - 4; + const stored = messages.find((message) => message.type === 'token_usage'); + assert.equal( + stored?.type === 'token_usage' ? stored.contextRemaining : undefined, + expected, + 'stored TokenUsageMessage must carry contextRemaining so transcript rebuilds keep the ctx segment', + ); + const live = events.find((event) => event.type === 'token_usage'); + assert.equal(live?.contextRemaining, expected); + }); + + test('omits contextRemaining from the stored token_usage message when the window is unknown', async () => { + const messages: StoredMessage[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 4, noCache: 4, cacheRead: 0, cacheWrite: undefined }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createBackend({ + appendMessage: async (message: StoredMessage) => { + messages.push(message); + }, + // No declared/fetched window for mock-model-id: degrade, never invent one. + connection: connection(), + apiKey: '[redacted]', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + void event; + } + + const stored = messages.find((message) => message.type === 'token_usage'); + assert.equal(stored?.type, 'token_usage'); + assert.equal(stored?.type === 'token_usage' && 'contextRemaining' in stored, false); + }); + + test('disables hidden AI SDK retries and traces the one explicit Runtime retry', async () => { + const attempts: ModelCallAttempt[] = []; + const stableTool = testTool('stable_tool', z.object({})); + const retryOnlyTool = testTool('retry_only_tool', z.object({})); + let surface: readonly MakaTool[] = [stableTool]; + let calls = 0; + const requestCompositions: RequestCompositionSnapshotInput[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls === 1) { + surface = [stableTool, retryOnlyTool]; + throw new APICallError({ + message: 'retry me', + url: 'https://provider.invalid/v1/messages', + requestBodyValues: {}, + statusCode: 503, + responseHeaders: { 'retry-after-ms': '1' }, + }); + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 4, noCache: 4, cacheRead: 0, cacheWrite: undefined }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [...surface], + resolveTools: () => surface, + recordModelCallAttempt: ({ attempt }) => { + attempts.push(attempt); + }, + recordRequestComposition: async (_runId, snapshot) => { + requestCompositions.push(snapshot); + return snapshot.compositionId; + }, + providerRetrySleep: async () => {}, + }); + + await drain(backend.send({ turnId: 'turn-1', runId: 'run-1', text: 'hi', context: [] })); + + assert.equal(calls, 2); + assert.equal( + model.doStreamCalls.every((call) => + Array.isArray(call.tools) + ? call.tools.every( + (tool) => !('name' in tool) || String(tool.name) !== retryOnlyTool.name, + ) + : !(retryOnlyTool.name in (call.tools ?? {})), + ), + true, + ); + assert.equal(requestCompositions.length, 1); + assert.ok( + attempts.every( + (attempt) => attempt.requestCompositionId === requestCompositions[0]?.compositionId, + ), + ); + assert.deepEqual( + attempts.map(({ attempt, status }) => ({ attempt, status })), + [ + { attempt: 0, status: 'failed' }, + { attempt: 1, status: 'completed' }, + ], + ); + }); + + test('preserves provider capacity in retry progress', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls === 1) { + throw new APICallError({ + message: 'The model is currently at capacity due to high demand.', + url: 'https://api.x.ai/v1/chat/completions', + requestBodyValues: {}, + data: { error: { code: 'resource-exhausted' } }, + }); + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, reason }) => ({ phase, reason })), + [ + { phase: 'scheduled', reason: 'provider_capacity' }, + { phase: 'started', reason: 'provider_capacity' }, + ], + ); + }); + + test('retries one idle watchdog timeout after preserving partial thinking', async () => { + const timers = manualWatchdogTimer(); + const assistants: AssistantMessage[] = []; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + if (calls === 1) { + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'partial thought', + }, + ], + options.abortSignal, + 'close', + ), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'partial thought') timers.fire(); + } + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, attempt, maxAttempts, reason }) => ({ + phase, + attempt, + maxAttempts, + reason, + })), + [ + { phase: 'scheduled', attempt: 2, maxAttempts: 2, reason: 'timeout' }, + { phase: 'started', attempt: 2, maxAttempts: 2, reason: 'timeout' }, + ], + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(assistants.length, 2); + assert.equal(assistants[0]?.thinking?.text, 'partial thought'); + assert.equal(assistants[0]?.text, ''); + assert.equal(assistants[1]?.text, 'recovered'); + assert.notEqual(assistants[0]?.id, assistants[1]?.id); + }); + + test('retries a retryable network failure after partial thinking by sealing it', async () => { + // Incident shape: the provider streamed thinking deltas, then the + // connection reset mid-step (ECONNRESET after ~120s). Recovery safety + // depends on what the attempt emitted, not on which side detected the + // cut: thinking is sealable, so the fragment is flushed under its own + // message id and the retry streams into a fresh id — the same contract + // as an idle-watchdog recovery. + const durable = durableTurnHarness('turn-econnreset-thinking', 'review the commits'); + const assistants: AssistantMessage[] = []; + let failCurrentStream: (() => void) | undefined; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls > 1) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + const failing = midStreamFailureStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial thought' }, + ], + connectionResetFailure(), + ); + failCurrentStream = failing.fail; + return { stream: failing.stream }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'partial thought') { + failCurrentStream?.(); + } + } + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, attempt, maxAttempts, reason }) => ({ + phase, + attempt, + maxAttempts, + reason, + })), + [ + { phase: 'scheduled', attempt: 2, maxAttempts: 2, reason: 'network' }, + { phase: 'started', attempt: 2, maxAttempts: 2, reason: 'network' }, + ], + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(assistants.length, 2); + assert.equal(assistants[0]?.thinking?.text, 'partial thought'); + assert.equal(assistants[0]?.text, ''); + assert.equal(assistants[1]?.text, 'recovered'); + assert.notEqual(assistants[0]?.id, assistants[1]?.id); + // The sealed fragment stays in the transcript but out of the retried + // provider request: the retry replays the failed attempt's projection, + // so the model never re-reads its own severed thinking. + const retryPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt); + assert.equal(retryPrompt.includes('partial thought'), false); + assert.match(retryPrompt, /review the commits/); + }); + + test('retries a retryable network failure before any observable output', async () => { + const durable = durableTurnHarness('turn-econnreset-no-output', 'review the commits'); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls > 1) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.error(connectionResetFailure()); + }, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, reason }) => ({ phase, reason })), + [ + { phase: 'scheduled', reason: 'network' }, + { phase: 'started', reason: 'network' }, + ], + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('stops after one sealed-thinking network recovery in the same provider step', async () => { + // Every attempt streams thinking and is cut mid-stream. The first cut + // seals and retries; the second is terminal, so one recovery per step + // bounds how many severed-thinking fragments a systematically cutting + // gateway can leave in the transcript. + const durable = durableTurnHarness('turn-econnreset-thinking-budget', 'review the commits'); + const assistants: AssistantMessage[] = []; + let failCurrentStream: (() => void) | undefined; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const failing = midStreamFailureStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: `reasoning-${calls}` }, + { + type: 'reasoning-delta', + id: `reasoning-${calls}`, + delta: `partial thought ${calls}`, + }, + ], + connectionResetFailure(), + ); + failCurrentStream = failing.fail; + return { stream: failing.stream }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + if (event.type === 'thinking_delta' && event.text.startsWith('partial thought ')) { + failCurrentStream?.(); + } + } + + assert.equal(calls, 2); + assert.equal( + events.filter( + (event): event is Extract => + event.type === 'provider_retry' && event.phase === 'scheduled', + ).length, + 1, + ); + const error = events.find( + (event): event is Extract => event.type === 'error', + ); + assert.equal(error?.reason, 'network'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(assistants.length, 2); + assert.equal(assistants[0]?.thinking?.text, 'partial thought 1'); + assert.equal(assistants[1]?.thinking?.text, 'partial thought 2'); + assert.notEqual(assistants[0]?.id, assistants[1]?.id); + }); + + test('does not retry a network failure after provider continuation metadata on thinking', async () => { + // Continuation identity (Responses reasoning item ids, encrypted + // content) cannot be replayed into a fresh request, so thinking that + // carries it stays non-recoverable even though the failure itself is + // retryable. The second reasoning part's delta is the fail trigger: + // stream ordering guarantees the metadata on the first part's + // reasoning-end was already consumed when it arrives. + const durable = durableTurnHarness('turn-econnreset-metadata', 'review the commits'); + let failCurrentStream: (() => void) | undefined; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const failing = midStreamFailureStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'completed provider reasoning', + }, + { + type: 'reasoning-end', + id: 'reasoning-1', + providerMetadata: { + openai: { + itemId: 'reasoning-item-1', + reasoningEncryptedContent: 'encrypted-reasoning', + }, + }, + }, + { type: 'reasoning-start', id: 'reasoning-2' }, + { type: 'reasoning-delta', id: 'reasoning-2', delta: 'second thought' }, + ], + connectionResetFailure(), + ); + failCurrentStream = failing.fail; + return { stream: failing.stream }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'second thought') { + failCurrentStream?.(); + } + } + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'network'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('retries DeepSeek OpenAI Chat reasoning marked only for field replay', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + if (calls === 1) { + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'ordinary DeepSeek reasoning', + }, + ], + options.abortSignal, + ), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'recovered' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: { + ...connection(), + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-pro', + models: [{ id: 'deepseek-v4-pro', apiProtocol: 'openai-chat' }], + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-pro', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'ordinary DeepSeek reasoning') { + timers.fire(); + } + } + + assert.equal(calls, 2); + assert.equal( + events.some((event) => event.type === 'provider_retry' && event.phase === 'started'), + true, + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('links a recovered tool call to the retry assistant step', async () => { + const timers = manualWatchdogTimer(); + const durable = durableTurnHarness('turn-1', 'read notes'); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + if (calls === 1) { + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-timeout' }, + { + type: 'reasoning-delta', + id: 'reasoning-timeout', + delta: 'timed-out thought', + }, + ], + options.abortSignal, + ), + }; + } + const chunks: LanguageModelV4StreamPart[] = + calls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-retry' }, + { + type: 'reasoning-delta', + id: 'reasoning-retry', + delta: 'recovered thought', + }, + { + type: 'reasoning-delta', + id: 'reasoning-retry', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-retry' } }, + }, + { type: 'reasoning-end', id: 'reasoning-retry' }, + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'timed-out thought') timers.fire(); + } + + const timeoutThinking = events.find( + (event) => event.type === 'thinking_complete' && event.text === 'timed-out thought', + ); + const recoveredThinking = events.find( + (event) => event.type === 'thinking_complete' && event.text === 'recovered thought', + ); + const toolStart = events.find( + (event): event is Extract => + event.type === 'tool_start' && event.toolUseId === 'read-1', + ); + + assert.equal(calls, 3); + assert.equal(timeoutThinking?.type, 'thinking_complete'); + assert.equal(recoveredThinking?.type, 'thinking_complete'); + assert.equal(toolStart?.stepId, recoveredThinking?.messageId); + assert.notEqual(toolStart?.stepId, timeoutThinking?.messageId); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + + test('stops after one recovered idle watchdog timeout in the same provider step', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: `reasoning-${calls}` }, + { + type: 'reasoning-delta', + id: `reasoning-${calls}`, + delta: `partial thought ${calls}`, + }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'thinking_delta' && event.text.startsWith('partial thought')) { + timers.fire(); + } + } + + assert.equal(calls, 2); + assert.equal( + events.filter((event) => event.type === 'provider_retry' && event.phase === 'scheduled') + .length, + 1, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('retries post-tool continuation once without re-running the durable tool result', async () => { + const timers = manualWatchdogTimer(); + const durable = durableTurnHarness('turn-1', 'read notes'); + let providerCalls = 0; + let toolCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + providerCalls += 1; + if (providerCalls === 1) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: hangingProviderStream( + [{ type: 'stream-start', warnings: [] }], + options.abortSignal, + ), + }; + }, + }); + const readTool: MakaTool = { + name: 'Read', + description: 'Read notes', + parameters: z.object({ path: z.string() }), + impl: async () => { + toolCalls += 1; + return 'notes contents'; + }, + }; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [readTool], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents(backend.send(durable.input()), events, durable.record); + await waitFor(() => providerCalls === 2); + timers.fire(); + await waitFor(() => providerCalls === 3); + timers.fire(); + await eventsPromise; + + assert.equal(providerCalls, 3); + assert.equal(toolCalls, 1); + assert.equal(events.filter((event) => event.type === 'tool_result').length, 1); + assert.equal( + durable.ledger.filter((event) => event.content?.kind === 'function_response').length, + 1, + ); + assert.equal( + events.filter((event) => event.type === 'provider_retry' && event.phase === 'scheduled') + .length, + 1, + ); + assert.equal( + events.find((event) => event.type === 'error')?.reason, + 'model_after_tool_timeout', + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('keeps a projection timeout as a generic timeout before continuation dispatch', async () => { + const durable = durableTurnHarness('turn-1', 'read notes'); + let providerCalls = 0; + let toolCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + providerCalls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'Read notes', + parameters: z.object({ path: z.string() }), + impl: async () => { + toolCalls += 1; + return 'notes contents'; + }, + }, + ], + loadTurnRuntimeEvents: async (turnId) => { + if (durable.ledger.some((event) => event.content?.kind === 'function_response')) { + throw Object.assign(new Error('durable projection timeout'), { name: 'TimeoutError' }); + } + return durable.loadTurnRuntimeEvents(turnId); + }, + }); + + const events: SessionEvent[] = []; + await collectEvents(backend.send(durable.input()), events, durable.record); + + assert.equal(providerCalls, 1); + assert.equal(toolCalls, 1); + assert.equal(events.filter((event) => event.type === 'tool_result').length, 1); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('does not retry an idle watchdog timeout after partial answer text', async () => { + const timers = manualWatchdogTimer(); + const traces: RunTraceEvent[] = []; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'partial answer' }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + recordRunTrace: (event) => traces.push(event), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'text_delta' && event.text === 'partial answer') timers.fire(); + } + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + const failureTrace = traces.find((event) => event.type === 'model_stream_failed'); + assert.equal(failureTrace?.data?.rawErrorName, 'Error'); + assert.match(String(failureTrace?.data?.redactedErrorMessage), /stream idle timeout/); + assert.equal(typeof failureTrace?.data?.redactedErrorStackSha256, 'string'); + }); + + test('retries an idle watchdog timeout after an unstarted Responses text item', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const appended: StoredMessage[] = []; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + if (calls === 1) { + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-item-1', phase: 'commentary' }, + }, + }, + ], + options.abortSignal, + ), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'recovered' }, + { type: 'text-end', id: 'text-2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + models: [{ id: 'gpt-5.6', apiProtocol: 'openai-responses' }], + }, + modelId: 'gpt-5.6', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await waitFor(() => calls === 1 && timers.armCount() >= 3); + timers.fire(); + await eventsPromise; + + assert.equal(calls, 2); + assert.equal( + events.some((event) => event.type === 'provider_retry' && event.phase === 'started'), + true, + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + const recovered = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.equal(recovered?.text, 'recovered'); + assert.equal(recovered?.providerOptions, undefined); + }); + + test('does not retry an idle watchdog timeout after provider continuation metadata', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'completed provider reasoning', + }, + { + type: 'reasoning-end', + id: 'reasoning-1', + providerMetadata: { + openai: { + itemId: 'reasoning-item-1', + reasoningEncryptedContent: 'encrypted-reasoning', + }, + }, + }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await waitFor(() => timers.armCount() >= 5); + timers.fire(); + await eventsPromise; + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('does not retry after provider-executed tool input starts', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-input-start', + id: 'provider-tool-1', + toolName: 'web_search', + providerExecuted: true, + }, + { + type: 'tool-input-delta', + id: 'provider-tool-1', + delta: '{"query":"release notes"}', + }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await waitFor(() => timers.armCount() >= 4); + timers.fire(); + await eventsPromise; + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('does not retry an idle watchdog timeout after text continuation metadata', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { openai: { itemId: 'message-item-1' } }, + }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await waitFor(() => timers.armCount() >= 4); + timers.fire(); + await eventsPromise; + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('does not retry an idle watchdog timeout after a terminal finish boundary', async () => { + const timers = manualWatchdogTimer(); + const finishConsumed = makeGate(); + let calls = 0; + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + type FakeStreamInput = { + abortSignal: AbortSignal; + onStreamActivity: () => void; + }; + ( + backend as unknown as { + modelAdapter: { startStream: (input: FakeStreamInput) => Promise }; + } + ).modelAdapter.startStream = async (input: FakeStreamInput) => { + calls += 1; + return { + events: (async function* () { + input.onStreamActivity(); + yield { kind: 'finish' as const, finishReason: 'stop' }; + finishConsumed.release(); + await new Promise((_resolve, reject) => { + const abort = () => reject(input.abortSignal.reason ?? new Error('aborted')); + if (input.abortSignal.aborted) abort(); + else input.abortSignal.addEventListener('abort', abort, { once: true }); + }); + })(), + outcome: Promise.resolve({ + kind: 'completed', + finishReason: 'stop', + request: { messages: [] }, + continuation: 'none', + }), + }; + }; + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await finishConsumed.promise; + timers.fire(); + await eventsPromise; + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry'), + false, + ); + assert.equal(events.find((event) => event.type === 'error')?.reason, 'timeout'); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + }); + + test('Stop aborts the turn while an idle-timeout retry is waiting', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'partial thought', + }, + ], + options.abortSignal, + ), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + streamWatchdogTimer: timers.clock, + providerRetrySleep: async (_delayMs, signal) => + await new Promise((_resolve, reject) => { + const abort = () => + reject(signal.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' })); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + if (event.type === 'thinking_delta' && event.text === 'partial thought') timers.fire(); + if (event.type === 'provider_retry' && event.phase === 'scheduled') { + await backend.stop('user_stop'); + } + } + + assert.equal(calls, 1); + assert.equal( + events.some((event) => event.type === 'provider_retry' && event.phase === 'started'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'user_stop'); + }); + + test('records the continuation replay gate and blocking diagnostics on stream failure', async () => { + const trace: RunTraceEvent[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + throw new Error('provider failed'); + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordRunTrace: (event) => trace.push(event), + }); + + await drain( + backend.send({ + turnId: 'turn-resume', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'original user', + }), + runtimeEvent({ + id: 'rt-invalid-role', + turnId: 'turn-source', + role: 'tool', + author: 'tool', + content: { kind: 'text', text: 'invalid text lane' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 2, + }, + }), + ); + + const failure = trace.find((event) => event.type === 'model_stream_failed'); + assert.equal(failure?.data?.priorReplayGate, 'runtime_replay_text_only'); + assert.deepEqual(failure?.data?.priorReplayDiagnosticCodes, ['unsupported_role']); + }); + + test('records turn, model, usage, and completion trace events without changing SessionEvents', async () => { + const trace: RunTraceEvent[] = []; + const events: SessionEvent[] = []; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'hello' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 4, + noCache: 4, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 2, + text: 1, + reasoning: 1, + }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordRunTrace: (event) => { + trace.push(event); + }, + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.deepEqual( + trace.map((event) => event.type), + [ + 'turn_started', + 'model_resolved', + 'model_stream_started', + 'model_stream_completed', + 'send_diagnostics_recorded', + ], + ); + assert.deepEqual( + trace.map((event) => event.phase), + ['turn', 'model', 'model', 'model', 'model'], + ); + assert.equal(trace[0]?.sessionId, 'session-1'); + assert.equal(trace[0]?.turnId, 'turn-1'); + assert.deepEqual( + events + .map((event) => event.type) + .filter((type) => type === 'text_delta' || type === 'token_usage' || type === 'complete'), + ['text_delta', 'token_usage', 'complete'], + ); + }); + + test('trace recorder failures are best-effort and do not change model execution', async () => { + const events: SessionEvent[] = []; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'hello' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 1, + noCache: 1, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 1, + text: 1, + reasoning: 0, + }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + recordRunTrace: () => { + throw new Error('trace sink unavailable'); + }, + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.deepEqual( + events + .map((event) => event.type) + .filter((type) => type === 'text_delta' || type === 'token_usage' || type === 'complete'), + ['text_delta', 'token_usage', 'complete'], + ); + }); + + test('records abort trace when stop is requested', async () => { + const trace: RunTraceEvent[] = []; + const backend = createBackend({ + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + }); + turnScope(backend, 'turn-1').runTrace = { + abortRequested: (reason: string) => { + trace.push({ + id: 'trace-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + phase: 'abort', + type: 'abort_requested', + message: 'Abort requested', + data: { reason }, + }); + }, + } as unknown as RunTrace; + + await backend.stop('redirect'); + + assert.equal(trace.length, 1); + assert.equal(trace[0]?.type, 'abort_requested'); + assert.equal(trace[0]?.data?.reason, 'redirect'); + }); +}); + +describe('AiSdkBackend tool execution', () => { + test('WebSearch telemetry never copies the user-derived query', async () => { + const telemetry: Array<{ argsSummary?: string }> = []; + const backend = createBackend({ + header: header('bypass'), + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + recordToolInvocation: (record) => { + telemetry.push({ argsSummary: record.argsSummary }); + }, + }); + const tool: MakaTool = { + name: 'WebSearch', + description: 'search web', + parameters: {}, + impl: async () => ({ + kind: 'web_search', + provider: 'tavily', + query: 'PRIVATE_QUERY_SENTINEL', + rows: [], + }), + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { push: () => {} }); + + await execute( + { query: 'PRIVATE_QUERY_SENTINEL', limit: 3 }, + { toolCallId: 'tool-web-search', abortSignal: new AbortController().signal }, + ); + + assert.deepEqual(telemetry, [{ argsSummary: '{"limit":3}' }]); + assert.doesNotMatch(JSON.stringify(telemetry), /PRIVATE_QUERY_SENTINEL/); + }); + + test('tool failure telemetry classifies and redacts generic implementation errors', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const telemetry: Array<{ status: string; errorClass?: string; bytesOut: number }> = []; + const backend = createBackend({ + header: header('ask'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + recordToolInvocation: (record) => { + telemetry.push({ + status: record.status, + errorClass: record.errorClass, + bytesOut: record.bytesOut ?? 0, + }); + }, + }); + const tool: MakaTool = { + name: 'Write', + description: 'write file', + parameters: {}, + impl: async () => { + const error = new Error('401 Authorization: Bearer sk-live-secret-token-value'); + Object.assign(error, { code: 401 }); + throw error; + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + }); + + const result = await execute( + { path: 'notes.md', content: 'hello' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + const resultText = (result as { error?: string }).error ?? ''; + const serialized = JSON.stringify({ messages, events, result }); + + assert.match(resultText, /Authorization: Bearer \[redacted\]/); + assert.equal(serialized.includes('sk-live-secret-token-value'), false); + assert.equal( + events.some( + (event) => + event.type === 'tool_result' && event.toolUseId === 'tool-1' && event.isError === true, + ), + true, + ); + assert.deepEqual(telemetry, [{ status: 'error', errorClass: 'auth', bytesOut: 0 }]); + }); + + test('flushes output deltas before successful and failed tool results', async () => { + const events: SessionEvent[] = []; + const backend = createBackend({ + header: header('ask'), + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + }); + const successTool: MakaTool = { + name: 'Streamer', + description: 'streams output', + parameters: {}, + impl: async (_args, ctx) => { + ctx.emitOutput('stdout', 'success chunk'); + return { ok: true }; + }, + }; + const failureTool: MakaTool = { + name: 'Streamer', + description: 'streams then fails', + parameters: {}, + impl: async (_args, ctx) => { + ctx.emitOutput('stderr', 'failure chunk'); + throw new Error('tool failed'); + }, + }; + const wrap = (tool: MakaTool) => + runtimeExecute(backend, tool, 'turn-1', { push: (event) => events.push(event) }); + + await wrap(successTool)( + {}, + { + toolCallId: 'tool-success', + abortSignal: new AbortController().signal, + }, + ); + await wrap(failureTool)( + {}, + { + toolCallId: 'tool-failure', + abortSignal: new AbortController().signal, + }, + ); + const eventKeys = events.map( + (event) => `${event.type}:${'toolUseId' in event ? event.toolUseId : ''}`, + ); + + assert.ok( + eventKeys.indexOf('tool_output_delta:tool-success') < + eventKeys.indexOf('tool_result:tool-success'), + 'successful tool output must flush before its result event', + ); + assert.ok( + eventKeys.indexOf('tool_output_delta:tool-failure') < + eventKeys.indexOf('tool_result:tool-failure'), + 'failed tool output must flush before its result event', + ); + }); + + test('pauses stream watchdog while a foreground subagent tool is running', async () => { + const backend = createBackend({ + header: header('explore'), + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + }); + let pauseCount = 0; + let resumeCount = 0; + turnScope(backend, 'turn-1').watchdog = { + pause: () => { + pauseCount += 1; + }, + resume: () => { + resumeCount += 1; + }, + }; + let release!: () => void; + const tool: MakaTool = { + name: 'agent_spawn', + description: 'spawn child agent', + parameters: {}, + categoryHint: 'subagent', + impl: async () => + new Promise((resolve) => { + release = () => + resolve({ + kind: 'subagent', + agentName: 'Researcher', + turnId: 'child-turn', + status: 'completed', + permissionMode: 'explore', + summary: 'done', + artifactIds: [], + }); + }), + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { push: () => {} }); + + const pending = execute( + {}, + { + toolCallId: 'tool-1', + abortSignal: new AbortController().signal, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(pauseCount, 1); + assert.equal(resumeCount, 0); + release(); + await pending; + assert.equal(resumeCount, 1); + }); + + test('pauses stream watchdog while a regular (non-subagent) tool is running', async () => { + // A long Bash command (apt-get install, a build) must not trip the model + // stream idle timeout: the model is between steps while the tool runs. + const backend = createBackend({ + header: header('explore'), + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + }); + let pauseCount = 0; + let resumeCount = 0; + turnScope(backend, 'turn-1').watchdog = { + pause: () => { + pauseCount += 1; + }, + resume: () => { + resumeCount += 1; + }, + }; + let release!: () => void; + const tool: MakaTool = { + name: 'Bash', + description: 'run a shell command', + parameters: {}, + impl: async () => + new Promise((resolve) => { + release = () => + resolve({ + kind: 'terminal', + cwd: '/app', + cmd: 'sleep 300', + status: 'completed', + exitCode: 0, + output: { + mode: 'pipes', + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }); + }), + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { push: () => {} }); + + const pending = execute( + {}, + { + toolCallId: 'tool-1', + abortSignal: new AbortController().signal, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(pauseCount, 1); + assert.equal(resumeCount, 0); + release(); + await pending; + assert.equal(resumeCount, 1); + }); + + test('keeps the stream alive while filtered tool input parts keep arriving', async () => { + let providerCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + providerCalls += 1; + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Preparing the report.' }, + { type: 'text-end', id: 'text-1' }, + { type: 'tool-input-start', id: 'tool-1', toolName: 'Write' }, + { type: 'tool-input-delta', id: 'tool-1', delta: '{"path":' }, + { type: 'tool-input-delta', id: 'tool-1', delta: '"report.md",' }, + { type: 'tool-input-delta', id: 'tool-1', delta: '"content":' }, + { type: 'tool-input-delta', id: 'tool-1', delta: '"complete"}' }, + { type: 'tool-input-end', id: 'tool-1' }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'Done.' }, + { type: 'text-end', id: 'text-2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: 25, + }), + }; + }, + }); + const backend = createBackend({ + header: header('bypass'), + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => model, + tools: [], + streamConnectTimeoutMs: 1_000, + streamIdleTimeoutMs: 100, + now: Date.now, + }); + + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ turnId: 'turn-1', text: 'write the report', context: [] }), + events, + ); + + assert.equal( + events.some((event) => event.type === 'error'), + false, + JSON.stringify(events.filter((event) => event.type === 'error')), + ); + assert.equal(providerCalls, 1); + const completion = events.find((event) => event.type === 'complete'); + assert.equal(completion?.type === 'complete' ? completion.stopReason : undefined, 'end_turn'); + }); + + test('caps concurrent subagent tools in one turn', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const backend = createBackend({ + header: header('explore'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + }); + let implStarted = 0; + const release: Array<() => void> = []; + const tool: MakaTool = { + name: 'agent_spawn', + description: 'read-only worker', + parameters: {}, + categoryHint: 'subagent', + impl: async () => { + implStarted += 1; + return new Promise((resolve) => { + release.push(() => resolve({ ok: true })); + }); + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + }); + + const pending = Array.from({ length: MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN }, (_, index) => + execute( + { objective: `research ${index}` }, + { toolCallId: `tool-${index}`, abortSignal: new AbortController().signal }, + ), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(implStarted, MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN); + + const rejected = await execute( + { objective: 'overflow' }, + { toolCallId: 'tool-overflow', abortSignal: new AbortController().signal }, + ); + assert.deepEqual(rejected, { + error: '子代理并发过多:同一轮最多 5 个子代理。请等待已有任务完成后再继续。', + }); + assert.equal(implStarted, MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN); + assert.equal( + events.some( + (event) => + event.type === 'tool_result' && event.toolUseId === 'tool-overflow' && event.isError, + ), + true, + ); + assert.equal(JSON.stringify(messages).includes('tool-overflow'), true); + + release.forEach((resume) => resume()); + await Promise.all(pending); + }); + + test('maps foreground subagent terminal states to persisted tool status', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const telemetry: Array<{ status: string; toolCallId?: string }> = []; + const backend = createBackend({ + header: header('explore'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + tools: [], + now: () => 1, + recordToolInvocation: (record) => { + telemetry.push({ status: record.status, toolCallId: record.toolCallId }); + }, + }); + const tool: MakaTool = { + name: 'agent_spawn', + description: 'spawn read-only worker', + parameters: {}, + categoryHint: 'subagent', + impl: async (args: unknown) => { + const input = args as { status: 'completed' | 'failed' | 'cancelled' }; + return { + kind: 'subagent', + agentName: 'Researcher', + turnId: `child-${input.status}`, + status: input.status, + permissionMode: 'explore', + summary: input.status, + artifactIds: [], + }; + }, + }; + const execute = runtimeExecute(backend, tool, 'turn-1', { + push: (event) => events.push(event), + }); + + await execute( + { status: 'failed' }, + { + toolCallId: 'tool-failed', + abortSignal: new AbortController().signal, + }, + ); + await execute( + { status: 'cancelled' }, + { + toolCallId: 'tool-cancelled', + abortSignal: new AbortController().signal, + }, + ); + await execute( + { status: 'completed' }, + { + toolCallId: 'tool-completed', + abortSignal: new AbortController().signal, + }, + ); + + assert.equal( + ( + messages.find( + (message) => + (message as { type?: string; toolUseId?: string }).type === 'tool_result' && + (message as { toolUseId?: string }).toolUseId === 'tool-failed', + ) as { isError?: boolean } | undefined + )?.isError, + true, + ); + assert.equal( + ( + events.find( + (event) => event.type === 'tool_result' && event.toolUseId === 'tool-cancelled', + ) as { isError?: boolean } | undefined + )?.isError, + true, + ); + assert.equal( + ( + events.find( + (event) => event.type === 'tool_result' && event.toolUseId === 'tool-completed', + ) as { isError?: boolean } | undefined + )?.isError, + false, + ); + assert.deepEqual(telemetry, [ + { status: 'error', toolCallId: 'tool-failed' }, + { status: 'aborted', toolCallId: 'tool-cancelled' }, + { status: 'success', toolCallId: 'tool-completed' }, + ]); + }); +}); + +describe('AiSdkBackend tool-call repair', () => { + test('repairs provider tool-name case drift to the canonical Maka tool name', () => { + const repaired = repairMakaToolCall({ + toolCall: { + toolCallId: 'tool-1', + toolName: 'bash', + input: '{"command":"pwd"}', + }, + availableToolNames: ['Bash', 'Read'], + error: new Error('No such tool'), + }); + + assert.equal(repaired?.toolName, 'Bash'); + assert.equal(repaired?.input, '{"command":"pwd"}'); + }); + + test('routes unrepairable tool calls into the structured invalid tool', () => { + const repaired = repairMakaToolCall({ + toolCall: { + toolCallId: 'tool-1', + toolName: 'DeleteEverything', + input: '{"path":"/"}', + }, + availableToolNames: ['Bash', 'Read'], + error: new Error('No such tool: Authorization: Bearer sk-live-secret-token-value'), + }); + + assert.equal(repaired?.toolName, INVALID_TOOL_NAME); + const input = JSON.parse(repaired?.input ?? '{}') as { tool?: string; error?: string }; + assert.equal(input.tool, 'DeleteEverything'); + assert.match(input.error ?? '', /No such tool/); + assert.equal((input.error ?? '').includes('sk-live-secret-token-value'), false); + }); + + test('does not recursively repair the internal invalid tool', () => { + const repaired = repairMakaToolCall({ + toolCall: { + toolCallId: 'tool-1', + toolName: INVALID_TOOL_NAME, + input: '{}', + }, + availableToolNames: ['Bash', 'Read'], + error: new Error('Invalid tool failed'), + }); + + assert.equal(repaired, null); + }); +}); + +describe('AiSdkBackend concurrent turns', () => { + // #1990: RuntimeKernel reuses one backend per Session and lets one generation + // hold several concurrent runs. A finishing turn used to clear the backend's + // shared "current run", so an overlapping turn's next tool call committed + // against no run at all and the whole turn died with "Operation failed". + test('a finishing turn does not strip the run identity of an overlapping turn', async () => { + const first = durableTurnHarness('turn-a', 'first', { + runId: 'run-a', + invocationId: 'invocation-a', + }); + const second = durableTurnHarness('turn-b', 'second', { + runId: 'run-b', + invocationId: 'invocation-b', + }); + const ledgers = new Map([ + ['turn-a', first], + ['turn-b', second], + ]); + const preparedRunIds: Array = []; + const executions: string[] = []; + // The overlapping turn must reach its tool AFTER the other turn is fully + // torn down: run identity was read at dispatch, so that is the window the + // crash lived in. Holding the provider stream (not the tool body) is what + // puts the tool call on the far side of the other turn's cleanup. + const firstTurnDone = makeGate(); + const overlappingStreamStarted = makeGate(); + + let servedToolCall = false; + const model = new MockLanguageModelV4({ + doStream: async ({ prompt }) => { + const servesOverlappingTurn = !servedToolCall && JSON.stringify(prompt).includes('second'); + if (servesOverlappingTurn) { + servedToolCall = true; + overlappingStreamStarted.release(); + await firstTurnDone.promise; + } + const chunks: LanguageModelV4StreamPart[] = servesOverlappingTurn + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'tool-b', + toolName: 'Read', + input: JSON.stringify({ path: 'b.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'done' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + ...testTool('Read', z.object({ path: z.string() })), + impl: async ({ path }: { path: string }) => { + executions.push(path); + return { body: path }; + }, + }, + ], + runtimeCommitSink: { + commitToolPrepared: async ({ runtimeEvent }) => { + preparedRunIds.push(runtimeEvent.runId); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + loadTurnRuntimeEvents: async (turnId: string) => + (ledgers.get(turnId) ?? first).loadTurnRuntimeEvents(turnId), + }); + + const overlapping = drainDurably( + backend.send(second.input({ runId: 'run-b', invocationId: 'invocation-b' })), + second, + ); + // Let the overlapping turn park mid-stream, run the other turn to completion + // on the SAME backend, then release the tool call into that aftermath. + await overlappingStreamStarted.promise; + await drainDurably( + backend.send(first.input({ runId: 'run-a', invocationId: 'invocation-a' })), + first, + ); + firstTurnDone.release(); + const events = await overlapping; + + assert.deepEqual(executions, ['b.md']); + assert.deepEqual(preparedRunIds, ['run-b']); + assert.equal( + events.some((event) => event.type === 'error'), + false, + 'the overlapping turn must not fail when a sibling turn finishes', + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + // Both turns are done, so neither left a scope behind for a later turn to + // inherit — the leak that would reintroduce shared per-turn state. + assert.equal(backendInternals(backend).activeTurns.size, 0); + }); + + // A scope that reaches activeTurns must always leave it. Setup runs before the + // provider pump exists, and a throw there used to strand the scope forever: + // nothing overwrites a Set entry, and stop()/dispose() only iterate. + test('a send that throws during setup leaves no scope registered', async () => { + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + }); + + await assert.rejects( + drain( + backend.send({ + turnId: 'turn-1', + text: 'hi', + context: [], + // A hosted Interaction Run for a different turn: beginTurn refuses it, + // and it throws before anything installs the turn's own cleanup. + hostedInteraction: { + sessionId: 'session-1', + turnId: 'a-different-turn', + } as never, + }), + ), + /mismatched hosted Interaction Run/, + ); + + assert.equal(backendInternals(backend).activeTurns.size, 0); + // And the backend is still usable: a leaked scope would pin dispose() to the + // stop path and broadcast endTurn to a dead runtime forever. + await backend.dispose(); + }); + + // A broadcast stop must reach every turn even when one of them cannot close. + // `endTurn` rejects when a durable sandbox denial cannot be written, and it is + // also the ONLY thing that rejects a tool parked on askUserQuestion — an abort + // signal does not wake the registry. So a stop that bails on the first failure + // parks the sibling forever: that turn's own send() cleanup is itself waiting + // on the tool the skipped endTurn was supposed to reject. + test('stop() closes every turn even when one turn fails to close', async () => { + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + }); + + const aborted: string[] = []; + const endedTurns: string[] = []; + for (const turnId of ['turn-a', 'turn-b']) { + const scope = turnScope(backend, turnId); + scope.runTrace = { + emit: () => {}, + abortRequested: () => aborted.push(turnId), + } as unknown as RunTrace; + } + // The first turn cannot complete teardown; the second parks on a question + // that only its own endTurn can reject. + const failing = turnScope(backend, 'turn-a'); + const endFailingTurn = failing.toolRuntime.endTurn.bind(failing.toolRuntime); + failing.toolRuntime.endTurn = async (reason) => { + endedTurns.push(failing.turnId); + await endFailingTurn(reason); + throw new Error('Could not durably deny every sandbox boundary request'); + }; + const parked = turnScope(backend, 'turn-b'); + const endParkedTurn = parked.toolRuntime.endTurn.bind(parked.toolRuntime); + parked.toolRuntime.endTurn = async (reason) => { + endedTurns.push(parked.turnId); + await endParkedTurn(reason); + }; + + const events: SessionEvent[] = []; + const sink = { push: (event: SessionEvent) => events.push(event) }; + // Each tool gets its OWN turn's abort signal, exactly as send() hands it out, + // so the broadcast is observed where tools actually receive it. + const abortObserved: string[] = []; + const abortCall = runtimeExecute( + backend, + { + ...testTool('WaitForAbort', z.object({})), + impl: async (_input: unknown, ctx: { abortSignal: AbortSignal }) => + new Promise((resolve) => { + ctx.abortSignal.addEventListener('abort', () => { + abortObserved.push('turn-a'); + resolve({ stopped: true }); + }); + }), + } as MakaTool, + 'turn-a', + sink, + )({}, { toolCallId: 'tool-a', abortSignal: failing.abortController.signal }); + const questionCall = runtimeExecute( + backend, + { + ...testTool('Ask', z.object({})), + impl: async ( + _input: unknown, + ctx: { askUserQuestion: (questions: unknown[]) => Promise }, + ) => ctx.askUserQuestion([{ question: 'Continue?', options: ['yes', 'no'] }]), + } as MakaTool, + 'turn-b', + sink, + )({}, { toolCallId: 'tool-b', abortSignal: parked.abortController.signal }); + await waitFor(() => events.some((event) => event.type === 'user_question_request')); + + await assert.rejects(backend.stop('user_stop'), /Could not durably deny/); + + // Every turn is aborted under its own controller, not just the first one: + // turn-a's tool observes the signal it was actually handed, and turn-b's + // controller is aborted even though turn-a's teardown failed. + assert.equal( + await Promise.race([ + abortCall.then(() => 'aborted'), + new Promise((resolve) => setTimeout(() => resolve('still running'), 50)), + ]), + 'aborted', + ); + assert.deepEqual(abortObserved, ['turn-a']); + assert.equal(parked.abortController.signal.aborted, true); + // The failing turn's error still surfaces, and the sibling is closed anyway: + // its parked question is rejected rather than left waiting forever. + assert.deepEqual(endedTurns, ['turn-a', 'turn-b']); + assert.deepEqual(aborted, ['turn-a', 'turn-b']); + // settleToolCall reports a failed tool rather than rejecting, so what + // matters is that it settles at all instead of parking forever. + assert.equal( + await Promise.race([ + questionCall.then( + () => 'settled', + () => 'settled', + ), + new Promise((resolve) => setTimeout(() => resolve('parked'), 50)), + ]), + 'settled', + ); + }); +}); + +describe('AiSdkBackend thinking persistence', () => { + test('emits a non-partial thinking_complete that survives read-model projection and materialization', async () => { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { type: 'reasoning-delta', id: 'r1', delta: 'Let me ' }, + { type: 'reasoning-delta', id: 'r1', delta: 'reason.' }, + // Anthropic delivers the signed signature on a standalone empty delta. + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-123' } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'Final answer.' }, + { type: 'text-end', id: 't1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 2, text: 1, reasoning: 1 }, + }, + }, + ]; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + const thinkingComplete = events.find( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.ok(thinkingComplete, 'backend must emit a thinking_complete event'); + assert.equal(thinkingComplete.text, 'Let me reason.'); + assert.equal(thinkingComplete.signature, 'sig-123'); + + // Thinking must be finalized before the assistant text so the read-model + // has an assistant row to attach it to (order-independent, but assert the + // intended emission order for clarity). + const thinkingIndex = events.findIndex((event) => event.type === 'thinking_complete'); + const textIndex = events.findIndex((event) => event.type === 'text_complete'); + assert.ok(thinkingIndex >= 0 && textIndex >= 0 && thinkingIndex < textIndex); + + // End-to-end: SessionEvent → RuntimeEvent → StoredMessage projection. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 42, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeEvents = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const runHeader = priorModelInvocation({ + modelId: 'mock-model-id', + runId: 'run-1', + turnId: 'turn-1', + }); + const projection = projectRuntimeEventsToStoredMessages(runtimeEvents, { + invocations: [runHeader], + }); + const assistant = projection.messages.find((message) => message.type === 'assistant'); + assert.ok(assistant && assistant.type === 'assistant'); + assert.equal(assistant.text, 'Final answer.'); + assert.equal(assistant.thinking?.text, 'Let me reason.'); + assert.equal(assistant.thinking?.signature, 'sig-123'); + }); + + test('persists reasoning for a thinking-only turn that produces no final text', async () => { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { type: 'reasoning-delta', id: 'r1', delta: 'silent ' }, + { type: 'reasoning-delta', id: 'r1', delta: 'thought' }, + { type: 'reasoning-end', id: 'r1' }, + // No text-* parts: the turn ends with reasoning only. + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 1 }, + }, + }, + ]; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }, + }); + const appended: unknown[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + // thinking_complete must be emitted even though there is no assistant text. + const thinkingComplete = events.find( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.ok(thinkingComplete, 'thinking-only turn must still emit thinking_complete'); + assert.equal(thinkingComplete.text, 'silent thought'); + // An AssistantMessage (empty text + thinking) is persisted for the turn. + const assistantMessage = appended.find( + (message): message is { type: string; text: string; thinking?: { text: string } } => + (message as { type?: string }).type === 'assistant', + ); + assert.ok(assistantMessage); + assert.equal(assistantMessage.text, ''); + assert.equal(assistantMessage.thinking?.text, 'silent thought'); + + // Full chain: RuntimeEvent projection keeps the reasoning on an empty-text + // assistant row without crashing. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 42, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeEvents = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const runHeader = priorModelInvocation({ + modelId: 'mock-model-id', + runId: 'run-1', + turnId: 'turn-1', + }); + const projection = projectRuntimeEventsToStoredMessages(runtimeEvents, { + invocations: [runHeader], + }); + const assistant = projection.messages.find((message) => message.type === 'assistant'); + assert.ok(assistant && assistant.type === 'assistant'); + assert.equal(assistant.text, ''); + assert.equal(assistant.thinking?.text, 'silent thought'); + }); + + test('text-only terminal replay fixture preserves signed thinking and usage exactly', async () => { + const openCodeClaudeConnection: LlmConnection = { + slug: 'opencode', + name: 'OpenCode Zen', + providerType: 'opencode', + defaultModel: 'claude-opus-4-8', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + // Turn 1: produce a signed thinking + text turn through the real backend. + const firstChunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { type: 'reasoning-delta', id: 'r1', delta: 'deep thought' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-replay' } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'the answer' }, + { type: 'text-end', id: 't1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 2, text: 1, reasoning: 1 }, + }, + }, + ]; + const firstModel = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: firstChunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const firstBackend = createBackend({ + connection: openCodeClaudeConnection, + modelId: 'claude-opus-4-8', + modelFactory: () => firstModel, + tools: [], + }); + + const firstEvents: SessionEvent[] = []; + for await (const event of firstBackend.send({ turnId: 'turn-prev', text: 'q', context: [] })) { + firstEvents.push(event); + } + const firstUsage = firstEvents.find( + (event): event is Extract => + event.type === 'token_usage', + ); + assert.deepEqual( + firstUsage && { + input: firstUsage.input, + output: firstUsage.output, + reasoning: firstUsage.reasoning, + total: firstUsage.total, + }, + { input: 1, output: 2, reasoning: 1, total: 3 }, + ); + + // Translate the emitted SessionEvents into the durable RuntimeEvent ledger. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = firstEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + + // Turn 2: replay the prior ledger and capture the outgoing provider request. + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: openCodeClaudeConnection, + modelId: 'claude-opus-4-8', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('claude-opus-4-8'), + runtimeContext, + }), + ); + + // The reasoning block + text + Anthropic signature must reach the AI SDK + // request in the same order emitted by the prior turn. This fails if + // signature forwarding regresses, text disappears, or replay degrades to + // an unstructured transcript. + const prompt = compactPrompt(secondModel) as ModelMessage[]; + const replayedAssistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(replayedAssistant && Array.isArray(replayedAssistant.content)); + assert.deepEqual( + replayedAssistant.content.map((part) => part.type), + ['reasoning', 'text'], + ); + const [reasoningPart, textPart] = replayedAssistant.content; + assert.equal(reasoningPart?.type, 'reasoning'); + assert.equal(reasoningPart.text, 'deep thought'); + assert.match(JSON.stringify(reasoningPart.providerOptions), /sig-replay/); + assert.equal(textPart?.type, 'text'); + assert.equal(textPart.text, 'the answer'); + }); + + test('signed thinking from a per-step tool-calling turn IS replayed, merged with its tool call', async () => { + // Per-step ledger: the tool_start carries the step id (stepId === the + // step's message id 'm1'), so the step's signed reasoning + text + tool call + // regroup into ONE assistant message on replay (reasoning leads, then text, + // then the tool call, then the tool result) — the Anthropic-valid shape. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const priorEvents: SessionEvent[] = [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: 'reasoning about the tool result', + signature: 'sig-tool', + }, + { + type: 'text_complete', + id: 'e4', + turnId: 'turn-prev', + ts: 4, + messageId: 'm1', + text: 'the answer', + }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('mock-model-id'), + runtimeContext, + }), + ); + + const prompt = JSON.stringify(compactPrompt(secondModel)); + // Reasoning (with signature), text, and the tool call all reach the request. + assert.match(prompt, /"type":"reasoning"/); + assert.match(prompt, /sig-tool/); + assert.match(prompt, /reasoning about the tool result/); + assert.match(prompt, /"toolName":"Read"|"toolCallId":"tool-1"/); + // Reasoning leads the tool call inside the assistant message (Anthropic order). + assert.ok(prompt.indexOf('reasoning about the tool result') < prompt.indexOf('tool-1')); + }); + + test('omits Responses reasoning without encrypted content from the wire request', async (t) => { + for (const replayCase of [ + { name: 'missing', openai: { itemId: 'rs_openai' } }, + { + name: 'null', + openai: { itemId: 'rs_openai', reasoningEncryptedContent: null }, + }, + { + name: 'empty string', + openai: { itemId: 'rs_openai', reasoningEncryptedContent: '' }, + }, + ] as const) { + await t.test(replayCase.name, async () => { + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'display-only reasoning without an encrypted replay payload', + providerOptions: { openai: replayCase.openai }, + }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'answer before the tool call' }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e3', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'Read', + args: { path: 'package.json' }, + }, + refs: { toolCallId: 'tool-1', stepId: 'm1' }, + }), + runtimeEvent({ + id: 'e4', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: 'file contents' }, + }, + refs: { toolCallId: 'tool-1' }, + }), + ]; + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const events = [ + { type: 'response.created', response: { id: 'response-current' } }, + { + type: 'response.completed', + response: { + id: 'response-current', + object: 'response', + created_at: 8, + model: 'gpt-5.5', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + const body = `${events + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as unknown as typeof globalThis.fetch; + const secondBackend = createBackend({ + connection: { + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5.5', + }, + apiKey: 'openai-test-token', + modelId: 'gpt-5.5', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('gpt-5.5'), + runtimeContext, + }), + ); + + const input = requestBody?.input; + assert.ok(Array.isArray(input)); + assert.equal( + input.some((item) => item?.type === 'reasoning'), + false, + ); + assert.deepEqual( + input.slice(0, 3).map((item) => { + assert.ok(item && typeof item === 'object' && !Array.isArray(item)); + const record = item as Record; + const content = Array.isArray(record.content) ? record.content : []; + const firstContent = content[0]; + return { + type: record.type ?? (typeof record.role === 'string' ? 'message' : undefined), + role: record.role, + text: + firstContent && typeof firstContent === 'object' && !Array.isArray(firstContent) + ? (firstContent as Record).text + : undefined, + callId: record.call_id, + name: record.name, + arguments: record.arguments, + output: record.output, + }; + }), + [ + { + type: 'message', + role: 'assistant', + text: 'answer before the tool call', + callId: undefined, + name: undefined, + arguments: undefined, + output: undefined, + }, + { + type: 'function_call', + role: undefined, + text: undefined, + callId: 'tool-1', + name: 'Read', + arguments: '{"path":"package.json"}', + output: undefined, + }, + { + type: 'function_call_output', + role: undefined, + text: undefined, + callId: 'tool-1', + name: undefined, + arguments: undefined, + output: '{"kind":"text","text":"file contents"}', + }, + ], + ); + }); + } + }); + + test('OpenAI Responses reasoning from a tool step is replayed with its encrypted content', async () => { + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const priorEvents: SessionEvent[] = [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: 'reasoning about the tool', + providerOptions: { + openai: { + itemId: 'rs_ark', + reasoningEncryptedContent: 'encrypted-ark-reasoning', + }, + }, + }, + { + type: 'thinking_complete', + id: 'e4', + turnId: 'turn-prev', + ts: 4, + messageId: 'm1', + text: 'unreplayable OpenAI reasoning', + providerOptions: { openai: { itemId: 'rs_without_encrypted_content' } }, + }, + { + type: 'text_complete', + id: 'e5', + turnId: 'turn-prev', + ts: 5, + messageId: 'm1', + text: '', + }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: { + slug: 'volcengine-agent-plan', + providerType: 'volcengine-agent-plan', + defaultModel: 'ark-code-latest', + }, + apiKey: 'ark-plan-token', + modelId: 'ark-code-latest', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('ark-code-latest'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content.filter((part) => part.type === 'reasoning'), + [ + { + type: 'reasoning', + text: 'reasoning about the tool', + providerOptions: { + openai: { + itemId: 'rs_ark', + reasoningEncryptedContent: 'encrypted-ark-reasoning', + }, + }, + }, + ], + ); + assert.ok( + assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), + ); + }); + + test('DeepSeek Responses replays plaintext reasoning without an OpenAI item id', async () => { + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const priorEvents: SessionEvent[] = [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: 'reasoning about the tool', + }, + { + type: 'thinking_complete', + id: 'e3-empty', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: '', + }, + { + type: 'text_complete', + id: 'e4', + turnId: 'turn-prev', + ts: 4, + messageId: 'm1', + text: '', + }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('deepseek-v4-flash'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + const reasoningParts = assistant.content.filter((part) => part.type === 'reasoning'); + assert.equal(reasoningParts.length, 1); + const reasoning = reasoningParts[0]; + assert.ok(reasoning && reasoning.type === 'reasoning'); + assert.equal(reasoning.text, 'reasoning about the tool'); + assert.ok( + assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), + ); + }); + + test('Alibaba Responses keeps multiple streamed reasoning items distinct through replay', async () => { + const tokenPlanConnection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + let sequenceNumber = 0; + const responseEvents: Array> = [ + { type: 'response.created', sequence_number: sequenceNumber++, response: { id: 'r' } }, + ]; + for (const [outputIndex, item] of [ + { id: 'reasoning-item-1', deltas: ['first ', 'summary'] }, + { id: 'reasoning-item-2', deltas: ['second summary'] }, + { id: 'reasoning-item-empty', deltas: [] }, + ].entries()) { + responseEvents.push({ + type: 'response.output_item.added', + sequence_number: sequenceNumber++, + output_index: outputIndex, + item: { type: 'reasoning', id: item.id, status: 'in_progress', content: [], summary: [] }, + }); + for (const delta of item.deltas) { + responseEvents.push({ + type: 'response.reasoning_summary_text.delta', + sequence_number: sequenceNumber++, + item_id: item.id, + output_index: outputIndex, + summary_index: 0, + delta, + }); + } + if (item.deltas.length > 0) { + responseEvents.push({ + type: 'response.reasoning_summary_text.done', + sequence_number: sequenceNumber++, + item_id: item.id, + output_index: outputIndex, + summary_index: 0, + text: item.deltas.join(''), + }); + } + responseEvents.push({ + type: 'response.output_item.done', + sequence_number: sequenceNumber++, + output_index: outputIndex, + item: { + type: 'reasoning', + id: item.id, + status: 'completed', + content: [], + summary: + item.deltas.length > 0 ? [{ type: 'summary_text', text: item.deltas.join('') }] : [], + }, + }); + } + responseEvents.push( + { + type: 'response.output_item.added', + sequence_number: sequenceNumber++, + output_index: 3, + item: { + type: 'message', + id: 'message-item', + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.output_text.delta', + sequence_number: sequenceNumber++, + item_id: 'message-item', + output_index: 3, + content_index: 0, + delta: 'answer', + }, + { + type: 'response.output_item.done', + sequence_number: sequenceNumber++, + output_index: 3, + item: { + type: 'message', + id: 'message-item', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'answer', annotations: [] }], + }, + }, + { + type: 'response.completed', + sequence_number: sequenceNumber++, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 3, total_tokens: 4 }, + }, + }, + ); + const rawSse = `${responseEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const firstBackend = createBackend({ + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + const firstEvents: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-prev', + text: 'question', + context: [], + })) { + firstEvents.push(event); + } + const thinkingCompletes = firstEvents.filter( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.deepEqual( + thinkingCompletes.map((event) => [ + event.text, + (event.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['first summary', 'reasoning-item-1'], + ['second summary', 'reasoning-item-2'], + ['', 'reasoning-item-empty'], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = firstEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + let replayRequestBody: Record | undefined; + const replayFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + replayRequestBody = JSON.parse(String(init?.body)) as Record; + const completed = { + type: 'response.completed', + response: { + id: 'response-replay', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }; + return new Response(`data: ${JSON.stringify(completed)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as unknown as typeof globalThis.fetch; + const secondBackend = createBackend({ + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch: replayFetch }), + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), + runtimeContext, + }), + ); + + const replayInput = replayRequestBody?.input; + assert.ok(Array.isArray(replayInput)); + assert.deepEqual( + replayInput.filter((item) => item?.type === 'reasoning'), + [ + { + type: 'reasoning', + id: 'reasoning-item-1', + summary: [{ type: 'summary_text', text: 'first summary' }], + }, + { + type: 'reasoning', + id: 'reasoning-item-2', + summary: [{ type: 'summary_text', text: 'second summary' }], + }, + { type: 'reasoning', id: 'reasoning-item-empty', summary: [] }, + ], + ); + }); + + test('Alibaba Responses fails when streamed reasoning differs from the final summary', async (t) => { + // The early stop tears down the SDK stream while its settlement promises + // are still in flight; when those rejections land is scheduler-owned (on + // Windows they were observed after the test boundary). Trap unhandled + // rejections for the lifetime of this turn and assert the mismatch path + // leaves none behind, on every event loop, not just the one that raced. + const leakedRejections: unknown[] = []; + const trapUnhandledRejection = (reason: unknown): void => { + leakedRejections.push(reason); + }; + process.on('unhandledRejection', trapUnhandledRejection); + t.after(() => { + process.off('unhandledRejection', trapUnhandledRejection); + }); + const appended: AssistantMessage[] = []; + const mismatchEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item', summary: [] }, + }, + { + type: 'response.reasoning_summary_text.delta', + item_id: 'reasoning-item', + summary_index: 0, + delta: 'streamed text', + }, + { + type: 'response.reasoning_summary_text.done', + item_id: 'reasoning-item', + summary_index: 0, + text: 'streamed text', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item', + summary: [{ type: 'summary_text', text: 'different final summary' }], + }, + }, + ]; + const mismatchSse = `${mismatchEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const mismatchFetch = (async () => + new Response(mismatchSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch: mismatchFetch }), + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal( + events.some((event) => event.type === 'error'), + true, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(JSON.stringify(appended).includes('makaResponses'), false); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createBackend({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), + runtimeContext, + }), + ); + assert.ok(compactPrompt(recoveryModel)); + // Let SDK teardown settle across macrotask cycles so a leaked rejection + // is caught before the trap comes off. + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual( + leakedRejections, + [], + 'reasoning-mismatch teardown must not leak unhandled rejections', + ); + }); + + test('Alibaba Responses preserves live compatibility reasoning across abrupt transport failure', async () => { + const partialEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-partial', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-partial', + content_index: 0, + delta: 'partial compatibility reasoning', + }, + ]; + const bytes = new TextEncoder().encode( + `${partialEvents.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\n`, + ); + const fetch = (async () => { + let emitted = false; + return new Response( + new ReadableStream({ + async pull(controller) { + if (!emitted) { + emitted = true; + controller.enqueue(bytes); + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.error(new Error('transport boom')); + }, + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal( + events.some( + (event) => + event.type === 'thinking_delta' && event.text === 'partial compatibility reasoning', + ), + true, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(JSON.stringify(appended).includes('makaResponses'), false); + }); + + test('Alibaba Responses keeps a finalized item valid when the next item id is unsafe', async () => { + const invalidItemId = 'invalid\nreasoning-item'; + const rawEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item-a', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'valid summary', + }, + { + type: 'response.reasoning_text.done', + item_id: 'reasoning-item-a', + content_index: 0, + text: 'valid summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item-a', + summary: [{ type: 'summary_text', text: 'valid summary' }], + }, + }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: invalidItemId, summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: invalidItemId, + content_index: 0, + delta: 'unsafe item summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: invalidItemId, + summary: [{ type: 'summary_text', text: 'unsafe item summary' }], + }, + }, + ]; + const rawSse = `${rawEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const backend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + const parts = appended[0]?.thinking?.parts; + assert.deepEqual( + parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['unsafe item summary', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createBackend({ + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max', 'run-1'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content + .filter((part) => part.type === 'reasoning') + .map((part) => [ + part.text, + (part.providerOptions?.['alibaba-token-plan-cn'] as { itemId?: unknown } | undefined) + ?.itemId, + ]), + [['valid summary', 'reasoning-item-a']], + ); + }); + + test('Alibaba Responses isolates a same-id delta that arrives after item completion', async () => { + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const rawEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item-a', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'valid summary', + }, + { + type: 'response.reasoning_text.done', + item_id: 'reasoning-item-a', + content_index: 0, + text: 'valid summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item-a', + summary: [{ type: 'summary_text', text: 'valid summary' }], + }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'late duplicate', + }, + { + type: 'response.completed', + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + }, + }, + ]; + const rawSse = `${rawEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const firstBackend = createBackend({ + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + const events: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-1', + text: 'question', + context: [], + })) { + events.push(event); + } + + assert.deepEqual( + appended[0]?.thinking?.parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['late duplicate', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createBackend({ + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max', 'run-1'), + runtimeContext, + }), + ); + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some( + (part) => part.type === 'reasoning' && part.text === 'valid summary', + ), + ), + true, + ); + }); + + test('Alibaba Responses skips reasoning it cannot safely replay', async () => { + const foreignSummary = 'summary issued by a different provider profile'; + const futureSummary = 'summary issued by a future durable state version'; + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'summary without a provider item identity', + }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: foreignSummary, + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan', + itemId: 'foreign-reasoning-item', + summaryPartLengths: [foreignSummary.length], + }, + }, + }, + refs: { providerEventId: 'm2' }, + }), + runtimeEvent({ + id: 'e3', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: futureSummary, + providerOptions: { + makaResponses: { + version: 2, + profile: 'alibaba-token-plan-cn', + itemId: 'future-reasoning-item', + summaryPartLengths: [futureSummary.length], + }, + }, + }, + refs: { providerEventId: 'm3' }, + }), + ]; + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(model) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some((part) => part.type === 'reasoning'), + ), + false, + ); + }); + + test('Alibaba Responses rejects malformed state owned by its profile', async () => { + const backend = createBackend({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => completionModel(), + tools: [], + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'current-profile reasoning with a widened state', + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item', + raw: 'must not persist', + }, + }, + }, + refs: { providerEventId: 'm1' }, + }), + ]; + + await assert.rejects( + drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('qwen3.8-max'), + runtimeContext, + }), + ), + /Malformed durable plaintext Responses reasoning state/, + ); + }); + + test('passes DeepSeek max reasoning through as the provider-native effort', async () => { + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const events = [ + { type: 'response.created', response: { id: 'response-current' } }, + { + type: 'response.completed', + response: { + id: 'response-current', + object: 'response', + created_at: 8, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return new Response( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; + const backend = createBackend({ + header: { ...header(), thinkingLevel: 'max' }, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-test-token', + modelId: 'deepseek-v4-flash', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + + await drain(backend.send({ turnId: 'turn-current', text: 'think', context: [] })); + + assert.deepEqual(requestBody?.reasoning, { effort: 'max' }); + assert.equal(requestBody?.include, undefined); + }); + + test('preserves every OpenAI Responses reasoning item through stream persistence and replay', async () => { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: 'first summary', + providerMetadata: { openai: { itemId: 'rs_first' } }, + }, + { + type: 'reasoning-end', + id: 'r1', + providerMetadata: { + openai: { + itemId: 'rs_first', + reasoningEncryptedContent: 'encrypted-first', + }, + }, + }, + { type: 'reasoning-start', id: 'r2' }, + { + type: 'reasoning-delta', + id: 'r2', + delta: 'second summary', + providerMetadata: { openai: { itemId: 'rs_second' } }, + }, + { + type: 'reasoning-end', + id: 'r2', + providerMetadata: { + openai: { + itemId: 'rs_second', + reasoningEncryptedContent: 'encrypted-second', + }, + }, + }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'Final answer.' }, + { type: 'text-end', id: 't1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 3, text: 1, reasoning: 2 }, + }, + }, + ]; + const firstModel = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }, + }); + const planConnection: LlmConnection = { + slug: 'volcengine-agent-plan', + name: 'Volcengine Ark Agent Plan (China)', + providerType: 'volcengine-agent-plan', + defaultModel: 'ark-code-latest', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + const appended: StoredMessage[] = []; + const firstBackend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: planConnection, + apiKey: 'ark-plan-token', + modelId: 'ark-code-latest', + modelFactory: () => firstModel, + tools: [], + }); + + const events: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-prev', + text: 'solve it', + context: [], + })) { + events.push(event); + } + + const thinkingCompletes = events.filter( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.deepEqual( + thinkingCompletes.map((event) => [event.text, event.providerOptions]), + [ + [ + 'first summary', + { + openai: { + itemId: 'rs_first', + reasoningEncryptedContent: 'encrypted-first', + }, + }, + ], + [ + 'second summary', + { + openai: { + itemId: 'rs_second', + reasoningEncryptedContent: 'encrypted-second', + }, + }, + ], + ], + ); + + const persistedAssistant = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.ok(persistedAssistant); + assert.deepEqual( + ( + persistedAssistant.thinking as + | { + parts?: Array<{ + text: string; + providerOptions?: Record; + }>; + } + | undefined + )?.parts, + thinkingCompletes.map(({ text, providerOptions }) => ({ text, providerOptions })), + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const projection = projectRuntimeEventsToStoredMessages(runtimeContext, { + invocations: [ + priorModelInvocation({ modelId: 'ark-code-latest', connectionSlug: planConnection.slug }), + ], + }); + const projectedAssistant = projection.messages.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.ok(projectedAssistant); + assert.deepEqual( + projectedAssistant.thinking?.parts, + thinkingCompletes.map(({ text, providerOptions }) => ({ text, providerOptions })), + ); + + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: planConnection, + apiKey: 'ark-plan-token', + modelId: 'ark-code-latest', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('ark-code-latest'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content.filter((part) => part.type === 'reasoning'), + [ + { + type: 'reasoning', + text: 'first summary', + providerOptions: { + openai: { + itemId: 'rs_first', + reasoningEncryptedContent: 'encrypted-first', + }, + }, + }, + { + type: 'reasoning', + text: 'second summary', + providerOptions: { + openai: { + itemId: 'rs_second', + reasoningEncryptedContent: 'encrypted-second', + }, + }, + }, + ], + ); + }); + + test('thinking-only tool step (no text) replays reasoning + tool call in one assistant message without an empty text block', async () => { + // Anthropic interleaved thinking's most common step shape: the step reasons, + // calls a tool, and produces NO closing text — the backend still flushes the + // step's AssistantMessage (text: '') so the signed block persists, and emits + // text_complete with empty text. On replay the step must merge into ONE + // assistant message [reasoning, tool-call] with NO empty text part between + // them (emitStep skips text.length === 0; an empty text block is provider + // noise and this locks that skip path). + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const priorEvents: SessionEvent[] = [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: 'plan the read', + signature: 'sig-interleaved', + }, + { type: 'text_complete', id: 'e4', turnId: 'turn-prev', ts: 4, messageId: 'm1', text: '' }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('mock-model-id'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as Array<{ role: string; content: unknown }>; + const assistantMessages = prompt.filter((message) => message.role === 'assistant'); + assert.equal( + assistantMessages.length, + 1, + 'reasoning and tool call must merge into one assistant message', + ); + const parts = assistantMessages[0]!.content as Array<{ type: string; text?: string }>; + // Reasoning leads the tool call; no text part at all (not even an empty one). + assert.deepEqual( + parts.map((part) => part.type), + ['reasoning', 'tool-call'], + ); + assert.equal(parts[0]!.text, 'plan the read'); + const promptJson = JSON.stringify(prompt); + assert.match(promptJson, /sig-interleaved/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + }); + + test('an orphan tool_result does not degrade replay: dropped, while paired history replays provider-native', async () => { + // Codex P2: `unmatched_tool_result` must not be a blocking diagnostic — the + // materializer intentionally drops the orphan (a standalone tool message is + // an Anthropic 400), so one orphan must not push the whole ledger back to + // stored-message projection. Paired call/result and the step's signed + // reasoning must all still reach the provider request. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const priorEvents: SessionEvent[] = [ + // Orphan: result with no prior tool_start (its call was sliced away). + { + type: 'tool_result', + id: 'e0', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-orphan', + isError: false, + content: { kind: 'text', text: 'orphan payload' }, + }, + // Paired per-step tool call + result + signed reasoning + text. + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 3, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 4, + messageId: 'm1', + text: 'plan the read', + signature: 'sig-paired', + }, + { + type: 'text_complete', + id: 'e4', + turnId: 'turn-prev', + ts: 5, + messageId: 'm1', + text: 'the answer', + }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('mock-model-id'), + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as Array<{ role: string; content: unknown }>; + const promptJson = JSON.stringify(prompt); + // Provider-native replay happened: reasoning + signature + paired tool pair. + assert.match(promptJson, /"type":"reasoning"/); + assert.match(promptJson, /sig-paired/); + assert.match(promptJson, /"toolCallId":"tool-1"/); + assert.match(promptJson, /file contents/); + // The orphan result is dropped — no tool message for it anywhere. + assert.doesNotMatch(promptJson, /tool-orphan/); + assert.doesNotMatch(promptJson, /orphan payload/); + }); + + test('signature-only (omitted) thinking is persisted and replays with its signature', async () => { + // Anthropic omitted/redacted thinking: a signed reasoning block whose text + // is empty (only a standalone signature-carrier delta, no reasoning-delta + // with text). The block must still persist + replay so the signature + // round-trips; gating on thinking text alone would silently drop it. + const firstChunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + // No text delta — only the signature carrier. + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-omitted' } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'omitted-answer' }, + { type: 'text-end', id: 't1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 2, text: 1, reasoning: 1 }, + }, + }, + ]; + const firstModel = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: firstChunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const persisted: AssistantMessage[] = []; + const firstBackend = createBackend({ + appendMessage: async (m) => { + if (m.type === 'assistant') persisted.push(m); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => firstModel, + tools: [], + }); + + const firstEvents: SessionEvent[] = []; + for await (const event of firstBackend.send({ turnId: 'turn-prev', text: 'q', context: [] })) { + firstEvents.push(event); + } + + // thinking_complete is emitted with empty text but the signature intact. + const thinkingComplete = firstEvents.find( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.ok(thinkingComplete, 'signature-only turn must still emit thinking_complete'); + assert.equal(thinkingComplete.text, ''); + assert.equal(thinkingComplete.signature, 'sig-omitted'); + // The persisted AssistantMessage carries the signed (empty-text) thinking. + assert.equal(persisted.at(-1)?.thinking?.text, ''); + assert.equal(persisted.at(-1)?.thinking?.signature, 'sig-omitted'); + + // Replay: pure-reasoning turn → the signed block reaches the next request. + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = firstEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + + const secondModel = completionModel(); + const secondBackend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => secondModel, + tools: [], + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + ...sameRouteReplayProvenance('mock-model-id'), + runtimeContext, + }), + ); + + const prompt = JSON.stringify(compactPrompt(secondModel)); + assert.match(prompt, /"type":"reasoning"/); + assert.match(prompt, /sig-omitted/); + }); + + test('does not synthesize assistant text when a stream ends without a trailing finish-step', async () => { + // Drive the backend through a patched one-step adapter whose signed + // thinking stream ends abruptly without a finish-step / finish event. + const appended: StoredMessage[] = []; + const events: SessionEvent[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + }); + type FakeStreamInput = { + abortSignal: AbortSignal; + }; + ( + backend as unknown as { + modelAdapter: { startStream: (input: FakeStreamInput) => Promise }; + } + ).modelAdapter.startStream = async (input: FakeStreamInput) => ({ + // The adapter boundary now exposes Maka-owned `ModelStreamEvent`s, not + // raw SDK chunks. This fake adapter yields events directly so the test + // drives the backend through the new contract with no trailing + // `step-finish` / `finish`. + events: (async function* () { + void input.abortSignal; + yield { kind: 'thinking', text: 'final thoughts' }; + yield { kind: 'thinking-signature', signature: 'sig-last' }; + })(), + outcome: Promise.resolve({ + kind: 'truncated', + failure: { + type: 'model_failure', + kind: 'provider_unavailable', + message: 'Provider stream ended without finishing (unknown)', + retryable: false, + }, + request: { messages: [] }, + continuation: 'none', + }), + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + const assistants = appended.filter((m): m is AssistantMessage => m.type === 'assistant'); + // Catch-all flush persists the thinking-only step without fabricating text. + assert.equal(assistants.length, 1); + const thinkingOnly = assistants.find((m) => m.thinking?.signature === 'sig-last'); + assert.ok(thinkingOnly, 'thinking-only last step must persist'); + assert.equal(thinkingOnly.text, ''); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + // No duplicate message ids anywhere in the ledger. + const ids = appended.map((m) => (m as { id: string }).id); + assert.equal(new Set(ids).size, ids.length, `duplicate ledger ids: ${ids.join(', ')}`); + }); + + test('flushes one AssistantMessage per step, each with its own thinking + signature, and stamps tool_start.stepId', async () => { + // Two-step tool turn: step 1 reasons + calls a tool; step 2 reasons + answers. + // Each step must persist its own AssistantMessage with its own signature, and + // the step-1 tool_start must carry the step-1 assistant id. + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { type: 'reasoning-delta', id: 'r1', delta: 'think one' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-step-1' } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'calling the tool' }, + { type: 'text-end', id: 't1' }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'a.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r2' }, + { type: 'reasoning-delta', id: 'r2', delta: 'think two' }, + { + type: 'reasoning-delta', + id: 'r2', + delta: '', + providerMetadata: { anthropic: { signature: 'sig-step-2' } }, + }, + { type: 'reasoning-end', id: 'r2' }, + { type: 'text-start', id: 't2' }, + { type: 'text-delta', id: 't2', delta: 'final answer' }, + { type: 'text-end', id: 't2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + + const assistants: AssistantMessage[] = []; + const events: SessionEvent[] = []; + const durable = durableTurnHarness('turn-1', 'hi'); + const backend = createBackend({ + appendMessage: async (m) => { + if (m.type === 'assistant') assistants.push(m); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + + for await (const event of backend.send(durable.input())) { + durable.record(event); + events.push(event); + } + + // Two assistant rows with distinct ids and correctly paired signatures. + assert.equal(assistants.length, 2); + assert.equal(assistants[0]!.text, 'calling the tool'); + assert.equal(assistants[0]!.thinking?.text, 'think one'); + assert.equal(assistants[0]!.thinking?.signature, 'sig-step-1'); + assert.equal(assistants[1]!.text, 'final answer'); + assert.equal(assistants[1]!.thinking?.text, 'think two'); + assert.equal(assistants[1]!.thinking?.signature, 'sig-step-2'); + assert.notEqual(assistants[0]!.id, assistants[1]!.id); + + // The tool_start of step 1 carries the step-1 assistant id. + const toolStart = events.find( + (event): event is Extract => + event.type === 'tool_start', + ); + assert.ok(toolStart, 'expected a tool_start event'); + assert.equal(toolStart.stepId, assistants[0]!.id); + + // Each step emits its own thinking_complete/text_complete pointing at its row. + const textCompletes = events.filter( + (event): event is Extract => + event.type === 'text_complete', + ); + assert.deepEqual( + textCompletes.map((event) => [event.messageId, event.text]), + [ + [assistants[0]!.id, 'calling the tool'], + [assistants[1]!.id, 'final answer'], + ], + ); + }); + + test('continues a reasoning tool step from Maka durable replay instead of SDK response shape', async () => { + let baseline: OpenAiResponsesSemanticBaseline | undefined; + let pending: Omit | undefined; + const transport = { + semanticBaseline: () => baseline, + hasPendingSemantic: () => pending !== undefined, + recordSemanticRequest: ( + _lane: string, + value: Omit, + ) => { + pending = value; + baseline = undefined; + }, + recordSemanticResponse: (_lane: string, responseMessages: readonly ModelMessage[]) => { + if (!pending) return; + baseline = { ...pending, responseMessages: structuredClone(responseMessages) }; + pending = undefined; + }, + clearSemantic: () => { + baseline = undefined; + pending = undefined; + }, + canRecordSemantic: () => true, + wrapFetch: (fetch: typeof globalThis.fetch) => fetch, + endLane: () => {}, + close: () => {}, + } as unknown as OpenAiResponsesTransportState; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'response-metadata', + id: 'resp-1', + modelId: 'gpt-5', + timestamp: new Date(0), + }, + { type: 'reasoning-start', id: 'r1' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: 'inspect first', + providerMetadata: { openai: { itemId: 'reasoning-1' } }, + }, + { + type: 'reasoning-end', + id: 'r1', + providerMetadata: { + openai: { + itemId: 'reasoning-1', + reasoningEncryptedContent: 'encrypted-reasoning', + }, + }, + }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'a.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'response-metadata', + id: 'resp-2', + modelId: 'gpt-5', + timestamp: new Date(0), + }, + { type: 'text-start', id: 't2' }, + { type: 'text-delta', id: 't2', delta: 'done' }, + { type: 'text-end', id: 't2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'inspect it'); + const backend = createBackend({ + connection: { + slug: 'openai-main', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + apiKey: 'openai-test-key', + modelId: 'gpt-5', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + openAiResponsesTransportState: transport, + }); + + await drainDurably(backend.send(durable.input()), durable); + + assert.equal(model.doStreamCalls.length, 2); + assert.equal(model.doStreamCalls[1]?.prompt.length, 1); + assert.equal(model.doStreamCalls[1]?.prompt[0]?.role, 'tool'); + assert.equal(model.doStreamCalls[1]?.providerOptions?.openai?.previousResponseId, 'resp-1'); + }); +}); + +// summaries must be shaped like real checkpoints while keeping their +// sentinel text greppable. +function structuredSummary(body: string): string { + return `## Goal\n${body}\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)`; +} + +describe('AiSdkBackend steering durability and identity', () => { + const steeringBackend = ( + model: MockLanguageModelV4, + options: Partial< + Pick + > = {}, + ): AiSdkBackend => + createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + ...options, + }); + + const pullOnce = ( + text: string, + ): (() => Array<{ id: string; messageId: string; content: { text: string } }>) => { + let pulled = false; + return () => { + if (pulled) return []; + pulled = true; + return [{ id: `lease-${text}`, messageId: `message-${text}`, content: { text } }]; + }; + }; + + const nextSteeringEvent = async ( + iterator: AsyncIterator, + ): Promise => { + for (;;) { + const next = await iterator.next(); + assert.equal(next.done, false, 'stream ended before the steering echo'); + const event = next.value as SessionEvent; + if (event.type === 'steering_message') return event; + } + }; + + test('the final provider boundary waits for an asynchronous steering lease and asks the model again', async () => { + // A tool-free turn runs exactly one provider step, and the top-of-loop + // drain happens before the model has said anything — so a steer typed + // while the answer streams has no boundary left to land on. Whether + // "Steer" works at all must not depend on the model happening to call a + // tool afterwards (#3529). + const model = textCompletionModel('the first answer'); + const durable = durableTurnHarness('turn-1', 'start'); + const backend = steeringBackend(model, { + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const acked: string[] = []; + const nacked: string[] = []; + let pulls = 0; + const boundary = deferred(); + const mutation = deferred(); + let completed = false; + const completion = drainDurably( + backend.send( + durable.input({ + pullSteering: async () => { + pulls += 1; + // Nothing to take before the model speaks; the interjection lands + // while the first (and only) step is streaming. + if (pulls !== 2) return []; + boundary.resolve(); + await mutation.promise; + return [ + { id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }, + ]; + }, + ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds), + nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds), + }), + ), + durable, + ).then((events) => { + completed = true; + return events; + }); + await boundary.promise; + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + completed, + false, + 'the final boundary cannot finish while its Host lease is pending', + ); + assert.equal(model.doStreamCalls.length, 1); + } finally { + mutation.resolve(); + } + const events = await completion; + + const steering = events.filter((event) => event.type === 'steering_message'); + assert.equal(steering.length, 1); + assert.deepEqual(acked, ['lease-late']); + assert.deepEqual(nacked, []); + // Echoing the message is not the point — the model has to be asked again + // with it. Draining without taking another step would satisfy every + // assertion above while the user still never gets an answer. + assert.equal(model.doStreamCalls.length, 2); + const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt); + assert.match(secondPrompt, /late steer/); + // …and it has to carry what the model just said, or the correction lands on + // work the model cannot see. + assert.match(secondPrompt, /the first answer/); + }); + + test('all three steering messages reach the same next model request in queue order', async () => { + const model = textCompletionModel('the first answer'); + const durable = durableTurnHarness('turn-1', 'start'); + const backend = steeringBackend(model, { + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + let pulls = 0; + const acked: string[] = []; + const instructions = ['third instruction', 'first instruction', 'edited second instruction']; + const events = await drainDurably( + backend.send( + durable.input({ + pullSteering: () => + ++pulls === 2 + ? instructions.map((text, index) => ({ + id: `lease-${index}`, + messageId: `message-${index}`, + content: { text }, + })) + : [], + ackSteering: (ids: readonly string[]) => acked.push(...ids), + }), + ), + durable, + ); + assert.equal(model.doStreamCalls.length, 2); + const prompt = JSON.stringify(model.doStreamCalls[1]?.prompt); + const positions = instructions.map((text) => prompt.indexOf(text)); + assert.ok(positions[0]! >= 0 && positions[1]! > positions[0]! && positions[2]! > positions[1]!); + assert.deepEqual(acked, ['lease-0', 'lease-1', 'lease-2']); + assert.deepEqual( + events.filter((event) => event.type === 'steering_message').map((event) => event.messageId), + ['message-0', 'message-1', 'message-2'], + ); + }); + + test('the late-steer edge is skipped without a durable current-run reader', async () => { + // The no-reader projection at the top of the loop appends steering alone — + // it never appends the assistant output of the step just finished. Taking + // the continuation edge there would ask the model to redirect work it + // cannot see, so the edge requires the reader the way the tool-call edge + // does. The turn still completes; the Host folds the message into the next + // Turn, which is the behaviour before #3529. + const model = textCompletionModel('the first answer'); + const backend = steeringBackend(model); + const acked: string[] = []; + let pulls = 0; + const events: SessionEvent[] = []; + for await (const event of backend.send({ + turnId: 'turn-1', + text: 'start', + context: [], + pullSteering: () => { + pulls += 1; + if (pulls !== 2) return []; + return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }]; + }, + ackSteering: (leaseIds) => acked.push(...leaseIds), + })) { + events.push(event); + } + + assert.equal(model.doStreamCalls.length, 1); + assert.equal(events.filter((event) => event.type === 'steering_message').length, 0); + assert.deepEqual(acked, []); + }); + + test('a stop that lands during the final drain wins over the injected steer', async () => { + // The final drain awaits a durable push, so an `after_step` stop can arrive + // while it is in flight. Deciding to take another step from flags read + // BEFORE that await would spend a provider step the user already stopped — + // which is precisely what `after_step` exists to prevent. + const model = textCompletionModel('done'); + const durable = durableTurnHarness('turn-1', 'start'); + // The reader has to be present, or the edge is skipped for that reason + // instead and this test would pass while exercising nothing. + const backend = steeringBackend(model, { + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + let pulls = 0; + const iterator = backend + .send( + durable.input({ + pullSteering: () => { + pulls += 1; + if (pulls !== 2) return []; + return [ + { id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }, + ]; + }, + ackSteering: () => {}, + }), + ) + [Symbol.asyncIterator](); + + for (;;) { + const next = await iterator.next(); + if (next.done) break; + const event = next.value as SessionEvent; + durable.record(event); + // Consuming the echo is what resolves the drain's push, so the stop lands + // in the window between that resolution and the post-drain decision. + if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step'); + } + + // The steer was still delivered — it is durable and the Host will carry it + // into the next Turn — but no further provider step was dispatched. + assert.equal(model.doStreamCalls.length, 1); + }); + + test('holds the provider request until the steering event is durably consumed', async () => { + // Persist-before-include: the initial user message is durable before the + // backend is invoked, and a steered message holds the same line via the + // seq-ack boundary — the consumer's pull is the ack, and AgentRun persists + // each event before pulling the next. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + const iterator = backend + .send({ + turnId: 'turn-1', + text: 'start', + context: [], + pullSteering: pullOnce('persist me first'), + }) + [Symbol.asyncIterator](); + + // The generator suspends at the steering yield: the event is delivered + // but not yet acked, so the persist boundary has not been crossed. + await nextSteeringEvent(iterator); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(model.doStreamCalls.length, 0); + + // Resuming consumption acks the steering event; only now may the provider + // request start, and it carries the steered directive. + const events: SessionEvent[] = []; + for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) { + events.push(next.value as SessionEvent); + } + assert.equal(model.doStreamCalls.length, 1); + assert.equal(JSON.stringify(model.doStreamCalls[0]?.prompt).includes('persist me first'), true); + assert.equal( + events.some((event) => event.type === 'complete' && event.stopReason === 'end_turn'), + true, + ); + }); + + test('persists canonical steering content and materializes attachments for the model', async () => { + const model = textCompletionModel('done'); + const pngBytes = new Uint8Array([137, 80, 78, 71]); + const image = { + kind: 'image' as const, + name: 'first.png', + mimeType: 'image/png', + bytes: pngBytes.length, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'first.png' }, + }; + const document = { + kind: 'pdf' as const, + name: 'second.pdf', + mimeType: 'application/pdf', + bytes: 12, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'second.pdf' }, + }; + const backend = steeringBackend(model, { + supportsVision: true, + readAttachmentBytes: async (ref) => { + assert.deepEqual(ref, image.ref); + return { ok: true, bytes: pngBytes }; + }, + }); + const content = { + text: 'inspect the authoritative inputs', + displayText: 'human-only command', + attachments: [image, document], + }; + let pulled = false; + const acked: string[] = []; + const iterator = backend + .send({ + turnId: 'turn-1', + text: 'start', + context: [], + pullSteering: () => { + if (pulled) return []; + pulled = true; + return [{ id: 'lease-content', messageId: 'message-content', content }]; + }, + ackSteering: (leaseIds) => acked.push(...leaseIds), + }) + [Symbol.asyncIterator](); + + const steeringEvent = await nextSteeringEvent(iterator); + assert.equal(steeringEvent.type, 'steering_message'); + if (steeringEvent.type !== 'steering_message') assert.fail('expected steering event'); + assert.deepEqual(steeringEvent.content, content); + assert.equal(model.doStreamCalls.length, 0); + assert.deepEqual(acked, []); + for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) {} + assert.deepEqual(acked, ['lease-content']); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const parts = prompt.at(-1)?.content as Array<{ + type: string; + text?: string; + mediaType?: string; + data?: unknown; + }>; + assert.deepEqual( + parts.map((part) => part.type), + ['text', 'file'], + ); + assert.equal( + parts[0]?.text, + buildSteeringEnvelope( + 'inspect the authoritative inputs\n\n\nThe attachment content is unavailable to Read.\nname: "first.png"\nmime_type: "image/png"\n\n\nThe attachment content is unavailable to Read.\nname: "second.pdf"\nmime_type: "application/pdf"\n', + ), + ); + assert.equal(parts[1]?.mediaType, 'image/png'); + assert.notEqual(parts[1]?.data, undefined); + assert.equal(JSON.stringify(prompt).includes('human-only command'), false); + }); + + test('a steering message never reaches the provider when the consumer detaches before the ack', async () => { + // The persist path failed or the turn is being torn down: the consumer + // walks away without acking the steering event. The dying request must + // never be sent carrying a directive the ledger does not have, and the + // lease is nacked so the queue reclaims the message. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + const acked: string[] = []; + const nacked: string[] = []; + const iterator = backend + .send({ + turnId: 'turn-1', + text: 'start', + context: [], + pullSteering: pullOnce('abandoned steer'), + ackSteering: (leaseIds) => acked.push(...leaseIds), + nackSteering: (leaseIds) => nacked.push(...leaseIds), + }) + [Symbol.asyncIterator](); + + await nextSteeringEvent(iterator); + await iterator.return?.(undefined); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(model.doStreamCalls.length, 0); + assert.deepEqual(acked, []); + assert.deepEqual(nacked, ['lease-abandoned steer']); + }); + + test('a user prompt that equals the envelope text never cancels a real steer', async () => { + // Identity, not text: the dedupe key is the structured steering marker, + // so a user message that happens to BE the envelope text verbatim cannot + // forge (or absorb) a steering message. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + const forged = buildSteeringEnvelope('fake'); + await drain( + backend.send({ + turnId: 'turn-1', + text: forged, + context: [], + pullSteering: pullOnce('fake'), + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: forged }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('fake') }] }, + ]); + }); + + test('degraded RuntimeEvent replay presents prior steering exactly once, in envelope form', async () => { + // A blocking replay diagnostic (here: a tool-role text event) degrades the + // provider-native shape to text-only RuntimeEvent replay. The canonical + // steering marker still produces one envelope with its structured id. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + const steeredEvent = runtimeTextEvent({ + id: 'rt-steer', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'steered earlier', + }); + (steeredEvent.content as { steering?: true }).steering = true; + const degradingEvent = runtimeTextEvent({ + id: 'rt-bad', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'boom', + }); + (degradingEvent as { role: string }).role = 'tool'; + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'original ask', + }), + steeredEvent, + degradingEvent, + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'ok', + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original ask' }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('steered earlier') }] }, + { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ]); + }); + + test('a steer that equals the current prompt still injects its envelope', async () => { + // Bare text is not an identity: deducting the steer against the verbatim + // user prompt would drop the directive from the provider request entirely + // while the ledger still records a steering_message. The envelope is the + // identity, and it never collides with plain user text. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + await drain( + backend.send({ + turnId: 'turn-1', + text: 'repeat this', + context: [], + pullSteering: pullOnce('repeat this'), + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'repeat this' }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('repeat this') }] }, + ]); + }); + + test('a steer that equals a historical user message still injects its envelope', async () => { + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + await drain( + backend.send({ + turnId: 'turn-current', + text: 'now do something else', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'repeat this', + }), + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'done before', + }), + ], + pullSteering: pullOnce('repeat this'), + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'repeat this' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'done before' }] }, + { role: 'user', content: [{ type: 'text', text: 'now do something else' }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('repeat this') }] }, + ]); + }); + + test('two identical steers inject two envelopes', async () => { + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + let pulled = false; + await drain( + backend.send({ + turnId: 'turn-1', + text: 'start', + context: [], + pullSteering: () => { + if (pulled) return []; + pulled = true; + return [ + { id: 'lease-1', messageId: 'message-1', content: { text: 'do it' } }, + { id: 'lease-2', messageId: 'message-2', content: { text: 'do it' } }, + ]; + }, + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'start' }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('do it') }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('do it') }] }, + ]); + }); + + test('a prior-turn steering event replays in its canonical envelope form', async () => { + // The persisted steering event carries raw text for the UI; every model + // projection wraps it. A future turn's history must show the model the + // same form the original request used — one canonical provider projection. + const model = textCompletionModel('done'); + const backend = steeringBackend(model); + const steeredEvent = runtimeTextEvent({ + id: 'rt-steer', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'steered earlier', + }); + (steeredEvent.content as { steering?: true }).steering = true; + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'original ask', + }), + steeredEvent, + runtimeTextEvent({ + id: 'rt-a', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + text: 'ok', + }), + ], + }), + ); + + assert.deepEqual(compactPrompt(model), [ + { role: 'user', content: [{ type: 'text', text: 'original ask' }] }, + { role: 'user', content: [{ type: 'text', text: buildSteeringEnvelope('steered earlier') }] }, + { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ]); + }); + + test('a prior-turn steering event replays its image attachments as image parts', async () => { + // The original steered request materialized its images natively through + // appendImageParts; a replay that kept only the envelope text would hand + // a recovery turn attachment references without the pixels the first + // request received. The steering provider identity must survive too. + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 7, 8, 9]); + const model = textCompletionModel('done'); + const backend = steeringBackend(model, { + supportsVision: true, + readAttachmentBytes: async () => ({ ok: true, bytes: pngBytes }), + }); + const steeredEvent = runtimeTextEvent({ + id: 'rt-steer', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'steered earlier', + }); + (steeredEvent.content as { steering?: true }).steering = true; + (steeredEvent.content as { attachments?: unknown[] }).attachments = [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: 'attachments/chart.png', + }, + }, + ]; + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [steeredEvent], + }), + ); + + const prompt = model.doStreamCalls[0]?.prompt ?? []; + const steeredReplay = prompt[0]; + const parts = steeredReplay?.content as Array<{ + type: string; + text?: string; + mediaType?: string; + }>; + assert.ok( + parts.find((part) => part.type !== 'text' && part.mediaType === 'image/png'), + `expected a native image part on the steering replay, got: ${JSON.stringify(parts)}`, + ); + assert.match( + parts[0]?.text ?? '', + /steered earlier/, + 'the envelope text stays the leading part', + ); + assert.ok( + steeredReplay?.providerOptions, + 'the steering provider identity survives the materialization', + ); + }); + + test('persists provider metadata a canonical event can read back', async () => { + // The failure this pins is not in the sanitiser, it is at this seam. + // + // A field the response did not carry arrives as an explicit `undefined` — + // Anthropic sends `{ type: 'direct', toolId: undefined }` when there is no + // tool id. JSON drops such a property, so the persisted event no longer + // reads back as it was written and `encodeCanonicalRuntimeEvent` refuses + // it. That refusal marked the runtime event store unavailable and the + // turn's terminal write then threw, so every turn that called any tool + // died about a tenth of a second after the tool returned. + // + // Asserting on the sanitiser alone cannot catch a regression here: the + // sanitiser is a pure function and could not have produced the bug. This + // streams the shape a real provider sends and encodes what was persisted. + const anchor = runtimeTextEvent({ + id: 'runtime-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'call the tool', + }); + const ledger: RuntimeEvent[] = [anchor]; + const mappingMemory = createSessionEventMapMemory(); + const mappingContext: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + now: monotonicClock(), + }; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'Read', + input: JSON.stringify({ path: 'ok.md' }), + providerMetadata: { + anthropic: { caller: { type: 'direct', toolId: undefined } }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Done.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'end_turn' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: 'Read', + description: 'read', + parameters: z.object({ path: z.string() }), + impl: async () => ({ body: 'ok' }), + }, + ], + loadTurnRuntimeEvents: async () => ledger, + }); + + for await (const event of backend.send({ + turnId: 'turn-1', + text: 'call the tool', + context: [], + headAnchorRuntimeEvent: anchor, + })) { + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + } + + // Every persisted event has to survive the encoder, because one that does + // not takes the store — and the turn — with it. + for (const event of ledger) { + assert.doesNotThrow( + () => encodeCanonicalRuntimeEvent(event), + `a persisted ${event.content?.kind} event must read back as it was written`, + ); + } + }); + + test('rebuilds reasoning metadata rather than persisting what the provider sent', async () => { + // The tool-call seam above carries `providerOptions` from the stream into + // the persisted event, so an omitted provider field arrives as an explicit + // `undefined` and the canonical encoder refuses the write. Reasoning looks + // like the same seam and is not: `translateChunk` rebuilds the reasoning + // metadata from two named string fields, so nothing the provider sends + // reaches the event verbatim and no `undefined` can ride along. + // + // This pins that, because it is the only reason the reasoning path needs + // no sanitiser. If reasoning metadata is ever passed through instead, this + // test fails and says so. + const anchor = runtimeTextEvent({ + id: 'runtime-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'think about it', + }); + const ledger: RuntimeEvent[] = [anchor]; + const mappingMemory = createSessionEventMapMemory(); + const mappingContext: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + now: monotonicClock(), + }; + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { + type: 'reasoning-delta', + id: 'reasoning-1', + delta: 'weighing it up', + providerMetadata: { + openai: { itemId: 'item-1', reasoningEncryptedContent: undefined }, + }, + }, + { type: 'reasoning-end', id: 'reasoning-1' }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Done.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'end_turn' }, + usage: emptyUsage(), + }, + ] satisfies LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: async () => ledger, + }); + + for await (const event of backend.send({ + turnId: 'turn-1', + text: 'think about it', + context: [], + headAnchorRuntimeEvent: anchor, + })) { + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + } + + const thinking = ledger.find((event) => event.content?.kind === 'thinking'); + assert.ok(thinking, 'the reasoning part has to reach the ledger for this to pin anything'); + assert.deepEqual( + thinking.content?.kind === 'thinking' ? thinking.content.providerOptions : undefined, + { openai: { itemId: 'item-1' } }, + 'the omitted field was not carried, because the metadata was rebuilt', + ); + for (const event of ledger) { + assert.doesNotThrow( + () => encodeCanonicalRuntimeEvent(event), + `a persisted ${event.content?.kind} event must read back as it was written`, + ); + } + }); + + test('merges citation metadata across multiple provider text items', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'One' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { + itemId: 'message-1', + annotations: [{ type: 'url_citation', start_index: 0, end_index: 3 }], + }, + }, + }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'Two' }, + { + type: 'text-end', + id: 'text-2', + providerMetadata: { + openai: { + itemId: 'message-2', + annotations: [{ type: 'url_citation', start_index: 0, end_index: 3 }], + }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'cite twice', context: [] })); + + const assistant = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.equal(assistant?.text, 'OneTwo'); + assert.equal(assistant?.contentOrder, undefined); + assert.deepEqual(assistant?.providerOptions, { + openai: { + annotations: [ + { type: 'url_citation', start_index: 0, end_index: 3 }, + { type: 'url_citation', start_index: 3, end_index: 6 }, + ], + }, + }); + }); + + test('preserves native Responses text item boundaries as separate assistant messages', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { type: 'text-delta', id: 'text-1', delta: 'I am checking it.' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { + type: 'text-start', + id: 'text-2', + providerMetadata: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + { type: 'text-delta', id: 'text-2', delta: 'It is ready.' }, + { + type: 'text-end', + id: 'text-2', + providerMetadata: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + modelId: 'gpt-5', + modelFactory: () => model, + tools: [], + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'inspect it', context: [] })); + + assert.deepEqual( + appended + .filter((message): message is AssistantMessage => message.type === 'assistant') + .map((message) => ({ + text: message.text, + providerOptions: message.providerOptions, + })), + [ + { + text: 'I am checking it.', + providerOptions: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { + text: 'It is ready.', + providerOptions: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + ], + ); + }); + + test('does not carry metadata from an empty Responses text item into the next item', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-empty', + providerMetadata: { + openai: { itemId: 'message-empty', phase: 'commentary' }, + }, + }, + { + type: 'text-end', + id: 'text-empty', + providerMetadata: { + openai: { itemId: 'message-empty', phase: 'commentary' }, + }, + }, + { + type: 'text-start', + id: 'text-final', + providerMetadata: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + { type: 'text-delta', id: 'text-final', delta: 'Done.' }, + { + type: 'text-end', + id: 'text-final', + providerMetadata: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + modelId: 'gpt-5', + modelFactory: () => model, + tools: [], + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'finish it', context: [] })); + + assert.deepEqual( + appended + .filter((message): message is AssistantMessage => message.type === 'assistant') + .map((message) => ({ + text: message.text, + providerOptions: message.providerOptions, + })), + [ + { + text: 'Done.', + providerOptions: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + ], + ); + }); + + test('executes native WebSearch inside the primary provider stream', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'WebSearch', + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + result: { + action: { type: 'search', queries: ['latest Maka'] }, + sources: [{ type: 'url', url: 'https://maka.example/' }], + }, + providerExecuted: true, + }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Maka is current.' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool()], + }); + const events: SessionEvent[] = []; + + await collectEvents(backend.send({ turnId: 'turn-1', text: 'search', context: [] }), events); + + assert.equal( + model.doStreamCalls[0]?.tools?.some( + (tool) => tool.type === 'provider' && tool.id === 'openai.web_search', + ), + true, + ); + const start = events.find((event) => event.type === 'tool_start'); + assert.equal(start?.type === 'tool_start' ? start.providerExecuted : undefined, true); + const result = events.find((event) => event.type === 'tool_result'); + assert.equal(result?.type === 'tool_result' ? result.providerExecuted : undefined, true); + assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'maka.example', + url: 'https://maka.example/', + snippet: '', + source: 'maka.example', + }, + ], + }); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + const assistant = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.deepEqual(assistant?.contentOrder, ['tools', 'text']); + assert.deepEqual(assistant?.providerOptions, { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }); + const mappingMemory = createSessionEventMapMemory(); + const anchor = runtimeTextEvent({ + id: 'native-search-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'search', + }); + const mappingContext: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId: 'invocation-search', + runId: 'run-search', + turnId: 'turn-1', + now: monotonicClock(), + }; + for (const event of events) { + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content) { + assert.doesNotThrow(() => encodeCanonicalRuntimeEvent(mapped)); + } + } + }); + + test('projects CC-format Anthropic web search without exposing encrypted content', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'search-cc-1', + toolName: 'WebSearch', + input: JSON.stringify({ query: 'latest Maka' }), + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-cc-1', + toolName: 'WebSearch', + result: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result', + }, + ], + providerExecuted: true, + }, + { type: 'text-start', id: 'text-cc-1' }, + { type: 'text-delta', id: 'text-cc-1', delta: 'Maka is current.' }, + { type: 'text-end', id: 'text-cc-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'end_turn' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'anthropic-messages' })], + }); + const events: SessionEvent[] = []; + + await collectEvents(backend.send({ turnId: 'turn-1', text: 'search', context: [] }), events); + + assert.equal( + model.doStreamCalls[0]?.tools?.some( + (tool) => tool.type === 'provider' && tool.id === 'anthropic.web_search_20250305', + ), + true, + ); + const result = events.find((event) => event.type === 'tool_result'); + const start = events.find((event) => event.type === 'tool_start'); + assert.deepEqual(start?.type === 'tool_start' ? start.args : undefined, { + query: 'latest Maka', + }); + assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'Maka', + url: 'https://maka.example/', + snippet: '2026-08-04', + source: 'maka.example', + }, + ], + }); + assert.doesNotMatch( + JSON.stringify(result?.type === 'tool_result' ? result.content : null), + /encrypted-result/, + ); + }); +}); + +function textCompletionModel(text: string): MockLanguageModelV4 { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: text }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 1, + noCache: 1, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 1, + text: 1, + reasoning: 0, + }, + }, + }, + ]; + return new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); +} + +function completionModel(): MockLanguageModelV4 { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 1, + noCache: 1, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 1, + text: 1, + reasoning: 0, + }, + }, + }, + ]; + return new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); +} + +function emptyUsage() { + return { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }; +} + +function imageReplayBackend( + model: MockLanguageModelV4, + options: { supportsVision: boolean; readAttachmentBytes: AttachmentByteReader }, +): AiSdkBackend { + return createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + ...options, + }); +} + +function imageReplayInput(): BackendSendInput { + return { + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read it', + }), + runtimeEvent({ + id: 'rt-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: { path: 'chart.png' } }, + }), + runtimeEvent({ + id: 'rt-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + isError: false, + result: { + kind: 'image', + mimeType: 'image/png', + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'artifact-1' }, + }, + }, + }), + ], + }; +} + +async function runPlanToolBoundary(input: { + turnId: string; + prompt: string; + toolName: string; + toolInput: unknown; + toolResult: unknown; + finalText?: string; +}): Promise<{ calls: number; events: SessionEvent[] }> { + const durable = durableTurnHarness(input.turnId, input.prompt); + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `${input.toolName}-call`, + toolName: input.toolName, + input: JSON.stringify(input.toolInput), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { + type: 'text-delta', + id: 'text-final', + delta: input.finalText ?? 'Unexpected continuation.', + }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + { + name: input.toolName, + description: `${input.toolName} test tool`, + parameters: z.object({}).passthrough(), + impl: async () => input.toolResult, + }, + ], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + }); + const events = await drainDurably(backend.send(durable.input()), durable); + return { calls, events }; +} + +function planExecution(status: 'completed' | 'cancelled') { + return { + executionId: 'execution-1', + planId: 'plan-1', + proposalId: 'proposal-1', + sessionId: 'session-1', + status, + steps: [ + { + id: 'change', + title: 'Change implementation', + description: 'Change code', + status: status === 'completed' ? ('completed' as const) : ('in_progress' as const), + updatedAt: 2, + }, + ], + startedAt: 1, + updatedAt: 2, + ...(status === 'completed' + ? { completedAt: 2 } + : { + cancelledAt: 2, + cancelReason: 'User cancelled the execution.', + }), + }; +} + +function countingToolLoopModel(toolCallsBeforeStop?: number): { + model: MockLanguageModelV4; + callCount: () => number; +} { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const shouldStop = toolCallsBeforeStop !== undefined && calls > toolCallsBeforeStop; + const chunks: LanguageModelV4StreamPart[] = shouldStop + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path: `notes-${calls}.md` }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + return { model, callCount: () => calls }; +} + +function runtimeTextEvent(input: { + id: string; + turnId: string; + role: 'user' | 'model'; + author: 'user' | 'agent'; + text: string; +}): RuntimeEvent { + return { + id: input.id, + invocationId: 'inv-1', + runId: 'run-prev', + sessionId: 'session-1', + turnId: input.turnId, + ts: 1, + partial: false, + role: input.role, + author: input.author, + content: { kind: 'text', text: input.text }, + }; +} + +function runtimeEvent(input: { + id: string; + turnId: string; + role: RuntimeEvent['role']; + author: RuntimeEvent['author']; + content?: RuntimeEvent['content']; + status?: RuntimeEvent['status']; + actions?: RuntimeEvent['actions']; + refs?: RuntimeEvent['refs']; +}): RuntimeEvent { + return { + id: input.id, + invocationId: 'inv-1', + runId: 'run-prev', + sessionId: 'session-1', + turnId: input.turnId, + ts: 1, + partial: false, + role: input.role, + author: input.author, + ...(input.content ? { content: input.content } : {}), + ...(input.status ? { status: input.status } : {}), + ...(input.actions ? { actions: input.actions } : {}), + ...(input.refs ? { refs: input.refs } : {}), + }; +} + +function clientToolCallEvent(id: string, stepId: string): RuntimeEvent { + return runtimeEvent({ + id, + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId }, + content: { + kind: 'function_call', + id: 'read-1', + name: 'Read', + args: { path: 'notes.md' }, + }, + }); +} + +function clientToolResultEvent(id: string): RuntimeEvent { + return runtimeEvent({ + id, + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'read-1', + name: 'Read', + result: { kind: 'text', text: 'file contents' }, + isError: false, + }, + }); +} + +async function replayPrompt( + runtimeContext: RuntimeEvent[], +): Promise> { + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + }); + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext, + }), + ); + return compactPrompt(model) as Array<{ role: string; content: any[] }>; +} + +function compactPrompt(model: MockLanguageModelV4): unknown { + return model.doStreamCalls[0]?.prompt.map((message) => ({ + role: message.role, + content: message.content, + })); +} + +function modelToolNames(model: MockLanguageModelV4): string[] { + return sortedModelToolNames(Object.keys(modelTools(model))); +} + +function modelTools(model: MockLanguageModelV4): Record { + const call = model.doStreamCalls[0] as unknown as Record | undefined; + const tools = call?.tools; + if (!tools) return {}; + if (Array.isArray(tools)) { + const out: Record = {}; + for (const tool of tools) { + if (tool && typeof tool === 'object') { + const record = tool as Record; + const name = + typeof record.name === 'string' + ? record.name + : typeof record.toolName === 'string' + ? record.toolName + : undefined; + if (name) out[name] = tool; + } + } + return out; + } + if (typeof tools === 'object') return tools as Record; + return {}; +} + +function sortedModelToolNames(toolNames: readonly string[]): string[] { + return [...toolNames].sort((a, b) => { + if (a === INVALID_TOOL_NAME) return 1; + if (b === INVALID_TOOL_NAME) return -1; + return a.localeCompare(b); + }); +} + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function utf8Bytes(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function testTool(name: string, parameters: unknown): MakaTool { + return { + name, + description: `${name} description`, + parameters, + impl: async () => ({ ok: true }), + }; +} + +function nativeApplyPatchTool(): MakaTool { + return { + name: 'apply_patch', + description: 'Apply one patch operation', + parameters: z.object({}), + providerTool: { kind: 'openai-apply-patch' }, + impl: async () => ({ status: 'completed' }), + }; +} + +async function collectEvents( + iterable: AsyncIterable, + events: SessionEvent[], + record?: (event: SessionEvent) => void, +): Promise { + for await (const event of iterable) { + record?.(event); + events.push(event); + } +} + +async function drain(iterable: AsyncIterable): Promise { + for await (const _ of iterable) { + // consume + } +} + +function durableTurnHarness( + turnId: string, + text: string, + identity: { runId?: string; invocationId?: string } = {}, +) { + const runId = identity.runId ?? 'run-1'; + const invocationId = identity.invocationId ?? 'invocation-1'; + const anchor: RuntimeEvent = { + id: `runtime-user-${turnId}`, + invocationId, + runId, + sessionId: 'session-1', + turnId, + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text }, + }; + const ledger: RuntimeEvent[] = [anchor]; + const memory = createSessionEventMapMemory(); + const ctx: RuntimeEventMapContext = { + sessionId: 'session-1', + invocationId, + runId, + turnId, + now: monotonicClock(), + }; + return { + anchor, + ledger, + loadTurnRuntimeEvents: async (requestedTurnId: string) => + ledger.filter((event) => event.turnId === requestedTurnId), + input: (overrides: Partial = {}): BackendSendInput => ({ + turnId, + text, + context: [], + headAnchorRuntimeEvent: anchor, + ...overrides, + }), + record: (event: SessionEvent): void => { + const mapped = mapSessionEventToRuntimeEvent(event, ctx, memory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + }, + }; +} + +async function drainDurably( + iterable: AsyncIterable, + durable: ReturnType, +): Promise { + const events: SessionEvent[] = []; + for await (const event of iterable) { + durable.record(event); + events.push(event); + } + return events; +} + +function makeGate(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +function manualWatchdogTimer(): { + clock: NonNullable; + fire: () => void; + armCount: () => number; +} { + let pending: { readonly token: object; readonly callback: () => void } | undefined; + let armCount = 0; + return { + clock: { + setTimer: (callback) => { + armCount += 1; + const token = {}; + pending = { token, callback }; + return token; + }, + clearTimer: (token) => { + if (pending?.token === token) pending = undefined; + }, + }, + fire: () => { + assert.ok(pending, 'watchdog timer must be armed'); + const callback = pending.callback; + pending = undefined; + callback(); + }, + armCount: () => armCount, + }; +} + +function connectionResetFailure(): Error { + // Transport reset identified only by the cause code, the same evidence + // shape provider-error-classification tests classify as retryable Network. + return Object.assign(new Error('Operation failed'), { + cause: { code: 'ECONNRESET' }, + }); +} + +/** + * Streams `chunks`, then hangs until `fail()` — mirroring a provider that + * streams part of a step and then drops the connection mid-stream. The chunks + * must already be consumed when the failure lands (controller.error() discards + * queued-but-unread chunks), so the test triggers `fail` from a streamed event. + */ +function midStreamFailureStream( + chunks: readonly LanguageModelV4StreamPart[], + failure: Error, +): { stream: ReadableStream; fail: () => void } { + let fail: () => void = () => {}; + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + fail = () => controller.error(failure); + }, + }); + return { stream, fail: () => fail() }; +} + +function hangingProviderStream( + chunks: readonly LanguageModelV4StreamPart[], + signal: AbortSignal | undefined, + abortMode: 'error' | 'close' = 'error', +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + const abort = () => { + if (abortMode === 'close') controller.close(); + else controller.error(signal?.reason ?? new Error('aborted')); + }; + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + }, + }); +} + +type BackendTestInput = Parameters[0]; +type BackendTestDefaultKey = 'sessionId' | 'header' | 'appendMessage' | 'apiKey' | 'newId' | 'now'; +type BackendTestOverrides = Omit & + Partial>; + +function createBackend(input: BackendTestOverrides): AiSdkBackend { + return createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + apiKey: 'sk-test', + newId: idGenerator(), + now: monotonicClock(), + ...input, + }); +} + +function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/tmp/maka', + cwd: '/tmp/maka', + createdAt: 1, + name: 'Test', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionId: 'test-connection-id', + llmConnectionSlug: 'anthropic-main', + connectionLocked: true, + model: 'claude-sonnet-4-5-20250929', + permissionMode, + schemaVersion: 1, + }; +} + +function priorModelInvocation(input: { + connectionId?: string; + modelId: string; + connectionSlug?: string; + runId?: string; + turnId?: string; + root?: RuntimeInvocationRootAuthority; + providerStateIdentity?: `sha256:${string}`; +}): RuntimeInvocationRecord { + const identity = { + sessionId: 'session-1', + invocationId: input.runId ?? 'run-prev', + runId: input.runId ?? 'run-prev', + turnId: input.turnId ?? 'turn-prev', + }; + return { + ...identity, + openedAt: 1, + opening: testInvocationOpening({ + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: input.connectionId ?? 'anthropic-main-connection', + llmConnectionSlug: input.connectionSlug ?? 'anthropic-main', + modelId: input.modelId, + providerStateIdentity: input.providerStateIdentity ?? `sha256:${'1'.repeat(64)}`, + }, + configuration: { cwd: '/tmp/maka' }, + root: input.root ?? { kind: 'user' }, + }), + terminalEvent: { + id: `${identity.runId}-terminal`, + ...identity, + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + }, + }; +} + +function sameRouteReplayProvenance( + modelId: string, + runId = 'run-prev', +): Pick { + return { + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'test-connection-id', modelId, runId }), + ], + }; +} + +function connection(): LlmConnection { + return { + slug: 'anthropic-main', + name: 'Anthropic', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; +} + +function idGenerator(): () => string { + let index = 0; + return () => `id-${++index}`; +} + +function monotonicClock(): () => number { + let value = 1_000; + return () => ++value; +} + +async function waitFor(predicate: () => boolean): Promise { + await pollFor(predicate, { + attempts: 20, + pollMs: 0, + message: 'condition was not met before timeout', + }); +} + +/** + * The execution scope for one turn, opened the same way `send()` opens it. + * Tests that drive ToolRuntime directly (without a provider stream) use this so + * they exercise the real per-turn wiring instead of a backend-wide singleton. + */ +interface TestTurnScope { + turnId: string; + abortController: AbortController; + toolRuntime: ToolRuntime; + watchdog: { pause(): void; resume(): void } | null; + runTrace: RunTrace | null; +} + +function backendInternals(backend: AiSdkBackend): { + activeTurns: Set; + openTurnScope(input: { turnId: string; text: string; context: [] }): TestTurnScope; +} { + return backend as unknown as { + activeTurns: Set; + openTurnScope(input: { turnId: string; text: string; context: [] }): TestTurnScope; + }; +} + +/** The live scope for a turn, opening one when the test drives a turn directly. */ +function turnScope(backend: AiSdkBackend, turnId: string): TestTurnScope { + const internals = backendInternals(backend); + for (const scope of internals.activeTurns) if (scope.turnId === turnId) return scope; + return internals.openTurnScope({ turnId, text: '', context: [] }); +} + +function runtimeExecute( + backend: AiSdkBackend, + tool: MakaTool, + turnId: string, + eventSink: { push(event: SessionEvent): void }, +) { + const runtime = turnScope(backend, turnId).toolRuntime; + // This drives the tool runtime beneath `send()`, so the stream that becomes + // the ledger is teed here instead. + const project = projectedTranscriptOf(backend); + const durableEventSink: DurableSessionEventSink = { + push: (event) => { + eventSink.push(event); + void project?.(event, turnId); + }, + pushAndWaitUntilConsumed: async (event) => { + eventSink.push(event); + await project?.(event, turnId); + }, + }; + return async ( + input: unknown, + context: { toolCallId: string; abortSignal: AbortSignal }, + ): Promise => + ( + await runtime.settleToolCall({ + tool, + turnId, + toolCallId: context.toolCallId, + input, + abortSignal: context.abortSignal, + eventSink: durableEventSink, + }) + ).result; +} diff --git a/packages/runtime/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json b/packages/runtime/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json new file mode 100644 index 0000000000..0870114281 --- /dev/null +++ b/packages/runtime/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-11T13:06:47.250Z", + "sessionId": "sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d", + "event": "PostToolUse", + "toolName": "Edit", + "file": "src/__tests__/ai-sdk-backend.test.ts", + "outcome": "clear", + "coverage": "complete", + "findingCount": 0, + "durationMs": 9, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index c7efec5b8b..3eb0c0f453 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -2242,6 +2242,46 @@ describe('AiSdkBackend model history', () => { ); }); + test('a persisted quote-only user event replays its excerpt into the provider prompt (#4804)', async () => { + // The headline behaviour of #4804 measured at the production seam: a + // stored user event whose text is empty but whose quotes carry the turn + // must reach the provider prompt as the excerpt itself, not be skipped + // as invisible or summarized as a count. + const model = completionModel(); + const backend = createBackend({ + connection: connection(), + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + } as never); + await drain( + backend.send({ + turnId: 'turn-current', + text: 'and the current ask', + context: [], + runtimeContext: [ + runtimeEvent({ + id: 'rt-quote', + turnId: 'turn-prev', + role: 'user', + author: 'user', + content: { + kind: 'text', + text: '', + quotes: [{ text: 'the deploy failed at step three' }], + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + const historical = prompt[0]?.content as Array<{ type: string; text?: string }>; + const joined = JSON.stringify(historical); + assert.match(joined, /the deploy failed at step three/, 'the excerpt reaches the prompt'); + assert.match(joined, /quoted_excerpt/, 'the excerpt renders in its canonical envelope'); + }); + test('current-turn image attachment keeps its Read reference unless vision support is explicit', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); const model = completionModel(); diff --git a/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-0bdcd9fd973aa77c93a0d202e20b5ae6.json b/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-0bdcd9fd973aa77c93a0d202e20b5ae6.json new file mode 100644 index 0000000000..882f290484 --- /dev/null +++ b/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-0bdcd9fd973aa77c93a0d202e20b5ae6.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": "mimosa-finding-ledger-batch/v1", + "batchId": "posttooluse-0bdcd9fd973aa77c93a0d202e20b5ae6", + "runId": null, + "runStatus": "completed", + "coverage": { + "status": "complete", + "reasons": [] + }, + "source": { + "component": "zcode-hook", + "operationId": "PostToolUse" + }, + "revision": null, + "diffHash": null, + "rulesVersion": null, + "sessionHash": "af04ddfd4aa5c6482de8b548f657337c75605bf7861401ff4bc0f5924b17b372", + "reportRef": null, + "events": [ + { + "eventId": "hook-0054c3f6d033aba073f09e7eb132aaa1", + "findingId": "mimosa-bae482b8fc2d0bf8f4f8d36c", + "type": "static_fix_verified", + "at": "2026-09-09T22:45:36.048Z", + "identity": { + "projectRelativeFile": "src/__tests__/foreign-session-store.test.ts", + "ruleId": "security", + "codeEvidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200", + "confidence": "stable" + }, + "scope": "direct", + "line": 6, + "endLine": 6, + "reasonCode": "static_rescan_passed", + "reportedToAgent": false, + "evidence": { + "kind": "static_scan", + "boundary": "observed", + "producer": "deterministic", + "evidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200" + }, + "sequence": 0 + } + ], + "observedFindingIds": [], + "verifiedFiles": [], + "recordedAt": "2026-09-09T22:45:36.292Z" +} diff --git a/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-600551e80e606fb26f5d2c60dafa0eae.json b/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-600551e80e606fb26f5d2c60dafa0eae.json new file mode 100644 index 0000000000..145c0e1f57 --- /dev/null +++ b/packages/storage/.mimosa/finding-ledger/v1/events/batch-posttooluse-600551e80e606fb26f5d2c60dafa0eae.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": "mimosa-finding-ledger-batch/v1", + "batchId": "posttooluse-600551e80e606fb26f5d2c60dafa0eae", + "runId": null, + "runStatus": "completed", + "coverage": { + "status": "complete", + "reasons": [] + }, + "source": { + "component": "zcode-hook", + "operationId": "PostToolUse" + }, + "revision": null, + "diffHash": null, + "rulesVersion": null, + "sessionHash": "af04ddfd4aa5c6482de8b548f657337c75605bf7861401ff4bc0f5924b17b372", + "reportRef": null, + "events": [ + { + "eventId": "hook-803f5922a55fcbaabdec2c4e4dcefc67", + "findingId": "mimosa-bae482b8fc2d0bf8f4f8d36c", + "type": "static_fix_verified", + "at": "2026-09-09T22:44:53.314Z", + "identity": { + "projectRelativeFile": "src/__tests__/foreign-session-store.test.ts", + "ruleId": "security", + "codeEvidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200", + "confidence": "stable" + }, + "scope": "direct", + "line": 3, + "endLine": 3, + "reasonCode": "static_rescan_passed", + "reportedToAgent": false, + "evidence": { + "kind": "static_scan", + "boundary": "observed", + "producer": "deterministic", + "evidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200" + }, + "sequence": 0 + } + ], + "observedFindingIds": [], + "verifiedFiles": [], + "recordedAt": "2026-09-09T22:44:53.552Z" +} diff --git a/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-34e6d564f858911bd3cbea4ad3606892.json b/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-34e6d564f858911bd3cbea4ad3606892.json new file mode 100644 index 0000000000..96fad1c399 --- /dev/null +++ b/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-34e6d564f858911bd3cbea4ad3606892.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": "mimosa-finding-ledger-batch/v1", + "batchId": "pretooluse-34e6d564f858911bd3cbea4ad3606892", + "runId": null, + "runStatus": "completed", + "coverage": { + "status": "complete", + "reasons": [] + }, + "source": { + "component": "zcode-hook", + "operationId": "PreToolUse" + }, + "revision": null, + "diffHash": null, + "rulesVersion": null, + "sessionHash": "af04ddfd4aa5c6482de8b548f657337c75605bf7861401ff4bc0f5924b17b372", + "reportRef": null, + "events": [ + { + "eventId": "hook-5316ccc55c6ec33bc0b94e94d8c8c615", + "findingId": "mimosa-bae482b8fc2d0bf8f4f8d36c", + "type": "finding_blocked", + "at": "2026-09-09T22:44:28.271Z", + "identity": { + "projectRelativeFile": "src/__tests__/foreign-session-store.test.ts", + "ruleId": "security", + "codeEvidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200", + "confidence": "stable" + }, + "scope": "direct", + "line": 3, + "endLine": 3, + "reasonCode": "deny", + "reportedToAgent": true, + "evidence": { + "kind": "source", + "boundary": "candidate", + "producer": "deterministic", + "evidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200" + }, + "sequence": 0 + } + ], + "observedFindingIds": [], + "verifiedFiles": [], + "recordedAt": "2026-09-09T22:44:28.510Z" +} diff --git a/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-5b0547869b8cb795cbdb2a06f03452e1.json b/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-5b0547869b8cb795cbdb2a06f03452e1.json new file mode 100644 index 0000000000..cb9355fb38 --- /dev/null +++ b/packages/storage/.mimosa/finding-ledger/v1/events/batch-pretooluse-5b0547869b8cb795cbdb2a06f03452e1.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": "mimosa-finding-ledger-batch/v1", + "batchId": "pretooluse-5b0547869b8cb795cbdb2a06f03452e1", + "runId": null, + "runStatus": "completed", + "coverage": { + "status": "complete", + "reasons": [] + }, + "source": { + "component": "zcode-hook", + "operationId": "PreToolUse" + }, + "revision": null, + "diffHash": null, + "rulesVersion": null, + "sessionHash": "af04ddfd4aa5c6482de8b548f657337c75605bf7861401ff4bc0f5924b17b372", + "reportRef": null, + "events": [ + { + "eventId": "hook-fe10641ee58c4516374aa62d08ecdd5a", + "findingId": "mimosa-bae482b8fc2d0bf8f4f8d36c", + "type": "finding_blocked", + "at": "2026-09-09T22:45:14.291Z", + "identity": { + "projectRelativeFile": "src/__tests__/foreign-session-store.test.ts", + "ruleId": "security", + "codeEvidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200", + "confidence": "stable" + }, + "scope": "direct", + "line": 6, + "endLine": 6, + "reasonCode": "deny", + "reportedToAgent": true, + "evidence": { + "kind": "source", + "boundary": "candidate", + "producer": "deterministic", + "evidenceHash": "39f3c4fadddf28dac4d4a882b71ce35511cddc98a48867049d5b191d23919200" + }, + "sequence": 0 + } + ], + "observedFindingIds": [], + "verifiedFiles": [], + "recordedAt": "2026-09-09T22:45:14.525Z" +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json new file mode 100644 index 0000000000..20ccd92b21 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json @@ -0,0 +1 @@ +{"schemaVersion":"mimosa-stop-continuation/v1","generation":"mtup3h7i-31752-8c73aad84b","used":false,"reportPersisted":false,"claim":null,"updatedAt":"2026-09-09T22:53:55.893Z"} \ No newline at end of file diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..66ab5dd89d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":["E:\\guahub\\gh\\fork\\maka\\packages\\storage\\src\\__tests__\\foreign-session-store.test.ts"],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtup3h7i-31752-8c73aad84b","createdAt":"2026-09-09T22:53:55.806Z","files":{"package.json":{"existed":true,"snapshot":"7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source"},"src/activation-secret-injector.ts":{"existed":true,"snapshot":"0b110039ef708c2c9902423a25f33f4921f3a70b3a630428d684e40c21234195.source"},"src/agent-graph-control-store.ts":{"existed":true,"snapshot":"72e06faa3d05727d394823b2cc266d9c8fd0035735066e32684de896bdd6ba51.source"},"src/agent-run-store.ts":{"existed":true,"snapshot":"2c739d41de1f9960f4b6ecd9506ad1d99f3577badf2b139568bfebbef86125b0.source"},"src/artifact-attachments.ts":{"existed":true,"snapshot":"a63f10f0db1adc9f555b633d83feb0ed6de45e36b3141eae8cf0078e46af8565.source"},"src/artifact-metadata-codec.ts":{"existed":true,"snapshot":"515fca9e5b2cc35f15c4308f67e386c75affe87e381fbad43e8a99e6cdbe0229.source"},"src/artifact-store.ts":{"existed":true,"snapshot":"ea3e70d7ec2a61e9202b51fcca0702e266eb9b6a55c4f29b55d5a3ab7b0796f2.source"},"src/artifact-stores.ts":{"existed":true,"snapshot":"43a8689657a5aa1eec81e0e31324721f39759d98f1b05490b797a27f0886d454.source"},"src/artifact-writer-bootstrap-lock.ts":{"existed":true,"snapshot":"5df1f4b103cac7d9d6915c6188f7e18f6bf56d5a4ba0013fb02e8853ef2946a6.source"},"src/artifact-writer-lock.ts":{"existed":true,"snapshot":"92a3dcf20fbd73d6cc64a24aefde2468c1d6f835d3e84366eaff76a109a9de7e.source"},"src/atomic-file-write.ts":{"existed":true,"snapshot":"8d49d5a7bb9e0e8b369230f063c819f023a830324fe924d443ae3d509bb48c55.source"},"src/bounded-evidence.ts":{"existed":true,"snapshot":"1f3eacfd12ac4859d4cd0be49b335c71efcc9c08b5d70f4cde6edd3e484d9e8b.source"},"src/claude-code-session-adapter.ts":{"existed":true,"snapshot":"b9225538e7a736ee5d7415380154496ee8741a528e052dbe307d4cf6d03c4e8b.source"},"src/claude-code-transcript-lineage.ts":{"existed":true,"snapshot":"54c305c6fbfd63a70c405ed49b73a58075e12294e8d5411066a7f7648a20a5fc.source"},"src/codex-session-adapter.ts":{"existed":true,"snapshot":"a4317e02deb291f32358532c9605fc59e2afd6d40c8f4763d41a6acbc27db88c.source"},"src/config-transfer.ts":{"existed":true,"snapshot":"0ca0b488c99389a538454274720d5620f6a156da5441ba5d55bc75421e3d558b.source"},"src/context-offload-snapshot.ts":{"existed":true,"snapshot":"50773baa95f8c0d216929a949baa423fad2627c50b043513a5d8700bdc5bd9c6.source"},"src/context-offload-store.ts":{"existed":true,"snapshot":"98af3582700f1deebe248e1e36259f156cd1315201010548bbea6b8d53f2ee7f.source"},"src/conversation-operational-state.ts":{"existed":true,"snapshot":"11628582812ce6bb217c12d57741fb98aab25be12f9863f6f826ca5de79314b8.source"},"src/credential-store.ts":{"existed":true,"snapshot":"76b4ea43c37a7b07d80ac638de5b6839c74a8fe0f7aef8b02989222bb0b65c6e.source"},"src/daily-review-authority.ts":{"existed":true,"snapshot":"ae55cbd3694d60eaa600883c201945bad237fcaad0a9c35e96adb81f85d42359.source"},"src/deep-research-authority.ts":{"existed":true,"snapshot":"2ab9a516edf02f01eebb2aab9f1e821de1fae75f04446e6d2e574a80b19ccb7e.source"},"src/deep-research-store.ts":{"existed":true,"snapshot":"aef16fd4c00f6758c3a632e1e1bd2557829e04c03441d2563169b3be99e1e4ee.source"},"src/encrypted-file-managed-secret-store.ts":{"existed":true,"snapshot":"42d57adf09dae74418030a000b661eaa3d5d28db14441db9a322ad0d6c9ec7ae.source"},"src/execution-record-codec.ts":{"existed":true,"snapshot":"6815ab75d190954e6d19490c6980185561bfaea64cc2ea227149e2a6ed1d509b.source"},"src/execution-stores.ts":{"existed":true,"snapshot":"17ec6647da1f28038ff309447ef7be89df9c52a6fdbe441b3803fb54aec82243.source"},"src/external-session-adapters.ts":{"existed":true,"snapshot":"c9eb514a847f5a0364eeb149efa5e1a41307f01bd7be056b968f0402f3883918.source"},"src/external-session-importer.ts":{"existed":true,"snapshot":"84efe92201ff6e0228d10104ca2bf549bed29b6512e3208b4c19fbf59e2d97c3.source"},"src/external-sessions.ts":{"existed":true,"snapshot":"228536d30624e5e32f773a0753c6c897499bf3ee993eef78c92f976bf670264e.source"},"src/failure-utils.ts":{"existed":true,"snapshot":"9e9d5fe8aeaa0b804fdd59bc3a9c1dd7e1894019815760082c71ae4597e87dd2.source"},"src/file-lifetime-owner.ts":{"existed":true,"snapshot":"84ad48a9cab2c64fc6da9687e90318eeeefa657980771d847cb872cdfb801f51.source"},"src/file-session-repository.ts":{"existed":true,"snapshot":"a6f3e04c1b23c93c40cf7553422d48a974d5e60cf0b2a293f95ad981245190ff.source"},"src/file-update-lock.ts":{"existed":true,"snapshot":"70c53985af782f6eadfaa6da18f8c7c3756bfeb5683659ddcec9c20e34630325.source"},"src/foreign-session-store.ts":{"existed":true,"snapshot":"d7996dc5368d97a754564cf6eff3cf8c9f07fb57ecb44c4ae87fd88bc91befd5.source"},"src/fs-native-extensions.d.ts":{"existed":true,"snapshot":"f9218d4ded1d2ba6408997d6b7dccdc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source"},"src/git-entry.ts":{"existed":true,"snapshot":"e7aede64028e5ba0465f4ff125dd3d8033eb58f925f2b4b7d0643cd00e30f853.source"},"src/git-exec.ts":{"existed":true,"snapshot":"694904532eacceb0389fbcbce31d02207910e14e06166c2dd49e1b607ccd51e1.source"},"src/git-worktree-child-executor.ts":{"existed":true,"snapshot":"0a79b9233b7f2e41758aeba4d0392f173d8d280b043351dbf6e8160c0f164a13.source"},"src/goal-authority.ts":{"existed":true,"snapshot":"d1bf25181eb1343fe3d2c48cd7ff20d605bb13ceee265ca177e1107b87bc5175.source"},"src/interaction-store-public.ts":{"existed":true,"snapshot":"d53c752587ce1286de28d4656f393a624cefdffee157e524ddf022dede6888ba.source"},"src/interaction-store.ts":{"existed":true,"snapshot":"661a90f061b2d6359d0605b14d48509c912df4817620f9e64905edead4230cd5.source"},"src/legacy-cron-expression.ts":{"existed":true,"snapshot":"85bd0cc26fbca8a854c73db12d5425d4f8aa89ac42405e5af202a04f68e3a7b4.source"},"src/legacy-run-header.ts":{"existed":true,"snapshot":"284f55c5cab2947b5454dcd0aa7a03b747bf5f8a5630abe5f088c52ee3b99a28.source"},"src/long-term-memory-store.ts":{"existed":true,"snapshot":"85c52c63fe0656944c59fb26d46702cfffbc0350b9d4c2138084e39099dffbaf.source"},"src/managed-dependency-environment.ts":{"existed":true,"snapshot":"46f2eea4b719d5f6193e26ce4cd4c4e8c5b059801ad0dc9c36ab094915f9b0e7.source"},"src/managed-secret-store.ts":{"existed":true,"snapshot":"57c2e6f87ce97dcbd3a71abf2034acd839cea24bd06f0806560f4eaa5d895e92.source"},"src/marker-file.ts":{"existed":true,"snapshot":"250f95dcb17ddd83609d9ccd3b63404084c7a8864b5a05a1bd00ee1d02352c2e.source"},"src/mcp-config-store.ts":{"existed":true,"snapshot":"3ddbcf1e0fcb503a91aeb4e6adf89098cce9fddd29dbbf26172871daa1f484d2.source"},"src/memory-bundle-io.ts":{"existed":true,"snapshot":"6d09b7f7d703b8abfef183e157cc60df77a2cfd59bc33e843177b5ecc0456762.source"},"src/memory-bundle-model.ts":{"existed":true,"snapshot":"0cf609297c4a4825455c7f345bdc2cb4fdbeeae1bbf02b14ee5fc7792200d277.source"},"src/memory-bundle-store.ts":{"existed":true,"snapshot":"e68073e26267c23a56e7b2a36443b1ef05e3cce496a048e338d90ddbf8262784.source"},"src/message-admission-store.ts":{"existed":true,"snapshot":"ae8183ef725fca11f60c5d3b3ec047505373c734673e96112e570f0d58adc0ad.source"},"src/model-call-ledger.ts":{"existed":true,"snapshot":"e166b72f65fc9beed44105e9921f0717e60fba2368b9223fd5bf5208ad73cd1c.source"},"src/model-call-usage-sql.ts":{"existed":true,"snapshot":"e42e7554c8d4fa1ba09af2eb501fe5db726d274558343d52181c53c6293280d0.source"},"src/model-facts-store.ts":{"existed":true,"snapshot":"4e73c8eddbd73054ae3fae5569ebaab6b155a18e1535c52c52665f10b8e4b4a5.source"},"src/native-file-lock.ts":{"existed":true,"snapshot":"d77a99b2e7350aee723d5181f2046475b7ccd2e6f278e258d1a9c874a5d75822.source"},"src/opencode-session-adapter.ts":{"existed":true,"snapshot":"e4057e840c1c735ab8a04a2bc19e27e1e7fb3100f9d3311cf257e961b8cc9180.source"},"src/operational-state-backup.ts":{"existed":true,"snapshot":"c891d5f71a2c80d9adf5dd0f06916904d9403646a4a39388444304756aaeb96c.source"},"src/operational-state-store-public.ts":{"existed":true,"snapshot":"1447df3d5a452b4921cb0fcd2fd1efeb338fc544266331a00b991a9ce9a4bd53.source"},"src/operational-state-store.ts":{"existed":true,"snapshot":"bf012cbc99025221bd64a40ecdffeb67b7a225eecfa1083cf94c83ee6dd5c55c.source"},"src/operational-target-schema.ts":{"existed":true,"snapshot":"0144512940369929821ec6058512449fb509608e17ea9ab7ed630e455df45834.source"},"src/pet-pack-store.ts":{"existed":true,"snapshot":"187e9c117e118d6614e58c24f935383d30b8f6359b1711261a1275b228fa3e60.source"},"src/plan-authority.ts":{"existed":true,"snapshot":"68d2881c319452721d336b8aadf67943ecc3814d7b2a3a31bf144c6ceeb5748d.source"},"src/plan-store.ts":{"existed":true,"snapshot":"e530215828585c8b6096416df5b9c6e5418bbbca5e173b6832ae06ab5cde9c8e.source"},"src/pricing-store.ts":{"existed":true,"snapshot":"49b61c12d523af1ba804b848425a8c91bee90c031d78e4abc0296bac5d0cdb98.source"},"src/process-lifetime-file-update-lock.ts":{"existed":true,"snapshot":"144ea3a6a65649d239eb4c99dd41a4c320de29f91a36500cad6d5a1c00750512.source"},"src/process-lifetime-owner.ts":{"existed":true,"snapshot":"0caf206be6552f359d108770f4316f4306ebdc9fabd86f02097c6d7d2af0ef84.source"},"src/production-session-snapshot.ts":{"existed":true,"snapshot":"a0605375810efdb61551d27732149dfa1cd462c3eb03418b1ae695f18ca2a4c6.source"},"src/project-catalog-authority.ts":{"existed":true,"snapshot":"c4518486119ca165bd47e47eebf70db8cb66d9e844e43bb701bc563b14843179.source"},"src/project-catalog.ts":{"existed":true,"snapshot":"aa2efed09f81648f7218260dc6b76168e75e28357c57b75a5c8f4b2e73164480.source"},"src/quiescent-session-snapshot.ts":{"existed":true,"snapshot":"7b17d4a01d1b5e4b5047fd09f5b9cd7a34edef934e81251c843eedcc5a5c2b80.source"},"src/read-image-snapshot-store.ts":{"existed":true,"snapshot":"1944219a0709bf42189b4215def150784e3020786d67120221f6204c4583f45b.source"},"src/root-authority.ts":{"existed":true,"snapshot":"9fc00f676d440ca8ce68c510a46ff79a0f87dc10b46ac4399e53f5f8f536f716.source"},"src/runtime-event-authority.ts":{"existed":true,"snapshot":"8dc7647ec230a67f2a3be117dcf54882b7ebc217ac63e0f4fd7eb9a947790b20.source"},"src/runtime-event-invariants.ts":{"existed":true,"snapshot":"53cb7d41ec896d9d7ed475ad1a87b68fd2349114335336d3e08a9100d1f577c9.source"},"src/runtime-event-persistence.ts":{"existed":true,"snapshot":"258c9aa4a8654c2f32f0db63a2b83d838a7158294c4163d816bfcb9c5416fb65.source"},"src/runtime-policy-stores.ts":{"existed":true,"snapshot":"38b0c5825ab435155899bf5d21c9bede6061db8e8f6f49d222768aef39c1ce83.source"},"src/runtime-transcript-query.ts":{"existed":true,"snapshot":"abed5e43741f705d2b6e42abe8bc0eaea3d873f6cc4f8df19b59f40c3a0be2ad.source"},"src/scheduled-task-store.ts":{"existed":true,"snapshot":"7e4483722c7538035f9d729f2cdb37cd276b7661c722ea1392378f5eb6ef9bb1.source"},"src/serialized-operation-lane.ts":{"existed":true,"snapshot":"b0867788fb85f4d84c3e0e90bf0d5f9432ba92e93a03f678dafbc4a4b0f3fb3f.source"},"src/session-bundle-canonical-tree.ts":{"existed":true,"snapshot":"480a50e0c1277fa3b68b95b691e3b4f6f6d634869c2690f50040efbcda3f5b0f.source"},"src/session-bundle-contract.ts":{"existed":true,"snapshot":"bcffaa56010ce6b9ef7960943ca455d04d17b1bbc1680618b2805ebb3a483117.source"},"src/session-bundle-file-service.ts":{"existed":true,"snapshot":"1cf66a95bca4ff2f0542df4f4d949a4d7faaa9115237a66193730f8157219c9a.source"},"src/session-bundle-manifest.ts":{"existed":true,"snapshot":"3af6058a9f7a897a386a0c4626ae3249d9902c84b6f961624ba40e4d2a4e9f4c.source"},"src/session-bundle-policy.ts":{"existed":true,"snapshot":"bb9183a1575e5d423129bf70d496ea6fbb356e0d9be056fe89de9ecca32f05b1.source"},"src/session-bundle-ustar.ts":{"existed":true,"snapshot":"9e3a00cfd2fbe588ca0e4382b4b25bd1a08c8a17c15f2400fcd9bfdb3571ea01.source"},"src/session-conversation-copy.ts":{"existed":true,"snapshot":"13ecbc7c9bff60ad8c3b8b4d7e358baeacfc9bbeeb3634ee9a18cad3b0613cff.source"},"src/session-copy-cleanup.ts":{"existed":true,"snapshot":"e91b796163811741651a66d0129f09fdd09b6fda769dfeb82fd499a0a49454fd.source"},"src/session-message-projection.ts":{"existed":true,"snapshot":"b38ae9dd2e02544fac2d2f16c379d43c813897e0d1a6265ac8bcccb1abbb2b5a.source"},"src/session-repository.ts":{"existed":true,"snapshot":"8fa6b5e5061a75611ab69b71a7963514cecdccb7fced0b9fdd376dee38e97452.source"},"src/session-store.ts":{"existed":true,"snapshot":"e8738d4236d6e49c159537af9d06f5c917d2f57be87d69d43b01d85467df96b9.source"},"src/session-todo-authority.ts":{"existed":true,"snapshot":"bcddd8d7a6e9fa0051e4a791689a04d434e1567b61dd363d3b2bf845ba73af7d.source"},"src/session-todo-store.ts":{"existed":true,"snapshot":"bca376a532c9cb05a7abc816eef2e44ab5c92e2be7770593d92b7a8ca8862193.source"},"src/settings-store.ts":{"existed":true,"snapshot":"b47ffc2aa43cf43c9787166f0abf486069f663244ef71334f4530fca16f31e14.source"},"src/shell-run-authority.ts":{"existed":true,"snapshot":"843ed7973606ff6618c2911320e422d5509729712a1f7a112c1ed452889eb8a6.source"},"src/shell-run-store.ts":{"existed":true,"snapshot":"93ce31066a037900868ab5dcfeb62853150c587ae27f1b17695374488fb68857.source"},"src/sqlite-artifact-metadata.ts":{"existed":true,"snapshot":"65932f1580159e63438031504db0442b1617ecb8f4511ec008aea2df7730f946.source"},"src/sqlite-artifact-schema.ts":{"existed":true,"snapshot":"c3a135a7bffe2c0fa864a552f511c4e8cfe4607c0a406eac91367303b157a708.source"},"src/sqlite-context-offload-schema.ts":{"existed":true,"snapshot":"df88da47ceff7b4fcb8336bbfb3062c3bdade41a721ae3398def25f3b62644f5.source"},"src/sqlite-context-offload-store.ts":{"existed":true,"snapshot":"e60d2275924c8653218639b9ff6f9fbf9bb66931d64d0d83891954e0e09fe5d5.source"},"src/sqlite-core-execution-schema.ts":{"existed":true,"snapshot":"3a17b51d4b33284ccf63d0c12c416d46f3833654568ced65072ee6b32af362b1.source"},"src/sqlite-legacy-scheduling.ts":{"existed":true,"snapshot":"31ecabb38719595fcb22828d2eb651de6a04c0cb25f90284ae63d8acfd2f0095.source"},"src/sqlite-long-term-memory-schema.ts":{"existed":true,"snapshot":"6f9af7086b3b96b3b929f6bebb718fcf0fe89f730639007b454c450238da4e8c.source"},"src/sqlite-long-term-memory-store.ts":{"existed":true,"snapshot":"587426d324fa60958636d3275163a4f83ad8521d0cc7834d4e709614602039e1.source"},"src/sqlite-runtime-schema.ts":{"existed":true,"snapshot":"41809820ad9b8e6cddd547b201d44515889957f25c1144dc25033dfaac0d932b.source"},"src/sqlite-runtime-store.ts":{"existed":true,"snapshot":"fa59f44a6f83dc7d6f67b36d9f49c54e07940b32dd19cce4bd4d91e93e910bdb.source"},"src/sqlite-session-catalog-query.ts":{"existed":true,"snapshot":"3267b92f41bb892ad301000262662e567194a967abda841d38ae99da4c77aed7.source"},"src/sqlite-session-metadata-schema.ts":{"existed":true,"snapshot":"1abafe213980bd360a0a3b7e79ff6d71c4e7f9ec6e7527d416f08daff8cfb164.source"},"src/sqlite-session-metadata-store.ts":{"existed":true,"snapshot":"c3973fb227903bf53858772876c7eabe65c00cb8bffa2506dcd52782d6b56e0e.source"},"src/sqlite-session-role-scope.ts":{"existed":true,"snapshot":"e152cc29e43dcab0cade60f82d040f00ddd840429a628d678b974093add045d6.source"},"src/sqlite-usage-schema.ts":{"existed":true,"snapshot":"0e16baec56243eff4244143e256338d5778258957d2b743573786af681dc412c.source"},"src/sqlite-usage-store.ts":{"existed":true,"snapshot":"a311b196030a210ac27edc0383a06164abebed68c5fe86ba75d7ccd1f83439e6.source"},"src/sqlite-workflow-schema.ts":{"existed":true,"snapshot":"9e55ff7008104a1d7ab7bd9d0497e0b546c88af6aab8c62ac1945a924fea7731.source"},"src/stable-storage.ts":{"existed":true,"snapshot":"10694684e058d6bc767c7de81d9f24464deb38360468454e60f1c60f2a0a2008.source"},"src/state-root-composition.ts":{"existed":true,"snapshot":"b7af208b4ba7a033bc4fa94cae80cc2c6c75b7d7f1a11e353fd5f2a9d299a59e.source"},"src/storage-id.ts":{"existed":true,"snapshot":"3418f2496390c0927201e4fdbcbba72dc04924e469a03b0e9ecf651850e27e75.source"},"src/storage-writer-composition.ts":{"existed":true,"snapshot":"a15a6285fc16a1e88b94f9efe840a227c3505b2e811992190ff37c689c538723.source"},"src/submitted-turn-intent.ts":{"existed":true,"snapshot":"7381e3390becc2937475260e76b0fb205714de3a9f9e635012ba6fe4c6f4d0da.source"},"src/telemetry-file-schema.ts":{"existed":true,"snapshot":"8881d69156be2045a7d4bf371839f7590e7cb385f19340747f041663e6d119c5.source"},"src/telemetry-repo.ts":{"existed":true,"snapshot":"675ce68b42b8ba76f109a6ef9249bd90c750c18b2088b034e8288da1cac979b6.source"},"src/tool-result-archive-evidence.ts":{"existed":true,"snapshot":"9cf7d4e97283673cf06fc02db8b76dfe66aa61af99dd8cc6296f5d7e8aa616ea.source"},"src/usage-stores.ts":{"existed":true,"snapshot":"f22c36e0cb294e0bb2134045cb414d397971b9e656c0daddd446fdd3ce22efa8.source"},"src/work-board-list-query.ts":{"existed":true,"snapshot":"81ffd3c1921b95e0ab6a3ea648204bed015675f8f85c8de68e8769bd8d7e79c6.source"},"src/work-board-store-error.ts":{"existed":true,"snapshot":"4dd3091e80cefbd4b2a4d166524df2feddc8b945ba700ed2e514ff4d6179086f.source"},"src/work-board-store.ts":{"existed":true,"snapshot":"632572a9dd209b21ac9be2428815573aa2dd763de1312b2ceee6820839058642.source"},"src/workspace-identity.ts":{"existed":true,"snapshot":"390caa98d55b7b3b629602d2596d126c4fe76d6d6ed67954c26ad8b9ff7063f6.source"},"src/workspace-root.ts":{"existed":true,"snapshot":"4e478ab9cb693bb5d1bbd7a1753197197915b9d74408e1d2c9456522ede65f27.source"},"src/workspace-version-authority-internal.ts":{"existed":true,"snapshot":"9ee9c4b9361396d90a775e7034698e3fe5f17d9416fab3b35024ef6eae00b9eb.source"},"src/write-queue.ts":{"existed":true,"snapshot":"002f7d4c05378ff450ece2af6782b43184460625e66ef065b3eca7f9ccfb1620.source"},"src/__tests__/activation-secret-injector.test.ts":{"existed":true,"snapshot":"50a0e9dca404dbc503d6c086c10e1f373a37be65da667af7bf722af397052219.source"},"src/__tests__/agent-graph-epochs.test.ts":{"existed":true,"snapshot":"f8ee4e06d9abc27d152a208e25298f8b9330d99a5b674993c44dd774841e6aba.source"},"src/__tests__/agent-graph-intent-claims.test.ts":{"existed":true,"snapshot":"cfa22a7f78e0046d0ae446854293eea480cf3b5ed2679d591b99d75da7347bf6.source"},"src/__tests__/agent-graph-schedule-updates.test.ts":{"existed":true,"snapshot":"7cd049534cc371d4d3a5e300353f241ca8b0c8fab6ed8c39dd028d80e42bb954.source"},"src/__tests__/agent-graph-supervisor-root-admission.test.ts":{"existed":true,"snapshot":"c75ed03ed75758326a4b5c3c002aa811572a97ef3d53e5f74e84f185930b7eea.source"},"src/__tests__/agent-graph-supervisor-wakes.test.ts":{"existed":true,"snapshot":"159c0a338daed5513bc163fb18307a9066036fe86fe344d9c616b07d6f6dd0d3.source"},"src/__tests__/agent-graph-timeline-metadata.test.ts":{"existed":true,"snapshot":"8939b0f4e3e262644694d0637555d14d3c97f218a7bea77a635c306d73cac369.source"},"src/__tests__/artifact-attachments.test.ts":{"existed":true,"snapshot":"01e0f2d853fe17ccbaaf523f6c5e2d42706c00b28dbe2bc15246231aa221a50e.source"},"src/__tests__/artifact-store.test.ts":{"existed":true,"snapshot":"b7079dbad7ddbf492932972e73b860b51df5f2971cbb176b1fc246ecc51451fe.source"},"src/__tests__/artifact-stores.test.ts":{"existed":true,"snapshot":"b9644fc799b3504a8845835638e29700b08e0b1b83f6f0861ca70ec22a6f9379.source"},"src/__tests__/artifact-writer-lock.test.ts":{"existed":true,"snapshot":"f3a1b7ebadf4fa0f9c7077119f454e00c2506594fe28b5873913d3b14bf3af4d.source"},"src/__tests__/atomic-file-write.test.ts":{"existed":true,"snapshot":"751ad237e2ddacf804b1fcf0fec4ba6c65818416f5e05e9983ca7d1c82cf56d3.source"},"src/__tests__/claimed-agent-graph-root-admission.test.ts":{"existed":true,"snapshot":"11c6efb3420f5cd72ad78c669e0b2d2b528b735789c5d8907c990355def94255.source"},"src/__tests__/claude-code-session-adapter.test.ts":{"existed":true,"snapshot":"c8619f33df98a891b8af93899fcda927504f89562ccfc7b1d0eac71cc644fae1.source"},"src/__tests__/claude-code-transcript-lineage.test.ts":{"existed":true,"snapshot":"562b3617d44f32142eb3ce38e334def04df6337b178871ba8ba0672f367bb4a8.source"},"src/__tests__/client-capability-session-grant-store.test.ts":{"existed":true,"snapshot":"ff721394b3de3f5ca2fd0d0278c85bb37fbbab581f93a22d38475578b45cc402.source"},"src/__tests__/codex-session-adapter.test.ts":{"existed":true,"snapshot":"b1e5fe0faa5605e6186d04c3618f48e6dff6d1ae50fe4832330eca0c2ebef913.source"},"src/__tests__/config-transfer.test.ts":{"existed":true,"snapshot":"666276a97a577f5026adde38e873776ba3ca048e69a7ce7ab1bc35550f07f7c2.source"},"src/__tests__/context-offload-snapshot.test.ts":{"existed":true,"snapshot":"63a8249af19a4b26abb7e6e829c01e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source"},"src/__tests__/context-offload-store.test.ts":{"existed":true,"snapshot":"7bbbf4ea0188ff2e1977dcf6667bc8bfadf6bc82e837086cc3756eac41e6ac41.source"},"src/__tests__/credential-store.test.ts":{"existed":true,"snapshot":"1ba2cca36caba115903e9e0f58483482ee810349d47e6fc492b9e45de3993ec7.source"},"src/__tests__/daily-review-authority.test.ts":{"existed":true,"snapshot":"fc814ec5d77a4b5ccf903dd5aab9ad9c20dcd6597c7d33925bd9fa018f5c5ccc.source"},"src/__tests__/external-session-importer.test.ts":{"existed":true,"snapshot":"ada1fbf873d37be538d04d62aa92925ee4bf2fb917b0b39c5768bedf83138ffd.source"},"src/__tests__/file-lifetime-owner.test.ts":{"existed":true,"snapshot":"904c9b7253dd6c4c9c4115b804ea2661b0a0f04a2216145e5ec719c0c9ec2635.source"},"src/__tests__/file-session-repository.test.ts":{"existed":true,"snapshot":"5518a8dd2844daabdac12512cb7a1edd86ceca5e5f6afa4561b24f343bee07e9.source"},"src/__tests__/file-update-lock.test.ts":{"existed":true,"snapshot":"c3e8638a33ec726581fe1f60597733bffe42c802ca50b84b8b7f4bfb21a8c8dc.source"},"src/__tests__/foreign-session-store.test.ts":{"existed":true,"snapshot":"d66655ebf390860cfa6222cdb35a0d9929039f8d5498871affe5c6a00705c152.source"},"src/__tests__/git-exec.test.ts":{"existed":true,"snapshot":"c17b71c2d1c9a64b48bd5f0e65ac91f78ae9a92432735bc1f2cb420af4814d06.source"},"src/__tests__/git-worktree-child-executor.test.ts":{"existed":true,"snapshot":"e14bda1726b70f3dfff599d0e584356e8c5bf9e4ae7236ab82d42137fcc13732.source"},"src/__tests__/goal-authority.test.ts":{"existed":true,"snapshot":"a59e6d66207e21bda9af6cec947236d232215df2399a91ee83e307bc261f9e4a.source"},"src/__tests__/invocation-opening-backfill.test.ts":{"existed":true,"snapshot":"19da778283a22193925d576747ce39a388a618ba5e34ec3be83fba618acf6c5c.source"},"src/__tests__/legacy-cron-expression.test.ts":{"existed":true,"snapshot":"148b788c445539da65302aa00d69d66f09f56154a02bbbb30a30b4fda5f072d5.source"},"src/__tests__/legacy-run-header.test.ts":{"existed":true,"snapshot":"8985912bc57fdddd12a6a0629d8271dd0f510370378aa45dd5894cf2e3cac104.source"},"src/__tests__/managed-dependency-environment-crash.test.ts":{"existed":true,"snapshot":"50bbb968805fa5596629c15cda92c01eb7287bff35cd9a0f3462beb5e62f7653.source"},"src/__tests__/managed-dependency-environment.test.ts":{"existed":true,"snapshot":"01383bc4c23f1febe28dc7b817c46e83507546b9d9e39d450b813e04746280b8.source"},"src/__tests__/managed-secret-store.test.ts":{"existed":true,"snapshot":"f8d9630d425b7c9af813a75d89ce55ceed202516f51a794cd183ecedbfc47873.source"},"src/__tests__/marker-file.test.ts":{"existed":true,"snapshot":"80034974180b5d088d1173eb2edfb8bbdbd8aa807a63ff8aa2367e88e88c5859.source"},"src/__tests__/mcp-config-store.test.ts":{"existed":true,"snapshot":"f8b8607bb3f6084a75091c2480c40dd22e4c4b92995467693c3757dce7af2f62.source"},"src/__tests__/memory-bundle-store.test.ts":{"existed":true,"snapshot":"aef819bb8ffa5f04af46467ed4de04a283b60c5c62e310874fd19a89abcbf078.source"},"src/__tests__/model-call-ledger.test.ts":{"existed":true,"snapshot":"f8650367cac798c2a15f90edaff1a9465c48e0274fe50dc34cf6f684ba093f99.source"},"src/__tests__/model-call-usage-query.test.ts":{"existed":true,"snapshot":"e6e0a6d63839ed6c44194936e1354fcab570887ddc2abcfe3d8f244e9202d19f.source"},"src/__tests__/model-facts-store.test.ts":{"existed":true,"snapshot":"7d25e909912fd4eda8f6d9cd79441a46b02ef360bd1e34b3b075cf6d75275010.source"},"src/__tests__/onboarding-transaction.test.ts":{"existed":true,"snapshot":"116c1ecfafa2a309609e79929c7085469b5b874112f8b540e6f90959086e3038.source"},"src/__tests__/opencode-session-adapter.test.ts":{"existed":true,"snapshot":"07fadffe5f0c37a379248609951b70aa9e9d6015dc8a383a62a4fc000044476c.source"},"src/__tests__/operational-state-backup.test.ts":{"existed":true,"snapshot":"b1f568e9e8cc04815be9e2086b34c0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source"},"src/__tests__/operational-state-store.test.ts":{"existed":true,"snapshot":"d7840849fd8327bb9a305f7708f08929872d3fe31a789c8dadff52e276499ae0.source"},"src/__tests__/pet-pack-store.test.ts":{"existed":true,"snapshot":"109d74f938a85294c61a1084bb5d6b6402c989b93d17ddbcc084ee8c424f6ee5.source"},"src/__tests__/process-lifetime-owner.test.ts":{"existed":true,"snapshot":"2bcfbef89ab65fd00a08bdcd52fef1a78dd42a822e5c01e6f1200ce39c281e81.source"},"src/__tests__/production-session-snapshot.test.ts":{"existed":true,"snapshot":"bf0ec2be49569dd1ee470fc4aa8a8e8d187b7f5ecbea3036ca6edfb6eaa7e171.source"},"src/__tests__/project-catalog-authority.test.ts":{"existed":true,"snapshot":"7263490a57b317c4383785078458a63cf076787fac4d6443d30a5d651f766a3c.source"},"src/__tests__/project-catalog.test.ts":{"existed":true,"snapshot":"810960299e37df778b8b38738d94bdb9a1b6ddec29ad53107e0aca445525044f.source"},"src/__tests__/public-entrypoints.test.ts":{"existed":true,"snapshot":"5e51a2c71b3353234ea0326839b930cfe6fb48d798ddd37c31f7c0564388042a.source"},"src/__tests__/quiescent-session-snapshot.test.ts":{"existed":true,"snapshot":"89df39ff4c27acff065c1d58502c3e3f8c060a1ea2f3d6422204e71e2eb61687.source"},"src/__tests__/read-image-snapshot-store.test.ts":{"existed":true,"snapshot":"110172c1b557a2922cd4f765f34f62a845434bc340a42ce8706d1b29845298c2.source"},"src/__tests__/recovery-persistence-authority.test.ts":{"existed":true,"snapshot":"ee7793d345da1fd98b5e0bb7ca1f7f4deaea210bd94b3679ebe88a5248d8978c.source"},"src/__tests__/regenerate-root-admission.test.ts":{"existed":true,"snapshot":"4d7e41e72fcdae82668405e604a70451ad2e524a80ae21778a208b3d5f34972d.source"},"src/__tests__/root-authority.test.ts":{"existed":true,"snapshot":"5a8ae5c838ca8ae9045824d2953a7daefefa835f5de4fa00a0a3cad3418c40f8.source"},"src/__tests__/root-turn-admission-normalization.test.ts":{"existed":true,"snapshot":"1b44f827b405d4fb44fbdeec5cec87368bfa5bd187dd0c6345eaa4916c09be6c.source"},"src/__tests__/runtime-policy-model-facts.test.ts":{"existed":true,"snapshot":"9258498671071e7cfecc47c4dc7c623362e2b8334589db059641658db6ae779b.source"},"src/__tests__/runtime-policy-stores.test.ts":{"existed":true,"snapshot":"23ae3e27970156eebbbccdce5d9db0a933efa9f95219e6c8da2b93c79a5c3c7e.source"},"src/__tests__/safe-boundary-continuation-admission.test.ts":{"existed":true,"snapshot":"cce9bb5694aa1d060ef31bcf0fc8a86da6e1d8029cc7e2a5319b427f31bd0565.source"},"src/__tests__/scheduled-task-row-operations.test.ts":{"existed":true,"snapshot":"b2a6946d892bf8a0f15dbe3ebac7ed41053454581aa7e626c2c89394b7a150a2.source"},"src/__tests__/session-bundle-canonical-tree.test.ts":{"existed":true,"snapshot":"b950bcebf7ddd53748505fdb4d9d6a857edf519f7d191e697eab95d79890dd87.source"},"src/__tests__/session-bundle-contract.test.ts":{"existed":true,"snapshot":"9233ff65304ac6ebc67d2f9192f2d9cb02b3289bf591239131a2beab0bd909e2.source"},"src/__tests__/session-bundle-file-service.test.ts":{"existed":true,"snapshot":"74b1eb5a7812a5e24db08f66a0ec19d43b24f917f52263e3977cca6a07875700.source"},"src/__tests__/session-bundle-manifest.test.ts":{"existed":true,"snapshot":"ac82d9fa974c255922bf29cfca4c560518c1c2e145a5c9bd690d6bf8052921fb.source"},"src/__tests__/session-bundle-policy.test.ts":{"existed":true,"snapshot":"c6e1c9c53b43d18715182db67d89ab8c7dc60dd06adf9368d071be0a3c2a2790.source"},"src/__tests__/session-bundle-ustar.test.ts":{"existed":true,"snapshot":"b38f5ac8a354528bf7849f0d436f2ea7c74eecbdecea11fb4f88b5dc0d693433.source"},"src/__tests__/session-copy-cleanup.test.ts":{"existed":true,"snapshot":"1ae1446df7b17bede6791f516c3c522981394bf16f03434679fd2209ff2a3b94.source"},"src/__tests__/session-repository-conformance.test.ts":{"existed":true,"snapshot":"9af30c25beb573d02bcfc5a15e29f34811f1396019383deb95ef9c970e3b6ecf.source"},"src/__tests__/session-repository.test.ts":{"existed":true,"snapshot":"23ed98860025866144d99f185da5cab87e0a9c425eebb9f33c51e0f60a56e8a1.source"},"src/__tests__/session-store.test.ts":{"existed":true,"snapshot":"5a9bb0f57602903fec0dd86de9c659b46cb66202bc2725379affeeea49fdfd03.source"},"src/__tests__/session-todo-authority.test.ts":{"existed":true,"snapshot":"0565cdd842e9666c3a8e0c586fe274eebe04b6275de0ef792a19ecfa7407b7ca.source"},"src/__tests__/session-todo-store.test.ts":{"existed":true,"snapshot":"b1cc31cf7c96f025d523f115ec69f70ca16063d0855349518de5dffee7a9c033.source"},"src/__tests__/settings-store-onboarding.test.ts":{"existed":true,"snapshot":"797f2fa9be3663837c4844a5136910f100c9038fbbf5b3ba4da529163d0c373a.source"},"src/__tests__/shell-run-authority.test.ts":{"existed":true,"snapshot":"d7f7374d14840e482b1a7f4fb4e9185e8a738d931615d21a5a985a7d5f47b9f5.source"},"src/__tests__/sqlite-artifact-metadata.test.ts":{"existed":true,"snapshot":"03839a42de4221907636530ce00e0ae085dc74c63042018d3d0bb28d116a7ac6.source"},"src/__tests__/sqlite-context-offload-store.test.ts":{"existed":true,"snapshot":"0bf73a3812ab803fcbe46feccbf511b4facf25e5cad9c7d4b98c8478d83f4b87.source"},"src/__tests__/sqlite-core-execution-store.test.ts":{"existed":true,"snapshot":"6f48c827648d07a3667b7cb9d6be7bdc3693b501b1bdbc33d927ec20f68b4802.source"},"src/__tests__/sqlite-long-term-memory-crash.test.ts":{"existed":true,"snapshot":"f0578cba6901e2540bd1d848d9976a7734c907740018aaf3d6febf7c2eb432ac.source"},"src/__tests__/sqlite-long-term-memory-store.test.ts":{"existed":true,"snapshot":"3969147664af86118d9b2cfe35839e9af672da81ed1b70728a5e50e2bccd9766.source"},"src/__tests__/sqlite-recovery-concurrency.test.ts":{"existed":true,"snapshot":"aaf41c9b5ba506e18ba1ab73276398a30801e6ebd02885e12944bdf0eedb25fc.source"},"src/__tests__/sqlite-runtime-crash.test.ts":{"existed":true,"snapshot":"ee006f08adc2d3a798496046f907efaa355da9e1dd6c2e54109168faad090be6.source"},"src/__tests__/sqlite-runtime-schema.test.ts":{"existed":true,"snapshot":"6b5e3837d390b0d3ba4ab082f6c2a83591d7bb20b1c621c9d56ec575934019fa.source"},"src/__tests__/sqlite-runtime-store.test.ts":{"existed":true,"snapshot":"1c8116448911a87e7b8c4e17dbfc64feceee84617f77d6750d280a889ed414f6.source"},"src/__tests__/sqlite-session-metadata-store.test.ts":{"existed":true,"snapshot":"76a5b8eec761364b7e838202d10f10c2d1349f3d0cfe0a7fc0ced38356f64f9e.source"},"src/__tests__/sqlite-usage-schema.test.ts":{"existed":true,"snapshot":"5dffef014972693a9310c35d631e4ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source"},"src/__tests__/sqlite-workflow-store.test.ts":{"existed":true,"snapshot":"9b5d6d06535bbda2cad79ece6b14d68e5fcd7b43f696ddd6a69caa8c75171d06.source"},"src/__tests__/stable-storage.test.ts":{"existed":true,"snapshot":"8da468a681539c9f3b2f2e336c35930205bbe4220918aaa20bb638deb3e87d28.source"},"src/__tests__/state-root-composition.test.ts":{"existed":true,"snapshot":"7b495c5b6637efac2e6b6d291181dedf4e9d3407e23bfdcacf6d4d0017ac612c.source"},"src/__tests__/storage-id.test.ts":{"existed":true,"snapshot":"66dbb381f656aeefac2a6d656264f23e499e00ba6f05254cdadfbbd0199ea795.source"},"src/__tests__/storage-writer-composition.test.ts":{"existed":true,"snapshot":"0e1b84cfb497e9e18f6967d340ee887984032927ed036ec6361c3a087eddede8.source"},"src/__tests__/tool-result-archive-evidence.test.ts":{"existed":true,"snapshot":"063dbd2b9bd5fb830aa2a39289fefa6bf4176d8acc47664ed07a6ad6f554b660.source"},"src/__tests__/usage-stores.test.ts":{"existed":true,"snapshot":"79665fa282288bb4d0d74979ba991a179ba27c54a855f5ca8e052df61248b17f.source"},"src/__tests__/work-board-store.test.ts":{"existed":true,"snapshot":"3c41843f8eeec84c816ed119e129aeaba9e346d168bd12455a4b0d224e5f3518.source"},"src/__tests__/workhub-coordination-root-admission.test.ts":{"existed":true,"snapshot":"bf3c74074f632a14c344c2e7a2b4e010ef8171c4ab1cfe25f5c1dd958d50a0e9.source"},"src/__tests__/workhub-message-assignment.test.ts":{"existed":true,"snapshot":"e6b133b580f3ec4dce18a252f6253f2f75a0b5e997318cb417fe01f4c0dd0ded.source"},"src/__tests__/workspace-identity.test.ts":{"existed":true,"snapshot":"cfdbf41ae122697978b6424b86658d701f7c664d931c1a50fa5cd149b0e6c003.source"},"src/__tests__/workspace-root.test.ts":{"existed":true,"snapshot":"f844d59f635a4247184a402724320c7d24fc5f003b4089e148a37c8667cbc024.source"},"src/__tests__/workspace-version-authority-persistence.test.ts":{"existed":true,"snapshot":"6daf17a78ca79953c07dba2784695a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source"},"src/__tests__/write-queue.test.ts":{"existed":true,"snapshot":"321caa87cd5198960b3d65b7597639d91a4e7ccd3e9784dec16ff6609234e886.source"},"src/__tests__/fixtures/artifact-writer-lock-holder.ts":{"existed":true,"snapshot":"a4cd691ba4b3e5ddc599b5303cafae915c4730a08d8b325a3df9be8dca0def15.source"},"src/__tests__/fixtures/context-offload-managed-publication-crash-child.ts":{"existed":true,"snapshot":"104ae0455b49b0dd17fe5b0d89ad03920f8a54e34455a118c6ce429c3704725e.source"},"src/__tests__/fixtures/control-directory-hygiene.ts":{"existed":true,"snapshot":"10a608d594755789190bd2273d1b4f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source"},"src/__tests__/fixtures/file-lifetime-owner-holder.ts":{"existed":true,"snapshot":"b19149e12ee8cc016b900bfd626927864d428b950a5b903d83bfc00e08bfb7be.source"},"src/__tests__/fixtures/file-session-repository-crash-writer.ts":{"existed":true,"snapshot":"fdf3aa99c8eb82c563fa0f23f229997e426c6a39c2af0df494af69e7e362911c.source"},"src/__tests__/fixtures/file-update-lock-holder.ts":{"existed":true,"snapshot":"5d68580fdc8228647ca90095b49ee0a7cc91f00b5463465a8d862234cf032343.source"},"src/__tests__/fixtures/git-repository.ts":{"existed":true,"snapshot":"c3670ed264cb7c6762d741b685d4a955c34f812692c81423ea852e2c5dda0920.source"},"src/__tests__/fixtures/invocation-opening.ts":{"existed":true,"snapshot":"c584098d8bd3389fe9e89fcb5b4372c136bb901d9b7a2c37a0369c32d4a5417f.source"},"src/__tests__/fixtures/managed-dependency-environment-crash-child.ts":{"existed":true,"snapshot":"daa932cc51bd3390e17db280faf1b88966c6bebb72eba62964cc15632eebf6a6.source"},"src/__tests__/fixtures/managed-dependency-environment-owner-child.ts":{"existed":true,"snapshot":"e6e64cb168b4524bf0335686f4417f268f5a83d5301f3b44e0d6011bfe48c2e9.source"},"src/__tests__/fixtures/mcp-config-lock-holder.ts":{"existed":true,"snapshot":"487ec79757a9a17fb4320b4deb9c0864c01fef0bfa657ca3e86c2b8ec34ae914.source"},"src/__tests__/fixtures/model-call-attempt.ts":{"existed":true,"snapshot":"f8b77f2ce84c6a0cea6a26f29c3db0beb37ea2f0e51f71b6536403a3315b1e90.source"},"src/__tests__/fixtures/process-lifetime-owner-holder.ts":{"existed":true,"snapshot":"65c368e22ce7610014a262af4ed3381ee647f76b32f9d7e987c19f14569ac35f.source"},"src/__tests__/fixtures/root-initialization-race.ts":{"existed":true,"snapshot":"953b5adc48bd5b60864af4a55adfd8339e78f487d4b7e3ac5c14e71694b71c4d.source"},"src/__tests__/fixtures/root-lock-holder.ts":{"existed":true,"snapshot":"5369dc7815b4f1c0116805a72b8814f7d307a2285382e962a3fc26b9e0baf0d6.source"},"src/__tests__/fixtures/root-resolver.ts":{"existed":true,"snapshot":"b3824836d5e0e3213efe069016a5cb1c3740fa80267090c595280f0e141b1d40.source"},"src/__tests__/fixtures/session-bundle-hydration-binding-crash.ts":{"existed":true,"snapshot":"aee92dfc4cbced6cbb5db32c1bb80a7d3cd4e1576d268fde522d93e15bc26f18.source"},"src/__tests__/fixtures/session-bundle-hydration-owner-write-failure.ts":{"existed":true,"snapshot":"8da1ad9f45c64980e5ebc1a73f3c778df4cb2312070093227bc598d44b7035d2.source"},"src/__tests__/fixtures/session-bundle-inspect-child.ts":{"existed":true,"snapshot":"d138eb9ae0da1d4f403abf4b292c612079b036090afc91a3b3da73ed7c3ff90c.source"},"src/__tests__/fixtures/session-bundle-inspect-source-mutator.ts":{"existed":true,"snapshot":"51ad1282a62435d08a3b2cc81fe361d57cb042577af736ec5ecdc1c8ca8fbf05.source"},"src/__tests__/fixtures/session-bundle-pack-destination-replacer.ts":{"existed":true,"snapshot":"08509565c867a7b3880dc679c094cd5c49a97c202bb0a9a7bbdac5eb76e9e24b.source"},"src/__tests__/fixtures/session-bundle-pack-link-replacer.ts":{"existed":true,"snapshot":"849fff466825150f84f0d75515fa9befa55492c0ff2a836576bb4eed28cbb42f.source"},"src/__tests__/fixtures/session-bundle-pack-linked-temp-remover.ts":{"existed":true,"snapshot":"deb514a0e5cd5efb1dc0b97f48658cfd0e5ea67c4d18abf3546bfd439dbabafe.source"},"src/__tests__/fixtures/session-bundle-pack-temp-replacer.ts":{"existed":true,"snapshot":"6639bdf8a8b878b7838782cbc99bf35796f08df75d7e3b8aecf6d68cef35eb00.source"},"src/__tests__/fixtures/settle-sandbox-boundary-worker.ts":{"existed":true,"snapshot":"acc59811923252bc07f5b90d2d006895b4863295874e2ecc3d67620a21793cc3.source"},"src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts":{"existed":true,"snapshot":"74fa9d7e6535592124401a34c1595cc7e41d6601da66d670347f1e18a1362e3a.source"},"src/__tests__/fixtures/work-board-cas-worker.ts":{"existed":true,"snapshot":"be2f232722c8aeb2da8d57e82cc308f805b3eec7c97962a8546e5bfe3c91269d.source"},"src/runtime-policy/codec.ts":{"existed":true,"snapshot":"7f8e5fa6a8beec10da43463031b54aaee6b8bac65d26b4bad5de325b87ab3e83.source"},"src/runtime-policy/connection-catalog-document.ts":{"existed":true,"snapshot":"b03ada6aca801b4466bffc0b996bc7fcadfd13cc3172f96d4760dee34e2737dd.source"},"src/runtime-policy/coordinator.ts":{"existed":true,"snapshot":"412f858ccc2b1f7414868d27d74cf511ba9b3e61827d3af74fe39f581cadc029.source"},"src/runtime-policy/credential-vault-document.ts":{"existed":true,"snapshot":"81601024e587ad4ccbb092622616a7449b0220737d33a5ed6cc7612f93c55c47.source"},"src/runtime-policy/document-io.ts":{"existed":true,"snapshot":"322ed7ea7f10d02ba1a241250feaec857cdbc868540fc767d0b7820a3dba292f.source"},"src/runtime-policy/errors.ts":{"existed":true,"snapshot":"722a3f28b7e87563ba80b4e948cd0683f12cd3a3d732cba567be5f465ade0031.source"},"src/runtime-policy/oauth-login-receipt-document.test.ts":{"existed":true,"snapshot":"968b14ff33c871e7656f44e4d1fccbc96a548904842a3d964aab0704496b06f5.source"},"src/runtime-policy/oauth-login-receipt-document.ts":{"existed":true,"snapshot":"05eae587b633856514517b9e0da97108b7aef6bdcfd59fd3db471dea8a642dc0.source"},"src/runtime-policy/onboarding-transaction.ts":{"existed":true,"snapshot":"3c04f9117b470c55eda73c045214f46bf01125eb6b4907b8ca91b8e61c1919d2.source"},"src/runtime-policy/operations.ts":{"existed":true,"snapshot":"9b270a2da44b49129749e862b68155f47a7b2097e9a2017ac5ea794ba64ebbb6.source"},"src/runtime-policy/policy-document.ts":{"existed":true,"snapshot":"61598d16b430d9cbca2719113acfcd593513dc7f761568a5ea4c8595e3d53fad.source"}},"complete":true,"candidateLimit":5000,"discoveredFiles":268,"capturedFiles":268,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-11T12:27:10.979Z"} \ No newline at end of file diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/002f7d4c05378ff450ece2af6782b43184460625e66ef065b3eca7f9ccfb1620.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/002f7d4c05378ff450ece2af6782b43184460625e66ef065b3eca7f9ccfb1620.source new file mode 100644 index 0000000000..236dc3c366 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/002f7d4c05378ff450ece2af6782b43184460625e66ef065b3eca7f9ccfb1620.source @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Serialize a write operation under `key` against a per-key promise + * chain held in `queueMap`. Once the chain for a key drains with no + * newer write queued behind it, the entry self-evicts so the Map + * does not accumulate one settled Promise per key forever. + * + * The returned promise rejects on operation failure so callers can + * observe errors; the Map-held chain swallows rejections only to keep + * the chain alive for subsequent writes. + */ +export function chainWrite( + queueMap: Map>, + key: string, + operation: () => Promise, +): Promise { + const previous = queueMap.get(key) ?? Promise.resolve(); + const next = previous.then(operation, operation); + const stored = next.catch(() => { + // Keep the chain alive after failures. + }); + const tracked = stored.finally(() => { + if (queueMap.get(key) === tracked) queueMap.delete(key); + }); + queueMap.set(key, tracked); + return next; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01383bc4c23f1febe28dc7b817c46e83507546b9d9e39d450b813e04746280b8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01383bc4c23f1febe28dc7b817c46e83507546b9d9e39d450b813e04746280b8.source new file mode 100644 index 0000000000..a2318cd487 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01383bc4c23f1febe28dc7b817c46e83507546b9d9e39d450b813e04746280b8.source @@ -0,0 +1,847 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { + access, + chmod, + type FileHandle, + mkdtemp, + mkdir, + open, + readFile, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, +} from '../managed-dependency-environment.js'; + +const FIXTURE_PRODUCER_RUNTIME_IDENTITY = `sha256:${'a'.repeat(64)}` as const; +const FIXTURE_PRODUCER_CAPABILITY = createManagedDependencyEnvironmentProducerCapability( + FIXTURE_PRODUCER_RUNTIME_IDENTITY, +); + +test('computes one shared environment identity for equivalent dependency inputs', () => { + const input = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: 'linux' as const, + arch: 'x64' as const, + producerRuntimeIdentitySha256: `sha256:${'1'.repeat(64)}` as const, + producerPolicyIdentitySha256: `sha256:${'2'.repeat(64)}` as const, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + + const first = computeManagedDependencyEnvironmentIdentity(input); + const second = computeManagedDependencyEnvironmentIdentity({ ...input }); + + assert.match(first.environmentId, /^sha256:[0-9a-f]{64}$/); + assert.equal(first.environmentId, second.environmentId); + assert.equal(first.manifestSha256, second.manifestSha256); + assert.equal(first.lockfileSha256, second.lockfileSha256); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ ...input, nodeAbi: '138' }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ + ...input, + platform: 'darwin', + }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ + ...input, + producerRuntimeIdentitySha256: `sha256:${'3'.repeat(64)}`, + }).environmentId, + ); + assert.notEqual( + first.environmentId, + computeManagedDependencyEnvironmentIdentity({ + ...input, + producerPolicyIdentitySha256: `sha256:${'4'.repeat(64)}`, + }).environmentId, + ); +}); + +test('rejects a producer that does not declare the exact hermetic capability', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-capability-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + + await assert.rejects( + createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: { + ...FIXTURE_PRODUCER_CAPABILITY, + network: 'unrestricted' as never, + }, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision() {}, + }, + }), + /producer capability is invalid/u, + ); +}); + +test('rejects a second authority for the same storage root in one process', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-single-owner-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision() {}, + }; + const first = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + + const second = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }).then( + (authority) => ({ authority }), + (error: unknown) => ({ error }), + ); + if ('authority' in second) { + await second.authority.close(); + assert.fail('a second authority acquired the same storage root'); + } + assert.match(String(second.error), /already has an active owner/u); + await first.close(); +}); + +test('rejects a published environment whose dependency content was modified', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-tamper-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await mkdir(join(input.outputRoot, 'fixture-package'), { + recursive: true, + }); + await writeFile(join(input.outputRoot, 'fixture-package', 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const identityInput = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(identityInput); + const firstAuthority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + const lease = await firstAuthority.acquire(identity, identityInput); + await writeFile(join(lease.dependencyRoot, 'fixture-package', 'index.js'), 'tampered\n', 'utf8'); + await lease.release(); + await firstAuthority.close(); + + const reopened = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + await assert.rejects(reopened.acquire(identity, identityInput), /does not match its receipt/u); + await reopened.close(); +}); + +test('keeps the receipt in a constrained authority outside the producer-owned artifact domain', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-receipt-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + const lease = await authority.acquire(identity, source); + const artifactRoot = dirname(lease.dependencyRoot); + const authorityDatabasePath = join( + storageRoot, + 'managed-workspaces', + 'dependency-environment-authority-v1.sqlite', + ); + await lease.release(); + await assert.rejects(readFile(join(artifactRoot, 'environment-receipt.json'), 'utf8'), { + code: 'ENOENT', + }); + await access(authorityDatabasePath); + const reopened = await authority.acquire(identity, source); + await reopened.release(); + await authority.close(); +}); + +test('rejects a coordinated artifact and co-located receipt rewrite', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-coordinated-tamper-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + const lease = await authority.acquire(identity, source); + const artifactRoot = dirname(lease.dependencyRoot); + await lease.release(); + await authority.close(); + + await writeFile(join(lease.dependencyRoot, 'index.js'), 'malicious\n', 'utf8'); + await writeFile( + join(artifactRoot, 'environment-receipt.json'), + `${JSON.stringify({ environmentId: identity.environmentId, contentTreeSha256: 'forged' })}\n`, + 'utf8', + ); + + const reopened = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + await assert.rejects( + reopened.acquire(identity, source), + /artifact contains an unowned entry|content does not match its receipt/u, + ); + await reopened.close(); +}); + +test('rejects an environment id that is not the digest of the requested identity', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-identity-forgery-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision() {}, + }; + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + await assert.rejects( + authority.acquire({ ...identity, environmentId: `sha256:${'f'.repeat(64)}` }, source), + /identity is not canonical/u, + ); + await assert.rejects( + authority.acquire( + { ...identity, environmentId: 'sha256:../../escaped' } as typeof identity, + source, + ), + /identity is not canonical/u, + ); + await assert.rejects(access(join(storageRoot, 'escaped')), { + code: 'ENOENT', + }); + await authority.close(); +}); + +test('rejects an NTFS alternate stream created inside a dependency artifact', { + skip: process.platform !== 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-ads-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + const target = join(input.outputRoot, 'index.js'); + await writeFile(target, 'trusted\n', 'utf8'); + await writeFile(`${target}:unhashed`, 'malicious\n', 'utf8'); + }, + }; + const source = dependencySourceForName('ads'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + await assert.rejects( + authority.acquire(identity, source), + (error: unknown) => + error instanceof Error && + error.message === 'Managed dependency environment contains an alternate data stream', + ); + await authority.close(); +}); + +test('rejects an NTFS alternate stream attached to the published dependency root', { + skip: process.platform !== 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-root-ads-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = dependencySourceForName('root-ads'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + const lease = await authority.acquire(identity, source); + await writeFile(`${lease.dependencyRoot}:unhashed`, 'malicious\n', 'utf8'); + await lease.release(); + await authority.close(); + + const reopened = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects( + reopened.acquire(identity, source), + (error: unknown) => + error instanceof Error && + error.message === 'Managed dependency environment contains an alternate data stream', + ); + await reopened.close(); +}); + +test('rejects an NTFS alternate stream attached to a published nested directory', { + skip: process.platform !== 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-目录-ads-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + const packageRoot = join(input.outputRoot, 'fixture-包'); + await mkdir(packageRoot); + await writeFile(join(packageRoot, 'index.js'), 'trusted\n', 'utf8'); + }, + }; + const source = dependencySourceForName('nested-directory-ads'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + const lease = await authority.acquire(identity, source); + await writeFile(`${join(lease.dependencyRoot, 'fixture-包')}:unhashed`, 'malicious\n', 'utf8'); + await lease.release(); + await authority.close(); + + const reopened = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + await assert.rejects( + reopened.acquire(identity, source), + (error: unknown) => + error instanceof Error && + error.message === 'Managed dependency environment contains an alternate data stream', + ); + await reopened.close(); +}); + +test('accepts a POSIX package bin symlink whose target remains inside the dependency root', { + skip: process.platform === 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-bin-link-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await mkdir(join(input.outputRoot, 'fixture-package'), { + recursive: true, + }); + await mkdir(join(input.outputRoot, '.bin'), { recursive: true }); + await writeFile(join(input.outputRoot, 'fixture-package', 'cli.js'), 'trusted\n', 'utf8'); + await symlink('../fixture-package/cli.js', join(input.outputRoot, '.bin', 'fixture-cli')); + }, + }; + const source = dependencySourceForName('posix-bin-link'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + }); + + const lease = await authority.acquire(identity, source); + assert.equal( + await readFile(join(lease.dependencyRoot, '.bin', 'fixture-cli'), 'utf8'), + 'trusted\n', + ); + await lease.release(); + await authority.close(); +}); + +test('publishes authority-owned file inodes instead of producer-owned inodes', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-inode-handoff-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let producerFileIdentity: { dev: bigint; ino: bigint } | undefined; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + const producerFile = join(input.outputRoot, 'payload'); + await writeFile(producerFile, 'trusted\n', 'utf8'); + const producerInfo = await stat(producerFile, { bigint: true }); + producerFileIdentity = { dev: producerInfo.dev, ino: producerInfo.ino }; + }, + }; + const source = dependencySourceForName('inode-handoff'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + + const lease = await authority.acquire(identity, source); + const publishedInfo = await stat(join(lease.dependencyRoot, 'payload'), { bigint: true }); + assert.notDeepEqual( + { dev: publishedInfo.dev, ino: publishedInfo.ino }, + producerFileIdentity, + 'published content retained the producer-owned inode', + ); + await lease.release(); + await authority.close(); +}); + +test('seals the complete authority-owned tree before publishing its receipt', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-durable-tree-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + const observedBoundaries: string[] = []; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await mkdir(join(input.outputRoot, 'nested', 'package'), { recursive: true }); + await writeFile(join(input.outputRoot, 'root.js'), 'root\n', 'utf8'); + await writeFile(join(input.outputRoot, 'nested', 'package', 'index.js'), 'nested\n', 'utf8'); + }, + }; + const source = dependencySourceForName('durable-tree'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + failpoint(point) { + observedBoundaries.push(point); + }, + }); + + const lease = await authority.acquire(identity, source); + assert.deepEqual(observedBoundaries, [ + 'after_environment_tree_durable', + 'after_environment_publish', + 'after_environment_receipt_durable', + 'before_environment_lease', + ]); + await lease.release(); + await authority.close(); +}); + +test('publishes Windows read-only dependency files without changing their final mode', { + skip: process.platform !== 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-readonly-file-')); + let publishedFile: string | undefined; + t.after(async () => { + if (publishedFile) await chmod(publishedFile, 0o644).catch(() => undefined); + await rm(storageRoot, { recursive: true, force: true }); + }); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + const path = join(input.outputRoot, 'readonly.js'); + await writeFile(path, 'readonly dependency\n', 'utf8'); + await chmod(path, 0o444); + }, + }; + const source = dependencySourceForName('readonly-file'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + + const lease = await authority.acquire(identity, source); + publishedFile = join(lease.dependencyRoot, 'readonly.js'); + assert.equal(await readFile(publishedFile, 'utf8'), 'readonly dependency\n'); + assert.equal((await stat(publishedFile)).mode & 0o222, 0); + await lease.release(); + await authority.close(); +}); + +test('isolates published POSIX content from a producer-retained writable handle', { + skip: process.platform === 'win32', +}, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-retained-handle-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let retainedHandle: FileHandle | undefined; + t.after(async () => retainedHandle?.close().catch(() => undefined)); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + retainedHandle = await open(join(input.outputRoot, 'payload'), 'w+'); + await retainedHandle.writeFile('trusted\n', 'utf8'); + await retainedHandle.sync(); + }, + }; + const source = dependencySourceForName('retained-handle'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const authority = await createManagedDependencyEnvironmentAuthority({ storageRoot, producer }); + + const lease = await authority.acquire(identity, source); + const malicious = Buffer.from('MALICIOUS\n', 'utf8'); + await retainedHandle?.write(malicious, 0, malicious.length, 0); + await retainedHandle?.truncate(malicious.length); + await retainedHandle?.sync(); + assert.equal(await readFile(join(lease.dependencyRoot, 'payload'), 'utf8'), 'trusted\n'); + await retainedHandle?.close(); + retainedHandle = undefined; + await lease.release(); + await authority.close(); +}); + +test('publishes one Maka-owned artifact for concurrent equivalent acquisitions', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-environment-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let provisionCalls = 0; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input) { + provisionCalls += 1; + await mkdir(join(input.outputRoot, 'fixture-package'), { + recursive: true, + }); + await writeFile( + join(input.outputRoot, 'fixture-package', 'index.js'), + 'export const source = "maka-owned";\n', + 'utf8', + ); + }, + }, + }); + const identityInput = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const identity = computeManagedDependencyEnvironmentIdentity(identityInput); + + const [first, second] = await Promise.all([ + authority.acquire(identity, { + manifestBytes: identityInput.manifestBytes, + lockfileBytes: identityInput.lockfileBytes, + }), + authority.acquire(identity, { + manifestBytes: identityInput.manifestBytes, + lockfileBytes: identityInput.lockfileBytes, + }), + ]); + + assert.equal(provisionCalls, 1); + assert.equal(first.environmentId, second.environmentId); + assert.equal(first.dependencyRoot, second.dependencyRoot); + await first.release(); + await second.release(); + await authority.close(); +}); + +test('close drains an acquisition through lease installation before deciding its outcome', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-close-drain-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let acknowledgeLeaseBoundary!: () => void; + const leaseBoundary = new Promise((resolve) => { + acknowledgeLeaseBoundary = resolve; + }); + let continueLeaseInstallation!: () => void; + const leaseInstallationAllowed = new Promise((resolve) => { + continueLeaseInstallation = resolve; + }); + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string }) { + await writeFile(join(input.outputRoot, 'payload'), 'trusted\n', 'utf8'); + }, + }; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + async failpoint(point) { + if (point !== 'before_environment_lease') return; + acknowledgeLeaseBoundary(); + await leaseInstallationAllowed; + }, + }); + const source = dependencySourceForName('close-drain'); + const identity = computeManagedDependencyEnvironmentIdentity(source); + const acquireTask = authority.acquire(identity, source); + await leaseBoundary; + + const closeTask = authority.close().then( + () => ({ closed: true as const }), + (error: unknown) => ({ error }), + ); + await Promise.resolve(); + continueLeaseInstallation(); + const [lease, closeResult] = await Promise.all([acquireTask, closeTask]); + if ('closed' in closeResult) { + await assert.rejects(lease.release(), /receipt authority is closed/u); + assert.fail('close succeeded while an acquisition was still installing its lease'); + } + assert.match(String(closeResult.error), /still has active leases/u); + await lease.release(); + await authority.close(); +}); + +test('collects the least-recently-used unleased environment under the cache quota', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-gc-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let provisionCalls = 0; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string; identity: { lockfileSha256: string } }) { + provisionCalls += 1; + await writeFile(input.outputRoot + '/payload', input.identity.lockfileSha256.slice(-8)); + }, + }; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + maxCacheBytes: 8, + }); + const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3,"name":"first"}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; + const firstIdentity = computeManagedDependencyEnvironmentIdentity(source); + const first = await authority.acquire(firstIdentity, source); + await first.release(); + + const secondSource = { + ...source, + lockfileBytes: Buffer.from('{"lockfileVersion":3,"name":"second"}\n'), + }; + const secondIdentity = computeManagedDependencyEnvironmentIdentity(secondSource); + const second = await authority.acquire(secondIdentity, secondSource); + await second.release(); + + const firstAgain = await authority.acquire(firstIdentity, source); + assert.equal(provisionCalls, 3); + await firstAgain.release(); + await authority.close(); +}); + +test('does not collect a published environment while its acquisition is still pending', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-pending-gc-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + let releasePendingPublish!: () => void; + const pendingPublish = new Promise((resolve) => { + releasePendingPublish = resolve; + }); + let acknowledgeReceiptDurable!: () => void; + const receiptDurable = new Promise((resolve) => { + acknowledgeReceiptDurable = resolve; + }); + let pendingDigest: string | undefined; + const producer = { + capability: FIXTURE_PRODUCER_CAPABILITY, + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeRuntime: fixtureNodeRuntime(), + async provision(input: { outputRoot: string; identity: { lockfileSha256: string } }) { + await writeFile(join(input.outputRoot, 'payload'), input.identity.lockfileSha256, 'utf8'); + }, + }; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer, + maxCacheBytes: 0, + async failpoint(point) { + if (point === 'after_environment_receipt_durable' && pendingDigest) { + acknowledgeReceiptDurable(); + await pendingPublish; + } + }, + }); + const firstSource = dependencySourceForName('first'); + const firstIdentity = computeManagedDependencyEnvironmentIdentity(firstSource); + const first = await authority.acquire(firstIdentity, firstSource); + const secondSource = dependencySourceForName('second'); + const secondIdentity = computeManagedDependencyEnvironmentIdentity(secondSource); + pendingDigest = secondIdentity.environmentId; + const secondTask = authority.acquire(secondIdentity, secondSource); + await receiptDurable; + await first.release(); + releasePendingPublish(); + const second = await secondTask; + assert.equal( + await readFile(join(second.dependencyRoot, 'payload'), 'utf8'), + secondIdentity.lockfileSha256, + ); + await second.release(); + await authority.close(); +}); + +function dependencySourceForName(name: string) { + return { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from(`{"lockfileVersion":3,"name":"${name}"}\n`), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: FIXTURE_PRODUCER_RUNTIME_IDENTITY, + producerPolicyIdentitySha256: FIXTURE_PRODUCER_CAPABILITY.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; +} + +function fixtureNodeRuntime() { + return { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + } as const; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0144512940369929821ec6058512449fb509608e17ea9ab7ed630e455df45834.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0144512940369929821ec6058512449fb509608e17ea9ab7ed630e455df45834.source new file mode 100644 index 0000000000..244850d365 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0144512940369929821ec6058512449fb509608e17ea9ab7ed630e455df45834.source @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DatabaseSync } from 'node:sqlite'; +import { migrateSqliteArtifactDatabase } from './sqlite-artifact-schema.js'; +import { migrateSqliteCoreExecutionDatabase } from './sqlite-core-execution-schema.js'; +import { migrateSqliteRuntimeDatabase } from './sqlite-runtime-schema.js'; +import { migrateSqliteSessionMetadataDatabase } from './sqlite-session-metadata-schema.js'; +import { migrateSqliteUsageDatabase } from './sqlite-usage-schema.js'; +import { migrateSqliteWorkflowDatabase } from './sqlite-workflow-schema.js'; + +class IncompleteOperationalSchemaError extends Error {} + +let cachedTargetSchema: ReadonlyMap | undefined; + +export function ensureOperationalSchemaRegistry(database: DatabaseSync): void { + database.exec(` + CREATE TABLE IF NOT EXISTS operational_schema_migrations ( + scope TEXT PRIMARY KEY, + version INTEGER NOT NULL CHECK (version >= 0), + applied_at INTEGER NOT NULL CHECK (applied_at >= 0) + ); + `); +} + +export function isCurrentOperationalTargetSchema(database: DatabaseSync): boolean { + try { + assertCurrentOperationalTargetSchema(database); + return true; + } catch (error) { + if (error instanceof IncompleteOperationalSchemaError) return false; + throw error; + } +} + +export function assertCurrentOperationalTargetSchema(database: DatabaseSync): void { + const target = (cachedTargetSchema ??= buildOperationalTargetSchema()); + const actual = readSchema(database); + for (const [name, required] of target) { + const observed = actual.get(name); + if (observed === undefined) throw incomplete(`missing required schema object ${name}`); + if (observed !== required) + throw incomplete(`schema object ${name} has an incompatible definition`); + } + for (const name of actual.keys()) { + if (!target.has(name)) throw incomplete(`unexpected schema object ${name}`); + } +} + +function buildOperationalTargetSchema(): ReadonlyMap { + const database = new DatabaseSync(':memory:'); + try { + migrateSqliteRuntimeDatabase(database); + migrateSqliteSessionMetadataDatabase(database); + migrateSqliteCoreExecutionDatabase(database); + migrateSqliteWorkflowDatabase(database); + migrateSqliteUsageDatabase(database); + migrateSqliteArtifactDatabase(database); + ensureOperationalSchemaRegistry(database); + return readSchema(database); + } finally { + database.close(); + } +} + +function readSchema(database: DatabaseSync): ReadonlyMap { + return new Map(readSchemaObjects(database).map((object) => [object.key, object.signature])); +} + +function readSchemaObjects( + database: DatabaseSync, +): Array<{ key: string; tableName: string; signature: string }> { + const rows = database + .prepare(` + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT GLOB 'sqlite_*' AND type IN ('table', 'index', 'trigger', 'view') AND sql IS NOT NULL + ORDER BY type, name + `) + .all() as Array<{ type: string; name: string; tbl_name: string; sql: string }>; + return rows.map(({ type, name, tbl_name, sql }) => ({ + key: `${type}:${name}`, + tableName: tbl_name, + signature: normalizeReleasedSchemaException(name, `${type}:${tbl_name}:${normalizeSql(sql)}`), + })); +} + +/** + * Released DDL for the pre-authority migration metadata tables, verbatim from + * commit 1caea265c^ (before SQLite became the sole operational authority). These + * strings are only ever read back through {@link readSchemaObjects} in a throwaway + * database, so their exact whitespace/case is irrelevant — the normalized + * `type:tbl_name:sql` signature (including CHECK/FK constraints and any attached + * index/trigger) is what the retirement gate recognizes. + */ +const RELEASED_LEGACY_RETIREMENT_DDL: ReadonlyMap = new Map([ + [ + 'cutover_journal', + `CREATE TABLE IF NOT EXISTS cutover_journal ( + store_name TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('started', 'completed')), + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + validation_json TEXT + )`, + ], + [ + 'runtime_import_sources', + `CREATE TABLE runtime_import_sources ( + source_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + imported_at INTEGER NOT NULL + )`, + ], + [ + 'session_metadata_import_sources', + `CREATE TABLE session_metadata_import_sources ( + source_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + session_id TEXT NOT NULL, + imported_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + )`, + ], +]); + +let cachedLegacyRetirementSchema: ReadonlyMap> | undefined; + +function buildLegacyRetirementSchema(): ReadonlyMap> { + const database = new DatabaseSync(':memory:'); + try { + database.exec('PRAGMA foreign_keys = OFF'); + for (const ddl of RELEASED_LEGACY_RETIREMENT_DDL.values()) database.exec(ddl); + const byTable = new Map>(); + for (const object of readSchemaObjects(database)) { + const bucket = byTable.get(object.tableName) ?? new Map(); + bucket.set(object.key, object.signature); + byTable.set(object.tableName, bucket); + } + return byTable; + } finally { + database.close(); + } +} + +/** + * Fail closed unless every schema object attached to a legacy metadata table + * (`tableName`) matches the released layout down to CHECK/FK constraints and has + * no extra index/trigger grafted on. Unlike a column-only comparison this rejects + * a table that shares the released column names/types but carries altered + * constraints or additional objects — the destructive retirement below must only + * DROP shapes it can positively recognize. + */ +export function assertReleasedLegacyRetirementShape( + database: DatabaseSync, + tableName: string, +): void { + const canonical = (cachedLegacyRetirementSchema ??= buildLegacyRetirementSchema()).get(tableName); + if (canonical === undefined) { + throw new Error(`No released legacy retirement signature is registered for ${tableName}`); + } + const observed = new Map(); + for (const object of readSchemaObjects(database)) { + if (object.tableName === tableName) observed.set(object.key, object.signature); + } + for (const [key, signature] of canonical) { + const actual = observed.get(key); + if (actual === undefined) { + throw new Error(`Legacy operational metadata object ${key} is missing its released shape`); + } + if (actual !== signature) { + throw new Error(`Legacy operational metadata object ${key} has an unfamiliar released shape`); + } + } + for (const key of observed.keys()) { + if (!canonical.has(key)) { + throw new Error( + `Legacy operational metadata table ${tableName} carries an unexpected object ${key}`, + ); + } + } +} + +function normalizeReleasedSchemaException(name: string, signature: string): string { + if (name !== 'workflow_quote_companion_cleanup') return signature; + return signature.replace(/RECORD_JSON TEXT(?=[,)])/u, 'RECORD_JSON TEXT NOT NULL'); +} + +function normalizeSql(value: string): string { + let normalized = ''; + let pendingSpace = false; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '-' && value[index + 1] === '-') { + for (; index < value.length && value[index] !== '\n'; index += 1) {} + pendingSpace ||= normalized.length > 0; + continue; + } + if (value[index] === '/' && value[index + 1] === '*') { + for (index += 2; index < value.length; index += 1) { + if (value[index] === '*' && value[index + 1] === '/') { + index += 1; + break; + } + } + pendingSpace ||= normalized.length > 0; + continue; + } + const quote = value[index]; + if (quote === "'" || quote === '"' || quote === '`' || quote === '[') { + if (pendingSpace) normalized += ' '; + const close = quote === '[' ? ']' : quote; + normalized += quote; + for (index += 1; index < value.length; index += 1) { + normalized += value[index]; + if (value[index] !== close) continue; + if (close !== ']' && value[index + 1] === close) { + index += 1; + normalized += value[index]; + continue; + } + break; + } + pendingSpace = false; + continue; + } + if (/\s/u.test(quote ?? '')) { + pendingSpace ||= normalized.length > 0; + continue; + } + if (quote === '(' || quote === ')' || quote === ',' || quote === ';') { + normalized = normalized.trimEnd(); + normalized += quote; + pendingSpace = false; + continue; + } + if (pendingSpace && !normalized.endsWith('(') && !normalized.endsWith(',')) normalized += ' '; + normalized += value[index]?.toUpperCase(); + pendingSpace = false; + } + return normalized; +} + +function incomplete(detail: string): IncompleteOperationalSchemaError { + return new IncompleteOperationalSchemaError(`Incomplete operational SQLite schema: ${detail}`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01e0f2d853fe17ccbaaf523f6c5e2d42706c00b28dbe2bc15246231aa221a50e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01e0f2d853fe17ccbaaf523f6c5e2d42706c00b28dbe2bc15246231aa221a50e.source new file mode 100644 index 0000000000..b9f26a35d8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/01e0f2d853fe17ccbaaf523f6c5e2d42706c00b28dbe2bc15246231aa221a50e.source @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments'; +import type { ReadImageSnapshotReader } from '@maka/core/context-offload'; +import { type StorageRef } from '@maka/core/events'; +import { + createArtifactAttachmentResourceReader, + createAttachmentByteReader, + createReadImageSnapshotPlanner, + createReadImageSnapshotter, +} from '../artifact-attachments.js'; +import { + createSqliteArtifactStoreWriteAuthority, + type ArtifactAuthorityStore, +} from '../artifact-store.js'; + +const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) { + return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records; +} + +function readArtifactBinary(store: ArtifactAuthorityStore, artifactId: string) { + return store.readBinaryInSession('session-1', artifactId); +} + +describe('artifact attachment authority', () => { + test('reads only live user-uploaded text within the invoking Session', async () => { + await withStore(async (store) => { + await store.create({ + id: 'notes-1', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'notes.txt', + kind: 'file', + content: 'attachment marker', + mimeType: 'text/plain', + source: 'user_upload', + now: 1, + }); + const reader = createArtifactAttachmentResourceReader({ artifactStore: store }); + const signal = new AbortController().signal; + + assert.deepEqual(await reader.readAttachmentResource('session-1', 'notes-1', signal), { + kind: 'text', + text: 'attachment marker', + }); + await assert.rejects( + reader.readAttachmentResource('session-2', 'notes-1', signal), + /not found in this Session/, + ); + await store.deleteUserArtifactInSession('session-1', 'notes-1'); + await assert.rejects( + reader.readAttachmentResource('session-1', 'notes-1', signal), + /not found in this Session/, + ); + }); + }); + + test('resolves a live session ref and never forwards tombstoned bytes', async () => { + await withStore(async (store) => { + await store.create({ + id: 'image-1', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'image.png', + kind: 'image', + content: png, + source: 'tool_result', + now: 1, + }); + const reader = createAttachmentByteReader({ + artifactStore: store, + sessionId: 'session-1', + }); + assert.deepEqual(await reader(sessionFileRef('image-1')), { + ok: true, + bytes: Buffer.from(png), + }); + await store.deleteUserArtifactInSession('session-1', 'image-1'); + assert.deepEqual(await reader(sessionFileRef('image-1')), { + ok: false, + reason: 'not_found', + }); + assert.deepEqual(await reader(sessionFileRef('image-1', 'other-session')), { + ok: false, + reason: 'session_mismatch', + }); + }); + }); + + test('rejects unsupported refs and applies the shared byte limit inside authority', async () => { + await withStore(async (store) => { + await store.create({ + id: 'large-image', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'large.png', + kind: 'image', + content: new Uint8Array(MAX_ATTACHMENT_BYTES + 1).fill(0x89), + source: 'tool_result', + now: 1, + }); + const reader = createAttachmentByteReader({ + artifactStore: store, + sessionId: 'session-1', + }); + + assert.deepEqual(await reader({ kind: 'workspace_file', relativePath: 'image.png' }), { + ok: false, + reason: 'unsupported_ref_kind', + }); + assert.deepEqual(await reader(sessionFileRef('large-image')), { + ok: false, + reason: 'too_large', + }); + const unavailableReader = createAttachmentByteReader({ + artifactStore: store, + sessionId: 'session-1', + readImageSnapshotsUnavailable: true, + }); + assert.deepEqual(await unavailableReader(sessionContextRef('ref-1')), { + ok: false, + reason: 'unavailable', + }); + }); + }); + + test('routes durable context refs through the Session-bound snapshot reader', async () => { + await withStore(async (store) => { + const reads: string[] = []; + const readImageSnapshots: ReadImageSnapshotReader = { + async read(ref) { + reads.push(ref.refId); + if (ref.refId === 'missing') return { ok: false, reason: 'not_found' }; + return { + ok: true, + record: { + refId: ref.refId, + sessionId: ref.sessionId, + owner: { kind: 'read_image_snapshot', ownerId: 'owner-1' }, + blobId: 'a'.repeat(64), + sizeBytes: png.byteLength, + mediaType: 'image/png', + createdAt: 1, + }, + bytes: png, + }; + }, + }; + const reader = createAttachmentByteReader({ + artifactStore: store, + sessionId: 'session-1', + readImageSnapshots, + }); + + assert.deepEqual(await reader(sessionContextRef('ref-1')), { + ok: true, + bytes: png, + }); + assert.deepEqual(await reader(sessionContextRef('missing')), { + ok: false, + reason: 'not_found', + }); + assert.deepEqual(await reader(sessionContextRef('ref-2', 'other-session')), { + ok: false, + reason: 'session_mismatch', + }); + assert.deepEqual(reads, ['ref-1', 'missing']); + }); + }); + + test('passes through real store not-found and unsupported-mime failures', async () => { + await withStore(async (store) => { + const reader = createAttachmentByteReader({ + artifactStore: store, + sessionId: 'session-1', + }); + assert.deepEqual(await reader(sessionFileRef('missing')), { + ok: false, + reason: 'not_found', + }); + + await store.create({ + id: 'unknown-binary', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'unknown.bin', + kind: 'file', + content: Uint8Array.from([0, 1, 2, 3]), + source: 'tool_result', + now: 1, + }); + assert.deepEqual(await reader(sessionFileRef('unknown-binary')), { + ok: false, + reason: 'unsupported_mime', + }); + }); + }); + + test('snapshotter rejects provider-unsafe images before publication', async () => { + await withStore(async (store) => { + await assert.rejects( + createReadImageSnapshotter(store)({ + sessionId: 'session-1', + turnId: 'turn-1', + name: 'large.png', + bytes: new Uint8Array(5 * 1024 * 1024 + 1), + mimeType: 'image/png', + }), + /Image exceeds the 5MB model input limit/, + ); + assert.deepEqual(await listArtifacts(store, 'session-1'), []); + }); + }); + + test('snapshotter reuses one content-addressed artifact for the same turn image', async () => { + await withStore(async (store) => { + const snapshot = createReadImageSnapshotter(store); + const input = { + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes: Uint8Array.from([1, 2, 3]), + mimeType: 'image/png', + }; + + const first = await snapshot(input); + const repeated = await snapshot(input); + + assert.deepEqual(repeated, first); + assert.equal((await listArtifacts(store, 'session-1')).length, 1); + }); + }); + + test('protects a durable projection image until its Session is purged', async () => { + await withStore(async (store) => { + const ref = await createReadImageSnapshotter(store)({ + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes: png, + mimeType: 'image/png', + }); + + assert.equal( + (await store.deleteUserArtifactInSession('session-1', ref.relativePath)).kind, + 'protected', + ); + assert.equal((await readArtifactBinary(store, ref.relativePath)).ok, true); + await store.purgeSessionArtifacts('session-1'); + assert.deepEqual(await readArtifactBinary(store, ref.relativePath), { + ok: false, + reason: 'not_found', + }); + }); + }); + + test('planner derives the final ref without publishing before commit', async () => { + await withStore(async (store) => { + const bytes = png.slice(); + const input = { + sessionId: 'session-1', + turnId: 'turn-1', + name: 'Tool Result image', + bytes, + mimeType: 'image/png', + }; + const plan = createReadImageSnapshotPlanner(store)(input); + + assert.deepEqual(await listArtifacts(store, 'session-1'), []); + bytes[0] = 0; + input.name = 'mutated after prepare'; + await Promise.all([plan.persist(), plan.persist()]); + const published = await listArtifacts(store, 'session-1'); + assert.deepEqual( + published.map((artifact) => artifact.id), + [plan.ref.relativePath], + ); + assert.equal(published[0]?.name, 'Tool Result image'); + assert.deepEqual(await readArtifactBinary(store, plan.ref.relativePath), { + ok: true, + base64: Buffer.from(png).toString('base64'), + mimeType: 'image/png', + }); + }); + }); +}); + +function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef { + return { kind: 'session_file', sessionId, relativePath }; +} + +function sessionContextRef(refId: string, sessionId = 'session-1'): StorageRef { + return { kind: 'session_context', sessionId, refId }; +} + +async function withStore(run: (store: ArtifactAuthorityStore) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-attachment-')); + const authority = createSqliteArtifactStoreWriteAuthority(root); + try { + const { store } = authority; + await run(store); + } finally { + authority.close(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/03839a42de4221907636530ce00e0ae085dc74c63042018d3d0bb28d116a7ac6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/03839a42de4221907636530ce00e0ae085dc74c63042018d3d0bb28d116a7ac6.source new file mode 100644 index 0000000000..e8ddc5b96f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/03839a42de4221907636530ce00e0ae085dc74c63042018d3d0bb28d116a7ac6.source @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import type { ArtifactRecord } from '@maka/core/artifacts'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { createSqliteArtifactMetadataRepository } from '../sqlite-artifact-metadata.js'; + +test('Artifact metadata changes only write changed rows', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-metadata-delta-')); + const repository = createSqliteArtifactMetadataRepository(root); + let inspector: DatabaseSync | undefined; + try { + const unchanged = artifactRecord('unchanged'); + const updated = artifactRecord('updated'); + const removed = artifactRecord('removed'); + repository.applyChanges({ upserts: [unchanged, updated, removed] }); + + inspector = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + inspector.exec(` + CREATE TABLE artifact_write_audit(kind TEXT NOT NULL); + CREATE TRIGGER artifact_write_audit_insert AFTER INSERT ON artifact_records + BEGIN INSERT INTO artifact_write_audit VALUES ('insert'); END; + CREATE TRIGGER artifact_write_audit_update AFTER UPDATE ON artifact_records + BEGIN INSERT INTO artifact_write_audit VALUES ('update'); END; + CREATE TRIGGER artifact_write_audit_delete AFTER DELETE ON artifact_records + BEGIN INSERT INTO artifact_write_audit VALUES ('delete'); END; + `); + + repository.applyChanges({ + upserts: [unchanged, { ...updated, summary: 'changed' }, artifactRecord('added')], + deleteIds: [removed.id], + }); + + const writes = inspector + .prepare('SELECT kind, count(*) AS count FROM artifact_write_audit GROUP BY kind') + .all() as Array<{ kind: string; count: number }>; + assert.deepEqual( + writes.map(({ kind, count }) => ({ kind, count })), + [ + { kind: 'delete', count: 1 }, + { kind: 'insert', count: 1 }, + { kind: 'update', count: 1 }, + ], + ); + assert.deepEqual( + repository + .readAll() + .map(({ id, summary }) => ({ id, summary })) + .sort((left, right) => left.id.localeCompare(right.id)), + [ + { id: 'added', summary: undefined }, + { id: 'unchanged', summary: undefined }, + { id: 'updated', summary: 'changed' }, + ], + ); + + inspector.exec(` + DROP TRIGGER artifact_write_audit_insert; + DROP TRIGGER artifact_write_audit_update; + DROP TRIGGER artifact_write_audit_delete; + DROP TABLE artifact_write_audit; + `); + } finally { + inspector?.close(); + repository.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('Artifact metadata recovery ignores records from unsupported sources', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-metadata-unsupported-')); + const repository = createSqliteArtifactMetadataRepository(root); + let inspector: DatabaseSync | undefined; + try { + const supported = artifactRecord('supported'); + repository.applyChanges({ upserts: [supported] }); + + const unsupported = { + ...artifactRecord('unsupported'), + source: 'retired_artifact_source', + }; + inspector = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + const insert = inspector.prepare(` + INSERT INTO artifact_records( + artifact_id, + session_id, + created_at, + relative_path, + record_json + ) VALUES (?, ?, ?, ?, ?) + `); + insert.run( + unsupported.id, + unsupported.sessionId, + unsupported.createdAt, + unsupported.relativePath, + JSON.stringify(unsupported), + ); + + assert.deepEqual(repository.readAll(), [supported]); + } finally { + inspector?.close(); + repository.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +function artifactRecord(id: string): ArtifactRecord { + return { + id, + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 1, + name: `${id}.txt`, + kind: 'file', + sizeBytes: id.length, + relativePath: `session-1/${id}-${id}.txt`, + source: 'tool_result', + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0565cdd842e9666c3a8e0c586fe274eebe04b6275de0ef792a19ecfa7407b7ca.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0565cdd842e9666c3a8e0c586fe274eebe04b6275de0ef792a19ecfa7407b7ca.source new file mode 100644 index 0000000000..929a15a097 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0565cdd842e9666c3a8e0c586fe274eebe04b6275de0ef792a19ecfa7407b7ca.source @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { + authenticateInteractiveSessionTodoWriter, + openInteractiveSessionTodoStoreForWrite, + type InteractiveSessionTodoWriter, +} from '../session-todo-authority.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + type StorageRootLease, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +describe('interactive SessionTodo authority', () => { + test('single-flights opens and invalidates the facade when closed', async () => { + await withInteractiveRoot(async (capability) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const [first, second] = await Promise.all([ + openInteractiveSessionTodoStoreForWrite(owner.lease), + openInteractiveSessionTodoStoreForWrite(owner.lease), + ]); + assert.equal(first, second); + assert.equal(authenticateInteractiveSessionTodoWriter(first), first); + await first.replaceAll('authority-session', [{ content: 'owned', status: 'pending' }]); + assert.deepEqual(await second.readOrBootstrap('authority-session'), { + items: [{ content: 'owned', status: 'pending' }], + }); + first.close(); + assert.throws(() => authenticateInteractiveSessionTodoWriter(first), isInvalidLease); + await assert.rejects(() => first.readOrBootstrap('authority-session'), isInvalidLease); + } finally { + if (!owner.closed) await owner.close(); + } + }); + }); + + test('rejects forged leases and facades', async () => { + await assert.rejects( + () => openInteractiveSessionTodoStoreForWrite({} as StorageRootLease<'interactive', 'write'>), + isInvalidLease, + ); + assert.throws( + () => authenticateInteractiveSessionTodoWriter({} as InteractiveSessionTodoWriter), + isInvalidLease, + ); + }); +}); + +async function withInteractiveRoot( + run: (capability: Awaited>>) => Promise, +): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-session-todo-authority-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + await run(capability); + } finally { + await rm(base, { recursive: true, force: true }); + } +} + +function isInvalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/05eae587b633856514517b9e0da97108b7aef6bdcfd59fd3db471dea8a642dc0.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/05eae587b633856514517b9e0da97108b7aef6bdcfd59fd3db471dea8a642dc0.source new file mode 100644 index 0000000000..ffc43870b8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/05eae587b633856514517b9e0da97108b7aef6bdcfd59fd3db471dea8a642dc0.source @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeConnectionSlug, + decodeProviderType, + decodeRuntimePolicyEntityId, +} from '@maka/core/runtime-policy'; +import { integer, record } from './codec.js'; +import { codecError, decodePersistedDomain } from './errors.js'; +import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; +import type { + InteractiveOAuthConnectionIdentity, + InteractiveOAuthLoginProvider, + InteractiveOAuthLoginTarget, +} from './operations.js'; + +const FILE = 'runtime-policy-oauth-login-receipts.json'; +const SCHEMA_VERSION = 1 as const; +const MAX_BYTES = 256 * 1024; +export const MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS = 256; + +export interface InteractiveOAuthLoginReceipt { + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + readonly phase: 'authenticated'; + readonly completionOrder: number; +} + +interface ReceiptDocument { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly nextCompletionOrder: number; + readonly receipts: readonly InteractiveOAuthLoginReceipt[]; +} + +const EMPTY: ReceiptDocument = { + schemaVersion: SCHEMA_VERSION, + nextCompletionOrder: 1, + receipts: [], +}; + +export async function readInteractiveOAuthLoginReceipts(root: string): Promise { + const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); + if (value === undefined) return EMPTY; + const document = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'nextCompletionOrder', + 'receipts', + ]); + if (document.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + if (!Array.isArray(document.receipts)) { + throw codecError('invalid_document', `${FILE}.receipts must be an array`); + } + const nextCompletionOrder = integer( + document.nextCompletionOrder, + `${FILE}.nextCompletionOrder`, + 1, + Number.MAX_SAFE_INTEGER, + 'invalid_document', + ); + const receipts = document.receipts.map((item, index) => decodeReceipt(item, index)); + if (receipts.length > MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS) { + throw codecError('invalid_document', `${FILE} exceeds its receipt limit`); + } + const attemptIds = new Set(); + let previousOrder = 0; + for (const receipt of receipts) { + if (attemptIds.has(receipt.attemptId)) { + throw codecError('invalid_document', `${FILE} repeats an attempt id`); + } + attemptIds.add(receipt.attemptId); + if (receipt.completionOrder <= previousOrder) { + throw codecError('invalid_document', `${FILE} receipts are not in completion order`); + } + previousOrder = receipt.completionOrder; + } + if (previousOrder >= nextCompletionOrder) { + throw codecError('invalid_document', `${FILE} completion order is invalid`); + } + return { schemaVersion: SCHEMA_VERSION, nextCompletionOrder, receipts }; +} + +export function findInteractiveOAuthLoginReceipt( + document: ReceiptDocument, + attemptId: string, +): InteractiveOAuthLoginReceipt | undefined { + return document.receipts.find((receipt) => receipt.attemptId === attemptId); +} + +export async function upsertInteractiveOAuthLoginReceipt( + root: string, + input: Omit, +): Promise { + if (!targetMatchesIdentity(input.target, input.connection)) { + throw codecError('invalid_document', 'OAuth login receipt identity is inconsistent'); + } + const document = await readInteractiveOAuthLoginReceipts(root); + const existing = findInteractiveOAuthLoginReceipt(document, input.attemptId); + if (existing) { + if ( + !sameTarget(existing.target, input.target) || + !sameIdentity(existing.connection, input.connection) + ) { + throw codecError( + 'invalid_document', + 'OAuth login receipt conflicts with the enrollment intent', + ); + } + return existing; + } + if (document.nextCompletionOrder >= Number.MAX_SAFE_INTEGER) { + throw codecError('invalid_document', 'OAuth login receipt order is exhausted'); + } + const receipt: InteractiveOAuthLoginReceipt = { + ...input, + phase: 'authenticated', + completionOrder: document.nextCompletionOrder, + }; + const receipts = [...document.receipts, receipt].slice(-MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS); + await writeJsonDocument( + root, + FILE, + { + schemaVersion: SCHEMA_VERSION, + nextCompletionOrder: document.nextCompletionOrder + 1, + receipts, + } satisfies ReceiptDocument, + MAX_BYTES, + ); + return receipt; +} + +export function sameInteractiveOAuthLoginTarget( + actual: InteractiveOAuthLoginTarget, + expected: InteractiveOAuthLoginTarget, +): boolean { + return sameTarget(actual, expected); +} + +function decodeReceipt(value: unknown, index: number): InteractiveOAuthLoginReceipt { + const item = record(value, `${FILE}.receipts[${index}]`, 'invalid_document', [ + 'attemptId', + 'target', + 'connection', + 'phase', + 'completionOrder', + ]); + if (item.phase !== 'authenticated') { + throw codecError('invalid_document', 'OAuth login receipt phase is invalid'); + } + const target = decodeTarget(item.target); + const connection = decodeIdentity(item.connection); + if (!targetMatchesIdentity(target, connection)) { + throw codecError('invalid_document', 'OAuth login receipt identity is inconsistent'); + } + return { + attemptId: decodeAttemptId(item.attemptId), + target, + connection, + phase: 'authenticated', + completionOrder: integer( + item.completionOrder, + `${FILE}.receipts[${index}].completionOrder`, + 1, + Number.MAX_SAFE_INTEGER, + 'invalid_document', + ), + }; +} + +function decodeTarget(value: unknown): InteractiveOAuthLoginTarget { + const base = record( + value, + 'OAuth login receipt target', + 'invalid_document', + ['kind', 'providerType', 'connectionId'], + ['kind'], + ); + if (base.kind === 'create') { + const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); + return { kind: 'create', providerType: decodeOAuthProvider(item.providerType) }; + } + if (base.kind === 'existing') { + const item = record(value, 'OAuth existing target', 'invalid_document', [ + 'kind', + 'connectionId', + ]); + return { kind: 'existing', connectionId: decodeId(item.connectionId) }; + } + throw codecError('invalid_document', 'OAuth login receipt target kind is invalid'); +} + +function decodeIdentity(value: unknown): InteractiveOAuthConnectionIdentity { + const item = record(value, 'OAuth login receipt connection', 'invalid_document', [ + 'connectionId', + 'slug', + 'providerType', + ]); + return { + connectionId: decodeId(item.connectionId), + slug: decodePersistedDomain(() => decodeConnectionSlug(item.slug)), + providerType: decodeOAuthProvider(item.providerType), + }; +} + +function decodeOAuthProvider(value: unknown): InteractiveOAuthLoginProvider { + const providerType = decodePersistedDomain(() => decodeProviderType(value)); + if ( + providerType !== 'openai-codex' && + providerType !== 'xai-oauth' && + providerType !== 'github-copilot' + ) { + throw codecError('invalid_document', 'OAuth login receipt provider is invalid'); + } + return providerType; +} + +function decodeId(value: unknown): string { + return decodePersistedDomain(() => decodeRuntimePolicyEntityId(value)); +} + +function decodeAttemptId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError('invalid_document', 'OAuth login receipt attempt id is invalid'); + } + return value; +} + +function sameTarget(actual: InteractiveOAuthLoginTarget, expected: InteractiveOAuthLoginTarget) { + return ( + actual.kind === expected.kind && + (actual.kind === 'create' + ? expected.kind === 'create' && actual.providerType === expected.providerType + : expected.kind === 'existing' && actual.connectionId === expected.connectionId) + ); +} + +function sameIdentity( + actual: InteractiveOAuthConnectionIdentity, + expected: InteractiveOAuthConnectionIdentity, +) { + return ( + actual.connectionId === expected.connectionId && + actual.slug === expected.slug && + actual.providerType === expected.providerType + ); +} + +function targetMatchesIdentity( + target: InteractiveOAuthLoginTarget, + connection: InteractiveOAuthConnectionIdentity, +): boolean { + return target.kind === 'create' + ? target.providerType === connection.providerType + : target.connectionId === connection.connectionId; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/063dbd2b9bd5fb830aa2a39289fefa6bf4176d8acc47664ed07a6ad6f554b660.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/063dbd2b9bd5fb830aa2a39289fefa6bf4176d8acc47664ed07a6ad6f554b660.source new file mode 100644 index 0000000000..600a38e204 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/063dbd2b9bd5fb830aa2a39289fefa6bf4176d8acc47664ed07a6ad6f554b660.source @@ -0,0 +1,289 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test, after, type TestContext } from 'node:test'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { openToolResultArchiveEvidenceReader } from '../tool-result-archive-evidence.js'; +import { MODEL_PROJECTION_TARGET_SQL } from '../sqlite-core-execution-schema.js'; +import { + trackControlDirectory, + removeTrackedControlDirectories, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'maka-archive-evidence-')); + t.after(() => rm(root, { recursive: true, force: true })); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const reader = await openToolResultArchiveEvidenceReader(owner.lease); + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + t.after(async () => { + db.close(); + reader.close(); + await owner.close(); + }); + const event = { + id: 'response', + sessionId: 'session', + runId: 'run', + invocationId: 'invocation', + turnId: 'turn', + ts: 1, + partial: false, + author: 'tool', + role: 'tool', + content: { + kind: 'function_response', + id: 'call', + name: 'Read', + result: 'raw', + modelProjection: { version: 1, kind: 'text', text: 'model' }, + }, + }; + db.prepare( + 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ).run( + 'response', + 'session', + 'invocation', + 'run', + 'turn', + 1, + 'function_response', + JSON.stringify(event), + 1, + ); + db.prepare('INSERT INTO core_agent_runs(session_id, run_id, created_at) VALUES (?, ?, ?)').run( + 'session', + 'run', + 1, + ); + const insert = (sequence: number, target: string | null, padding = '') => { + const record = { + id: 'transition-' + sequence, + type: 'model_projection_transition_recorded', + sessionId: 'session', + runId: 'run', + turnId: 'turn', + ts: 2, + data: { ...(target === null ? {} : { runtimeEventId: target }), padding }, + }; + db.prepare('INSERT INTO core_agent_run_events VALUES (?, ?, ?, ?, ?, ?, ?)').run( + 'session', + 'run', + sequence, + record.id, + record.type, + 2, + JSON.stringify(record), + ); + }; + return { root, owner, reader, db, insert }; +} + +test('target evidence ignores 12k unrelated records, survives reopen and checks Session scope', async (t) => { + const f = await fixture(t); + f.db.exec('BEGIN'); + for (let i = 0; i < 12000; i += 1) f.insert(i, 'unrelated-' + i); + f.insert(12000, 'response'); + f.db.exec('COMMIT'); + const result = await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.transitions.length, 1); + assert.deepEqual(await f.reader.read({ sessionId: 'foreign', runtimeEventId: 'response' }), { + ok: false, + reason: 'not_found', + }); + f.reader.close(); + const reopened = await openToolResultArchiveEvidenceReader(f.owner.lease); + try { + assert.deepEqual( + await reopened.read({ sessionId: 'session', runtimeEventId: 'response' }), + result, + ); + } finally { + reopened.close(); + } + const plan = f.db + .prepare(`EXPLAIN QUERY PLAN SELECT run_id, sequence FROM core_agent_run_events INDEXED BY core_model_projection_target + WHERE event_type = 'model_projection_transition_recorded' AND session_id = ? AND ${MODEL_PROJECTION_TARGET_SQL} = ? LIMIT ?`) + .all('session', 'response', 65); + assert.match(JSON.stringify(plan), /SEARCH.*core_model_projection_target/); +}); + +test('checks byte and record budgets before fetching any ledger JSON', async (t) => { + const f = await fixture(t); + f.insert(1, 'response', 'x'.repeat(3 * 1024 * 1024)); + let materialized = 0; + const prepare = DatabaseSync.prototype.prepare; + t.mock.method(DatabaseSync.prototype, 'prepare', function (this: DatabaseSync, sql: string) { + const statement = prepare.call(this, sql); + const get = statement.get.bind(statement); + const all = statement.all.bind(statement); + const count = (row: Record | undefined) => { + for (const key of ['payload_json', 'evidence_json', 'record_json']) + if (typeof row?.[key] === 'string') materialized += Buffer.byteLength(row[key]); + }; + t.mock.method(statement, 'get', (...args: Parameters) => { + const row = get(...args); + count(row); + return row; + }); + t.mock.method(statement, 'all', (...args: Parameters) => { + const rows = all(...args); + rows.forEach(count); + return rows; + }); + return statement; + }); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'too_large', + }); + assert.equal(materialized, 0); + f.db.exec('DELETE FROM core_agent_run_events'); + for (let i = 0; i < 65; i += 1) f.insert(i, 'response'); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'too_large', + }); + assert.equal(materialized, 0); + f.db.exec('DELETE FROM core_agent_run_events'); + f.db + .prepare( + "UPDATE runtime_events SET payload_json = json_set(payload_json, '$.content.modelProjection.text', ?) WHERE event_id = 'response'", + ) + .run('x'.repeat(3 * 1024 * 1024)); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'too_large', + }); + assert.equal(materialized, 0, 'oversized projection is refused before returning evidence JSON'); +}); + +test('unscoped malformed transitions prevent a false complete history', async (t) => { + const f = await fixture(t); + f.insert(1, null); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'corrupt', + }); + f.db.exec('DELETE FROM core_agent_run_events'); + f.insert(1, 'response'); + f.db.exec("UPDATE core_agent_run_events SET record_json = '{'"); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'corrupt', + }); +}); + +test('upgrades the target index without rewriting immutable transition records', async (t) => { + const f = await fixture(t); + f.insert(1, 'response'); + const before = f.db.prepare('SELECT record_json FROM core_agent_run_events').all(); + f.reader.close(); + f.db.exec( + "DROP INDEX core_model_projection_target; UPDATE operational_schema_migrations SET version = 8 WHERE scope = 'core_execution'", + ); + const reader = await openToolResultArchiveEvidenceReader(f.owner.lease); + try { + assert.equal( + (await reader.read({ sessionId: 'session', runtimeEventId: 'response' })).ok, + true, + ); + assert.deepEqual(f.db.prepare('SELECT record_json FROM core_agent_run_events').all(), before); + assert.equal( + f.db + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'core_execution'") + .get()?.version, + 9, + ); + } finally { + reader.close(); + } +}); + +test('reader close and root revocation never return evidence', async (t) => { + const f = await fixture(t); + await f.owner.close(); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'unavailable', + }); + f.reader.close(); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'unavailable', + }); +}); + +test('database read failures are unavailable, not corrupt evidence', async (t) => { + const f = await fixture(t); + const prepare = DatabaseSync.prototype.prepare; + const failure = t.mock.method( + DatabaseSync.prototype, + 'prepare', + function (this: DatabaseSync, sql: string) { + if (sql.includes('AS bytes FROM runtime_events')) + throw new Error('injected database unavailable'); + return prepare.call(this, sql); + }, + ); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'unavailable', + }); + failure.mock.restore(); + assert.equal( + (await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' })).ok, + true, + ); +}); + +test('invalid event JSON and transition envelopes remain corrupt', async (t) => { + const f = await fixture(t); + f.insert(1, 'response'); + const saved = f.db + .prepare("SELECT payload_json FROM runtime_events WHERE event_id = 'response'") + .get()!; + f.db.exec("UPDATE runtime_events SET payload_json = '{' WHERE event_id = 'response'"); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'corrupt', + }); + f.db + .prepare("UPDATE runtime_events SET payload_json = ? WHERE event_id = 'response'") + .run(saved.payload_json!); + f.db.exec( + "UPDATE core_agent_run_events SET record_json = json_set(record_json, '$.ts', 'invalid-timestamp')", + ); + assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), { + ok: false, + reason: 'corrupt', + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/07fadffe5f0c37a379248609951b70aa9e9d6015dc8a383a62a4fc000044476c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/07fadffe5f0c37a379248609951b70aa9e9d6015dc8a383a62a4fc000044476c.source new file mode 100644 index 0000000000..056d8ed900 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/07fadffe5f0c37a379248609951b70aa9e9d6015dc8a383a62a4fc000044476c.source @@ -0,0 +1,525 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { decodeCanonicalMessage } from '@maka/core/session'; +import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; +import { + OPENCODE_SESSION_ADAPTER_ID, + OpenCodeSessionAdapter, +} from '../opencode-session-adapter.js'; + +// Captured from opencode 1.18.21 with `opencode db`: one session that reads +// files, runs commands, writes one, and ends on an aborted message. Paths are +// rewritten and long tool output truncated; the record shapes are verbatim. +// Resolved against `src` rather than the compiled location: the fixture is +// data, so it is not emitted into `dist` beside the test that reads it. +const FIXTURE = fileURLToPath( + new URL('../../src/__tests__/fixtures/opencode-session-1.18.21.json', import.meta.url), +); + +interface Fixture { + session: { + id: string; + title: string; + directory: string; + time_created: number; + time_updated: number; + time_archived: number | null; + parent_id: string | null; + }; + messages: { id: string; time_created: number; data: unknown }[]; + parts: { id: string; message_id: string; time_created: number; data: unknown }[]; +} + +describe('OpenCodeSessionAdapter', () => { + test('reports absent when no database exists', async () => { + await withOpenCodeHome(async (home) => { + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + assert.equal(await adapter.detect(), false); + assert.deepEqual(await adapter.listSessions(), []); + }); + }); + + test('an unreadable database is reported, not answered as an empty catalog', async () => { + await withOpenCodeHome(async (home) => { + await writeFile(join(home, 'opencode.db'), 'not a database'); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + // The file is there, so the source is present; what fails is reading it. + assert.equal(await adapter.detect(), true); + await assert.rejects(adapter.listSessions(), /could not be (opened|read)/u); + // Reporting "not found" here would send a user looking for a session + // that exists in a database this could not open. + await assert.rejects(adapter.readSession('ses_anything'), /could not be (opened|read)/u); + }); + }); + + test('a session whose transcript tables are missing fails instead of importing empty', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, undefined, { omitPartTable: true }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + // The session row still reads, so the catalog offers it... + assert.equal((await adapter.listSessions()).length, 1); + // ...and the import must not answer with a conversation of nothing. A + // future opencode that renames these tables would otherwise silently + // import every session as empty and report success. + await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u); + }); + }); + + test('a user message with no prompt does not produce a turn holding no messages', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, (f) => { + // One user message, no parts at all. + f.messages = [{ id: 'msg_empty', time_created: 1, data: { role: 'user' } }]; + f.parts = []; + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + assert.deepEqual( + session.messages, + [], + 'no turn_state is emitted for a turn that holds nothing', + ); + }); + }); + + test('lists a captured session with its directory and title', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + assert.equal(await adapter.detect(), true); + const sessions = await adapter.listSessions(); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.id, fixture.session.id); + assert.equal(sessions[0]?.cwd, fixture.session.directory); + assert.equal(sessions[0]?.name, fixture.session.title); + assert.equal(sessions[0]?.createdAt, fixture.session.time_created); + }); + }); + + test('a cwd query selects by the session directory', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + assert.equal((await adapter.listSessions({ cwd: fixture.session.directory })).length, 1); + assert.equal((await adapter.listSessions({ cwd: '/somewhere/else' })).length, 0); + }); + }); + + test('child sessions are neither listed nor readable as conversations', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, (f) => { + f.session.parent_id = 'ses_parent'; + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + assert.deepEqual(await adapter.listSessions(), []); + await assert.rejects(adapter.readSession(fixture.session.id), /child of another session/u); + }); + }); + + test('converts the captured session into canonical Maka messages', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + + assert.equal(session.sourceSessionId, fixture.session.id); + assert.equal(session.metadata.cwd, fixture.session.directory); + // Every emitted message has to survive Maka's own decoder, or the + // import would be rejected at the persistence boundary rather than here. + for (const message of session.messages) { + assert.ok(decodeCanonicalMessage(message), `undecodable: ${message.type}`); + } + + const kinds = new Set(session.messages.map((message) => message.type)); + assert.ok(kinds.has('user')); + assert.ok(kinds.has('assistant')); + assert.ok(kinds.has('tool_call')); + assert.ok(kinds.has('tool_result')); + assert.ok(kinds.has('turn_state')); + }); + }); + + test('pairs every tool result with the call it answers', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + + const callIds = new Set( + session.messages.filter((m) => m.type === 'tool_call').map((m) => m.id), + ); + const results = session.messages.filter((m) => m.type === 'tool_result'); + assert.ok(results.length > 0, 'the capture contains completed tool calls'); + for (const result of results) { + assert.ok( + callIds.has((result as { toolUseId: string }).toolUseId), + 'every result names a call that was emitted', + ); + } + }); + }); + + test('reasoning is carried as thinking rather than as reply text', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + + const thinking = session.messages.filter( + (m) => m.type === 'assistant' && (m as { thinking?: unknown }).thinking !== undefined, + ); + assert.ok(thinking.length > 0, 'the capture contains reasoning parts'); + for (const message of thinking) { + assert.equal((message as { text: string }).text, ''); + } + }); + }); + + test('an aborted final message closes its turn as aborted, not completed', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + + const states = session.messages.filter((m) => m.type === 'turn_state') as { + status: string; + }[]; + assert.ok(states.length > 0); + // The capture ends on a message carrying MessageAbortedError. + assert.equal(states.at(-1)?.status, 'aborted'); + // Earlier turns finished on `finish: "stop"` and must not be dragged + // into the last one's verdict. + assert.ok( + states.slice(0, -1).some((state) => state.status === 'completed'), + 'completed turns are recorded as completed', + ); + }); + }); + + test('a turn left waiting on a tool call is aborted rather than completed', async () => { + await withOpenCodeHome(async (home) => { + // Drop everything after the first tool-calls message, which is what a + // run killed between a call and its answer leaves behind. + const fixture = await seed(home, (f) => { + const cut = f.messages.findIndex( + (m) => (m.data as { finish?: string }).finish === 'tool-calls', + ); + assert.ok(cut > 0, 'the capture has a tool-calls message'); + const kept = f.messages.slice(0, cut + 1); + const keptIds = new Set(kept.map((m) => m.id)); + f.messages = kept; + f.parts = f.parts.filter((p) => keptIds.has(p.message_id)); + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + const states = session.messages.filter((m) => m.type === 'turn_state') as { + status: string; + }[]; + assert.equal(states.at(-1)?.status, 'aborted'); + }); + }); + + test('an in-flight tool call is imported without a synthesised result', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, (f) => { + for (const part of f.parts) { + const data = part.data as { type?: string; state?: { status?: string } }; + if (data.type === 'tool' && data.state) data.state.status = 'running'; + } + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + assert.ok(session.messages.some((m) => m.type === 'tool_call')); + assert.equal( + session.messages.filter((m) => m.type === 'tool_result').length, + 0, + 'no result is invented for a call that never answered', + ); + }); + }); + + test('a terminal tool failure is imported as an errored result, not a dangling call', async () => { + await withOpenCodeHome(async (home) => { + // The reviewer's reproduction: a failed call inside a step that a later + // `finish: "stop"` closes. Without a result the transcript asserts the + // tool never replied, inside a turn recorded as completed. + const fixture = await seed(home, (f) => { + f.messages = [ + { id: 'm_user', time_created: 1, data: { role: 'user' } }, + { + id: 'm_call', + time_created: 2, + data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' }, + }, + { + id: 'm_stop', + time_created: 3, + data: { role: 'assistant', finish: 'stop', modelID: 'm' }, + }, + ]; + f.parts = [ + { + id: 'p_prompt', + message_id: 'm_user', + time_created: 1, + data: { type: 'text', text: 'go' }, + }, + { + id: 'p_tool', + message_id: 'm_call', + time_created: 2, + data: { + type: 'tool', + tool: 'bash', + callID: 'call_failed', + state: { status: 'error', input: { command: 'false' }, error: 'exit 1' }, + }, + }, + { + id: 'p_text', + message_id: 'm_stop', + time_created: 3, + data: { type: 'text', text: 'done' }, + }, + ]; + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + + const result = session.messages.find((m) => m.type === 'tool_result') as + | { toolUseId: string; isError: boolean; content: { text: string } } + | undefined; + assert.ok(result, 'a failed call still answered, and the answer is a failure'); + assert.equal(result?.toolUseId, 'call_failed'); + assert.equal(result?.isError, true); + assert.equal(result?.content.text, 'exit 1'); + }); + }); + + test('a call still running when the session was written gets no result', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, (f) => { + f.messages = [ + { id: 'm_user', time_created: 1, data: { role: 'user' } }, + { + id: 'm_call', + time_created: 2, + data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' }, + }, + ]; + f.parts = [ + { + id: 'p_prompt', + message_id: 'm_user', + time_created: 1, + data: { type: 'text', text: 'go' }, + }, + { + id: 'p_tool', + message_id: 'm_call', + time_created: 2, + data: { + type: 'tool', + tool: 'bash', + callID: 'call_running', + state: { status: 'running', input: {} }, + }, + }, + ]; + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + assert.ok(session.messages.some((m) => m.type === 'tool_call')); + assert.equal(session.messages.filter((m) => m.type === 'tool_result').length, 0); + }); + }); + + test('parts keep the order the session recorded them in', async () => { + await withOpenCodeHome(async (home) => { + // opencode accepts text -> reasoning -> tool and its own replay keeps + // that order. Bucketing by type would emit reasoning first. + const fixture = await seed(home, (f) => { + f.messages = [ + { id: 'm_user', time_created: 1, data: { role: 'user' } }, + { + id: 'm_reply', + time_created: 2, + data: { role: 'assistant', finish: 'stop', modelID: 'm' }, + }, + ]; + f.parts = [ + { + id: 'p_prompt', + message_id: 'm_user', + time_created: 1, + data: { type: 'text', text: 'go' }, + }, + { + id: 'p1', + message_id: 'm_reply', + time_created: 2, + data: { type: 'text', text: 'first' }, + }, + { + id: 'p2', + message_id: 'm_reply', + time_created: 3, + data: { type: 'reasoning', text: 'second' }, + }, + { + id: 'p3', + message_id: 'm_reply', + time_created: 4, + data: { + type: 'tool', + tool: 'bash', + callID: 'call_third', + state: { status: 'completed', input: {}, output: 'ok' }, + }, + }, + ]; + return f; + }); + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + const session = await adapter.readSession(fixture.session.id); + const reply = session.messages.filter((m) => m.type !== 'user'); + assert.deepEqual( + reply.slice(0, 4).map((m) => { + if (m.type !== 'assistant') return m.type; + return (m as { thinking?: unknown }).thinking !== undefined ? 'thinking' : 'text'; + }), + ['text', 'thinking', 'tool_call', 'tool_result'], + ); + }); + }); + + test('an undecodable transcript row fails the import rather than truncating it', async () => { + await withOpenCodeHome(async (home) => { + const fixture = await seed(home, (f) => { + f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }]; + f.parts = []; + return f; + }); + // Replace the message payload with something that is not JSON. + const db = new DatabaseSync(join(home, 'opencode.db')); + try { + db.prepare('UPDATE message SET data = ? WHERE id = ?').run('{not json', 'm_user'); + } finally { + db.close(); + } + const adapter = new OpenCodeSessionAdapter({ opencodeHome: home }); + await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u); + }); + }); + + test('the registry exposes the adapter under its own id', async () => { + const registry = createExternalSessionAdapterRegistry(); + const adapter = registry.get(OPENCODE_SESSION_ADAPTER_ID); + assert.ok(adapter); + assert.equal(adapter?.id, OPENCODE_SESSION_ADAPTER_ID); + }); +}); + +async function withOpenCodeHome(run: (home: string) => Promise): Promise { + const home = await mkdtemp(join(tmpdir(), 'maka-opencode-adapter-')); + try { + await run(home); + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +async function seed( + home: string, + mutate?: (fixture: Fixture) => Fixture, + options: { omitPartTable?: boolean } = {}, +): Promise { + const raw = JSON.parse(await readFile(FIXTURE, 'utf8')) as Fixture; + const fixture = mutate ? mutate(raw) : raw; + const db = new DatabaseSync(join(home, 'opencode.db')); + try { + db.exec(` + CREATE TABLE session ( + id text PRIMARY KEY, project_id text, workspace_id text, parent_id text, + slug text, directory text NOT NULL, path text, title text, + version text, time_created integer, time_updated integer, + time_compacting integer, time_archived integer + ); + CREATE TABLE message ( + id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer, data text NOT NULL + ); + `); + if (!options.omitPartTable) { + db.exec(` + CREATE TABLE part ( + id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer, data text NOT NULL + ); + `); + } + db.prepare( + 'INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run( + fixture.session.id, + fixture.session.parent_id, + fixture.session.directory, + fixture.session.title, + fixture.session.time_created, + fixture.session.time_updated, + fixture.session.time_archived, + ); + const message = db.prepare( + 'INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)', + ); + for (const row of fixture.messages) { + message.run(row.id, fixture.session.id, row.time_created, JSON.stringify(row.data)); + } + if (options.omitPartTable) return fixture; + const part = db.prepare( + 'INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)', + ); + for (const row of fixture.parts) { + part.run( + row.id, + row.message_id, + fixture.session.id, + row.time_created, + JSON.stringify(row.data), + ); + } + } finally { + db.close(); + } + return fixture; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/08509565c867a7b3880dc679c094cd5c49a97c202bb0a9a7bbdac5eb76e9e24b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/08509565c867a7b3880dc679c094cd5c49a97c202bb0a9a7bbdac5eb76e9e24b.source new file mode 100644 index 0000000000..0888f0ca61 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/08509565c867a7b3880dc679c094cd5c49a97c202bb0a9a7bbdac5eb76e9e24b.source @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const [stateRoot, workspaceRoot, destination, resultPath, limitsJson, identityHex] = + process.argv.slice(2); +if ( + stateRoot === undefined || + workspaceRoot === undefined || + destination === undefined || + resultPath === undefined || + limitsJson === undefined || + identityHex === undefined +) { + process.exit(2); +} + +const originalLink = fs.promises.link.bind(fs.promises); +fs.promises.link = async (existingPath, newPath) => { + await originalLink(existingPath, newPath); + await fs.promises.rm(newPath); + await fs.promises.writeFile(newPath, 'UNRELATED'); +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); +try { + await createSessionBundleFileService().pack({ + snapshot: { + stateRoot, + workspaceRoot, + stateIdentity: { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from(identityHex, 'hex'), + }, + }, + envelope: { + sessionId: 'cloud-session-1', + lastCommittedActivationId: 'activation-9', + }, + destination, + limits: JSON.parse(limitsJson), + }); + process.exit(3); +} catch (error) { + await fs.promises.writeFile( + resultPath, + JSON.stringify({ + code: error instanceof SessionBundleFileError ? error.code : 'unexpected', + destinationContents: await fs.promises.readFile(destination, 'utf8'), + }), + ); + process.exit(error instanceof SessionBundleFileError && error.code === 'source_changed' ? 0 : 4); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0a79b9233b7f2e41758aeba4d0392f173d8d280b043351dbf6e8160c0f164a13.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0a79b9233b7f2e41758aeba4d0392f173d8d280b043351dbf6e8160c0f164a13.source new file mode 100644 index 0000000000..e5ef906414 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0a79b9233b7f2e41758aeba4d0392f173d8d280b043351dbf6e8160c0f164a13.source @@ -0,0 +1,522 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { copyFile, mkdir, mkdtemp, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; +import { + SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION, + isSubagentWorkspaceBinding, + type ProvisionSubagentWorktreeInput, + type SubagentWorkspaceBinding, + type SubagentWorktreeExecutor, +} from '@maka/core/subagent-workspace'; +import { execGitBytes, execGitText, type GitExecOptions } from './git-exec.js'; +import { resolveProjectLocation } from './project-catalog.js'; + +const LEASE_PATTERN = /^subagent_worktree_([a-f0-9]{32})$/; +const WORKTREE_DIRECTORY_PATTERN = /^[a-f0-9]{32}$/; + +export interface CreateGitWorktreeChildExecutorInput { + storageRoot: string; +} + +/** + * Host-owned Git worktree allocator for linked child Sessions. + * + * Lease identity, lease branch, and path are deterministic. A retry therefore + * adopts the same worktree instead of creating a second filesystem side + * effect. The child may check out its own task branch without changing the + * host-owned lease identity. Worktrees intentionally survive terminal child + * runs so Session resume/follow-up keeps the exact workspace. + */ +export function createGitWorktreeChildExecutor( + input: CreateGitWorktreeChildExecutorInput, +): SubagentWorktreeExecutor { + return new GitWorktreeChildExecutor(join(input.storageRoot, 'subagent-worktrees')); +} + +class GitWorktreeChildExecutor implements SubagentWorktreeExecutor { + private readonly inFlight = new Map>(); + private readonly repositoryTails = new Map>(); + + constructor(private readonly worktreeRoot: string) {} + + async isAvailable( + input: Pick, + ): Promise { + try { + const source = await resolveProjectLocation({ path: input.sourceCwd }); + return source.kind === 'git' && source.git !== undefined; + } catch { + return false; + } + } + + async provision(input: ProvisionSubagentWorktreeInput): Promise { + const suffix = leaseSuffix(input.leaseId); + const existing = this.inFlight.get(input.leaseId); + if (existing) return existing; + const task = this.provisionOnce(input, suffix).finally(() => { + if (this.inFlight.get(input.leaseId) === task) this.inFlight.delete(input.leaseId); + }); + this.inFlight.set(input.leaseId, task); + return task; + } + + async ensure(binding: SubagentWorkspaceBinding): Promise { + if (!isSubagentWorkspaceBinding(binding)) { + throw new Error('Invalid subagent worktree binding'); + } + await this.assertOwnedBindingLocation(binding); + const inspected = await this.inspectOwnedWorktree(binding.worktreePath); + if (!inspected) { + throw new Error(`Subagent worktree is unavailable: ${binding.worktreePath}`); + } + if (inspected.gitCommonDir !== normalize(binding.gitCommonDir)) { + throw new Error(`Subagent worktree binding changed: ${binding.worktreePath}`); + } + const [lease, baseCommit] = await Promise.all([ + gitConfigGet(inspected.worktreePath, branchLeaseConfigKey(binding.branch)), + gitConfigGet(inspected.worktreePath, branchBaseConfigKey(binding.branch)), + ]); + if (lease !== binding.leaseId || baseCommit !== binding.baseCommit) { + throw new Error(`Subagent worktree lease changed: ${binding.worktreePath}`); + } + } + + async capturePatch(binding: SubagentWorkspaceBinding): Promise { + await this.ensure(binding); + return this.withRepositoryAllocation(binding.gitCommonDir, async () => { + const temporary = await mkdtemp(join(tmpdir(), 'maka-subagent-patch-')); + const indexPath = join(temporary, 'index'); + try { + const currentIndex = ( + await runGit(binding.worktreePath, ['rev-parse', '--git-path', 'index']) + ).trim(); + // Preserve staged and committed ignored paths, then overlay all working-tree changes. + await copyFile( + isAbsolute(currentIndex) ? currentIndex : resolve(binding.worktreePath, currentIndex), + indexPath, + ); + const gitOptions = { gitIndexFile: indexPath }; + await runGit(binding.worktreePath, ['add', '--all', '--'], gitOptions); + return await runGitBytes( + binding.worktreePath, + [ + 'diff', + '--cached', + '--binary', + '--full-index', + '--no-ext-diff', + '--no-textconv', + '--no-color', + binding.baseCommit, + '--', + ], + gitOptions, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + }); + } + + async recover(liveBindings: readonly SubagentWorkspaceBinding[]): Promise { + const liveByPath = new Map(); + for (const binding of liveBindings) { + if (!isSubagentWorkspaceBinding(binding)) { + throw new Error('Invalid live subagent worktree binding'); + } + await this.assertOwnedBindingLocation(binding); + const key = normalize(binding.worktreePath); + if (liveByPath.has(key)) { + throw new Error(`Duplicate live subagent worktree binding: ${binding.worktreePath}`); + } + liveByPath.set(key, binding); + } + + if (!(await isDirectory(this.worktreeRoot))) { + if (liveBindings.length > 0) { + throw new Error('Live subagent worktree bindings exist without a worktree root'); + } + return; + } + const root = normalize(await realpath(this.worktreeRoot)); + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !WORKTREE_DIRECTORY_PATTERN.test(entry.name)) { + throw new Error(`Unexpected entry in subagent worktree root: ${entry.name}`); + } + const path = join(root, entry.name); + const live = liveByPath.get(path); + if (live) { + await this.ensure(live); + liveByPath.delete(path); + continue; + } + await this.retireOrphan(path, entry.name); + } + if (liveByPath.size > 0) { + throw new Error(`Live subagent worktree is unavailable: ${liveByPath.keys().next().value}`); + } + } + + async retire(binding: SubagentWorkspaceBinding): Promise { + if (!isSubagentWorkspaceBinding(binding)) { + throw new Error('Invalid subagent worktree binding'); + } + await this.assertOwnedBindingLocation(binding); + if (!(await isDirectory(binding.worktreePath))) return; + await this.ensure(binding); + await this.withRepositoryAllocation(binding.gitCommonDir, () => + this.removeOwnedWorktree(binding.worktreePath, binding.branch, binding.gitCommonDir), + ); + } + + private async provisionOnce( + input: ProvisionSubagentWorktreeInput, + suffix: string, + ): Promise { + if (!input.sourceSessionId) throw new Error('Subagent worktree source Session is required'); + const source = await resolveProjectLocation({ path: input.sourceCwd }); + if (source.kind !== 'git' || !source.git) { + throw new Error('Worktree child execution requires a Git project'); + } + const root = await ensureDirectory(this.worktreeRoot); + const worktreePath = join(root, suffix); + const branch = `maka/subagent/${suffix}`; + const gitCommonDir = normalize(source.git.commonDir); + return this.withRepositoryAllocation(gitCommonDir, () => + this.provisionResolved(input.leaseId, source.git!.worktreeRoot, { + worktreePath, + branch, + gitCommonDir, + }), + ); + } + + private async provisionResolved( + leaseId: string, + sourceWorktreeRoot: string, + target: { + worktreePath: string; + branch: string; + gitCommonDir: string; + }, + ): Promise { + const { worktreePath, branch, gitCommonDir } = target; + const adopted = await this.inspectOwnedWorktree(worktreePath); + if (adopted) { + if (adopted.gitCommonDir !== gitCommonDir) { + throw new Error(`Subagent worktree belongs to another Git repository: ${worktreePath}`); + } + return this.finalizeBinding(leaseId, branch, adopted); + } + + const branchCommit = await gitRevParseOptional(sourceWorktreeRoot, branch); + const storedLease = await gitConfigGet(sourceWorktreeRoot, branchLeaseConfigKey(branch)); + if (branchCommit && storedLease !== leaseId) { + throw new Error(`Subagent worktree branch is already owned: ${branch}`); + } + + let baseCommit: string; + if (branchCommit) { + baseCommit = + (await gitConfigGet(sourceWorktreeRoot, branchBaseConfigKey(branch))) ?? branchCommit; + await runGit(sourceWorktreeRoot, ['worktree', 'add', '--quiet', worktreePath, branch]); + } else { + await assertCleanGitWorktree(sourceWorktreeRoot); + baseCommit = await gitRevParse(sourceWorktreeRoot, 'HEAD'); + await runGit(sourceWorktreeRoot, [ + 'worktree', + 'add', + '--quiet', + '--detach', + worktreePath, + baseCommit, + ]); + await runGit(worktreePath, ['switch', '--quiet', '-c', branch]); + } + + const inspected = await this.inspectOwnedWorktree(worktreePath); + if (!inspected || inspected.gitCommonDir !== gitCommonDir) { + throw new Error(`Git did not create the expected subagent worktree: ${worktreePath}`); + } + const checkedOutBranch = await gitCurrentBranch(inspected.worktreePath); + if (checkedOutBranch !== branch) { + throw new Error(`Git did not check out the expected subagent branch: ${worktreePath}`); + } + await setBranchLease(worktreePath, branch, leaseId, baseCommit); + return { + schemaVersion: SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION, + kind: 'git_worktree', + leaseId, + gitCommonDir, + worktreePath: inspected.worktreePath, + branch, + baseCommit, + }; + } + + private async withRepositoryAllocation( + gitCommonDir: string, + operation: () => Promise, + ): Promise { + const previous = this.repositoryTails.get(gitCommonDir) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => current); + this.repositoryTails.set(gitCommonDir, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.repositoryTails.get(gitCommonDir) === tail) { + this.repositoryTails.delete(gitCommonDir); + } + } + } + + private async finalizeBinding( + leaseId: string, + leaseBranch: string, + inspected: InspectedWorktree, + ): Promise { + const lease = await gitConfigGet(inspected.worktreePath, branchLeaseConfigKey(leaseBranch)); + if (lease && lease !== leaseId) { + throw new Error(`Subagent worktree lease changed: ${inspected.worktreePath}`); + } + if (!lease && (await gitCurrentBranch(inspected.worktreePath)) !== leaseBranch) { + throw new Error(`Subagent worktree lease is unavailable: ${inspected.worktreePath}`); + } + const baseCommit = + (await gitConfigGet(inspected.worktreePath, branchBaseConfigKey(leaseBranch))) ?? + (await gitRevParse(inspected.worktreePath, leaseBranch)); + await setBranchLease(inspected.worktreePath, leaseBranch, leaseId, baseCommit); + return { + schemaVersion: SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION, + kind: 'git_worktree', + leaseId, + gitCommonDir: inspected.gitCommonDir, + worktreePath: inspected.worktreePath, + branch: leaseBranch, + baseCommit, + }; + } + + private async inspectOwnedWorktree(path: string): Promise { + if (!(await isDirectory(path))) return undefined; + const location = await resolveProjectLocation({ path }); + if (location.kind !== 'git' || !location.git?.isWorktree) { + throw new UnlinkedWorktreeError(path); + } + const worktreePath = normalize(await realpath(location.git.worktreeRoot)); + if (worktreePath !== normalize(path)) { + throw new Error(`Subagent workspace resolves outside its Host-owned path: ${path}`); + } + return { + worktreePath, + gitCommonDir: normalize(location.git.commonDir), + }; + } + + private async assertOwnedBindingLocation(binding: SubagentWorkspaceBinding): Promise { + const suffix = leaseSuffix(binding.leaseId); + const root = normalize(await realpath(this.worktreeRoot)); + if ( + normalize(binding.worktreePath) !== join(root, suffix) || + binding.branch !== `maka/subagent/${suffix}` + ) { + throw new Error( + `Subagent worktree binding is outside the Host-owned root: ${binding.worktreePath}`, + ); + } + } + + private async retireOrphan(path: string, suffix: string): Promise { + let inspected: InspectedWorktree | undefined; + try { + inspected = await this.inspectOwnedWorktree(path); + } catch (error) { + if (!(error instanceof UnlinkedWorktreeError)) throw error; + await rm(path, { recursive: true, force: true }); + return; + } + if (!inspected) return; + const branch = `maka/subagent/${suffix}`; + const leaseId = `subagent_worktree_${suffix}`; + const branchCommit = await gitRevParseOptional(path, branch); + const storedLease = await gitConfigGet(path, branchLeaseConfigKey(branch)); + if (storedLease !== undefined && storedLease !== leaseId) { + throw new Error(`Orphan subagent worktree lease changed: ${path}`); + } + const currentBranch = await gitCurrentBranch(path); + if (!branchCommit && currentBranch !== undefined) { + throw new Error(`Orphan subagent worktree is attached to an unmanaged branch: ${path}`); + } + if (branchCommit && storedLease === undefined && currentBranch !== branch) { + throw new Error(`Orphan subagent worktree ownership is unavailable: ${path}`); + } + await this.withRepositoryAllocation(inspected.gitCommonDir, () => + this.removeOwnedWorktree(path, branch, inspected.gitCommonDir), + ); + } + + private async removeOwnedWorktree( + path: string, + leaseBranch: string, + gitCommonDir: string, + ): Promise { + await runGit(path, ['clean', '-ffdx']); + await runGit(path, ['checkout', '--detach', '--force', 'HEAD']); + await runGit(path, ['clean', '-ffdx']); + if (await gitRevParseOptional(path, leaseBranch)) { + await runGit(path, ['branch', '-D', leaseBranch]); + } + // Windows cannot remove a process's current directory, so run the final removal elsewhere. + await runGit(gitCommonDir, ['worktree', 'remove', '--force', path]); + } +} + +class UnlinkedWorktreeError extends Error { + readonly name = 'UnlinkedWorktreeError'; + + constructor(path: string) { + super(`Subagent workspace is not a linked Git worktree: ${path}`); + } +} + +interface InspectedWorktree { + worktreePath: string; + gitCommonDir: string; +} + +async function assertCleanGitWorktree(path: string): Promise { + const status = await runGit(path, [ + 'status', + '--porcelain=v1', + '--untracked-files=normal', + '--ignore-submodules=none', + ]); + if (status.trim()) { + throw new Error( + 'Worktree child execution requires the source Git worktree to have no uncommitted changes', + ); + } +} + +async function setBranchLease( + cwd: string, + branch: string, + leaseId: string, + baseCommit: string, +): Promise { + await runGit(cwd, ['config', '--local', branchLeaseConfigKey(branch), leaseId]); + await runGit(cwd, ['config', '--local', branchBaseConfigKey(branch), baseCommit]); +} + +function branchLeaseConfigKey(branch: string): string { + return `branch.${branch}.maka-worktree-lease`; +} + +function branchBaseConfigKey(branch: string): string { + return `branch.${branch}.maka-worktree-base`; +} + +async function gitConfigGet(cwd: string, key: string): Promise { + try { + const output = await runGit(cwd, ['config', '--local', '--get', key]); + return output.trim() || undefined; + } catch (error) { + if (gitExitCode(error) === 1) return undefined; + throw error; + } +} + +async function gitRevParse(cwd: string, ref: string): Promise { + return (await runGit(cwd, ['rev-parse', '--verify', ref])).trim(); +} + +async function gitRevParseOptional(cwd: string, ref: string): Promise { + try { + return await gitRevParse(cwd, ref); + } catch (error) { + if (gitExitCode(error) === 128) return undefined; + throw error; + } +} + +async function gitCurrentBranch(cwd: string): Promise { + try { + const branch = await runGit(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']); + return branch.trim() || undefined; + } catch (error) { + if (gitExitCode(error) === 1) return undefined; + throw error; + } +} + +async function runGit( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + return execGitText(cwd, args, options); +} + +async function runGitBytes( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + return execGitBytes(cwd, args, options); +} + +function gitExitCode(error: unknown): number | undefined { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined; + return typeof error.code === 'number' ? error.code : undefined; +} + +function leaseSuffix(leaseId: string): string { + const match = LEASE_PATTERN.exec(leaseId); + if (!match?.[1]) throw new Error(`Invalid subagent worktree lease id: ${leaseId}`); + return match[1]; +} + +async function ensureDirectory(path: string): Promise { + await mkdir(path, { recursive: true }); + const canonical = normalize(await realpath(path)); + if (!isAbsolute(canonical) || !(await stat(canonical)).isDirectory()) { + throw new Error(`Invalid subagent worktree root: ${path}`); + } + return canonical; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0b110039ef708c2c9902423a25f33f4921f3a70b3a630428d684e40c21234195.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0b110039ef708c2c9902423a25f33f4921f3a70b3a630428d684e40c21234195.source new file mode 100644 index 0000000000..1251c6321f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0b110039ef708c2c9902423a25f33f4921f3a70b3a630428d684e40c21234195.source @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeManagedSecretEnvironmentName, + decodeManagedSecretReference, + managedSecretRevision, + managedSecretValue, + ManagedSecretError, + type ManagedSecretActivationContext, + type ManagedSecretMaterial, + type ManagedSecretReference, + type ManagedSecretStore, +} from './managed-secret-store.js'; + +export interface ActivationSecretEnvironmentBinding { + readonly reference: ManagedSecretReference; + readonly target: { + readonly kind: 'environment'; + readonly name: string; + }; +} + +export interface ActivationSecretInjectionLease { + /** Idempotent for a completed cleanup; failures remain retryable. */ + release(): Promise; +} + +export interface ActivationSecretEnvironmentEntry { + readonly name: string; + readonly value: string; +} + +/** + * Implemented by the sandbox/control-plane boundary. One call owns the complete + * environment effect for an Activation: it must either return one lease for the + * whole batch or reject without applying any entry. + */ +export interface ActivationSecretSink { + injectEnvironmentVariables(input: { + readonly entries: readonly ActivationSecretEnvironmentEntry[]; + }): Promise; +} + +/** + * Concrete V1 sink for an isolated sandbox-launch environment object. Callers + * should pass a fresh environment overlay, not the host-wide `process.env`, so + * concurrent Activations cannot observe each other's values. + */ +export class ActivationEnvironmentSecretSink implements ActivationSecretSink { + constructor(private readonly environment: NodeJS.ProcessEnv) {} + + async injectEnvironmentVariables(input: { + readonly entries: readonly ActivationSecretEnvironmentEntry[]; + }): Promise { + const entries = normalizeEnvironmentEntries(input.entries); + const previous = entries.map(({ name }) => ({ + name, + hadPrevious: Object.hasOwn(this.environment, name), + value: this.environment[name], + })); + let applied = 0; + try { + for (const entry of entries) { + this.environment[entry.name] = entry.value; + applied += 1; + } + } catch (error) { + restoreEnvironment(this.environment, previous.slice(0, applied)); + throw error; + } + let released = false; + return { + release: async () => { + if (released) return; + restoreEnvironment(this.environment, previous); + released = true; + }, + }; + } +} + +export interface PrepareActivationSecretInjectionInput { + /** Trusted, owner-checked context supplied out of band by the control plane. */ + readonly context: ManagedSecretActivationContext; + readonly bindings: readonly ActivationSecretEnvironmentBinding[]; + readonly sink: ActivationSecretSink; +} + +export interface PreparedActivationSecretInjection { + readonly references: readonly ManagedSecretReference[]; + /** + * Literal-value redaction for runtime events and diagnostics emitted while + * this lease is active. Callers must drain those surfaces before `release`. + */ + redact(value: string): string; + /** Releases the complete batch effect. */ + release(): Promise; +} + +/** + * Resolves and validates every reference before handing one complete batch to + * the effect-owning sink. No secret value is included in public errors or in + * the returned handle. + */ +export class ActivationSecretInjector { + constructor(private readonly store: ManagedSecretStore) {} + + async prepare( + input: PrepareActivationSecretInjectionInput, + ): Promise { + const bindings = normalizeBindings(input.bindings); + const material = snapshotMaterial( + await this.store.resolveForActivation({ + context: input.context, + references: bindings.map((binding) => binding.reference), + }), + ); + if (material.length !== bindings.length) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret resolution returned an invalid material set', + ); + } + if ( + !material.every((secret, index) => + sameReference(secret?.reference, bindings[index]?.reference), + ) + ) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret resolution returned mismatched material references', + ); + } + + let lease: ActivationSecretInjectionLease | undefined; + try { + if (bindings.length > 0) { + lease = await input.sink.injectEnvironmentVariables({ + entries: Object.freeze( + bindings.map((binding, index) => + Object.freeze({ + name: binding.target.name, + value: material[index]!.value, + }), + ), + ), + }); + } + } catch { + throw new ManagedSecretError('injection_failed', 'Managed Secret injection failed'); + } + + const handle = new PreparedInjectionHandle( + lease, + bindings.map((item) => item.reference), + material.map((item) => item.value), + ); + return handle; + } +} + +function snapshotMaterial(value: readonly ManagedSecretMaterial[]): ManagedSecretMaterial[] { + if (!Array.isArray(value)) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret resolution returned an invalid material set', + ); + } + try { + return value.map((secret) => ({ + reference: decodeManagedSecretReference(secret?.reference), + revision: managedSecretRevision(secret?.revision), + value: managedSecretValue(secret?.value), + })); + } catch { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret resolution returned an invalid material set', + ); + } +} + +function sameReference( + actual: ManagedSecretReference | undefined, + expected: ManagedSecretReference | undefined, +): boolean { + if (!actual || !expected) return false; + try { + const normalized = decodeManagedSecretReference(actual); + return ( + normalized.schemaVersion === expected.schemaVersion && + normalized.secretId === expected.secretId + ); + } catch { + return false; + } +} + +class PreparedInjectionHandle implements PreparedActivationSecretInjection { + readonly references: readonly ManagedSecretReference[]; + #lease: ActivationSecretInjectionLease | undefined; + readonly #values: string[]; + + constructor( + lease: ActivationSecretInjectionLease | undefined, + references: readonly ManagedSecretReference[], + values: readonly string[], + ) { + this.#lease = lease; + this.references = references.map((reference) => ({ ...reference })); + this.#values = uniqueLongestFirst(values); + } + + redact(value: string): string { + return redactLiteralSecrets(value, this.#values); + } + + async release(): Promise { + if (!this.#lease) return; + try { + await this.#lease.release(); + } catch { + throw new ManagedSecretError('cleanup_failed', 'Managed Secret cleanup failed'); + } + this.#lease = undefined; + this.#values.length = 0; + } +} + +function normalizeEnvironmentEntries( + value: readonly ActivationSecretEnvironmentEntry[], +): readonly ActivationSecretEnvironmentEntry[] { + if (!Array.isArray(value) || value.length > 128) { + throw new ManagedSecretError('invalid_input', 'Managed Secret environment batch is invalid'); + } + const names = new Set(); + return value.map((candidate) => { + const name = decodeManagedSecretEnvironmentName(candidate?.name); + const portableName = name.toUpperCase(); + if (names.has(portableName)) { + throw new ManagedSecretError( + 'invalid_input', + 'Managed Secret environment targets must be unique', + ); + } + names.add(portableName); + return { name, value: managedSecretValue(candidate?.value) }; + }); +} + +function restoreEnvironment( + environment: NodeJS.ProcessEnv, + previous: readonly { + readonly name: string; + readonly hadPrevious: boolean; + readonly value?: string; + }[], +): void { + for (let index = previous.length - 1; index >= 0; index -= 1) { + const entry = previous[index]!; + if (entry.hadPrevious) environment[entry.name] = entry.value; + else delete environment[entry.name]; + } +} + +function normalizeBindings( + value: readonly ActivationSecretEnvironmentBinding[], +): readonly ActivationSecretEnvironmentBinding[] { + if (!Array.isArray(value) || value.length > 128) { + throw new ManagedSecretError('invalid_input', 'Managed Secret bindings must be bounded'); + } + const names = new Set(); + return value.map((candidate) => { + const reference = decodeManagedSecretReference(candidate?.reference); + if (candidate?.target?.kind !== 'environment') { + throw new ManagedSecretError('invalid_input', 'Managed Secret injection target is invalid'); + } + const name = decodeManagedSecretEnvironmentName(candidate.target.name); + const portableName = name.toUpperCase(); + if (names.has(portableName)) { + throw new ManagedSecretError( + 'invalid_input', + 'Managed Secret environment targets must be unique', + ); + } + names.add(portableName); + return { reference, target: { kind: 'environment' as const, name } }; + }); +} + +function uniqueLongestFirst(values: readonly string[]): string[] { + return [...new Set(values)].sort((left, right) => right.length - left.length); +} + +function redactLiteralSecrets(value: string, secrets: readonly string[]): string { + if (secrets.length === 0) return value; + let marker = '\0'; + while (value.includes(marker) || secrets.some((secret) => secret.includes(marker))) + marker += '\0'; + let result = value; + for (const secret of secrets) result = result.split(secret).join(marker); + return result.split(marker).join('[redacted]'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0bf73a3812ab803fcbe46feccbf511b4facf25e5cad9c7d4b98c8478d83f4b87.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0bf73a3812ab803fcbe46feccbf511b4facf25e5cad9c7d4b98c8478d83f4b87.source new file mode 100644 index 0000000000..1eb41126c9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0bf73a3812ab803fcbe46feccbf511b4facf25e5cad9c7d4b98c8478d83f4b87.source @@ -0,0 +1,1149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test, { type TestContext } from 'node:test'; +import type { ContextOffloadLimits } from '@maka/core/context-offload'; +import { + CONTEXT_OFFLOAD_DATABASE_NAME, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, + SqliteContextOffloadStore, +} from '../sqlite-context-offload-store.js'; + +const execFileAsync = promisify(execFile); +const managedPublicationCrashChild = fileURLToPath( + new URL('./fixtures/context-offload-managed-publication-crash-child.js', import.meta.url), +); + +test('creates the dedicated WAL schema with incremental auto-vacuum', async (t) => { + const fixture = await createFixture(t); + fixture.store.close(); + const database = new DatabaseSync(fixture.path); + t.after(() => database.close()); + + assert.equal(pragmaNumber(database, 'user_version'), 3); + assert.equal(pragmaNumber(database, 'auto_vacuum'), 2); + assert.equal(pragmaText(database, 'journal_mode'), 'wal'); + assert.deepEqual( + database + .prepare( + `SELECT name FROM sqlite_schema + WHERE type = 'table' AND name LIKE 'context_%' + ORDER BY name`, + ) + .all() + .map((row) => row.name), + [ + 'context_blobs', + 'context_file_deletions', + 'context_gc_candidates', + 'context_refs', + 'context_session_usage', + 'context_store_usage', + ], + ); +}); + +test('atomically persists one idempotent owner identity and verifies reads', async (t) => { + const fixture = await createFixture(t, { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 64, + workspacePhysicalBytes: 64, + }); + const bytes = new TextEncoder().encode('snapshot'); + const expectedSha256 = sha256(bytes); + const input = { + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot' as const, ownerId: 'read-call-1' }, + bytes, + mediaType: 'image/png', + expectedSha256, + }; + + const first = await fixture.store.put(input); + const retried = await fixture.store.put(input); + assert.equal(first.ok, true); + assert.deepEqual(retried, first); + if (!first.ok) return; + assert.equal(first.record.blobId, expectedSha256); + assert.deepEqual( + await fixture.store.read({ + sessionId: input.sessionId, + refId: first.record.refId, + maxBytes: bytes.byteLength, + }), + { + ok: true, + record: first.record, + bytes, + }, + ); + assert.deepEqual(await fixture.store.usage('session-1'), { + references: 1, + logicalBytes: bytes.byteLength, + physicalBytes: bytes.byteLength, + }); + + assert.deepEqual( + await fixture.store.put({ ...input, bytes: new TextEncoder().encode('changed') }), + { + ok: false, + reason: 'identity_conflict', + }, + ); + assert.deepEqual(await fixture.store.put({ ...input, expectedSha256: '0'.repeat(64) }), { + ok: false, + reason: 'identity_conflict', + }); + assert.deepEqual(await fixture.store.put({ ...input, mediaType: 'image/jpeg' }), { + ok: false, + reason: 'identity_conflict', + }); +}); + +test('stores managed binary values as durable file locators instead of SQLite payloads', async (t) => { + const fixture = await createFixture(t); + const bytes = new Uint8Array(1_024).fill(0x5a); + const blobId = sha256(bytes); + const stored = await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot', ownerId: 'read-call-1' }, + bytes, + mediaType: 'image/png', + }); + assert.equal(stored.ok, true); + if (!stored.ok) return; + + const database = new DatabaseSync(fixture.path); + const row = database + .prepare('SELECT storage_kind, payload, size_bytes FROM context_blobs WHERE blob_id = ?') + .get(Buffer.from(blobId, 'hex')) as { + storage_kind: string; + payload: Uint8Array; + size_bytes: number; + }; + assert.equal( + ( + database.prepare('SELECT COUNT(*) AS count FROM context_file_deletions').get() as { + count: number; + } + ).count, + 0, + ); + database.close(); + const locator = Buffer.from(row.payload).toString('utf8'); + assert.equal(row.storage_kind, 'managed_file'); + assert.equal(row.size_bytes, bytes.byteLength); + assert.equal(locator, `sha256/${blobId.slice(0, 2)}/${blobId}`); + assert.ok(row.payload.byteLength < bytes.byteLength); + + const valuePath = join(fixture.root, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, locator); + assert.deepEqual(new Uint8Array(await readFile(valuePath)), bytes); + assert.equal((await stat(valuePath)).isFile(), true); + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: stored.record.refId, + maxBytes: bytes.byteLength, + }), + { ok: true, record: stored.record, bytes }, + ); + + await writeFile(valuePath, new Uint8Array(bytes.byteLength)); + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: stored.record.refId, + maxBytes: bytes.byteLength, + }), + { ok: false, reason: 'corrupt' }, + ); + + await fixture.store.releaseReference({ + sessionId: 'session-1', + refId: stored.record.refId, + }); + assert.deepEqual( + await fixture.store.collectGarbage({ + olderThan: 1_001, + maxBlobs: 1, + maxBytes: bytes.byteLength, + }), + { deletedBlobs: 1, deletedBytes: bytes.byteLength, hasMore: false }, + ); + await assert.rejects(stat(valuePath), (error) => isNodeError(error, 'ENOENT')); + const afterGc = new DatabaseSync(fixture.path); + assert.equal( + ( + afterGc.prepare('SELECT COUNT(*) AS count FROM context_file_deletions').get() as { + count: number; + } + ).count, + 0, + ); + afterGc.close(); +}); + +test('repairs a missing managed blob when an inline owner retries identical bytes', async (t) => { + const fixture = await createFixture(t); + const bytes = new TextEncoder().encode('shared-value'); + const tool = await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'tool_result_archive', ownerId: 'tool-1' }, + bytes, + mediaType: 'application/octet-stream', + }); + const image = await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot', ownerId: 'read-1' }, + bytes, + mediaType: 'image/png', + }); + assert.equal(tool.ok, true); + assert.equal(image.ok, true); + if (!tool.ok || !image.ok) return; + + const blobId = sha256(bytes); + const valuePath = join( + fixture.root, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, + `sha256/${blobId.slice(0, 2)}/${blobId}`, + ); + await unlink(valuePath); + + assert.deepEqual( + await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'tool_result_archive', ownerId: 'tool-1' }, + bytes, + mediaType: 'application/octet-stream', + }), + tool, + ); + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: tool.record.refId, + maxBytes: bytes.byteLength, + }), + { ok: true, record: tool.record, bytes }, + ); +}); + +test('removes managed publication state when quota admission fails', async (t) => { + const fixture = await createFixture(t, { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 64, + workspacePhysicalBytes: 0, + }); + const bytes = new TextEncoder().encode('over-quota'); + const blobId = sha256(bytes); + assert.deepEqual( + await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot', ownerId: 'read-call-1' }, + bytes, + mediaType: 'image/png', + }), + { ok: false, reason: 'workspace_quota_exceeded' }, + ); + await assert.rejects( + stat( + join( + fixture.root, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, + `sha256/${blobId.slice(0, 2)}/${blobId}`, + ), + ), + (error) => isNodeError(error, 'ENOENT'), + ); + const database = new DatabaseSync(fixture.path); + assert.equal( + ( + database.prepare('SELECT COUNT(*) AS count FROM context_file_deletions').get() as { + count: number; + } + ).count, + 0, + ); + database.close(); +}); + +test('recovers a durable managed-file publication intent after process exit', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-publication-crash-')); + t.after(() => rm(root, { recursive: true, force: true })); + await assert.rejects( + execFileAsync(process.execPath, [managedPublicationCrashChild], { + env: { ...process.env, MAKA_CONTEXT_OFFLOAD_CRASH_ROOT: root, NODE_NO_WARNINGS: '1' }, + windowsHide: true, + }), + (error: unknown) => error instanceof Error && 'code' in error && Number(error.code) === 73, + ); + + const bytes = new TextEncoder().encode('crash-safe-managed-value'); + const blobId = sha256(bytes); + const locator = `sha256/${blobId.slice(0, 2)}/${blobId}`; + const valuePath = join(root, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, locator); + assert.deepEqual(new Uint8Array(await readFile(valuePath)), bytes); + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + const crashed = new DatabaseSync(path); + assert.equal( + ( + crashed + .prepare('SELECT COUNT(*) AS count FROM context_file_deletions WHERE locator = ?') + .get(Buffer.from(locator, 'utf8')) as { count: number } + ).count, + 1, + ); + assert.equal( + (crashed.prepare('SELECT COUNT(*) AS count FROM context_blobs').get() as { count: number }) + .count, + 0, + ); + crashed.close(); + + const recovered = new SqliteContextOffloadStore(path, { limits: defaultLimits() }); + t.after(() => recovered.close()); + assert.deepEqual( + await recovered.collectGarbage({ olderThan: 1, maxBlobs: 1, maxBytes: bytes.byteLength }), + { deletedBlobs: 0, deletedBytes: 0, hasMore: false }, + ); + await assert.rejects(stat(valuePath), (error) => isNodeError(error, 'ENOENT')); + const afterRecovery = new DatabaseSync(path); + assert.equal( + ( + afterRecovery.prepare('SELECT COUNT(*) AS count FROM context_file_deletions').get() as { + count: number; + } + ).count, + 0, + ); + afterRecovery.close(); +}); + +test('recovers deterministic managed-file staging after process exit', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-staging-crash-')); + t.after(() => rm(root, { recursive: true, force: true })); + await assert.rejects( + execFileAsync(process.execPath, [managedPublicationCrashChild], { + env: { + ...process.env, + MAKA_CONTEXT_OFFLOAD_CRASH_ROOT: root, + MAKA_CONTEXT_OFFLOAD_CRASH_POINT: 'after_managed_file_staging', + NODE_NO_WARNINGS: '1', + }, + windowsHide: true, + }), + (error: unknown) => error instanceof Error && 'code' in error && Number(error.code) === 73, + ); + + const bytes = new TextEncoder().encode('crash-safe-managed-value'); + const blobId = sha256(bytes); + const directory = join( + root, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, + `sha256/${blobId.slice(0, 2)}`, + ); + const stagingPath = join(directory, `.${blobId}.publish.tmp`); + assert.deepEqual(new Uint8Array(await readFile(stagingPath)), bytes); + + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + const recovered = new SqliteContextOffloadStore(path, { limits: defaultLimits() }); + t.after(() => recovered.close()); + assert.deepEqual(await recovered.usage(), { + references: 0, + logicalBytes: 0, + physicalBytes: bytes.byteLength, + }); + assert.deepEqual( + await recovered.collectGarbage({ olderThan: 1, maxBlobs: 1, maxBytes: bytes.byteLength }), + { deletedBlobs: 0, deletedBytes: 0, hasMore: false }, + ); + await assert.rejects(stat(stagingPath), (error) => isNodeError(error, 'ENOENT')); + assert.equal((await recovered.usage()).physicalBytes, 0); +}); + +test('bounds pending managed-file deletion bytes and reports continuation', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-pending-files-')); + t.after(() => rm(root, { recursive: true, force: true })); + const values = ['pending-one', 'pending-two', 'pending-three']; + for (const [index, value] of values.entries()) { + await assert.rejects( + execFileAsync(process.execPath, [managedPublicationCrashChild], { + env: { + ...process.env, + MAKA_CONTEXT_OFFLOAD_CRASH_ROOT: root, + MAKA_CONTEXT_OFFLOAD_CRASH_POINT: 'after_managed_file_staging', + MAKA_CONTEXT_OFFLOAD_OWNER_ID: `read-${index}`, + MAKA_CONTEXT_OFFLOAD_VALUE: value, + NODE_NO_WARNINGS: '1', + }, + windowsHide: true, + }), + (error: unknown) => error instanceof Error && 'code' in error && Number(error.code) === 73, + ); + } + + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + const recovered = new SqliteContextOffloadStore(path, { limits: defaultLimits() }); + t.after(() => recovered.close()); + const totalBytes = values.reduce((total, value) => total + Buffer.byteLength(value), 0); + assert.equal((await recovered.usage()).physicalBytes, totalBytes); + const byteBudget = Math.max(...values.map((value) => Buffer.byteLength(value))); + for (const hasMore of [true, true, false]) { + const before = (await recovered.usage()).physicalBytes; + assert.deepEqual( + await recovered.collectGarbage({ olderThan: 1, maxBlobs: 64, maxBytes: byteBudget }), + { deletedBlobs: 0, deletedBytes: 0, hasMore }, + ); + assert.ok(before - (await recovered.usage()).physicalBytes <= byteBudget); + } + assert.equal((await recovered.usage()).physicalBytes, 0); +}); + +test('rejects a managed-value directory that resolves outside the Storage Root', async (t) => { + const fixture = await createFixture(t); + const outside = await mkdtemp(join(tmpdir(), 'maka-context-offload-outside-')); + t.after(() => rm(outside, { recursive: true, force: true })); + await symlink( + outside, + join(fixture.root, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + assert.deepEqual( + await fixture.store.put({ + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot', ownerId: 'read-call-1' }, + bytes: new TextEncoder().encode('image'), + mediaType: 'image/png', + }), + { ok: false, reason: 'unavailable' }, + ); + assert.deepEqual(await readdir(outside), []); +}); + +test('reopens durable records and preserves owner idempotency', async (t) => { + const fixture = await createFixture(t); + const bytes = new TextEncoder().encode('durable'); + const first = await fixture.store.put(putInput('session-1', 'archive-1', bytes)); + assert.equal(first.ok, true); + if (!first.ok) return; + fixture.store.close(); + + const reopened = new SqliteContextOffloadStore(fixture.path, { + limits: fixture.limits, + now: () => 2_000, + idFactory: () => 'unexpected-new-reference', + }); + t.after(() => reopened.close()); + assert.deepEqual(await reopened.put(putInput('session-1', 'archive-1', bytes)), first); + assert.deepEqual( + await reopened.read({ + sessionId: 'session-1', + refId: first.record.refId, + maxBytes: bytes.byteLength, + }), + { ok: true, record: first.record, bytes }, + ); +}); + +test('rejects a database schema newer than this authority understands', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-newer-')); + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + t.after(() => rm(root, { recursive: true, force: true })); + const database = new DatabaseSync(path); + database.exec('PRAGMA user_version = 4'); + database.close(); + + assert.throws( + () => + new SqliteContextOffloadStore(path, { + limits: { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 1, + workspacePhysicalBytes: 1, + }, + }), + /schema 4 is newer than supported version 3/u, + ); +}); + +test('rejects a current schema missing a query-required index', async (t) => { + const fixture = await createFixture(t); + fixture.store.close(); + const database = new DatabaseSync(fixture.path); + database.exec('DROP INDEX context_refs_session'); + database.close(); + + assert.throws( + () => new SqliteContextOffloadStore(fixture.path, { limits: fixture.limits }), + /missing index context_refs_session/u, + ); +}); + +test('deduplicates physical bytes while quotas count each Session reference logically', async (t) => { + const fixture = await createFixture(t, { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 8, + workspacePhysicalBytes: 4, + }); + const bytes = new TextEncoder().encode('same'); + + const first = await fixture.store.put(putInput('session-1', 'owner-1', bytes)); + const crossSession = await fixture.store.put(putInput('session-2', 'owner-2', bytes)); + const secondReference = await fixture.store.put(putInput('session-1', 'owner-3', bytes)); + assert.equal(first.ok, true); + assert.equal(crossSession.ok, true); + assert.equal(secondReference.ok, true); + assert.deepEqual(await fixture.store.usage('session-1'), { + references: 2, + logicalBytes: 8, + physicalBytes: 4, + }); + assert.deepEqual(await fixture.store.usage('session-2'), { + references: 1, + logicalBytes: 4, + physicalBytes: 4, + }); + + assert.deepEqual(await fixture.store.put(putInput('session-1', 'owner-4', bytes)), { + ok: false, + reason: 'session_quota_exceeded', + }); + assert.deepEqual( + await fixture.store.put(putInput('session-2', 'owner-5', new TextEncoder().encode('else'))), + { ok: false, reason: 'workspace_quota_exceeded' }, + ); +}); + +test('fails closed before returning bytes for Session mismatch and size limits', async (t) => { + const fixture = await createFixture(t); + const stored = await fixture.store.put( + putInput('session-1', 'archive-1', new TextEncoder().encode('archive')), + ); + assert.equal(stored.ok, true); + if (!stored.ok) return; + + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-2', + refId: stored.record.refId, + maxBytes: 100, + }), + { ok: false, reason: 'session_mismatch' }, + ); + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: stored.record.refId, + maxBytes: 3, + }), + { ok: false, reason: 'too_large' }, + ); + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: 'missing', + maxBytes: 100, + }), + { ok: false, reason: 'not_found' }, + ); +}); + +test('enforces configured owner hard caps before commit and return', async (t) => { + const ownerMaxBytes = { + read_image_snapshot: 5, + tool_result_archive: 7, + } as const; + const fixture = await createFixture(t, { + ownerMaxBytes, + sessionLogicalBytes: 32, + workspacePhysicalBytes: 32, + }); + + assert.deepEqual( + await fixture.store.put({ + ...putInput( + 'session-1', + 'large-image', + new Uint8Array(ownerMaxBytes.read_image_snapshot + 1), + ), + owner: { kind: 'read_image_snapshot', ownerId: 'large-image' }, + }), + { ok: false, reason: 'too_large' }, + ); + assert.deepEqual( + await fixture.store.put({ + ...putInput( + 'session-1', + 'large-archive', + new Uint8Array(ownerMaxBytes.tool_result_archive + 1), + ), + owner: { kind: 'tool_result_archive', ownerId: 'large-archive' }, + }), + { ok: false, reason: 'too_large' }, + ); + assert.deepEqual(await fixture.store.usage(), { + references: 0, + logicalBytes: 0, + physicalBytes: 0, + }); + + const accepted = await fixture.store.put( + putInput('session-1', 'accepted-archive', new Uint8Array(ownerMaxBytes.tool_result_archive)), + ); + assert.equal(accepted.ok, true); + if (!accepted.ok) return; + fixture.store.close(); + + const lowerReadLimit = new SqliteContextOffloadStore(fixture.path, { + limits: { + ...fixture.limits, + ownerMaxBytes: { ...ownerMaxBytes, tool_result_archive: 6 }, + }, + }); + t.after(() => lowerReadLimit.close()); + assert.deepEqual( + await lowerReadLimit.read({ + sessionId: 'session-1', + refId: accepted.record.refId, + maxBytes: ownerMaxBytes.tool_result_archive, + }), + { ok: false, reason: 'too_large' }, + ); +}); + +test('detects payload corruption instead of returning unverified bytes', async (t) => { + const fixture = await createFixture(t); + const stored = await fixture.store.put( + putInput('session-1', 'archive-1', new TextEncoder().encode('original')), + ); + assert.equal(stored.ok, true); + if (!stored.ok) return; + + const database = new DatabaseSync(fixture.path); + database + .prepare('UPDATE context_blobs SET payload = ?') + .run(new TextEncoder().encode('tampered')); + database.close(); + + assert.deepEqual( + await fixture.store.read({ + sessionId: 'session-1', + refId: stored.record.refId, + maxBytes: 100, + }), + { ok: false, reason: 'corrupt' }, + ); +}); + +test('rolls back blob and reference together when publication fails', async (t) => { + const fixture = await createFixture(t, undefined, (point) => { + if (point === 'after_ref_insert') throw new Error('injected publication failure'); + }); + + assert.deepEqual( + await fixture.store.put( + putInput('session-1', 'archive-1', new TextEncoder().encode('archive')), + ), + { ok: false, reason: 'unavailable' }, + ); + assert.deepEqual(await fixture.store.usage(), { + references: 0, + logicalBytes: 0, + physicalBytes: 0, + }); +}); + +test('releases only the authorized Session reference without deleting shared bytes', async (t) => { + const fixture = await createFixture(t); + const bytes = new TextEncoder().encode('shared'); + const first = await fixture.store.put(putInput('session-1', 'owner-1', bytes)); + const second = await fixture.store.put(putInput('session-2', 'owner-2', bytes)); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + if (!first.ok) return; + + await fixture.store.releaseReference({ sessionId: 'session-2', refId: first.record.refId }); + assert.equal((await fixture.store.usage('session-1')).references, 1); + await fixture.store.releaseReference({ sessionId: 'session-1', refId: first.record.refId }); + await fixture.store.releaseReference({ sessionId: 'session-1', refId: first.record.refId }); + assert.deepEqual(await fixture.store.usage('session-1'), { + references: 0, + logicalBytes: 0, + physicalBytes: bytes.byteLength, + }); +}); + +test('copies references atomically without copying physical bytes', async (t) => { + const fixture = await createFixture(t, { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 16, + workspacePhysicalBytes: 16, + }); + const first = await fixture.store.put( + putInput('source', 'source-1', new TextEncoder().encode('first')), + ); + const second = await fixture.store.put( + putInput('source', 'source-2', new TextEncoder().encode('second')), + ); + const third = await fixture.store.put({ + ...putInput('source', 'source-3', new TextEncoder().encode('first')), + mediaType: 'text/plain', + }); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + assert.equal(third.ok, true); + if (!first.ok || !second.ok || !third.ok) return; + + const copyInput = { + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: first.record.refId, + targetOwner: { kind: 'tool_result_archive' as const, ownerId: 'target-1' }, + }, + { + sourceRefId: second.record.refId, + targetOwner: { kind: 'tool_result_archive' as const, ownerId: 'target-2' }, + }, + ], + }; + const copied = await fixture.store.copyReferences(copyInput); + assert.equal(copied.ok, true); + if (!copied.ok) return; + assert.deepEqual(await fixture.store.copyReferences(copyInput), copied); + assert.deepEqual(await fixture.store.usage('target'), { + references: 2, + logicalBytes: 11, + physicalBytes: 11, + }); + assert.equal( + ( + await fixture.store.read({ + sessionId: 'target', + refId: copied.copied[0]?.targetRefId ?? '', + maxBytes: 16, + }) + ).ok, + true, + ); + + assert.deepEqual( + await fixture.store.copyReferences({ + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: second.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-over-quota' }, + }, + ], + }), + { ok: false, reason: 'session_quota_exceeded' }, + ); + + assert.deepEqual( + await fixture.store.copyReferences({ + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: second.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'new-before-conflict' }, + }, + { + sourceRefId: second.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-1' }, + }, + ], + }), + { ok: false, reason: 'identity_conflict' }, + ); + assert.deepEqual( + await fixture.store.copyReferences({ + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: third.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-1' }, + }, + ], + }), + { ok: false, reason: 'identity_conflict' }, + ); + assert.deepEqual( + await fixture.store.copyReferences({ + sourceSessionId: 'source', + targetSessionId: 'mime-conflict-target', + references: [ + { + sourceRefId: first.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-1' }, + }, + { + sourceRefId: third.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-1' }, + }, + ], + }), + { ok: false, reason: 'identity_conflict' }, + ); + assert.equal((await fixture.store.usage('target')).references, 2); + assert.equal((await fixture.store.usage('mime-conflict-target')).references, 0); +}); + +test('retires only one Session and collects shared blobs after the last reference', async (t) => { + const fixture = await createFixture(t); + const bytes = new TextEncoder().encode('shared'); + await fixture.store.put(putInput('session-1', 'owner-1', bytes)); + await fixture.store.put(putInput('session-2', 'owner-2', bytes)); + + assert.deepEqual(await fixture.store.retireSession('session-1'), { + releasedReferences: 1, + releasedLogicalBytes: bytes.byteLength, + }); + assert.deepEqual( + await fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 16 }), + { deletedBlobs: 0, deletedBytes: 0, hasMore: false }, + ); + assert.equal((await fixture.store.usage('session-2')).references, 1); + + assert.deepEqual(await fixture.store.retireSession('session-2'), { + releasedReferences: 1, + releasedLogicalBytes: bytes.byteLength, + }); + assert.deepEqual( + await fixture.store.collectGarbage({ olderThan: 1_000, maxBlobs: 1, maxBytes: 16 }), + { deletedBlobs: 0, deletedBytes: 0, hasMore: false }, + ); + assert.deepEqual( + await fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 16 }), + { deletedBlobs: 1, deletedBytes: bytes.byteLength, hasMore: false }, + ); + assert.deepEqual(await fixture.store.usage(), { + references: 0, + logicalBytes: 0, + physicalBytes: 0, + }); +}); + +test('garbage collection obeys both batch limits and rolls back failed deletion', async (t) => { + let failGc = false; + const fixture = await createFixture(t, undefined, (point) => { + if (point === 'after_gc_blob_delete' && failGc) throw new Error('injected GC failure'); + }); + const bytes = new TextEncoder().encode('four'); + const first = await fixture.store.put(putInput('session-1', 'owner-1', bytes)); + const second = await fixture.store.put( + putInput('session-1', 'owner-2', new TextEncoder().encode('fives')), + ); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + if (!first.ok || !second.ok) return; + await fixture.store.releaseReference({ sessionId: 'session-1', refId: first.record.refId }); + await fixture.store.releaseReference({ sessionId: 'session-1', refId: second.record.refId }); + + assert.deepEqual( + await fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 2, maxBytes: 4 }), + { deletedBlobs: 1, deletedBytes: 4, hasMore: true }, + ); + await assert.rejects( + fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 4 }), + /byte limit 4 cannot fit eligible blob of 5 bytes/u, + ); + assert.equal((await fixture.store.usage()).physicalBytes, 5); + failGc = true; + await assert.rejects( + fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 8 }), + /injected GC failure/u, + ); + assert.equal((await fixture.store.usage()).physicalBytes, 5); + failGc = false; + assert.deepEqual( + await fixture.store.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 8 }), + { deletedBlobs: 1, deletedBytes: 5, hasMore: false }, + ); +}); + +test('migrates v1 orphan blobs into the indexed garbage candidate set', async (t) => { + const fixture = await createFixture(t); + const stored = await fixture.store.put( + putInput('session-1', 'owner-1', new TextEncoder().encode('orphan')), + ); + assert.equal(stored.ok, true); + if (!stored.ok) return; + await fixture.store.releaseReference({ sessionId: 'session-1', refId: stored.record.refId }); + fixture.store.close(); + + const database = new DatabaseSync(fixture.path); + database.exec( + 'DROP TABLE context_gc_candidates; DROP TABLE context_file_deletions; PRAGMA user_version = 1', + ); + database.close(); + const migrated = new SqliteContextOffloadStore(fixture.path, { limits: fixture.limits }); + t.after(() => migrated.close()); + assert.deepEqual(await migrated.collectGarbage({ olderThan: 1_001, maxBlobs: 1, maxBytes: 16 }), { + deletedBlobs: 1, + deletedBytes: 6, + hasMore: false, + }); +}); + +test('bounds payload reads before collecting migrated v2 inline images', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-v2-gc-')); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + const legacy = new DatabaseSync(path); + // Schema from the released v2 authority (8b93dd52b), before managed values. + legacy.exec(`PRAGMA auto_vacuum = INCREMENTAL; + CREATE TABLE context_blobs ( + blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32), + payload BLOB NOT NULL, + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0 AND length(payload) = size_bytes), + created_at INTEGER NOT NULL CHECK(created_at >= 0) + ); + + CREATE TABLE context_refs ( + ref_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + owner_kind TEXT NOT NULL CHECK( + owner_kind IN ('read_image_snapshot', 'tool_result_archive') + ), + owner_id TEXT NOT NULL, + blob_id BLOB NOT NULL REFERENCES context_blobs(blob_id) ON DELETE RESTRICT, + media_type TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK(created_at >= 0), + UNIQUE(session_id, owner_kind, owner_id) + ); + + CREATE INDEX context_refs_session + ON context_refs(session_id, created_at, ref_id); + + CREATE INDEX context_refs_blob + ON context_refs(blob_id); + + CREATE TABLE context_gc_candidates ( + blob_id BLOB PRIMARY KEY + REFERENCES context_blobs(blob_id) ON DELETE CASCADE, + unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0) + ); + + CREATE INDEX context_gc_candidates_eligible + ON context_gc_candidates(unreferenced_at, blob_id); + + CREATE TABLE context_session_usage ( + session_id TEXT PRIMARY KEY, + reference_count INTEGER NOT NULL CHECK(reference_count >= 0), + logical_bytes INTEGER NOT NULL CHECK(logical_bytes >= 0) + ); + + CREATE TABLE context_store_usage ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + blob_count INTEGER NOT NULL CHECK(blob_count >= 0), + physical_bytes INTEGER NOT NULL CHECK(physical_bytes >= 0) + ); + + INSERT INTO context_store_usage(singleton, blob_count, physical_bytes) + VALUES (1, 0, 0); + + PRAGMA user_version = 2; + `); + const mib = 1024 * 1024; + try { + const insert = legacy.prepare('INSERT INTO context_blobs VALUES (?, ?, ?, ?)'); + const orphan = legacy.prepare('INSERT INTO context_gc_candidates VALUES (?, ?)'); + legacy.exec('BEGIN'); + for (let i = 0; i < 65; i += 1) { + const bytes = Buffer.alloc(mib, i); + const hash = createHash('sha256').update(bytes).digest(); + insert.run(hash, bytes, bytes.length, 1); + orphan.run(hash, 2); + } + legacy + .prepare('UPDATE context_store_usage SET blob_count = 65, physical_bytes = ?') + .run(65 * mib); + legacy.exec('COMMIT'); + } finally { + legacy.close(); + } + const store = new SqliteContextOffloadStore(path, { limits: defaultLimits() }); + t.after(() => store.close()); + const inspect = new DatabaseSync(path); + t.after(() => inspect.close()); + assert.equal(pragmaNumber(inspect, 'user_version'), 3); + assert.equal( + inspect.prepare("SELECT count(*) AS n FROM context_blobs WHERE storage_kind = 'inline'").get() + ?.n, + 65, + ); + + let payloadBytes = 0; + const countPayload = (row: Record | undefined) => { + if (row?.payload instanceof Uint8Array) payloadBytes += row.payload.byteLength; + }; + const prepare = DatabaseSync.prototype.prepare; + t.mock.method(DatabaseSync.prototype, 'prepare', function (this: DatabaseSync, sql: string) { + const statement = prepare.call(this, sql); + const all = statement.all.bind(statement); + const get = statement.get.bind(statement); + t.mock.method(statement, 'all', (...args: Parameters) => { + const rows = all(...args); + for (const row of rows) countPayload(row); + return rows; + }); + t.mock.method(statement, 'get', (...args: Parameters) => { + const row = get(...args); + countPayload(row); + return row; + }); + return statement; + }); + await assert.rejects( + store.collectGarbage({ olderThan: 3, maxBlobs: 64, maxBytes: mib - 1 }), + /byte limit/, + ); + assert.equal(payloadBytes, 0, 'a rejected batch must not materialize any payload'); + const result = await store.collectGarbage({ olderThan: 3, maxBlobs: 64, maxBytes: 16 * mib }); + assert.deepEqual(result, { deletedBlobs: 16, deletedBytes: 16 * mib, hasMore: true }); + assert.equal(payloadBytes, 16 * mib, 'only admitted inline payloads are materialized'); + assert.equal((await store.usage()).physicalBytes, 49 * mib); +}); + +test('lifecycle queries use Session and garbage eligibility indexes', async (t) => { + const fixture = await createFixture(t); + fixture.store.close(); + const database = new DatabaseSync(fixture.path); + t.after(() => database.close()); + + const retirementPlan = database + .prepare( + `EXPLAIN QUERY PLAN + SELECT r.blob_id, b.size_bytes + FROM context_refs r INDEXED BY context_refs_session + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.session_id = ?`, + ) + .all('session-1'); + assert.match(JSON.stringify(retirementPlan), /context_refs_session/u); + + const garbagePlan = database + .prepare( + `EXPLAIN QUERY PLAN + SELECT c.blob_id, b.size_bytes + FROM context_gc_candidates c INDEXED BY context_gc_candidates_eligible + JOIN context_blobs b ON b.blob_id = c.blob_id + WHERE c.unreferenced_at < ? + ORDER BY c.unreferenced_at, c.blob_id + LIMIT ?`, + ) + .all(1_001, 2); + assert.match(JSON.stringify(garbagePlan), /context_gc_candidates_eligible/u); + assert.doesNotMatch(JSON.stringify(garbagePlan), /SCAN b(?:\W|$)/u); + + const fileDeletionPlan = database + .prepare( + `EXPLAIN QUERY PLAN + SELECT locator FROM context_file_deletions + ORDER BY enqueued_at, locator + LIMIT ?`, + ) + .all(2); + assert.match(JSON.stringify(fileDeletionPlan), /context_file_deletions_pending/u); +}); + +function putInput(sessionId: string, ownerId: string, bytes: Uint8Array) { + return { + sessionId, + owner: { kind: 'tool_result_archive' as const, ownerId }, + bytes, + mediaType: 'application/json', + }; +} + +async function createFixture( + t: TestContext, + limits: ContextOffloadLimits = { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 16 * 1024 * 1024, + workspacePhysicalBytes: 32 * 1024 * 1024, + }, + failpoint?: ConstructorParameters[1]['failpoint'], +) { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-')); + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + let nextId = 1; + const store = new SqliteContextOffloadStore(path, { + limits, + now: () => 1_000, + idFactory: () => `ref-${nextId++}`, + failpoint, + }); + t.after(async () => { + store.close(); + await rm(root, { recursive: true, force: true }); + }); + return { limits, path, root, store }; +} + +const TEST_OWNER_MAX_BYTES = Object.freeze({ + read_image_snapshot: 5 * 1024 * 1024, + tool_result_archive: 8 * 1024 * 1024, +}); + +function defaultLimits(): ContextOffloadLimits { + return { + ownerMaxBytes: TEST_OWNER_MAX_BYTES, + sessionLogicalBytes: 16 * 1024 * 1024, + workspacePhysicalBytes: 32 * 1024 * 1024, + }; +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +function pragmaNumber(database: DatabaseSync, name: string): number { + const row = database.prepare(`PRAGMA ${name}`).get() as Record; + const value = row[name]; + if (typeof value !== 'number') throw new Error(`Expected numeric PRAGMA ${name}`); + return value; +} + +function pragmaText(database: DatabaseSync, name: string): string { + const row = database.prepare(`PRAGMA ${name}`).get() as Record; + const value = row[name]; + if (typeof value !== 'string') throw new Error(`Expected text PRAGMA ${name}`); + return value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0ca0b488c99389a538454274720d5620f6a156da5441ba5d55bc75421e3d558b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0ca0b488c99389a538454274720d5620f6a156da5441ba5d55bc75421e3d558b.source new file mode 100644 index 0000000000..8b5349d637 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0ca0b488c99389a538454274720d5620f6a156da5441ba5d55bc75421e3d558b.source @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { LlmConnection } from '@maka/core/llm-connections'; +import { isRetiredProvider } from '@maka/core/provider-registry'; + +/** + * Config import / export — Alma-style selective bundle. + * + * A single JSON bundle carries a manifest (`includedData`) plus a `data` map + * keyed by category. The user chooses which categories to export; the manifest + * records exactly what is present, and import applies only those categories. + * + * This module is category-agnostic on purpose: each category payload is opaque + * (`unknown`) here. The desktop layer owns what goes into each one — including + * the sensitive parts (deciding whether to gather credentials, and stripping + * secret fields out of `settings` when credentials are NOT included). Keeping + * that knowledge out of this module means the pure transfer logic never has to + * understand the AppSettings/credential shapes. + * + * `credentials` is an opt-in category that carries plaintext secrets. It is + * never implied: if it is not listed in `includedData`, any `data.credentials` + * payload is dropped on read so a hand-edited or mislabeled file cannot sneak + * secrets past a config-only import. The UI must warn before selecting it. + * + * Reads fail closed on unknown schema versions (mirrors credential-store). + */ + +export const CONFIG_TRANSFER_SCHEMA_VERSION = 1; + +export const CONFIG_CATEGORIES = ['connections', 'settings', 'credentials', 'memory'] as const; +export type ConfigCategory = (typeof CONFIG_CATEGORIES)[number]; + +/** Categories that carry plaintext secrets and must be explicitly opted into. */ +export const SENSITIVE_CATEGORIES: ReadonlySet = new Set(['credentials']); + +export type ConfigData = Partial>; + +export interface ConfigBundle { + schemaVersion: number; + exportedAt: string; + appVersion: string; + includedData: ConfigCategory[]; + data: ConfigData; +} + +export interface BuildConfigBundleInput { + appVersion: string; + /** Only the categories the user selected; presence here == inclusion. */ + data: ConfigData; + /** Injectable clock for deterministic tests. */ + now?: () => Date; +} + +export function buildConfigBundle(input: BuildConfigBundleInput): ConfigBundle { + const now = input.now ?? (() => new Date()); + const includedData = CONFIG_CATEGORIES.filter((c) => input.data[c] !== undefined); + const data: ConfigData = {}; + for (const category of includedData) { + data[category] = cloneJson(input.data[category]); + } + return { + schemaVersion: CONFIG_TRANSFER_SCHEMA_VERSION, + exportedAt: now().toISOString(), + appVersion: input.appVersion, + includedData, + data, + }; +} + +export function serializeConfigBundle(bundle: ConfigBundle): string { + return `${JSON.stringify(bundle, null, 2)}\n`; +} + +export type ConfigParseFailure = { + ok: false; + reason: 'not_json' | 'malformed' | 'unsupported_version'; + message: string; +}; + +export type ConfigParseResult = { ok: true; bundle: ConfigBundle } | ConfigParseFailure; + +export function parseConfigBundle(raw: string): ConfigParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, reason: 'not_json', message: 'File is not valid JSON.' }; + } + if (!isJsonObject(parsed)) { + return { ok: false, reason: 'malformed', message: 'Config bundle must be a JSON object.' }; + } + const version = parsed.schemaVersion; + if (typeof version !== 'number') { + return { + ok: false, + reason: 'malformed', + message: 'Config bundle is missing a numeric schemaVersion.', + }; + } + if (version !== CONFIG_TRANSFER_SCHEMA_VERSION) { + return { + ok: false, + reason: 'unsupported_version', + message: `Unsupported config schemaVersion ${version} (this build reads ${CONFIG_TRANSFER_SCHEMA_VERSION}).`, + }; + } + if (!Array.isArray(parsed.includedData) || !parsed.includedData.every(isConfigCategory)) { + return { + ok: false, + reason: 'malformed', + message: 'includedData must be an array of known categories.', + }; + } + const rawData = isJsonObject(parsed.data) ? parsed.data : {}; + const includedData = [...new Set(parsed.includedData as ConfigCategory[])]; + const data: ConfigData = {}; + // Only surface categories that are BOTH declared in the manifest AND present + // in `data`. A category listed but absent, or present but not listed, is + // ignored — never guessed. This is what drops an unlisted `credentials`. + for (const category of includedData) { + if (rawData[category] !== undefined) data[category] = rawData[category]; + } + const bundle: ConfigBundle = { + schemaVersion: CONFIG_TRANSFER_SCHEMA_VERSION, + exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : '', + appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : '', + includedData: includedData.filter((c) => data[c] !== undefined), + data, + }; + return { ok: true, bundle }; +} + +// --- connection merge planning (import applies this against the live store) --- + +export type ConnectionConflictStrategy = 'skip' | 'overwrite'; + +export interface ConnectionMergePlan { + create: LlmConnection[]; + overwrite: LlmConnection[]; + skipped: Array<{ slug: string; reason: 'exists' | 'provider_retired' }>; +} + +export function planConnectionMerge( + existing: readonly LlmConnection[], + incoming: readonly LlmConnection[], + strategy: ConnectionConflictStrategy, +): ConnectionMergePlan { + const existingSlugs = new Set(existing.map((c) => c.slug)); + const plan: ConnectionMergePlan = { create: [], overwrite: [], skipped: [] }; + const seen = new Set(); + for (const conn of incoming) { + if (seen.has(conn.slug)) continue; // de-dupe within the imported set + seen.add(conn.slug); + // A backup taken before a provider was retired still carries its + // connection, and the catalog refuses to create one — rightly, since it + // could never execute. Planning it as skipped is what keeps that refusal + // from aborting the restore partway and leaving the rest of the bundle + // (settings, credentials, memory) unapplied. Its credential is skipped + // with it: only a created or overwritten slug gets its secret written. + if (isRetiredProvider(conn.providerType)) { + plan.skipped.push({ slug: conn.slug, reason: 'provider_retired' }); + continue; + } + if (existingSlugs.has(conn.slug)) { + if (strategy === 'overwrite') plan.overwrite.push(cloneJson(conn)); + else plan.skipped.push({ slug: conn.slug, reason: 'exists' }); + } else { + plan.create.push(cloneJson(conn)); + } + } + return plan; +} + +export function isConfigCategory(value: unknown): value is ConfigCategory { + return typeof value === 'string' && (CONFIG_CATEGORIES as readonly string[]).includes(value); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0caf206be6552f359d108770f4316f4306ebdc9fabd86f02097c6d7d2af0ef84.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0caf206be6552f359d108770f4316f4306ebdc9fabd86f02097c6d7d2af0ef84.source new file mode 100644 index 0000000000..1d2021212e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0caf206be6552f359d108770f4316f4306ebdc9fabd86f02097c6d7d2af0ef84.source @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, readdir, type FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + openStableNativeLockFile, + releaseNativeFileLock, + tryAcquireNativeFileLock, + unlinkStableNativeLockFile, +} from './native-file-lock.js'; + +const OWNER_REFERENCE_PREFIX = 'lock-v1:'; +const OWNER_REFERENCE_PATTERN = + /^lock-v1:([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/; +const OWNER_FILE_PATTERN = + /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.lease$/; + +export interface ProcessLifetimeRecoveryClaim { + retire(): Promise; + close(): Promise; +} + +export interface ProcessLifetimeOwner { + readonly reference: string; + tryClaimReleased(reference: string): Promise; + retireUnreferencedReleasedOwners(referenced: ReadonlySet): Promise; + close(): Promise; +} + +export async function acquireProcessLifetimeOwner(root: string): Promise { + const ownersRoot = join(root, 'owners'); + await mkdir(ownersRoot, { recursive: true, mode: 0o700 }); + const ownersRootStat = await lstat(ownersRoot); + if (!ownersRootStat.isDirectory() || ownersRootStat.isSymbolicLink()) { + throw new Error(`Process lifetime owner root is not a directory: ${ownersRoot}`); + } + if (process.platform !== 'win32') await chmod(ownersRoot, 0o700); + + const reference = `${OWNER_REFERENCE_PREFIX}${randomUUID()}`; + const path = ownerPath(ownersRoot, reference); + const handle = await openStableNativeLockFile(path); + if (!tryAcquireNativeFileLock(handle)) { + await handle.close(); + throw new Error(`New process lifetime owner reference is already active: ${reference}`); + } + return new ProcessLifetimeOwnerImpl(ownersRoot, reference, path, handle); +} + +export function isProcessLifetimeOwnerReference(reference: string): boolean { + return OWNER_REFERENCE_PATTERN.test(reference); +} + +class ProcessLifetimeOwnerImpl implements ProcessLifetimeOwner { + #closed = false; + + constructor( + private readonly ownersRoot: string, + readonly reference: string, + private readonly path: string, + private readonly handle: FileHandle, + ) {} + + async tryClaimReleased(reference: string): Promise { + if (this.#closed) throw new Error('Process lifetime owner is closed'); + if (reference === this.reference) return undefined; + const path = ownerPath(this.ownersRoot, reference); + const handle = await openStableNativeLockFile(path); + if (!tryAcquireNativeFileLock(handle)) { + await handle.close(); + return undefined; + } + return new ProcessLifetimeRecoveryClaimImpl(path, handle); + } + + async retireUnreferencedReleasedOwners(referenced: ReadonlySet): Promise { + for (const entry of await readdir(this.ownersRoot, { withFileTypes: true })) { + const match = OWNER_FILE_PATTERN.exec(entry.name); + if (!match) continue; + const reference = `${OWNER_REFERENCE_PREFIX}${match[1]}`; + if (referenced.has(reference)) continue; + const claim = await this.tryClaimReleased(reference); + if (claim) await claim.retire(); + } + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + try { + // Removing the name while the old inode is still locked prevents a gap + // where two recovery claimants could lock two generations of the path. + await unlinkStableNativeLockFile(this.handle, this.path); + } finally { + releaseNativeFileLock(this.handle); + await this.handle.close(); + } + } +} + +class ProcessLifetimeRecoveryClaimImpl implements ProcessLifetimeRecoveryClaim { + #closed = false; + + constructor( + private readonly path: string, + private readonly handle: FileHandle, + ) {} + + async retire(): Promise { + if (this.#closed) return; + this.#closed = true; + try { + await unlinkStableNativeLockFile(this.handle, this.path); + } finally { + releaseNativeFileLock(this.handle); + await this.handle.close(); + } + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + releaseNativeFileLock(this.handle); + await this.handle.close(); + } +} + +function ownerPath(ownersRoot: string, reference: string): string { + const match = OWNER_REFERENCE_PATTERN.exec(reference); + if (!match) throw new Error(`Invalid process lifetime owner reference: ${reference}`); + return join(ownersRoot, `${match[1]}.lease`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0cf609297c4a4825455c7f345bdc2cb4fdbeeae1bbf02b14ee5fc7792200d277.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0cf609297c4a4825455c7f345bdc2cb4fdbeeae1bbf02b14ee5fc7792200d277.source new file mode 100644 index 0000000000..9746e8fa3d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0cf609297c4a4825455c7f345bdc2cb4fdbeeae1bbf02b14ee5fc7792200d277.source @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { LOCAL_MEMORY_MAX_BYTES } from '@maka/core/local-memory'; + +export const MEMORY_DOCUMENT_MAX_BYTES = LOCAL_MEMORY_MAX_BYTES; + +export type MemoryDocumentName = 'memory' | 'pending'; +export type MemoryRevision = `sha256:${string}`; +export type MemoryBackupKind = 'save' | 'reset' | 'restore'; + +export type MemoryDocumentSnapshot = + | { + readonly kind: 'missing'; + readonly revision: null; + readonly byteLength: 0; + } + | { + readonly kind: 'document'; + readonly revision: MemoryRevision; + readonly byteLength: number; + readonly bytes: Uint8Array; + } + | { + readonly kind: 'safe_mode'; + readonly revision: MemoryRevision; + readonly byteLength: number; + readonly reason: 'invalid_utf8' | 'oversize'; + }; + +export interface MemoryBundleSnapshot { + readonly revision: MemoryRevision; + readonly memory: MemoryDocumentSnapshot; + readonly pending: MemoryDocumentSnapshot; +} + +export interface MemoryBackupSnapshot { + readonly kind: MemoryBackupKind; + readonly revision: MemoryRevision; + readonly updatedAt: number; + readonly document: Exclude; +} + +export interface CommitMemoryBundleInput { + readonly expectedRevision: MemoryRevision; + readonly memory: Uint8Array; + readonly pending: Uint8Array | null; + readonly backup?: Exclude; +} + +export interface RestoreMemoryBackupInput { + readonly expectedRevision: MemoryRevision; + readonly expectedBackupRevision: MemoryRevision; + readonly kind: MemoryBackupKind; +} + +export interface MemoryBundleMutationResult { + readonly changed: boolean; + readonly snapshot: MemoryBundleSnapshot; +} + +export type MemoryBundleStoreErrorCode = + | 'invalid_document' + | 'io_failed' + | 'commit_outcome_unknown' + | 'recovery_conflict'; + +export class MemoryBundleStoreError extends Error { + constructor( + readonly code: MemoryBundleStoreErrorCode, + message: string, + readonly candidateRevision?: MemoryRevision, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'MemoryBundleStoreError'; + } +} + +export class MemoryBundleRevisionConflictError extends Error { + readonly code = 'revision_conflict'; + + constructor( + readonly expectedRevision: MemoryRevision, + readonly actual: MemoryBundleSnapshot, + ) { + super( + `Memory bundle revision conflict: expected ${expectedRevision}, actual ${actual.revision}`, + ); + this.name = 'MemoryBundleRevisionConflictError'; + } +} + +export class MemoryBundleBackupNotFoundError extends Error { + readonly code = 'backup_not_found'; + + constructor(readonly kind: MemoryBackupKind) { + super(`Memory backup not found: ${kind}`); + this.name = 'MemoryBundleBackupNotFoundError'; + } +} + +export class MemoryBundleBackupRevisionConflictError extends Error { + readonly code = 'backup_revision_conflict'; + + constructor( + readonly kind: MemoryBackupKind, + readonly expectedRevision: MemoryRevision, + readonly actualRevision: MemoryRevision, + ) { + super( + `Memory ${kind} backup revision conflict: expected ${expectedRevision}, actual ${actualRevision}`, + ); + this.name = 'MemoryBundleBackupRevisionConflictError'; + } +} + +export interface MemoryBundleTarget { + readonly snapshot: MemoryBundleSnapshot; + readonly memory: Buffer; + readonly pending: Buffer | null; +} + +export function validateDocumentBytes(name: MemoryDocumentName, input: Uint8Array): Buffer { + if (!(input instanceof Uint8Array)) { + throw invalidMemoryDocument(`${displayName(name)} bytes are required`); + } + const bytes = Buffer.from(input); + if (bytes.byteLength > MEMORY_DOCUMENT_MAX_BYTES) { + throw invalidMemoryDocument( + `${displayName(name)} exceeds its ${MEMORY_DOCUMENT_MAX_BYTES} byte limit`, + ); + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw invalidMemoryDocument(`${displayName(name)} is not valid UTF-8`, error); + } + return bytes; +} + +export function bundleTarget( + memory: Buffer, + pending: Buffer | null, + allowUnsafeMemory = false, +): MemoryBundleTarget { + const memoryBytes = Buffer.from(memory); + const pendingBytes = pending === null ? null : Buffer.from(pending); + const snapshot = bundleSnapshot( + allowUnsafeMemory ? snapshotForBytes(memoryBytes) : documentSnapshot(memoryBytes), + pendingBytes === null ? missingDocument() : documentSnapshot(pendingBytes), + ); + return { snapshot, memory: memoryBytes, pending: pendingBytes }; +} + +export function bundleSnapshot( + memory: MemoryDocumentSnapshot, + pending: MemoryDocumentSnapshot, +): MemoryBundleSnapshot { + const hash = createHash('sha256'); + hash.update('maka-memory-bundle-v1\0'); + updateBundleHash(hash, 'memory', memory); + updateBundleHash(hash, 'pending', pending); + return { + revision: `sha256:${hash.digest('hex')}`, + memory: cloneDocument(memory), + pending: cloneDocument(pending), + }; +} + +export function snapshotForBytes( + bytes: Uint8Array, +): Exclude { + const documentRevision = revision(bytes); + if (bytes.byteLength > MEMORY_DOCUMENT_MAX_BYTES) { + return { + kind: 'safe_mode', + revision: documentRevision, + byteLength: bytes.byteLength, + reason: 'oversize', + }; + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return { + kind: 'safe_mode', + revision: documentRevision, + byteLength: bytes.byteLength, + reason: 'invalid_utf8', + }; + } + return documentSnapshot(bytes, documentRevision); +} + +export function documentSnapshot( + bytes: Uint8Array, + documentRevision: MemoryRevision = revision(bytes), +): Extract { + return { + kind: 'document', + revision: documentRevision, + byteLength: bytes.byteLength, + bytes: Uint8Array.from(bytes), + }; +} + +export function missingDocument(): Extract { + return { kind: 'missing', revision: null, byteLength: 0 }; +} + +export function revision(bytes: Uint8Array): MemoryRevision { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +export function isRevision(input: unknown): input is MemoryRevision { + return typeof input === 'string' && /^sha256:[a-f0-9]{64}$/.test(input); +} + +export function invalidMemoryDocument(message: string, cause?: unknown): MemoryBundleStoreError { + return new MemoryBundleStoreError( + 'invalid_document', + message, + undefined, + cause === undefined ? undefined : { cause }, + ); +} + +export function memoryBundleIoFailed(message: string, cause: unknown): MemoryBundleStoreError { + return new MemoryBundleStoreError('io_failed', message, undefined, { cause }); +} + +export function memoryBundleRecoveryConflict(message: string): MemoryBundleStoreError { + return new MemoryBundleStoreError('recovery_conflict', message); +} + +function updateBundleHash( + hash: ReturnType, + name: MemoryDocumentName, + document: MemoryDocumentSnapshot, +): void { + hash.update(`${name}\0${document.kind}\0${document.byteLength}\0`); + hash.update(document.revision ?? 'missing'); + hash.update('\0'); +} + +function cloneDocument(document: MemoryDocumentSnapshot): MemoryDocumentSnapshot { + return document.kind === 'document' + ? { ...document, bytes: Uint8Array.from(document.bytes) } + : { ...document }; +} + +function displayName(name: MemoryDocumentName): string { + return name === 'memory' ? 'MEMORY.md' : 'PENDING.md'; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e16baec56243eff4244143e256338d5778258957d2b743573786af681dc412c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e16baec56243eff4244143e256338d5778258957d2b743573786af681dc412c.source new file mode 100644 index 0000000000..a576298f95 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e16baec56243eff4244143e256338d5778258957d2b743573786af681dc412c.source @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_USAGE_SCHEMA_VERSION = 7; + +/** + * The canonical ledger's columns, in the order every statement binds them. + * + * Ordered so `attempt_id, completed_at, session_id` — the three a damaged row + * keeps — come first, and the pricing columns follow. + */ +export const MODEL_CALL_COLUMNS = [ + 'attempt_id', + 'completed_at', + 'session_id', + 'logical_call_id', + 'turn_id', + 'call_kind', + 'connection_slug', + 'provider_id', + 'model_id', + 'latency_ms', + 'status', + 'error_class', + 'usage_basis', + 'input_tokens', + 'output_tokens', + 'cache_read_input_tokens', + 'cache_miss_input_tokens', + 'cache_write_input_tokens', + 'reasoning_tokens', + 'cost_basis', + 'cost_usd', +] as const; + +/** + * The JSON path each pricing column was read from before the columns existed, + * used once by the migration that converted the rows. + */ +const MODEL_CALL_COLUMN_SOURCES: readonly (readonly [string, string])[] = [ + ['logical_call_id', '$.logicalCallId'], + ['turn_id', '$.turnId'], + ['call_kind', '$.callKind'], + ['connection_slug', '$.connectionSlug'], + ['provider_id', '$.providerId'], + ['model_id', '$.modelId'], + ['latency_ms', '$.latencyMs'], + ['status', '$.status'], + ['error_class', '$.errorClass'], + ['usage_basis', '$.usageBasis'], + ['input_tokens', '$.inputTokens'], + ['output_tokens', '$.outputTokens'], + ['cache_read_input_tokens', '$.cacheReadInputTokens'], + ['cache_miss_input_tokens', '$.cacheMissInputTokens'], + ['cache_write_input_tokens', '$.cacheWriteInputTokens'], + ['reasoning_tokens', '$.reasoningTokens'], + ['cost_basis', '$.costBasis'], + ['cost_usd', '$.costUsd'], +]; + +/** The columns that are present together or not at all. See the table's CHECK. */ +const MODEL_CALL_REQUIRED_COLUMNS = [ + 'logical_call_id', + 'turn_id', + 'call_kind', + 'provider_id', + 'model_id', + 'latency_ms', + 'status', + 'usage_basis', + 'cost_basis', +] as const; + +const MODEL_CALL_TOKEN_COLUMNS = [ + 'input_tokens', + 'output_tokens', + 'cache_read_input_tokens', + 'cache_miss_input_tokens', + 'cache_write_input_tokens', + 'reasoning_tokens', +] as const; + +const NO_TOKENS = MODEL_CALL_TOKEN_COLUMNS.map((column) => `${column} IS NULL`).join(' AND '); + +/** + * Canonical model-call accounting ledger (#1679). + * + * One column per field a cost answer reads, so the totals are a `SUM` the + * database can compute rather than every row of a workspace's history parsed + * into memory first. + * + * A row is either a complete pricing record or a tombstone that kept only the + * identity and timestamp of a call whose stored form was damaged. Never half of + * each — which is what makes `cost_basis IS NOT NULL` a sound test for "this row + * can be counted", and its negation the count a read reports as unreadable. + * + * The vocabularies (`status`, `call_kind`) are deliberately not constrained + * here. They are already validated where a record is decoded, and a CHECK on + * them would turn one damaged row into a failed migration for the whole + * workspace. + */ +const MODEL_CALL_TABLE = ` + CREATE TABLE IF NOT EXISTS %TABLE% ( + attempt_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL CHECK (completed_at >= 0), + session_id TEXT, + logical_call_id TEXT, + turn_id TEXT, + call_kind TEXT, + connection_slug TEXT, + provider_id TEXT, + model_id TEXT, + latency_ms INTEGER, + status TEXT, + error_class TEXT, + usage_basis TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_input_tokens INTEGER, + cache_miss_input_tokens INTEGER, + cache_write_input_tokens INTEGER, + reasoning_tokens INTEGER, + cost_basis TEXT, + cost_usd REAL, + CHECK (${MODEL_CALL_REQUIRED_COLUMNS.map( + (column) => `(${column} IS NULL) = (cost_basis IS NULL)`, + ).join(' AND ')}), + -- A price that could not be resolved must never surface as an amount, and a + -- priced call must carry one. Zero stays legal: it is the only way to say a + -- call was genuinely free. + CHECK (cost_basis IS NOT 'priced' OR cost_usd IS NOT NULL), + CHECK (cost_basis IS NOT 'unpriced' OR cost_usd IS NULL), + -- "The provider reported no usage" and "it reported zero" are different + -- facts, so a missing-usage row carries no token counts at all. + CHECK (usage_basis IS NOT 'missing' OR (${NO_TOKENS})) + ) +`; + +export function migrateSqliteUsageDatabase(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS usage_llm_calls ( + storage_key TEXT PRIMARY KEY, + id TEXT NOT NULL, + ts INTEGER NOT NULL CHECK (ts >= 0), + record_json TEXT NOT NULL, + session_id TEXT + ); + + CREATE INDEX IF NOT EXISTS usage_llm_calls_ts + ON usage_llm_calls(ts DESC, id); + + CREATE TABLE IF NOT EXISTS usage_tool_invocations ( + storage_key TEXT PRIMARY KEY, + id TEXT NOT NULL, + ts INTEGER NOT NULL CHECK (ts >= 0), + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS usage_tool_invocations_ts + ON usage_tool_invocations(ts DESC, id); + + ${MODEL_CALL_TABLE.replace('%TABLE%', 'usage_model_call_attempts')}; + + -- The AgentRun sequence is the projection's sole progress authority. A run + -- is behind exactly when its latest model-call event is newer than this + -- checkpoint; unreadable evidence is retained without pinning later calls. + CREATE TABLE IF NOT EXISTS usage_model_call_projection_checkpoints ( + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + applied_through_sequence INTEGER NOT NULL CHECK (applied_through_sequence >= 0), + unreadable_events INTEGER NOT NULL DEFAULT 0 CHECK (unreadable_events >= 0), + PRIMARY KEY (session_id, run_id), + FOREIGN KEY (session_id, run_id) + REFERENCES core_agent_runs(session_id, run_id) + ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS usage_pricing_authority ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + + INSERT OR IGNORE INTO usage_pricing_authority(singleton, revision) + VALUES (1, 0); + + CREATE TABLE IF NOT EXISTS usage_pricing_overrides ( + model_key TEXT PRIMARY KEY, + record_json TEXT NOT NULL + ); + `); + db.exec('DROP TABLE IF EXISTS usage_model_call_reprojection'); + ensureColumn(db, 'usage_llm_calls', 'session_id', 'TEXT'); + db.exec(` + UPDATE usage_llm_calls + SET session_id = json_extract(record_json, '$.sessionId') + WHERE session_id IS NULL AND json_valid(record_json); + + CREATE INDEX IF NOT EXISTS usage_llm_calls_session_ts + ON usage_llm_calls(session_id, ts DESC, id); + `); + // A ledger old enough to predate Session attribution has no column to carry + // through, and the conversion below reads one. + ensureColumn(db, 'usage_model_call_attempts', 'session_id', 'TEXT'); + spreadModelCallRecordJson(db); + db.exec(` + CREATE INDEX IF NOT EXISTS usage_model_call_attempts_completed_at + ON usage_model_call_attempts(completed_at DESC, attempt_id); + + CREATE INDEX IF NOT EXISTS usage_model_call_attempts_session_completed_at + ON usage_model_call_attempts(session_id, completed_at DESC, attempt_id); + `); +} + +/** + * Converts rows that stored the record as one JSON blob into the columns. + * + * Not rebuilt from the AgentRun authority, the usual move for a read model: + * deleting a Session drops its runs and cascades their events while these rows + * are kept on purpose, so for those calls this table is the last copy and a + * wipe-and-replay would erase their spend. See the header of + * `model-call-ledger.ts`. + * + * A blob that does not yield a whole record — damaged text, or a row some other + * schema left behind — keeps its identity and timestamp and loses the rest. That + * is the same claim the old JSON reader made by counting it as unreadable, and + * it is why the conversion is all-or-nothing per row: a half-filled row would + * make the table's own CHECK unsatisfiable and take the whole migration with it. + */ +function spreadModelCallRecordJson(db: DatabaseSync): void { + if (!hasColumn(db, 'usage_model_call_attempts', 'record_json')) return; + const extracted = MODEL_CALL_COLUMN_SOURCES.map( + ([column, path]) => + `CASE WHEN json_valid(record_json) THEN json_extract(record_json, '${path}') END AS ${column}`, + ).join(',\n '); + const readable = [ + ...MODEL_CALL_REQUIRED_COLUMNS.map((column) => `${column} IS NOT NULL`), + "(cost_basis IS NOT 'priced' OR cost_usd IS NOT NULL)", + "(cost_basis IS NOT 'unpriced' OR cost_usd IS NULL)", + `(usage_basis IS NOT 'missing' OR (${NO_TOKENS}))`, + ].join('\n AND '); + const pricing = MODEL_CALL_COLUMN_SOURCES.map( + ([column]) => `CASE WHEN readable THEN ${column} END`, + ).join(',\n '); + // The old table moves aside so the new one is created under its final name: + // a table renamed into place keeps a rewritten `CREATE` statement, and the + // schema guard compares those texts. + db.exec(` + ALTER TABLE usage_model_call_attempts RENAME TO usage_model_call_attempts_blob; + + ${MODEL_CALL_TABLE.replace('%TABLE%', 'usage_model_call_attempts')}; + + INSERT INTO usage_model_call_attempts(${MODEL_CALL_COLUMNS.join(', ')}) + WITH extracted AS ( + SELECT + attempt_id, + completed_at, + -- A tombstone keeps its Session: the row still says a call happened here. + COALESCE( + session_id, + CASE WHEN json_valid(record_json) THEN json_extract(record_json, '$.sessionId') END + ) AS session_id, + ${extracted} + FROM usage_model_call_attempts_blob + ), + classified AS ( + SELECT *, (${readable}) AS readable FROM extracted + ) + SELECT + attempt_id, + completed_at, + session_id, + ${pricing} + FROM classified; + + DROP TABLE usage_model_call_attempts_blob; + `); +} + +function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void { + if (hasColumn(db, table, column)) return; + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +function hasColumn(db: DatabaseSync, table: string, column: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return columns.some((candidate) => candidate.name === column); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e1b84cfb497e9e18f6967d340ee887984032927ed036ec6361c3a087eddede8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e1b84cfb497e9e18f6967d340ee887984032927ed036ec6361c3a087eddede8.source new file mode 100644 index 0000000000..d0c0c02ae1 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/0e1b84cfb497e9e18f6967d340ee887984032927ed036ec6361c3a087eddede8.source @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import type { ContextOffloadLimits } from '@maka/core/context-offload'; +import { acquireOperationalStateDatabase } from '../operational-state-store.js'; +import { CONTEXT_OFFLOAD_DATABASE_NAME } from '../sqlite-context-offload-store.js'; +import { openStorageWriterComposition } from '../storage-writer-composition.js'; +import { + resolveStorageRoot, + runWithStorageRootLease, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +const contextOffloadLimits: ContextOffloadLimits = Object.freeze({ + ownerMaxBytes: Object.freeze({ read_image_snapshot: 1024, tool_result_archive: 1024 }), + sessionLogicalBytes: 4096, + workspacePhysicalBytes: 4096, +}); + +test('storage writer composition rejects reuse until close completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-composition-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const first = await openStorageWriterComposition(owner.lease); + await assert.rejects(openStorageWriterComposition(owner.lease)); + + const closing = first.close(); + await assert.rejects(openStorageWriterComposition(owner.lease)); + await closing; + + const reopened = await openStorageWriterComposition(owner.lease); + await reopened.execution.sessionStore.list(); + await reopened.close(); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('opening a second storage writer composition creates a usable lifecycle', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-composition-failure-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const first = await openStorageWriterComposition(owner.lease); + await first.close(); + const second = await openStorageWriterComposition(owner.lease); + await second.execution.sessionStore.list(); + await second.usage.telemetry.logs({ range: 'all' }, 0, 1); + await second.close(); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('context-offload authority is optional and participates in composition close', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-context-composition-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const withoutContext = await openStorageWriterComposition(owner.lease); + assert.equal(withoutContext.contextOffload, undefined); + await withoutContext.close(); + + const withContext = await openStorageWriterComposition(owner.lease, { + contextOffloadLimits, + }); + assert.ok(withContext.contextOffload); + assert.deepEqual( + await withContext.contextOffload.read({ + sessionId: 'session-1', + refId: 'missing', + maxBytes: 1024, + }), + { ok: false, reason: 'not_found' }, + ); + await withContext.close(); + + const reopened = await openStorageWriterComposition(owner.lease, { + contextOffloadLimits, + }); + assert.ok(reopened.contextOffload); + await reopened.close(); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('an unavailable context-offload authority does not fail the storage composition', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-context-unavailable-')); + try { + await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME)); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const composition = await openStorageWriterComposition(owner.lease, { + contextOffloadLimits, + }); + assert.equal(composition.contextOffload, undefined); + assert.ok(composition.contextOffloadUnavailable); + assert.ok(composition.contextOffloadUnavailable.cause instanceof Error); + await composition.execution.sessionStore.list(); + await composition.artifacts.listPage('session-1', { offset: 0, limit: 1 }); + await composition.close(); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a failed close keeps the lease unavailable', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-composition-close-failure-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const composition = await openStorageWriterComposition(owner.lease); + const operationalState = await runWithStorageRootLease( + owner.lease, + 'interactive', + 'write', + async (storageRoot) => acquireOperationalStateDatabase(storageRoot), + ); + operationalState.database.close(); + operationalState.close(); + + await assert.rejects(composition.close(), AggregateError); + const [reopen] = await Promise.allSettled([openStorageWriterComposition(owner.lease)]); + if (reopen.status === 'fulfilled') await reopen.value.close(); + assert.equal(reopen.status, 'rejected'); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a failed runtime-policy hook rolls back before reopening', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-storage-composition-hook-failure-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const failure = new Error('runtime-policy hook failed'); + await assert.rejects( + openStorageWriterComposition(owner.lease, { + afterRuntimePolicyOpened: () => { + throw failure; + }, + }), + (error) => error === failure, + ); + + const reopened = await openStorageWriterComposition(owner.lease); + await reopened.execution.sessionStore.list(); + await reopened.projectCatalog.list(); + await reopened.close(); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/104ae0455b49b0dd17fe5b0d89ad03920f8a54e34455a118c6ce429c3704725e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/104ae0455b49b0dd17fe5b0d89ad03920f8a54e34455a118c6ce429c3704725e.source new file mode 100644 index 0000000000..398f0aa798 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/104ae0455b49b0dd17fe5b0d89ad03920f8a54e34455a118c6ce429c3704725e.source @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { + CONTEXT_OFFLOAD_DATABASE_NAME, + SqliteContextOffloadStore, +} from '../../sqlite-context-offload-store.js'; + +const root = process.env.MAKA_CONTEXT_OFFLOAD_CRASH_ROOT; +if (!root) throw new Error('Missing context-offload crash fixture root'); + +const store = new SqliteContextOffloadStore(join(root, CONTEXT_OFFLOAD_DATABASE_NAME), { + limits: { + ownerMaxBytes: { + read_image_snapshot: 5 * 1024 * 1024, + tool_result_archive: 8 * 1024 * 1024, + }, + sessionLogicalBytes: 16 * 1024 * 1024, + workspacePhysicalBytes: 32 * 1024 * 1024, + }, + failpoint(point) { + const requested = process.env.MAKA_CONTEXT_OFFLOAD_CRASH_POINT ?? 'after_managed_file_publish'; + if (point === requested) process.exit(73); + }, +}); + +await store.put({ + sessionId: 'session-1', + owner: { + kind: 'read_image_snapshot', + ownerId: process.env.MAKA_CONTEXT_OFFLOAD_OWNER_ID ?? 'read-call-1', + }, + bytes: new TextEncoder().encode( + process.env.MAKA_CONTEXT_OFFLOAD_VALUE ?? 'crash-safe-managed-value', + ), + mediaType: 'image/png', +}); +throw new Error('Managed-file publication crash failpoint was not reached'); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10694684e058d6bc767c7de81d9f24464deb38360468454e60f1c60f2a0a2008.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10694684e058d6bc767c7de81d9f24464deb38360468454e60f1c60f2a0a2008.source new file mode 100644 index 0000000000..50d515bf83 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10694684e058d6bc767c7de81d9f24464deb38360468454e60f1c60f2a0a2008.source @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants, type BigIntStats } from 'node:fs'; +import { chmod, lstat, mkdir, open } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +export interface ReadStableBoundedFileInput { + readonly path: string; + readonly maxBytes: number; + invalidFile(): Error; +} + +export interface StableBoundedFileHandle { + stat(options: { bigint: true }): Promise; + read( + buffer: TBuffer, + offset?: number, + length?: number, + position?: number | null, + ): Promise<{ bytesRead: number; buffer: TBuffer }>; + close(): Promise; +} + +export interface ReadStableBoundedFileDependencies { + open(path: string, flags: string | number): Promise; + lstat(path: string, options: { bigint: true }): Promise; +} + +const openStableFile = open; +const lstatStableFile = lstat; +const defaultReadDependencies: ReadStableBoundedFileDependencies = { + open: openStableFile, + lstat: lstatStableFile, +}; + +/** Reads one immutable regular-file snapshot without trusting its pathname or declared size. */ +export async function readStableBoundedFile( + input: ReadStableBoundedFileInput, + dependencies: Partial = {}, +): Promise { + if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0) { + throw new RangeError('maxBytes must be a non-negative safe integer'); + } + const deps = { ...defaultReadDependencies, ...dependencies }; + let handle: StableBoundedFileHandle; + try { + handle = await deps.open(input.path, stableReadFlags()); + } catch (error) { + if (isInvalidStableFileError(error)) throw input.invalidFile(); + throw error; + } + try { + const initial = await stableFileSnapshot(handle, input, deps); + const bytes = Buffer.allocUnsafe(input.maxBytes + 1); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + if (offset > input.maxBytes) throw input.invalidFile(); + + const final = await stableFileSnapshot(handle, input, deps); + if (!sameStableFileSnapshot(initial, final) || BigInt(offset) !== initial.size) { + throw input.invalidFile(); + } + return bytes.subarray(0, offset); + } finally { + await handle.close(); + } +} + +export async function syncFile(path: string): Promise { + // Windows rejects fsync on a read-only handle (EPERM). Durable store files + // are writer-owned, so reopen the existing file read/write without creating + // or truncating it before re-establishing the stable-storage barrier. + const handle = await open(path, 'r+'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Create (or harden) an owner-only directory: recursive mkdir plus a + * fail-closed chmod, since mkdir's mode only applies on creation. Callers that + * store secrets use this before creating their file or update lock. + */ +export async function hardenDirectory(dir: string, mode: number = 0o700): Promise { + await mkdir(dir, { recursive: true, mode }); + if (process.platform === 'win32') { + await chmod(dir, mode).catch(() => {}); + return; + } + await chmod(dir, mode); +} + +export async function syncDirectoryChain( + path: string, + root: string, + beforeSync?: (path: string) => void | Promise, +): Promise { + const boundary = resolve(root); + let current = resolve(path); + const pathFromBoundary = relative(boundary, current); + if ( + pathFromBoundary === '..' || + pathFromBoundary.startsWith(`..${sep}`) || + isAbsolute(pathFromBoundary) + ) { + throw new Error(`Durability path escapes workspace root: ${path}`); + } + while (true) { + await beforeSync?.(current); + await syncDirectory(current); + if (current === boundary) return; + current = dirname(current); + } +} + +export async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return; + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function stableFileSnapshot( + handle: StableBoundedFileHandle, + input: ReadStableBoundedFileInput, + dependencies: ReadStableBoundedFileDependencies, +): Promise { + let handleStat: BigIntStats; + let pathStat: BigIntStats; + try { + [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + dependencies.lstat(input.path, { bigint: true }), + ]); + } catch (error) { + if (isNodeError(error, 'ENOENT') || isInvalidStableFileError(error)) { + throw input.invalidFile(); + } + throw error; + } + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.size > BigInt(input.maxBytes) || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw input.invalidFile(); + } + return handleStat; +} + +function sameStableFileSnapshot(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); +} + +function stableReadFlags(): string | number { + return process.platform === 'win32' + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +function isInvalidStableFileError(error: unknown): boolean { + return ( + isNodeError(error, 'ELOOP') || isNodeError(error, 'ENOTDIR') || isNodeError(error, 'ENXIO') + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/109d74f938a85294c61a1084bb5d6b6402c989b93d17ddbcc084ee8c424f6ee5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/109d74f938a85294c61a1084bb5d6b6402c989b93d17ddbcc084ee8c424f6ee5.source new file mode 100644 index 0000000000..5e8033b17f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/109d74f938a85294c61a1084bb5d6b6402c989b93d17ddbcc084ee8c424f6ee5.source @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { PET_PACK_SCHEMA_V1 } from '@maka/core/pet'; +import { + PET_PACK_DIRECTORY, + PET_PACK_MANIFEST_FILE, + PET_PACK_SPRITE_SHEET_MAX_BYTES, + PetPackStoreError, + createPetPackStore, +} from '../pet-pack-store.js'; + +function manifest(overrides: Record = {}): Record { + return { + schema: PET_PACK_SCHEMA_V1, + id: 'likun.maodie', + displayName: '我的耄耋', + description: 'A user-installed custom pet.', + spriteSheet: { + path: 'assets/maodie.png', + format: 'png', + frameWidth: 32, + frameHeight: 32, + columns: 2, + rows: 2, + frameCount: 4, + }, + animations: { + idle: { frames: [0], fps: 2, loop: true }, + working: { frames: [1], fps: 4, loop: true }, + 'needs-input': { frames: [2], fps: 2, loop: true }, + ready: { frames: [3], fps: 4, loop: false, fallback: 'idle' }, + blocked: { frames: [2], fps: 2, loop: true }, + }, + ...overrides, + }; +} + +function pngHeader(width: number, height: number): Buffer { + const bytes = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes); + bytes.writeUInt32BE(13, 8); + bytes.write('IHDR', 12, 'ascii'); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +describe('PetPackStore', () => { + test('atomically installs, lists, reads, and removes a custom pet', async () => { + await withStore(async ({ root, store }) => { + const installed = await store.install({ + manifest: manifest(), + spriteSheet: pngHeader(64, 64), + }); + assert.equal(installed.id, 'likun.maodie'); + assert.deepEqual( + (await store.list()).map((item) => item.id), + ['likun.maodie'], + ); + assert.equal((await store.get('likun.maodie'))?.displayName, '我的耄耋'); + + const asset = await store.readSpriteSheet('likun.maodie'); + assert.equal(asset?.format, 'png'); + assert.deepEqual(Buffer.from(asset?.bytes ?? []), pngHeader(64, 64)); + + const packRoot = join(root, ...PET_PACK_DIRECTORY.split('/'), 'likun.maodie'); + assert.equal( + JSON.parse(await readFile(join(packRoot, PET_PACK_MANIFEST_FILE), 'utf8')).id, + 'likun.maodie', + ); + assert.deepEqual( + (await readdir(join(root, ...PET_PACK_DIRECTORY.split('/')))).filter((name) => + name.startsWith('.install-'), + ), + [], + ); + + assert.equal(await store.remove('likun.maodie'), true); + assert.equal(await store.remove('likun.maodie'), false); + assert.deepEqual(await store.list(), []); + }); + }); + + test('rejects malformed manifests before publishing any directory', async () => { + await withStore(async ({ root, store }) => { + const invalid = manifest({ script: 'pet.js' }); + await assert.rejects(store.install({ manifest: invalid, spriteSheet: pngHeader(64, 64) })); + await assert.rejects(readdir(join(root, ...PET_PACK_DIRECTORY.split('/'))), { + code: 'ENOENT', + }); + }); + }); + + test('rejects mismatched dimensions, invalid headers, and oversized assets', async () => { + await withStore(async ({ store }) => { + await assert.rejects( + store.install({ manifest: manifest(), spriteSheet: pngHeader(32, 64) }), + isStoreError('invalid_asset'), + ); + await assert.rejects( + store.install({ manifest: manifest(), spriteSheet: Buffer.from('not an image') }), + isStoreError('invalid_asset'), + ); + await assert.rejects( + store.install({ + manifest: manifest(), + spriteSheet: new Uint8Array(PET_PACK_SPRITE_SHEET_MAX_BYTES + 1), + }), + isStoreError('invalid_asset'), + ); + assert.deepEqual(await store.list(), []); + }); + }); + + test('never replaces an installed pet with the same id', async () => { + await withStore(async ({ store }) => { + await store.install({ manifest: manifest(), spriteSheet: pngHeader(64, 64) }); + await assert.rejects( + store.install({ + manifest: manifest({ displayName: 'Replacement' }), + spriteSheet: pngHeader(64, 64), + }), + isStoreError('already_installed'), + ); + assert.equal((await store.get('likun.maodie'))?.displayName, '我的耄耋'); + }); + }); + + test('snapshots caller-owned manifest and image bytes before waiting for publication', async () => { + await withStore(async ({ store }) => { + const mutableManifest = manifest({ id: 'likun.snapshot' }); + const mutableSprite = pngHeader(64, 64); + const install = store.install({ + manifest: mutableManifest, + spriteSheet: mutableSprite, + }); + mutableManifest.displayName = 'Mutated after admission'; + mutableSprite.fill(0); + + await install; + assert.equal((await store.get('likun.snapshot'))?.displayName, '我的耄耋'); + assert.deepEqual( + Buffer.from((await store.readSpriteSheet('likun.snapshot'))?.bytes ?? []), + pngHeader(64, 64), + ); + }); + }); + + test('rejects non-canonical ids at every direct lookup boundary', async () => { + await withStore(async ({ store }) => { + await assert.rejects(store.get('../maodie'), isStoreError('invalid_id')); + await assert.rejects(store.readSpriteSheet('/tmp/maodie'), isStoreError('invalid_id')); + await assert.rejects(store.remove('MAODIE'), isStoreError('invalid_id')); + }); + }); + + test('detects sprite sheets redirected outside the installed pack', { + skip: + process.platform === 'win32' + ? 'Windows file-symlink permissions are not guaranteed in CI' + : false, + }, async () => { + await withStore(async ({ root, store }) => { + await store.install({ manifest: manifest(), spriteSheet: pngHeader(64, 64) }); + const packRoot = join(root, ...PET_PACK_DIRECTORY.split('/'), 'likun.maodie'); + const assetPath = join(packRoot, 'assets', 'maodie.png'); + const outsidePath = join(root, 'outside.png'); + await writeFile(outsidePath, pngHeader(64, 64)); + await rm(assetPath); + await symlink(outsidePath, assetPath); + + await assert.rejects(store.readSpriteSheet('likun.maodie'), isStoreError('corrupt_pack')); + }); + }); + + test('rejects a store root redirected outside the state root', async () => { + await withStore(async ({ root, store }) => { + const outside = await mkdtemp(join(tmpdir(), 'maka-pet-pack-outside-')); + try { + await symlink( + outside, + join(root, 'pets'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await assert.rejects(store.list(), isStoreError('corrupt_store')); + await assert.rejects( + store.install({ manifest: manifest(), spriteSheet: pngHeader(64, 64) }), + isStoreError('corrupt_store'), + ); + assert.deepEqual(await readdir(outside), []); + } finally { + await rm(join(root, 'pets'), { force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + }); +}); + +function isStoreError(code: PetPackStoreError['code']): (error: unknown) => boolean { + return (error) => error instanceof PetPackStoreError && error.code === code; +} + +async function withStore( + run: (input: { root: string; store: ReturnType }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-pet-pack-store-')); + try { + await run({ root, store: createPetPackStore(root) }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10a608d594755789190bd2273d1b4f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10a608d594755789190bd2273d1b4f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source new file mode 100644 index 0000000000..b0e9db3a26 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/10a608d594755789190bd2273d1b4f3f16ae71cfc68ed78e0ceeb48eee97c9d3.source @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + resolveRootControlNamespace, + resolveRootOwnershipNamespace, +} from '../../root-authority.js'; + +// A storage root's control directory lives under the real OS account home, not +// inside the temporary root a test creates, so removing the temporary directory +// leaves the control directory behind. `resolveRootControlNamespace` reads +// `userInfo().homedir` on purpose — the directory carries `owner.lock`, which +// decides who may write a State Root, and an environment variable must not be +// able to move that trust boundary. Tests therefore have to remove what they +// created, and these helpers are the one place that knows how. + +/** Removes the control directory for a rootId. Safe to call when none exists. */ +export async function removeControlDirectory(rootId: string): Promise { + if (rootId.length === 0) return; + await Promise.all([ + rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }), + rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }), + ]); +} + +const trackedRootIds = new Set(); + +/** + * Records a resolved root's control directory for teardown and returns the + * capability unchanged, so it can wrap a `resolveStorageRoot` call in place. + * The record survives the root itself, which is what teardown needs. Node's + * test runner gives each file its own process, so the set is per-file state. + */ +export function trackControlDirectory( + capability: Capability, +): Capability { + trackedRootIds.add(capability.rootId); + return capability; +} + +/** Removes every control directory recorded by `trackControlDirectory` so far. */ +export async function removeTrackedControlDirectories(): Promise { + const rootIds = [...trackedRootIds]; + trackedRootIds.clear(); + await Promise.all(rootIds.map(removeControlDirectory)); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/110172c1b557a2922cd4f765f34f62a845434bc340a42ce8706d1b29845298c2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/110172c1b557a2922cd4f765f34f62a845434bc340a42ce8706d1b29845298c2.source new file mode 100644 index 0000000000..3c9ee900b0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/110172c1b557a2922cd4f765f34f62a845434bc340a42ce8706d1b29845298c2.source @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { ReadImageSnapshotStoreError, type ContextOffloadLimits } from '@maka/core/context-offload'; +import { + createInteractiveContextOffloadReader, + openInteractiveContextOffloadStoreForWrite, + type InteractiveContextOffloadWriter, +} from '../context-offload-store.js'; +import { + createReadImageSnapshotReader, + createReadImageSnapshotStore, +} from '../read-image-snapshot-store.js'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type InteractiveRootOwner, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('derives Read image storage only from an authentic context writer', () => { + assert.throws( + () => createReadImageSnapshotStore({} as InteractiveContextOffloadWriter, 'session-1'), + /authentic interactive context-offload writer/u, + ); +}); + +test('snapshots one stable Read image identity and authorizes reads by Session', async () => { + await withReadImageStore(defaultLimits(), async (images, writer) => { + const bytes = new TextEncoder().encode('image'); + const input = { + ownerId: 'read-call-1', + bytes, + mimeType: 'image/png', + }; + const snapshotting = images.snapshot(input); + input.ownerId = 'mutated-owner'; + input.mimeType = 'image/jpeg'; + bytes.fill(0x78); + + const ref = await snapshotting; + assert.deepEqual(ref, { + kind: 'session_context', + sessionId: 'session-1', + refId: ref.refId, + }); + assert.deepEqual( + await images.snapshot({ + ownerId: 'read-call-1', + bytes: new TextEncoder().encode('image'), + mimeType: 'image/png', + }), + ref, + ); + const read = await images.read(ref); + assert.equal(read.ok, true); + if (!read.ok) return; + assert.equal(read.record.owner.kind, 'read_image_snapshot'); + assert.equal(read.record.owner.ownerId, 'read-call-1'); + assert.equal(read.record.mediaType, 'image/png'); + assert.deepEqual(read.bytes, new TextEncoder().encode('image')); + const reader = createReadImageSnapshotReader( + createInteractiveContextOffloadReader(writer), + 'session-1', + ); + assert.deepEqual(await reader.read(ref), read); + assert.deepEqual(Object.keys(reader), ['read']); + assert.deepEqual(await images.read({ ...ref, sessionId: 'session-2' }), { + ok: false, + reason: 'session_mismatch', + }); + const sessionTwoImages = createReadImageSnapshotStore(writer, 'session-2'); + assert.deepEqual(await sessionTwoImages.read(ref), { + ok: false, + reason: 'session_mismatch', + }); + + await assert.rejects( + images.snapshot({ + ownerId: 'read-call-1', + bytes: new TextEncoder().encode('changed'), + mimeType: 'image/png', + }), + (error) => + error instanceof ReadImageSnapshotStoreError && error.reason === 'identity_conflict', + ); + await assert.rejects( + images.snapshot({ + ownerId: 'read-call-1', + bytes: new TextEncoder().encode('image'), + mimeType: 'image/jpeg', + }), + (error) => + error instanceof ReadImageSnapshotStoreError && error.reason === 'identity_conflict', + ); + await assert.rejects( + images.snapshot({ + ownerId: 'not-an-image', + bytes: new Uint8Array([1]), + mimeType: 'text/plain', + }), + /media type must be an image/u, + ); + }); +}); + +test('maps configured quota failures and rejects non-image owner references', async () => { + await withReadImageStore( + { + ownerMaxBytes: { + read_image_snapshot: 4, + tool_result_archive: 64, + }, + sessionLogicalBytes: 64, + workspacePhysicalBytes: 64, + }, + async (images, writer) => { + await assert.rejects( + images.snapshot({ + ownerId: 'over-configured-limit', + bytes: new Uint8Array(5), + mimeType: 'image/png', + }), + (error) => error instanceof ReadImageSnapshotStoreError && error.reason === 'too_large', + ); + + const archive = await writer.put({ + sessionId: 'session-1', + owner: { kind: 'tool_result_archive', ownerId: 'archive-1' }, + bytes: new TextEncoder().encode('{}'), + mediaType: 'application/json', + }); + assert.equal(archive.ok, true); + if (!archive.ok) return; + assert.deepEqual( + await images.read({ + kind: 'session_context', + sessionId: 'session-1', + refId: archive.record.refId, + }), + { ok: false, reason: 'corrupt' }, + ); + }, + ); +}); + +test('enforces the Read image product cap before touching storage', async () => { + const limits: ContextOffloadLimits = { + ownerMaxBytes: { + read_image_snapshot: MAX_READ_IMAGE_BYTES + 1, + tool_result_archive: 64, + }, + sessionLogicalBytes: MAX_READ_IMAGE_BYTES + 1, + workspacePhysicalBytes: MAX_READ_IMAGE_BYTES + 1, + }; + await withReadImageStore(limits, async (images, writer) => { + await assert.rejects( + images.snapshot({ + ownerId: 'over-product-limit', + bytes: new Uint8Array(MAX_READ_IMAGE_BYTES + 1), + mimeType: 'image/png', + }), + (error) => error instanceof ReadImageSnapshotStoreError && error.reason === 'too_large', + ); + assert.deepEqual(await writer.usage(), { + references: 0, + logicalBytes: 0, + physicalBytes: 0, + }); + }); +}); + +function defaultLimits(): ContextOffloadLimits { + return { + ownerMaxBytes: { + read_image_snapshot: MAX_READ_IMAGE_BYTES, + tool_result_archive: 64, + }, + sessionLogicalBytes: MAX_READ_IMAGE_BYTES * 2, + workspacePhysicalBytes: MAX_READ_IMAGE_BYTES * 2, + }; +} + +async function withReadImageStore( + limits: ContextOffloadLimits, + run: ( + images: ReturnType, + writer: InteractiveContextOffloadWriter, + ) => Promise, +): Promise { + await withInteractiveOwner(async (owner) => { + const writer = await openInteractiveContextOffloadStoreForWrite(owner.lease, { limits }); + try { + await run(createReadImageSnapshotStore(writer, 'session-1'), writer); + } finally { + await writer.close(); + } + }); +} + +async function withInteractiveOwner(run: (owner: InteractiveRootOwner) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'maka-read-image-context-store-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await run(owner); + } finally { + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11628582812ce6bb217c12d57741fb98aab25be12f9863f6f826ca5de79314b8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11628582812ce6bb217c12d57741fb98aab25be12f9863f6f826ca5de79314b8.source new file mode 100644 index 0000000000..e390a0a657 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11628582812ce6bb217c12d57741fb98aab25be12f9863f6f826ca5de79314b8.source @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { WORKSPACE_AUTHORITY_SESSION_ID } from '@maka/core/workspace-version-authority'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { isRuntimeStorageSafeId } from './runtime-event-invariants.js'; + +export interface ConversationOperationalStateStore { + purge(sessionId: string): Promise; + close(): void; +} + +export function createConversationOperationalStateStore( + workspaceRoot: string, +): ConversationOperationalStateStore { + return new SqliteConversationOperationalStateStore(workspaceRoot); +} + +class SqliteConversationOperationalStateStore implements ConversationOperationalStateStore { + readonly #lease: OperationalStateDatabaseLease; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(workspaceRoot); + } + + async purge(sessionId: string): Promise { + if (!isRuntimeStorageSafeId(sessionId)) throw new Error('Invalid session id'); + if (sessionId === WORKSPACE_AUTHORITY_SESSION_ID) { + throw new Error('Workspace authority control-plane state cannot be purged as a conversation'); + } + this.#lease.transaction('write', () => { + const database = this.#lease.database; + database + .prepare( + ` + DELETE FROM tool_journal_events + WHERE runtime_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + OR operation_id IN ( + SELECT operation_id + FROM tool_operations + WHERE call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR dispatch_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR result_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + ) + `, + ) + .run(sessionId, sessionId, sessionId, sessionId); + database + .prepare( + ` + DELETE FROM tool_operations + WHERE call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR dispatch_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR result_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + `, + ) + .run(sessionId, sessionId, sessionId); + database.prepare('DELETE FROM runtime_partial_snapshots WHERE session_id = ?').run(sessionId); + database.prepare('DELETE FROM runtime_events WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM core_agent_run_projections WHERE session_id = ?') + .run(sessionId); + database.prepare('DELETE FROM core_root_turn_admissions WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM core_root_turn_start_rejections WHERE session_id = ?') + .run(sessionId); + // Cascades this run's events and the Usage projection's checkpoints. + // `usage_model_call_attempts` is deliberately absent from this list: + // deleting a conversation must not erase its spend from all-time Usage + // totals, so those rows outlive the authority they were projected from. + database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM core_client_capability_session_grants WHERE session_id = ?') + .run(sessionId); + database.prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?').run(sessionId); + }); + } + + close(): void { + this.#lease.close(); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/116c1ecfafa2a309609e79929c7085469b5b874112f8b540e6f90959086e3038.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/116c1ecfafa2a309609e79929c7085469b5b874112f8b540e6f90959086e3038.source new file mode 100644 index 0000000000..a8cdace540 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/116c1ecfafa2a309609e79929c7085469b5b874112f8b540e6f90959086e3038.source @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { + readConnectionOnboardingIntent, + writeConnectionOnboardingIntent, + prepareConnectionOnboardingIntent, +} from '../runtime-policy/onboarding-transaction.js'; + +const roots: string[] = []; + +async function root(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'maka-onboarding-intent-')); + roots.push(directory); + return directory; +} + +after(async () => { + await Promise.all(roots.map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +const BASE = { + connectionId: '00000000-0000-4000-8000-000000000001', + slug: 'openai-compatible-2', + providerType: 'openai-compatible', + suppliedSecret: 'relay-secret', + enabledModelIds: ['relay/model'], + discovery: { models: [{ id: 'relay/model' }], source: 'fetched', fetchedAt: 123 }, + invalidateLastTest: false, +}; + +test('an onboarding intent round-trips its endpoint override through the journal', async () => { + const directory = await root(); + const intent = prepareConnectionOnboardingIntent({ + ...BASE, + baseUrl: 'https://relay.example.test/v1', + }); + await writeConnectionOnboardingIntent(directory, intent); + assert.deepEqual(await readConnectionOnboardingIntent(directory), intent); + const persisted = JSON.parse( + await readFile(join(directory, 'runtime-policy-onboarding.json'), 'utf8'), + ) as { schemaVersion: number; slug: string }; + assert.deepEqual(persisted, { ...intent }); + assert.equal(persisted.schemaVersion, 2); + assert.equal(persisted.slug, 'openai-compatible-2'); +}); + +test('a journal written before the baseUrl field replays as no override', async () => { + const directory = await root(); + // The exact persisted shape an older build leaves behind on crash: no + // `baseUrl` key at all. Recovery must replay it, not reject the document. + const { slug: _slug, ...legacyBase } = BASE; + await writeFile( + join(directory, 'runtime-policy-onboarding.json'), + JSON.stringify({ schemaVersion: 1, ...legacyBase }), + ); + const replayed = await readConnectionOnboardingIntent(directory); + assert.equal(replayed?.schemaVersion, 1); + assert.equal(replayed?.slug, null); + assert.equal(replayed?.baseUrl, null); + assert.deepEqual(replayed?.enabledModelIds, ['relay/model']); +}); + +test('a caller-chosen display name round-trips through the journal', async () => { + const directory = await root(); + const intent = prepareConnectionOnboardingIntent({ + ...BASE, + name: 'Work Relay', + baseUrl: 'https://relay.example.test/v1', + }); + assert.equal(intent.name, 'Work Relay'); + await writeConnectionOnboardingIntent(directory, intent); + assert.deepEqual(await readConnectionOnboardingIntent(directory), intent); +}); + +test('a journal written before the name field replays with the provider default', async () => { + const directory = await root(); + // Schema v2 predates `name`: the key is simply absent on crash replay. + const { slug: _slug, ...legacyBase } = BASE; + await writeFile( + join(directory, 'runtime-policy-onboarding.json'), + JSON.stringify({ + schemaVersion: 2, + slug: 'openai-compatible-2', + baseUrl: null, + ...legacyBase, + }), + ); + const replayed = await readConnectionOnboardingIntent(directory); + assert.equal(replayed?.schemaVersion, 2); + assert.equal(replayed?.name, null); +}); + +test('a malformed requested display name fails input decode, never the journal', async () => { + assert.throws( + () => + prepareConnectionOnboardingIntent({ + ...BASE, + name: 42, + baseUrl: null, + }), + /connection name/, + ); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11c6efb3420f5cd72ad78c669e0b2d2b528b735789c5d8907c990355def94255.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11c6efb3420f5cd72ad78c669e0b2d2b528b735789c5d8907c990355def94255.source new file mode 100644 index 0000000000..b39eccaa31 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/11c6efb3420f5cd72ad78c669e0b2d2b528b735789c5d8907c990355def94255.source @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION } from '@maka/core/agent-graph-control'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; +import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; + +describe('claimed agent graph root admission', () => { + test('round-trips an exact, deeply frozen durable descriptor', async () => { + await withTempRoot(async (_root, openStore) => { + const store = openStore(); + const result = await store.admitRootTurn(admissionInput()); + assert.equal(result.kind, 'admitted'); + assert.equal(result.admission.execution.kind, 'claimed_agent_graph_intent'); + if (result.admission.execution.kind !== 'claimed_agent_graph_intent') return; + assert.deepEqual(result.admission.execution.claim, claim()); + assert.equal(result.admission.execution.agentId, 'trusted-agent'); + assert.equal(result.admission.execution.agentName, 'Trusted Agent'); + assert.equal(Object.isFrozen(result.admission.execution), true); + assert.equal(Object.isFrozen(result.admission.execution.claim), true); + + const reopened = openStore(); + const stored = await reopened.readRootTurnAdmission('session-child', 'turn-next'); + assert.deepEqual(stored, result.admission); + assert.equal(Object.isFrozen(stored?.execution), true); + if (stored?.execution.kind === 'claimed_agent_graph_intent') { + assert.equal(Object.isFrozen(stored.execution.claim), true); + } + }); + }); + + test('rejects descriptor unknown fields and malformed claims', async () => { + await withTempRoot(async (_root, openStore) => { + const store = openStore(); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + ...admissionInput().execution, + unexpected: true, + } as unknown as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + kind: 'claimed_agent_graph_intent', + claim: { ...claim(), claimId: 'not-a-claim-id' }, + agentId: 'trusted-agent', + agentName: 'Trusted Agent', + } as unknown as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + kind: 'claimed_agent_graph_intent', + claim: { ...claim(), unexpected: true }, + agentId: 'trusted-agent', + agentName: 'Trusted Agent', + } as unknown as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + }); + }); + + test('rejects every claim target identity drift before writing admission', async () => { + await withTempRoot(async (root, openStore) => { + const store = openStore(); + for (const [field, value] of [ + ['targetSessionId', 'different-session'], + ['targetTurnId', 'different-turn'], + ['targetRunId', 'different-run'], + ] as const) { + const sessionId = `session-drift-${field}`; + const turnId = 'turn-next'; + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + sessionId, + execution: { + kind: 'claimed_agent_graph_intent', + claim: { + ...claim({ + targetSessionId: sessionId, + targetTurnId: turnId, + targetRunId: 'run-next', + }), + [field]: value, + }, + agentId: 'trusted-agent', + agentName: 'Trusted Agent', + }, + }), + ), + /claim target does not match admission identity/, + ); + await assert.rejects( + access(join(root, 'sessions', sessionId, 'turn-admissions', `${turnId}.json`)), + (error: unknown) => (error as NodeJS.ErrnoException).code === 'ENOENT', + ); + } + }); + }); + + test('rejects queue sources and a missing canonical UserMessage', async () => { + await withTempRoot(async (root, openStore) => { + const store = openStore(); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + normalizedInput: { text: 'queued graph prompt' }, + sourceMessages: [ + { + messageId: 'message-next', + content: { text: 'queued graph prompt' }, + placement: 'current_turn', + disposition: 'turn_started', + }, + ], + }), + ), + /host-authored execution cannot have source messages/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + proposedUserMessageId: null, + }), + ), + /execution has an invalid UserMessage requirement/, + ); + await assert.rejects( + access(join(root, 'sessions', 'session-child', 'turn-admissions', 'turn-next.json')), + (error: unknown) => (error as NodeJS.ErrnoException).code === 'ENOENT', + ); + }); + }); +}); + +function admissionInput(overrides: Partial = {}): AdmitRootTurnInput { + const sessionId = overrides.sessionId ?? 'session-child'; + const turnId = overrides.turnId ?? 'turn-next'; + const proposedRunId = overrides.proposedRunId ?? 'run-next'; + return { + sessionId, + turnId, + proposedRunId, + proposedUserMessageId: 'message-next', + execution: { + kind: 'claimed_agent_graph_intent', + claim: claim({ + targetSessionId: sessionId, + targetTurnId: turnId, + targetRunId: proposedRunId, + }), + agentId: 'trusted-agent', + agentName: 'Trusted Agent', + }, + previousRootTurnId: null, + normalizedInput: { text: 'canonical graph prompt' }, + sourceMessages: [], + admittedAt: 50, + ...overrides, + }; +} + +function claim( + overrides: Partial> = {}, +): ReturnType { + return { ...baseClaim(), ...overrides }; +} + +function baseClaim() { + return { + schemaVersion: AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, + claimId: `graph_claim_${'a'.repeat(32)}`, + graphId: 'graph-1', + intentId: `graph_intent_${'b'.repeat(32)}`, + intentFingerprint: `sha256:${'c'.repeat(64)}`, + readinessContextFingerprint: `sha256:${'d'.repeat(64)}`, + targetOperatorId: 'summarizer', + targetSessionId: 'session-child', + targetTurnId: 'turn-next', + targetRunId: 'run-next', + claimedAt: 40, + }; +} + +async function withTempRoot( + run: ( + root: string, + openStore: () => ReturnType, + ) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-claimed-graph-admission-')); + const stores: ReturnType[] = []; + try { + await run(root, () => { + const store = createSqliteAgentRunStore(root); + stores.push(store); + return store; + }); + } finally { + for (const store of stores.reverse()) store.close?.(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/13ecbc7c9bff60ad8c3b8b4d7e358baeacfc9bbeeb3634ee9a18cad3b0613cff.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/13ecbc7c9bff60ad8c3b8b4d7e358baeacfc9bbeeb3634ee9a18cad3b0613cff.source new file mode 100644 index 0000000000..eff3083ae3 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/13ecbc7c9bff60ad8c3b8b4d7e358baeacfc9bbeeb3634ee9a18cad3b0613cff.source @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionHeader } from '@maka/core/session'; + +export function isDiscardableConversationCopy(header: SessionHeader): boolean { + const copy = header.conversationCopy; + return ( + copy?.state === 'preparing' || + (copy?.kind === 'revision' && + copy.state === 'committed' && + header.revisionState === 'preparing') + ); +} + +export function isValidConversationCopyTransition( + current: SessionHeader, + next: SessionHeader['conversationCopy'], +): boolean { + const previous = current.conversationCopy; + return ( + previous !== undefined && + next !== undefined && + previous.kind === next.kind && + previous.sourceSessionId === next.sourceSessionId && + previous.sourceTurnId === next.sourceTurnId && + previous.requestFingerprint === next.requestFingerprint && + previous.intent === next.intent && + (previous.state !== 'committed' || next.state === 'committed') && + (previous.state !== 'preparing' || next.state === 'preparing' || next.state === 'committed') + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1447df3d5a452b4921cb0fcd2fd1efeb338fc544266331a00b991a9ce9a4bd53.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1447df3d5a452b4921cb0fcd2fd1efeb338fc544266331a00b991a9ce9a4bd53.source new file mode 100644 index 0000000000..bacb95b2c3 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1447df3d5a452b4921cb0fcd2fd1efeb338fc544266331a00b991a9ce9a4bd53.source @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Published operational-state-store surface. + * + * The owning module also exports the schema-migration internals; those run + * against a caller-supplied database and stay package-private, exactly as the + * deleted barrel kept them. + */ +export { + acquireOperationalStateDatabase, + OperationalStateMigrationBlockedError, + OPERATIONAL_STATE_DATABASE_NAME, + OPERATIONAL_STATE_SCHEMA_VERSION, + resolveOperationalStateDatabasePath, +} from './operational-state-store.js'; +export type { + OperationalStateDatabaseLease, + OperationalStateDatabaseOptions, +} from './operational-state-store.js'; diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/144ea3a6a65649d239eb4c99dd41a4c320de29f91a36500cad6d5a1c00750512.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/144ea3a6a65649d239eb4c99dd41a4c320de29f91a36500cad6d5a1c00750512.source new file mode 100644 index 0000000000..8849833378 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/144ea3a6a65649d239eb4c99dd41a4c320de29f91a36500cad6d5a1c00750512.source @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, rmdir, unlink } from 'node:fs/promises'; +import { + openStableNativeLockFile, + releaseNativeFileLock, + tryAcquireNativeFileLock, +} from './native-file-lock.js'; + +const LOCK_POLL_MS = 25; +const LOCK_TIMEOUT_MS = 10_000; +const lockGates = new Map>(); + +export async function withLegacyFileUpdateLockLease( + targetPath: string, + operation: (inheritedFd: number) => Promise, + timeoutMs: number = LOCK_TIMEOUT_MS, +): Promise { + const lockPath = `${targetPath}.lock`; + const leasePath = `${targetPath}.lease`; + const supervisionPath = `${targetPath}.supervised`; + const deadline = Date.now() + timeoutMs; + return runWithLockGate(leasePath, deadline, async () => { + const lease = await openStableNativeLockFile(leasePath); + let leased = false; + let supervised = false; + let completed = false; + try { + while (!(leased = tryAcquireNativeFileLock(lease))) { + await waitForLockTurn(lockPath, deadline); + } + // The inherited advisory lease follows the legacy child process. A surviving + // supervision marker therefore proves that its directory lock is ownerless + // once a later process can acquire this lease. + await recoverSupervisedLegacyLock(lockPath, supervisionPath); + await createSupervisionMarker(supervisionPath); + supervised = true; + const result = await operation(lease.fd); + completed = true; + return result; + } finally { + try { + if (supervised && completed) await unlink(supervisionPath).catch(ignoreMissing); + } finally { + if (leased) releaseNativeFileLock(lease); + await lease.close(); + } + } + }); +} + +/** + * The callback may pass the lease fd as an extra child stdio descriptor. The + * advisory lock then survives a parent crash until that exact child exits. + */ +export async function withProcessLifetimeFileUpdateLock( + targetPath: string, + operation: (inheritableLeaseFd: number) => Promise, + timeoutMs: number = LOCK_TIMEOUT_MS, +): Promise { + const lockPath = `${targetPath}.lock`; + const deadline = Date.now() + timeoutMs; + const leasePath = `${targetPath}.lease`; + return runWithLockGate(leasePath, deadline, async () => { + const lease = await openStableNativeLockFile(leasePath); + let leased = false; + let markerCreated = false; + try { + while (!(leased = tryAcquireNativeFileLock(lease))) { + await waitForLockTurn(lockPath, deadline); + } + await recoverSupervisedLegacyLock(lockPath, `${targetPath}.supervised`); + await acquireLegacyMarker(lockPath, deadline); + markerCreated = true; + return await operation(lease.fd); + } finally { + try { + if (markerCreated) await unlink(lockPath).catch(ignoreMissing); + } finally { + if (leased) releaseNativeFileLock(lease); + await lease.close(); + } + } + }); +} + +async function createSupervisionMarker(path: string): Promise { + const marker = await open( + path, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, + 0o600, + ); + await marker.close(); +} + +async function recoverSupervisedLegacyLock( + lockPath: string, + supervisionPath: string, +): Promise { + const supervision = await lstat(supervisionPath).catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + }); + if (!supervision) return; + if (!supervision.isFile() || supervision.isSymbolicLink()) { + throw new Error(`File update supervision marker is not a regular file: ${supervisionPath}`); + } + const lock = await lstat(lockPath).catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + }); + if (lock?.isDirectory() && !lock.isSymbolicLink()) await rmdir(lockPath); + await unlink(supervisionPath); +} + +async function runWithLockGate( + lockPath: string, + deadline: number, + operation: () => Promise, +): Promise { + const previous = lockGates.get(lockPath); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + lockGates.set(lockPath, current); + try { + if (previous) await waitForGate(previous, lockPath, deadline); + return await operation(); + } finally { + release(); + if (lockGates.get(lockPath) === current) lockGates.delete(lockPath); + } +} + +async function waitForGate( + previous: Promise, + lockPath: string, + deadline: number, +): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw lockTimeout(lockPath); + let timer: ReturnType | undefined; + try { + await Promise.race([ + previous.catch(() => undefined), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(lockTimeout(lockPath)), remaining); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function acquireLegacyMarker(lockPath: string, deadline: number): Promise { + for (;;) { + try { + const marker = await open( + lockPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, + 0o600, + ); + await marker.close(); + return; + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + } + const existing = await lstat(lockPath).catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + }); + if (!existing) continue; + if (existing.isFile() && !existing.isSymbolicLink()) { + // Current writers hold the advisory lease before publishing this marker. + // Owning the lease proves a remaining regular marker is stale. + await unlink(lockPath); + continue; + } + if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw new Error(`File update lock path is not a regular marker: ${lockPath}`); + } + // Older builds use the directory itself as their live lock and publish no + // owner identity, so it cannot be safely stolen. + await waitForLockTurn(lockPath, deadline); + } +} + +async function waitForLockTurn(lockPath: string, deadline: number): Promise { + if (Date.now() >= deadline) throw lockTimeout(lockPath); + await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS)); +} + +function lockTimeout(lockPath: string): Error { + return new Error(`File update is locked by another process (${lockPath})`); +} + +function ignoreMissing(error: unknown): void { + if (!isNodeError(error, 'ENOENT')) throw error; +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/148b788c445539da65302aa00d69d66f09f56154a02bbbb30a30b4fda5f072d5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/148b788c445539da65302aa00d69d66f09f56154a02bbbb30a30b4fda5f072d5.source new file mode 100644 index 0000000000..37a1e8cdbd --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/148b788c445539da65302aa00d69d66f09f56154a02bbbb30a30b4fda5f072d5.source @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { canonicalizeLegacyPlanReminderCronExpression } from '../legacy-cron-expression.js'; + +test('converts released single-value steps without changing wildcard steps', () => { + assert.equal(canonicalizeLegacyPlanReminderCronExpression('5/10 * * * *'), '5 * * * *'); + assert.equal(canonicalizeLegacyPlanReminderCronExpression('*/5 * * * *'), '*/5 * * * *'); +}); + +test('rejects a source expression outside the released grammar', () => { + assert.throws(() => canonicalizeLegacyPlanReminderCronExpression('0 0 30 2 *')); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/159c0a338daed5513bc163fb18307a9066036fe86fe344d9c616b07d6f6dd0d3.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/159c0a338daed5513bc163fb18307a9066036fe86fe344d9c616b07d6f6dd0d3.source new file mode 100644 index 0000000000..e4b0845317 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/159c0a338daed5513bc163fb18307a9066036fe86fe344d9c616b07d6f6dd0d3.source @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, test } from 'node:test'; +import { AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION } from '@maka/core/agent-graph-supervisor-wake'; +import { + createSqliteSessionMetadataStore, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, +} from '../sqlite-session-metadata-store.js'; + +describe('SQLite Agent Graph supervisor wakes', () => { + test('tracks retry attempts separately and marks delivery only after completion', async () => { + let now = 10; + const store = createSqliteSessionMetadataStore(':memory:', { now: () => now++ }); + try { + const claim = await store.claimAgentGraphSupervisorWake({ + schemaVersion: AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + graphId: 'graph-1', + wakeId: 'wake-1', + snapshotVersion: 'snapshot-1', + rootSessionId: 'session-1', + }); + assert.equal(claim.created, true); + assert.equal(claim.wake.status, 'pending'); + + const first = await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-1', + turnId: 'turn-1', + }); + assert.equal(first.acquired, true); + assert.equal(first.wake.status, 'running'); + assert.equal(first.wake.attemptCount, 1); + await store.completeAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-1', + status: 'retryable_failed', + failureReason: 'provider failed after prompt persistence', + }); + + const second = await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-2', + turnId: 'turn-2', + }); + assert.equal(second.acquired, true); + assert.equal(second.wake.attemptCount, 2); + const parked = await store.completeAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-2', + status: 'waiting_permission', + }); + assert.equal(parked.status, 'waiting_permission'); + assert.equal( + (await store.listAgentGraphSupervisorWakeAttempts('graph-1', 'wake-1'))[1]?.completedAt, + undefined, + ); + assert.equal( + ( + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-3', + turnId: 'turn-3', + }) + ).acquired, + false, + ); + const delivered = await store.completeAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-2', + status: 'delivered', + }); + assert.equal(delivered.status, 'delivered'); + assert.deepEqual( + (await store.listAgentGraphSupervisorWakeAttempts('graph-1', 'wake-1')).map( + (attempt) => attempt.status, + ), + ['retryable_failed', 'delivered'], + ); + assert.equal( + ( + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-3', + turnId: 'turn-3', + }) + ).acquired, + false, + ); + } finally { + store.close(); + } + }); + + test('recovers pre-run pending wakes without guessing the AgentRun state', async () => { + let now = 20; + const store = createSqliteSessionMetadataStore(':memory:', { now: () => now++ }); + try { + await store.claimAgentGraphSupervisorWake({ + schemaVersion: AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + graphId: 'graph-pending', + wakeId: 'wake-pending', + snapshotVersion: 'snapshot-pending', + rootSessionId: 'session-pending', + }); + await store.claimAgentGraphSupervisorWake({ + schemaVersion: AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + graphId: 'graph-running', + wakeId: 'wake-running', + snapshotVersion: 'snapshot-running', + rootSessionId: 'session-running', + }); + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-running', + wakeId: 'wake-running', + attemptId: 'attempt-running', + turnId: 'turn-running', + }); + + assert.equal(await store.recoverAgentGraphSupervisorWakes(), 1); + assert.equal( + (await store.readAgentGraphSupervisorWake('graph-pending', 'wake-pending'))?.status, + 'retryable_failed', + ); + assert.equal( + (await store.readAgentGraphSupervisorWake('graph-running', 'wake-running'))?.status, + 'running', + ); + assert.equal( + (await store.listAgentGraphSupervisorWakeAttempts('graph-running', 'wake-running'))[0] + ?.failureReason, + undefined, + ); + assert.deepEqual( + (await store.listUnsettledAgentGraphSupervisorWakes()).map((wake) => wake.wakeId), + ['wake-running'], + ); + } finally { + store.close(); + } + }); + + test('terminally supersedes only non-delivered wakes for retired Sessions', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + for (const wakeId of ['wake-pending', 'wake-running', 'wake-delivered']) { + await store.claimAgentGraphSupervisorWake({ + schemaVersion: AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + graphId: 'graph-1', + wakeId, + snapshotVersion: wakeId, + rootSessionId: 'session-1', + }); + } + for (const wakeId of ['wake-running', 'wake-delivered']) { + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId, + attemptId: `${wakeId}-attempt`, + turnId: `${wakeId}-turn`, + }); + } + await store.completeAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-delivered', + attemptId: 'wake-delivered-attempt', + status: 'delivered', + }); + + assert.equal( + await store.supersedeAgentGraphSupervisorWakes({ + rootSessionIds: ['session-1'], + reason: 'session_retired', + }), + 2, + ); + assert.equal( + (await store.readAgentGraphSupervisorWake('graph-1', 'wake-pending'))?.status, + 'superseded', + ); + assert.equal( + (await store.listAgentGraphSupervisorWakeAttempts('graph-1', 'wake-running'))[0]?.status, + 'superseded', + ); + assert.equal( + (await store.readAgentGraphSupervisorWake('graph-1', 'wake-delivered'))?.status, + 'delivered', + ); + assert.deepEqual(await store.listRetryableAgentGraphSupervisorWakes(), []); + } finally { + store.close(); + } + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/17ec6647da1f28038ff309447ef7be89df9c52a6fdbe441b3803fb54aec82243.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/17ec6647da1f28038ff309447ef7be89df9c52a6fdbe441b3803fb54aec82243.source new file mode 100644 index 0000000000..3f12d037e1 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/17ec6647da1f28038ff309447ef7be89df9c52a6fdbe441b3803fb54aec82243.source @@ -0,0 +1,787 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AgentRunEvent, AgentRunEventType, AgentRunProjectionKey } from '@maka/core/agent-run'; +import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; +import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; +import type { RuntimeTranscriptQueries } from './runtime-transcript-query.js'; +import type { + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; +import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import type { SessionListFilter } from '@maka/core/runtime-inputs'; +import { + createSqliteAgentRunStore, + type AdmitRootTurnInput, + type AdmitRootTurnResult, + type CommitRootTurnStartRejectionInput, + type BoundedEvidenceReadResult, + type DurableAgentRunStore, + type DurableRuntimeEventStore, + type EvidenceReadBudget, + type RootTurnAdmission, + type RootTurnAdmissionAuthorization, + type RootTurnSourceMessageReceipt, +} from './agent-run-store.js'; +import { + createConversationOperationalStateStore, + type ConversationOperationalStateStore, +} from './conversation-operational-state.js'; +import { createSessionStore, type SessionAuthorityStore } from './session-store.js'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootKind, + type StorageRootLease, +} from './root-authority.js'; +import { + closeSqliteInteractionStoreFacade, + openSqliteInteractiveInteractionStoreForRead, + openSqliteInteractiveInteractionStoreForWrite, + type InteractiveInteractionStoreReaderFacade, + type InteractiveInteractionStoreWriterFacade, +} from './interaction-store.js'; +import { + openRuntimeEventPersistence, + openRuntimeEventReadPersistence, +} from './runtime-event-persistence.js'; +import type { + CommitToolOutcomeInput, + CommitToolPreparedInput, + SessionRuntimeEventEntry, + ToolCommitResult, + ToolOperationRecord, +} from './sqlite-runtime-store.js'; + +const executionStoresWriterBrand: unique symbol = Symbol('ExecutionStoresWriter'); +const executionStoresReaderBrand: unique symbol = Symbol('ExecutionStoresReader'); +const executionStoresWriterKinds = new WeakMap(); +const executionStoresReaderKinds = new WeakMap(); +const executionStoresWritersByLease = new WeakMap(); +const executionStoresWritersOpeningByLease = new WeakMap>(); + +export { + normalizeRootTurnAdmissionPayload, + rootTurnAdmissionRecordFits, +} from './agent-run-store.js'; +export { isSessionNotFoundError } from './session-store.js'; +export { + SessionMetadataConflictError, + SessionMetadataVersionConflictError, +} from './sqlite-session-metadata-store.js'; + +export type { + AdmitRootTurnInput, + AdmitRootTurnResult, + CommitRootTurnStartRejectionInput, + CommitRootTurnStartRejectionResult, + BoundedEvidenceReadResult, + EvidenceReadBudget, + ImmutableSteeringMessageProof, + RootTurnAdmission, + RootTurnAdmissionAuthorization, + RootTurnAdmissionStore, + RootTurnStartRejectionStore, + RootTurnSourceMessage, + RootTurnSourceMessageReceipt, + RootTurnStartRejection, + RuntimeEventScanBudget, + RuntimeEventScanResult, +} from './agent-run-store.js'; +export type { + MarkMessagesHandedOffInput, + MessageAdmissionStore, + PendingMessageAdmission, + ProvenSteeringMessageHandoff, +} from './message-admission-store.js'; +export { submittedTurnIntentsEqual } from './submitted-turn-intent.js'; +export type { SubmittedTurnIntent } from './submitted-turn-intent.js'; +export type { + ProbeSessionRemovalResult, + ExternalSessionImportLookupResult, + SessionCatalogPageCursor, + SessionCatalogPageResult, + SessionCatalogRecord, + SessionHeaderSnapshot, + SessionTranscriptMessageLookupRequest, + SessionTranscriptPageRequest, + CoordinationTranscriptReference, + SessionTranscriptRecordScanPage, + SessionTranscriptRecordScanRequest, + SessionTranscriptStoragePage, + SessionTranscriptStorageFragment, + SessionTurnContribution, + SessionTurnContributionPage, + SessionTurnLandmark, + SessionTurnLandmarkSnapshot, +} from './session-store.js'; + +export type ExecutionSessionWriter = SessionAuthorityStore; +export type { + RuntimeTranscriptInvocation, + RuntimeTranscriptLandmark, +} from './runtime-transcript-query.js'; +export type ExecutionAgentRunWriter = DurableAgentRunStore; +export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & + RuntimeTranscriptQueries & + RuntimeContinuationAuthorityStore & { + readonly toolBoundaryProtocol: ToolBoundaryProtocol; + commitToolPrepared(input: CommitToolPreparedInput): Promise; + commitToolOutcome(input: CommitToolOutcomeInput): Promise; + listUnsettledToolOperations(sessionId: string): Promise; + appendRuntimePartialBatch( + sessionId: string, + runId: string, + events: readonly RuntimeEvent[], + ): Promise; + readSessionRuntimeEventEntries(sessionId: string): Promise; + }; +interface ExecutionStoresWriterBase { + readonly kind: K; + readonly [executionStoresWriterBrand]: K; + purgeConversationOperationalState(sessionId: string): Promise; + readonly sessionStore: Readonly; + readonly agentRunStore: Readonly; + readonly runtimeEventStore: Readonly; +} + +export interface InteractiveExecutionStoresWriter extends ExecutionStoresWriterBase<'interactive'> { + readonly interactionStore: InteractiveInteractionStoreWriterFacade; +} + +interface ExecutionStoresWriters { + readonly interactive: InteractiveExecutionStoresWriter; +} + +export type ExecutionStoresWriter = ExecutionStoresWriters[K]; + +export interface ExecutionSessionReader { + list(filter?: SessionListFilter): Promise; + readHeader(sessionId: string): Promise; + readMessages(sessionId: string): Promise; + listTurns(sessionId: string): Promise; + close?(): Promise; +} + +export interface ExecutionAgentRunReader { + readEvents(sessionId: string, runId: string): Promise; + readEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise>; + readEventsByTypeBounded( + sessionId: string, + runId: string, + type: AgentRunEventType, + budget: EvidenceReadBudget, + ): Promise>; + readEventProjection( + sessionId: string, + type: AgentRunProjectionKey, + ): Promise; + readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; + readRootTurnSourceMessageReceipt( + sessionId: string, + sourceMessageId: string, + ): Promise; +} + +export interface ExecutionRuntimeEventReader { + /** + * A Session's run inventory, read from its canonical events. This is the + * definition of the inventory, not a cache of it, so nothing writes or + * repairs it. + */ + listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; + readRuntimeEvents(sessionId: string, runId: string): Promise; + readRuntimeEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise>; + readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; + readSessionRuntimeEvents(sessionId: string): Promise; + /** Session-wide events with the ordinal that fixes their transcript order. */ + readSessionRuntimeEventEntries( + sessionId: string, + ): Promise>; +} + +interface ExecutionStoresReaderBase { + readonly kind: K; + readonly [executionStoresReaderBrand]: K; + readonly sessionStore: Readonly; + readonly agentRunStore: Readonly; + readonly runtimeEventStore: Readonly; +} + +export interface InteractiveExecutionStoresReader extends ExecutionStoresReaderBase<'interactive'> { + readonly interactionStore: InteractiveInteractionStoreReaderFacade; +} + +interface ExecutionStoresReaders { + readonly interactive: InteractiveExecutionStoresReader; +} + +export type ExecutionStoresReader = ExecutionStoresReaders[K]; + +export function authenticateExecutionStoresWriter( + stores: ExecutionStoresWriter, + expectedKind: K, +): ExecutionStoresWriter { + if (executionStoresWriterKinds.get(stores) !== expectedKind) { + throw invalidExecutionStores(expectedKind, 'write'); + } + return stores; +} + +export function authenticateExecutionStoresReader( + stores: ExecutionStoresReader, + expectedKind: K, +): ExecutionStoresReader { + if (executionStoresReaderKinds.get(stores) !== expectedKind) { + throw invalidExecutionStores(expectedKind, 'read'); + } + return stores; +} + +export async function openInteractiveExecutionStoresForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise> { + const interactionStore = await openSqliteInteractiveInteractionStoreForWrite(lease); + return openExecutionStoresForWrite(lease, 'interactive', { + interactionStore, + }); +} + +async function openExecutionStoresForWrite( + lease: StorageRootLease, + kind: K, + extension: E, +): Promise & E> { + await assertStorageRootLease(lease, kind, 'write'); + const existing = executionStoresWritersByLease.get(lease); + if (existing) return existing as ExecutionStoresWriterBase & E; + + const opening = executionStoresWritersOpeningByLease.get(lease); + if (opening) { + await opening; + return openExecutionStoresForWrite(lease, kind, extension); + } + + let releaseOpening!: () => void; + const openingGate = new Promise((resolve) => { + releaseOpening = resolve; + }); + executionStoresWritersOpeningByLease.set(lease, openingGate); + try { + return await createExecutionStoresForWrite(lease, kind, extension); + } finally { + executionStoresWritersOpeningByLease.delete(lease); + releaseOpening(); + } +} + +async function createExecutionStoresForWrite( + lease: StorageRootLease, + kind: K, + extension: E, +): Promise & E> { + const sessionStore = createSessionStore(lease.canonicalPath); + const agentRunStore = createSqliteAgentRunStore(lease.canonicalPath); + const interactionStore = + 'interactionStore' in extension + ? (extension.interactionStore as InteractiveInteractionStoreWriterFacade) + : undefined; + const runtimePersistence = await openRuntimeEventPersistence({ + workspaceRoot: lease.canonicalPath, + }).catch(async (error) => { + await sessionStore.close?.().catch(() => {}); + agentRunStore.close?.(); + if (interactionStore) closeSqliteInteractionStoreFacade(interactionStore); + throw error; + }); + const runtimeEventStore = runtimePersistence.runtimeEventStore; + let conversationOperationalStateStore: ConversationOperationalStateStore; + try { + conversationOperationalStateStore = createConversationOperationalStateStore( + lease.canonicalPath, + ); + } catch (error) { + await closeExecutionStorePersistence(sessionStore, runtimePersistence, { + agentRunStore, + interactionStore, + }).catch(() => {}); + throw error; + } + await agentRunStore.ready?.().catch(async (error) => { + await closeExecutionStorePersistence(sessionStore, runtimePersistence, { + agentRunStore, + conversationOperationalStateStore, + interactionStore, + }).catch(() => {}); + throw error; + }); + const run = (operation: () => Promise) => + runWithStorageRootLease(lease, kind, 'write', operation); + let closeTask: Promise | undefined; + + const stores: ExecutionStoresWriterBase & E = { + ...extension, + kind, + [executionStoresWriterBrand]: kind, + purgeConversationOperationalState: (sessionId) => + run(() => conversationOperationalStateStore.purge(sessionId)), + sessionStore: { + ready: () => run(() => sessionStore.ready()), + create: (input, initialBoundary) => run(() => sessionStore.create(input, initialBoundary)), + createImportedSession: (input, messages, externalOrigin) => + run(() => sessionStore.createImportedSession(input, messages, externalOrigin)), + lookupExternalSessionImports: (adapterId, sourceSessionIds, recentSessionIdLimit) => + run(() => + sessionStore.lookupExternalSessionImports( + adapterId, + sourceSessionIds, + recentSessionIdLimit, + ), + ), + probeStableSessionCreate: (sessionId, requestFingerprint) => + run(() => sessionStore.probeStableSessionCreate(sessionId, requestFingerprint)), + createStableSession: (request, initialBoundary) => + run(() => sessionStore.createStableSession(request, initialBoundary)), + assignWorkHubMessage: (request) => run(() => sessionStore.assignWorkHubMessage(request)), + readWorkHubAssignment: (actionId) => run(() => sessionStore.readWorkHubAssignment(actionId)), + readActiveWorkHubAssignmentsByTarget: (targetSessionIds, maxAssignmentsPerTarget) => + run(() => + sessionStore.readActiveWorkHubAssignmentsByTarget( + targetSessionIds, + maxAssignmentsPerTarget, + ), + ), + readWorkHubReplacement: (delegationId) => + run(() => sessionStore.readWorkHubReplacement(delegationId)), + readWorkHubReplacementAbort: (delegationId) => + run(() => sessionStore.readWorkHubReplacementAbort(delegationId)), + readWorkHubSupersession: (delegationId) => + run(() => sessionStore.readWorkHubSupersession(delegationId)), + readWorkHubStopRequest: (delegationId) => + run(() => sessionStore.readWorkHubStopRequest(delegationId)), + readWorkHubStopResolution: (delegationId) => + run(() => sessionStore.readWorkHubStopResolution(delegationId)), + claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), + readWorkHubActionClaim: (actionId) => + run(() => sessionStore.readWorkHubActionClaim(actionId)), + discardStableConversationCopy: (sessionId, requestFingerprint) => + run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), + createSubagent: (input, initialBoundary) => + run(() => sessionStore.createSubagent(input, initialBoundary)), + createAgentGraphOperator: (input, request, expectedRevision, initialBoundary) => + run(() => + sessionStore.createAgentGraphOperator(input, request, expectedRevision, initialBoundary), + ), + readExecutionBoundary: (sessionId) => + run(() => sessionStore.readExecutionBoundary(sessionId)), + createSandboxBoundaryRequest: (input) => + run(() => sessionStore.createSandboxBoundaryRequest(input)), + readSandboxBoundaryRequest: (sessionId, requestId) => + run(() => sessionStore.readSandboxBoundaryRequest(sessionId, requestId)), + listPendingSandboxBoundaryRequests: (sessionId) => + run(() => sessionStore.listPendingSandboxBoundaryRequests(sessionId)), + listSandboxBoundaryRestartClosures: (sessionId) => + run(() => sessionStore.listSandboxBoundaryRestartClosures(sessionId)), + hasExplicitSandboxBoundaryDenial: (identities) => + run(() => sessionStore.hasExplicitSandboxBoundaryDenial(identities)), + settleSandboxBoundaryRequest: (input) => + run(() => sessionStore.settleSandboxBoundaryRequest(input)), + setExecutionBoundaryKind: (sessionId, boundaryKind, projection) => + run(() => sessionStore.setExecutionBoundaryKind(sessionId, boundaryKind, projection)), + list: (filter) => run(() => sessionStore.list(filter)), + listCatalogPage: (filter, cursor, limit, expectedRevision) => + run(() => sessionStore.listCatalogPage(filter, cursor, limit, expectedRevision)), + listHeaders: () => run(() => sessionStore.listHeaders()), + listForRecovery: () => run(() => sessionStore.listForRecovery()), + readHeaderSnapshot: (sessionId) => run(() => sessionStore.readHeaderSnapshot(sessionId)), + readHeaderRecordSnapshot: (sessionId) => + run(() => sessionStore.readHeaderRecordSnapshot(sessionId)), + readCatalogRecord: (sessionId, roleScope) => + run(() => sessionStore.readCatalogRecord(sessionId, roleScope)), + probeSessionRemoval: (sessionId) => run(() => sessionStore.probeSessionRemoval(sessionId)), + readMessagesSnapshot: (sessionId) => run(() => sessionStore.readMessagesSnapshot(sessionId)), + readTranscriptMessagesSnapshot: (sessionId, request) => + run(() => sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), + readCoordinationTranscriptIndexState: () => + run(() => sessionStore.readCoordinationTranscriptIndexState()), + appendCoordinationTranscriptIndex: (records) => + run(() => sessionStore.appendCoordinationTranscriptIndex(records)), + readCoordinationTranscriptIndex: (request) => + run(() => sessionStore.readCoordinationTranscriptIndex(request)), + readTranscriptHighWaterSnapshot: (sessionId) => + run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), + listTurnsSnapshot: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), + readHeader: (sessionId) => run(() => sessionStore.readHeader(sessionId)), + readMessages: (sessionId) => run(() => sessionStore.readMessages(sessionId)), + readMessagesAfter: (sessionId, request) => + run(() => sessionStore.readMessagesAfter(sessionId, request)), + listTurns: (sessionId) => run(() => sessionStore.listTurns(sessionId)), + appendMessage: (sessionId, message) => + run(() => sessionStore.appendMessage(sessionId, message)), + appendMessages: (sessionId, messages) => + run(() => sessionStore.appendMessages(sessionId, messages)), + commitMessageCatalogProjection: (sessionId, message) => + run(() => sessionStore.commitMessageCatalogProjection(sessionId, message)), + commitMessageAdmission: (admission) => + run(() => sessionStore.commitMessageAdmission(admission)), + readMessageAdmission: (sessionId, messageId) => + run(() => sessionStore.readMessageAdmission(sessionId, messageId)), + hasCancelledMessageAdmission: (sessionId, messageId) => + run(() => sessionStore.hasCancelledMessageAdmission(sessionId, messageId)), + claimMessageAdmissionCancellation: (sessionId, messageId, claimId) => + run(() => sessionStore.claimMessageAdmissionCancellation(sessionId, messageId, claimId)), + listMessageAdmissions: (sessionId) => + run(() => sessionStore.listMessageAdmissions(sessionId)), + markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)), + updateMessageAdmission: (admission) => + run(() => sessionStore.updateMessageAdmission(admission)), + reorderMessageAdmissions: (sessionId, messageIds) => + run(() => sessionStore.reorderMessageAdmissions(sessionId, messageIds)), + cancelMessageAdmissions: (sessionId, messageIds) => + run(() => sessionStore.cancelMessageAdmissions(sessionId, messageIds)), + subscribeTranscriptChanges: (listener) => sessionStore.subscribeTranscriptChanges(listener), + updateHeader: (sessionId, patch) => run(() => sessionStore.updateHeader(sessionId, patch)), + updateHeaderVersioned: (sessionId, patch, expectedRevision) => + run(() => sessionStore.updateHeaderVersioned(sessionId, patch, expectedRevision)), + updateSessionConfiguration: (sessionId, input) => + run(() => sessionStore.updateSessionConfiguration(sessionId, input)), + setFlagged: (sessionId, isFlagged) => + run(() => sessionStore.setFlagged(sessionId, isFlagged)), + rename: (sessionId, name) => run(() => sessionStore.rename(sessionId, name)), + setGeneratedTitleIfAbsent: (sessionId, title) => + run(() => sessionStore.setGeneratedTitleIfAbsent(sessionId, title)), + remove: (sessionId) => run(() => sessionStore.remove(sessionId)), + setSessionsArchivedVersioned: (sessions, isArchived) => + run(() => sessionStore.setSessionsArchivedVersioned(sessions, isArchived)), + removeSessionsVersioned: (sessions, archiveSessions) => + run(() => sessionStore.removeSessionsVersioned(sessions, archiveSessions)), + reconcileOrphanedAgentGraphRetirements: () => + run(() => sessionStore.reconcileOrphanedAgentGraphRetirements()), + listPendingSessionRetirementCleanupIds: (sessionId) => + run(() => sessionStore.listPendingSessionRetirementCleanupIds(sessionId)), + completeSessionRetirementCleanup: (sessionId) => + run(() => sessionStore.completeSessionRetirementCleanup(sessionId)), + close: () => + (closeTask ??= (async () => { + if (executionStoresWritersByLease.get(lease) === stores) { + executionStoresWritersByLease.delete(lease); + } + await closeExecutionStorePersistence(sessionStore, runtimePersistence, { + agentRunStore, + conversationOperationalStateStore, + interactionStore, + }); + })()), + }, + agentRunStore: { + appendEvent: (sessionId, runId, event, options) => + run(() => agentRunStore.appendEvent(sessionId, runId, event, options)), + readEvents: (sessionId, runId) => run(() => agentRunStore.readEvents(sessionId, runId)), + readEventsBounded: (sessionId, runId, budget) => + run(() => agentRunStore.readEventsBounded(sessionId, runId, budget)), + readEventsByTypeBounded: (sessionId, runId, type, budget) => + run(() => agentRunStore.readEventsByTypeBounded(sessionId, runId, type, budget)), + readEventsForRecovery: (sessionId, runId) => + run(() => agentRunStore.readEventsForRecovery(sessionId, runId)), + readEventsForEvidence: (sessionId, runId) => + run(() => agentRunStore.readEventsForEvidence(sessionId, runId)), + readEventProjection: (sessionId, type) => + run(() => agentRunStore.readEventProjection(sessionId, type)), + readEventLedgerRevision: (sessionId) => + run(() => agentRunStore.readEventLedgerRevision(sessionId)), + repairEventProjection: (sessionId, type, event, options) => + run(() => agentRunStore.repairEventProjection(sessionId, type, event, options)), + admitRootTurn: (input: AdmitRootTurnInput): Promise => + run(() => agentRunStore.admitRootTurn(input)), + readRootTurnAdmission: (sessionId, turnId) => + run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), + readRootTurnStartRejection: (sessionId, turnId) => + run(() => agentRunStore.readRootTurnStartRejection(sessionId, turnId)), + commitRootTurnStartRejection: (input: CommitRootTurnStartRejectionInput) => + run(() => agentRunStore.commitRootTurnStartRejection(input)), + readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => + run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)), + listRootTurnAdmissionsForRecovery: (sessionId) => + run(() => agentRunStore.listRootTurnAdmissionsForRecovery(sessionId)), + }, + runtimeEventStore: { + durability: runtimeEventStore.durability, + continuationAuthorityCapability: runtimeEventStore.continuationAuthorityCapability, + toolBoundaryProtocol: runtimePersistence.runtimeCommitStore.toolBoundaryProtocol, + appendRuntimeEvent: (sessionId, runId, event, options) => + run(() => runtimeEventStore.appendRuntimeEvent(sessionId, runId, event, options)), + appendRuntimePartialBatch: (sessionId, runId, events) => + run(() => runtimeEventStore.appendRuntimePartialBatch(sessionId, runId, events)), + importConversationCopyRuntimeEvents: (sessionId, batches) => + run(() => runtimeEventStore.importConversationCopyRuntimeEvents(sessionId, batches)), + ensureTerminalRuntimeEventDurable: (sessionId, runId, event) => + run(() => runtimeEventStore.ensureTerminalRuntimeEventDurable(sessionId, runId, event)), + readRuntimeEvents: (sessionId, runId) => + run(() => runtimeEventStore.readRuntimeEvents(sessionId, runId)), + scanRuntimeEvents: (sessionId, runId, budget, visit) => + run(() => runtimeEventStore.scanRuntimeEvents(sessionId, runId, budget, visit)), + readRuntimeEventsBounded: (sessionId, runId, budget) => + run(() => runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, budget)), + readImmutableRuntimeEvents: (sessionId, runId) => + run(() => runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), + readImmutableRuntimePrefix: (input) => + run(() => runtimeEventStore.readImmutableRuntimePrefix(input)), + listSessionInvocations: (sessionId) => + run(() => runtimeEventStore.listSessionInvocations(sessionId)), + readRunInvocation: (sessionId, runId) => + run(() => runtimeEventStore.readRunInvocation(sessionId, runId)), + listSessionInvocationsBounded: (sessionId, limit) => + run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), + listSessionInvocationsPage: (sessionId, input) => + run(() => runtimeEventStore.listSessionInvocationsPage(sessionId, input)), + readInvocation: (sessionId, invocationId) => + run(() => runtimeEventStore.readInvocation(sessionId, invocationId)), + readSessionRuntimeEvents: (sessionId) => + run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), + readSessionRuntimeEventEntries: (sessionId) => + run(() => runtimeEventStore.readSessionRuntimeEventEntries(sessionId)), + resequenceSessionEventOrdinals: (sessionId) => + run(() => runtimeEventStore.resequenceSessionEventOrdinals(sessionId)), + readTranscriptHighWater: (sessionId) => + run(() => runtimeEventStore.readTranscriptHighWater(sessionId)), + readTranscriptInvocations: (sessionId, request) => + run(() => runtimeEventStore.readTranscriptInvocations(sessionId, request)), + readTranscriptLandmarks: (sessionId, throughOrdinal, limit) => + run(() => runtimeEventStore.readTranscriptLandmarks(sessionId, throughOrdinal, limit)), + claimContinuation: (input) => run(() => runtimeEventStore.claimContinuation(input)), + readContinuationClaimByBoundary: (boundaryDigest) => + run(() => runtimeEventStore.readContinuationClaimByBoundary(boundaryDigest)), + readContinuationClaimStateByBoundary: (boundaryDigest) => + run(() => runtimeEventStore.readContinuationClaimStateByBoundary(boundaryDigest)), + listContinuationClaimsForRecovery: (sessionId) => + run(() => runtimeEventStore.listContinuationClaimsForRecovery(sessionId)), + commitContinuationStart: (input) => + run(() => runtimeEventStore.commitContinuationStart(input)), + commitContinuationRepairStart: (input) => + run(() => runtimeEventStore.commitContinuationRepairStart(input)), + readImmutableSteeringMessageProof: (sessionId, messageId) => + run(() => runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId)), + repairImmutableSteeringMessageProofsForRecovery: (sessionId) => + run(() => runtimeEventStore.repairImmutableSteeringMessageProofsForRecovery(sessionId)), + commitToolPrepared: (input) => + run(() => runtimePersistence.runtimeCommitStore.commitToolPrepared(input)), + commitToolOutcome: (input) => + run(() => runtimePersistence.runtimeCommitStore.commitToolOutcome(input)), + listUnsettledToolOperations: (sessionId) => + run(() => runtimePersistence.runtimeCommitStore.listUnsettledToolOperations(sessionId)), + }, + }; + freezeExecutionStoresFacade(stores); + executionStoresWriterKinds.set(stores, kind); + executionStoresWritersByLease.set(lease, stores); + return stores; +} + +export async function openInteractiveExecutionStoresForRead( + lease: StorageRootLease<'interactive', 'read'>, +): Promise> { + const interactionStore = await openSqliteInteractiveInteractionStoreForRead(lease); + return openExecutionStoresForRead(lease, 'interactive', { interactionStore }); +} + +async function openExecutionStoresForRead( + lease: StorageRootLease, + kind: K, + extension: E, +): Promise & E> { + await assertStorageRootLease(lease, kind, 'read'); + const sessionStore = createSessionStore(lease.canonicalPath); + const agentRunStore = createSqliteAgentRunStore(lease.canonicalPath); + const interactionStore = + 'interactionStore' in extension + ? (extension.interactionStore as InteractiveInteractionStoreReaderFacade) + : undefined; + await agentRunStore.ready?.().catch(async (error) => { + await sessionStore.close?.().catch(() => {}); + agentRunStore.close?.(); + if (interactionStore) closeSqliteInteractionStoreFacade(interactionStore); + throw error; + }); + const runtimePersistence = await openRuntimeEventReadPersistence({ + workspaceRoot: lease.canonicalPath, + }).catch(async (error) => { + await sessionStore.close?.().catch(() => {}); + agentRunStore.close?.(); + if (interactionStore) closeSqliteInteractionStoreFacade(interactionStore); + throw error; + }); + const runtimeEventStore = runtimePersistence.runtimeEventStore; + const run = (operation: () => Promise) => + runWithStorageRootLease(lease, kind, 'read', operation); + + const stores: ExecutionStoresReaderBase & E = { + ...extension, + kind, + [executionStoresReaderBrand]: kind, + sessionStore: { + list: (filter) => run(() => sessionStore.list(filter)), + readHeader: (sessionId) => run(() => sessionStore.readHeaderSnapshot(sessionId)), + readMessages: (sessionId) => run(() => sessionStore.readMessagesSnapshot(sessionId)), + listTurns: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), + close: () => + closeExecutionStorePersistence(sessionStore, runtimePersistence, { + agentRunStore, + interactionStore, + }), + }, + agentRunStore: { + readEvents: (sessionId, runId) => run(() => agentRunStore.readEvents(sessionId, runId)), + readEventsBounded: (sessionId, runId, budget) => + run(() => agentRunStore.readEventsBounded(sessionId, runId, budget)), + readEventsByTypeBounded: (sessionId, runId, type, budget) => + run(() => agentRunStore.readEventsByTypeBounded(sessionId, runId, type, budget)), + readEventProjection: (sessionId, type) => + run(() => agentRunStore.readEventProjection(sessionId, type)), + readRootTurnAdmission: (sessionId, turnId) => + run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), + readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => + run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)), + }, + runtimeEventStore: { + readRuntimeEvents: (sessionId, runId) => + run(() => runtimeEventStore.readRuntimeEvents(sessionId, runId)), + readRuntimeEventsBounded: (sessionId, runId, budget) => + run(() => runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, budget)), + readImmutableRuntimeEvents: (sessionId, runId) => + run(() => runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), + listSessionInvocations: (sessionId) => + run(() => runtimeEventStore.listSessionInvocations(sessionId)), + readRunInvocation: (sessionId, runId) => + run(() => runtimeEventStore.readRunInvocation(sessionId, runId)), + listSessionInvocationsBounded: (sessionId, limit) => + run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), + listSessionInvocationsPage: (sessionId, input) => + run(() => runtimeEventStore.listSessionInvocationsPage(sessionId, input)), + readInvocation: (sessionId, invocationId) => + run(() => runtimeEventStore.readInvocation(sessionId, invocationId)), + readSessionRuntimeEvents: (sessionId) => + run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), + readSessionRuntimeEventEntries: (sessionId) => + run(() => runtimeEventStore.readSessionRuntimeEventEntries(sessionId)), + }, + }; + freezeExecutionStoresFacade(stores); + executionStoresReaderKinds.set(stores, kind); + return stores; +} + +function freezeExecutionStoresFacade(stores: { + readonly sessionStore: object; + readonly agentRunStore: object; + readonly runtimeEventStore: object; +}): void { + Object.freeze(stores.sessionStore); + Object.freeze(stores.agentRunStore); + Object.freeze(stores.runtimeEventStore); + Object.freeze(stores); +} + +async function closeExecutionStorePersistence( + sessionStore: { close?(): Promise }, + runtimePersistence: { close(): void }, + extras: { + agentRunStore?: Pick; + conversationOperationalStateStore?: Pick; + interactionStore?: + | InteractiveInteractionStoreReaderFacade + | InteractiveInteractionStoreWriterFacade; + } = {}, +): Promise { + const errors: unknown[] = []; + try { + runtimePersistence.close(); + } catch (error) { + errors.push(error); + } + try { + await sessionStore.close?.(); + } catch (error) { + errors.push(error); + } + try { + extras.agentRunStore?.close?.(); + } catch (error) { + errors.push(error); + } + try { + extras.conversationOperationalStateStore?.close(); + } catch (error) { + errors.push(error); + } + try { + if (extras.interactionStore) { + closeSqliteInteractionStoreFacade(extras.interactionStore); + } + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close execution store persistence'); + } +} + +function invalidExecutionStores( + kind: StorageRootKind, + access: 'read' | 'write', +): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + `Expected authentic ${kind} ${access} execution stores`, + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/187e9c117e118d6614e58c24f935383d30b8f6359b1711261a1275b228fa3e60.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/187e9c117e118d6614e58c24f935383d30b8f6359b1711261a1275b228fa3e60.source new file mode 100644 index 0000000000..43ead82c08 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/187e9c117e118d6614e58c24f935383d30b8f6359b1711261a1275b228fa3e60.source @@ -0,0 +1,549 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; +import { + decodePetPackManifest, + isPetPackId, + PetManifestValidationError, + type PetPackManifestV1, + type PetSpriteFormat, +} from '@maka/core/pet'; + +export const PET_PACK_DIRECTORY = 'pets/v1'; +export const PET_PACK_MANIFEST_FILE = 'pet.json'; +export const PET_PACK_MANIFEST_MAX_BYTES = 64 * 1024; +export const PET_PACK_SPRITE_SHEET_MAX_BYTES = 4 * 1024 * 1024; + +export type PetPackStoreErrorCode = + | 'invalid_id' + | 'invalid_asset' + | 'already_installed' + | 'corrupt_store' + | 'corrupt_pack' + | 'io_failed'; + +export class PetPackStoreError extends Error { + readonly code: PetPackStoreErrorCode; + readonly petId?: string; + + constructor( + code: PetPackStoreErrorCode, + message: string, + options: { readonly petId?: string; readonly cause?: unknown } = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'PetPackStoreError'; + this.code = code; + this.petId = options.petId; + } +} + +export interface InstallPetPackInput { + /** Untrusted JSON value decoded at the caller boundary. */ + readonly manifest: unknown; + /** Complete PNG or WebP sprite sheet bytes. */ + readonly spriteSheet: Uint8Array; +} + +export interface PetSpriteSheetAsset { + readonly format: PetSpriteFormat; + readonly bytes: Uint8Array; +} + +export interface PetPackStore { + list(): Promise; + get(petId: string): Promise; + install(input: InstallPetPackInput): Promise; + readSpriteSheet(petId: string): Promise; + remove(petId: string): Promise; +} + +export function createPetPackStore(stateRoot: string): PetPackStore { + return new FilePetPackStore(stateRoot); +} + +class FilePetPackStore implements PetPackStore { + private mutationQueue: Promise = Promise.resolve(); + private readonly petsRoot: string; + private readonly root: string; + + constructor(private readonly stateRoot: string) { + this.petsRoot = join(stateRoot, 'pets'); + this.root = join(this.petsRoot, 'v1'); + } + + async list(): Promise { + if (!(await this.storeRootExists())) return []; + let entries; + try { + entries = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return []; + throw ioFailed('Unable to list installed pet packs', error); + } + + const manifests: PetPackManifestV1[] = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name.startsWith('.')) continue; + if (!isPetPackId(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { + throw corruptPack(entry.name, 'Pet pack root contains an invalid entry'); + } + manifests.push(await this.readInstalledManifest(entry.name)); + } + return manifests; + } + + async get(petId: string): Promise { + const admittedId = admitPetId(petId); + if (!(await this.storeRootExists())) return undefined; + const packRoot = join(this.root, admittedId); + try { + const metadata = await lstat(packRoot); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw corruptPack(admittedId, 'Installed pet pack is not a regular directory'); + } + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return undefined; + if (error instanceof PetPackStoreError) throw error; + throw ioFailed(`Unable to inspect pet pack ${admittedId}`, error, admittedId); + } + return this.readInstalledManifest(admittedId); + } + + async install(input: InstallPetPackInput): Promise { + const manifest = snapshotManifest(decodePetPackManifest(input.manifest)); + const spriteSheet = Buffer.from(input.spriteSheet); + return await this.serial(async () => { + assertSpriteSheet(manifest, spriteSheet, 'invalid_asset'); + await this.ensureRoot(); + + const destination = join(this.root, manifest.id); + if (await pathExists(destination)) { + throw new PetPackStoreError( + 'already_installed', + `Pet pack ${manifest.id} is already installed`, + { petId: manifest.id }, + ); + } + + const staging = await mkdtemp(join(this.root, '.install-')); + try { + if (process.platform !== 'win32') await chmod(staging, 0o700); + const manifestPath = join(staging, PET_PACK_MANIFEST_FILE); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + + const assetPath = storedAssetPath(staging, manifest); + await mkdir(dirname(assetPath), { recursive: true, mode: 0o700 }); + await writeFile(assetPath, spriteSheet, { mode: 0o600, flag: 'wx' }); + if (process.platform !== 'win32') { + await chmod(manifestPath, 0o600); + await chmod(assetPath, 0o600); + } + + try { + await rename(staging, destination); + } catch (error) { + if (hasErrorCode(error, 'EEXIST') || hasErrorCode(error, 'ENOTEMPTY')) { + throw new PetPackStoreError( + 'already_installed', + `Pet pack ${manifest.id} is already installed`, + { petId: manifest.id, cause: error }, + ); + } + throw error; + } + return manifest; + } catch (error) { + if (error instanceof PetPackStoreError) throw error; + throw ioFailed(`Unable to install pet pack ${manifest.id}`, error, manifest.id); + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } + }); + } + + async readSpriteSheet(petId: string): Promise { + const manifest = await this.get(petId); + if (!manifest) return undefined; + const packRoot = join(this.root, manifest.id); + try { + const bytes = await readBoundedRegularFile( + storedAssetPath(packRoot, manifest), + PET_PACK_SPRITE_SHEET_MAX_BYTES, + packRoot, + ); + assertSpriteSheet(manifest, bytes, 'corrupt_pack'); + return { format: manifest.spriteSheet.format, bytes: new Uint8Array(bytes) }; + } catch (error) { + if (error instanceof PetPackStoreError) throw error; + throw corruptPack(manifest.id, 'Installed pet sprite sheet is unreadable', error); + } + } + + remove(petId: string): Promise { + return this.serial(async () => { + const admittedId = admitPetId(petId); + if (!(await this.storeRootExists())) return false; + const destination = join(this.root, admittedId); + const quarantine = join(this.root, `.remove-${admittedId}-${randomUUID()}`); + try { + await rename(destination, quarantine); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw ioFailed(`Unable to unpublish pet pack ${admittedId}`, error, admittedId); + } + try { + await rm(quarantine, { recursive: true }); + return true; + } catch (error) { + throw ioFailed(`Unable to remove pet pack ${admittedId}`, error, admittedId); + } + }); + } + + private async readInstalledManifest(petId: string): Promise { + const packRoot = join(this.root, petId); + try { + const bytes = await readBoundedRegularFile( + join(packRoot, PET_PACK_MANIFEST_FILE), + PET_PACK_MANIFEST_MAX_BYTES, + packRoot, + ); + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const manifest = decodePetPackManifest(JSON.parse(text)); + if (manifest.id !== petId) { + throw corruptPack(petId, 'Installed pet manifest id does not match its directory'); + } + return manifest; + } catch (error) { + if (error instanceof PetPackStoreError) throw error; + if ( + error instanceof PetManifestValidationError || + error instanceof SyntaxError || + error instanceof TypeError + ) { + throw corruptPack(petId, 'Installed pet manifest is invalid', error); + } + throw corruptPack(petId, 'Installed pet manifest is unreadable', error); + } + } + + private async ensureRoot(): Promise { + try { + await mkdir(this.stateRoot, { recursive: true, mode: 0o700 }); + await ensurePlainDirectory(this.petsRoot); + await ensurePlainDirectory(this.root); + } catch (error) { + if (error instanceof PetPackStoreError) throw error; + throw ioFailed('Unable to create the pet pack store', error); + } + } + + private async storeRootExists(): Promise { + for (const path of [this.petsRoot, this.root]) { + try { + const metadata = await lstat(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new PetPackStoreError( + 'corrupt_store', + 'Pet pack store contains a redirected or non-directory root', + ); + } + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + if (error instanceof PetPackStoreError) throw error; + throw ioFailed('Unable to inspect the pet pack store', error); + } + } + return true; + } + + private async serial(operation: () => Promise): Promise { + const previous = this.mutationQueue; + let release!: () => void; + this.mutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} + +function admitPetId(value: unknown): string { + if (!isPetPackId(value)) { + throw new PetPackStoreError('invalid_id', 'Pet pack id is not canonical'); + } + return value; +} + +function storedAssetPath(packRoot: string, manifest: PetPackManifestV1): string { + return join(packRoot, ...manifest.spriteSheet.path.split('/')); +} + +async function ensurePlainDirectory(path: string): Promise { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (!hasErrorCode(error, 'EEXIST')) throw error; + } + const metadata = await lstat(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new PetPackStoreError( + 'corrupt_store', + 'Pet pack store contains a redirected or non-directory root', + ); + } + if (process.platform !== 'win32') await chmod(path, 0o700); +} + +function snapshotManifest(manifest: PetPackManifestV1): PetPackManifestV1 { + const animations: PetPackManifestV1['animations'] = { + idle: snapshotAnimation(manifest.animations.idle), + working: snapshotAnimation(manifest.animations.working), + 'needs-input': snapshotAnimation(manifest.animations['needs-input']), + ready: snapshotAnimation(manifest.animations.ready), + blocked: snapshotAnimation(manifest.animations.blocked), + ...(manifest.animations.swarm === undefined + ? {} + : { swarm: snapshotAnimation(manifest.animations.swarm) }), + ...(manifest.animations.cancelled === undefined + ? {} + : { cancelled: snapshotAnimation(manifest.animations.cancelled) }), + ...(manifest.animations.poke === undefined + ? {} + : { poke: snapshotAnimation(manifest.animations.poke) }), + ...(manifest.animations.wake === undefined + ? {} + : { wake: snapshotAnimation(manifest.animations.wake) }), + ...(manifest.animations.sleep === undefined + ? {} + : { sleep: snapshotAnimation(manifest.animations.sleep) }), + }; + return { + schema: manifest.schema, + id: manifest.id, + displayName: manifest.displayName, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + spriteSheet: { ...manifest.spriteSheet }, + animations, + }; +} + +function snapshotAnimation( + animation: PetPackManifestV1['animations']['idle'], +): PetPackManifestV1['animations']['idle'] { + return { + frames: [...animation.frames], + fps: animation.fps, + loop: animation.loop, + ...(animation.fallback === undefined ? {} : { fallback: animation.fallback }), + }; +} + +async function readBoundedRegularFile( + path: string, + maxBytes: number, + containmentRoot: string, +): Promise { + const [canonicalRoot, canonicalPath] = await Promise.all([ + realpath(containmentRoot), + realpath(path), + ]); + const fromRoot = relative(canonicalRoot, canonicalPath); + if (fromRoot === '' || fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) { + throw new Error('Pet pack file escapes its package root'); + } + + const before = await lstat(path, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(maxBytes)) { + throw new Error(`Pet pack file must be a regular file no larger than ${maxBytes} bytes`); + } + + const handle = await open(path, 'r'); + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) { + throw new Error('Pet pack file changed while opening'); + } + const initialSize = Number(opened.size); + const output = Buffer.allocUnsafe(Math.min(initialSize, maxBytes) + 1); + let offset = 0; + while (offset < output.length) { + const { bytesRead } = await handle.read(output, offset, output.length - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > maxBytes) throw new Error(`Pet pack file exceeds ${maxBytes} bytes`); + return output.subarray(0, offset); + } finally { + await handle.close(); + } +} + +function assertSpriteSheet( + manifest: PetPackManifestV1, + bytes: Uint8Array, + code: Extract, +): void { + if (bytes.byteLength === 0 || bytes.byteLength > PET_PACK_SPRITE_SHEET_MAX_BYTES) { + throw new PetPackStoreError( + code, + `Pet sprite sheet must be between 1 and ${PET_PACK_SPRITE_SHEET_MAX_BYTES} bytes`, + { petId: manifest.id }, + ); + } + let dimensions: { readonly width: number; readonly height: number }; + try { + dimensions = readImageDimensions(Buffer.from(bytes), manifest.spriteSheet.format); + } catch (error) { + throw new PetPackStoreError(code, 'Pet sprite sheet has an invalid image header', { + petId: manifest.id, + cause: error, + }); + } + const expectedWidth = manifest.spriteSheet.frameWidth * manifest.spriteSheet.columns; + const expectedHeight = manifest.spriteSheet.frameHeight * manifest.spriteSheet.rows; + if (dimensions.width !== expectedWidth || dimensions.height !== expectedHeight) { + throw new PetPackStoreError( + code, + `Pet sprite sheet must be ${expectedWidth}x${expectedHeight}, got ${dimensions.width}x${dimensions.height}`, + { petId: manifest.id }, + ); + } +} + +function readImageDimensions( + bytes: Buffer, + format: PetSpriteFormat, +): { readonly width: number; readonly height: number } { + return format === 'png' ? readPngDimensions(bytes) : readWebpDimensions(bytes); +} + +function readPngDimensions(bytes: Buffer): { readonly width: number; readonly height: number } { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if ( + bytes.length < 33 || + !bytes.subarray(0, signature.length).equals(signature) || + bytes.readUInt32BE(8) !== 13 || + bytes.toString('ascii', 12, 16) !== 'IHDR' + ) { + throw new Error('Invalid PNG header'); + } + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + if (width === 0 || height === 0) throw new Error('Invalid PNG dimensions'); + return { width, height }; +} + +function readWebpDimensions(bytes: Buffer): { readonly width: number; readonly height: number } { + if ( + bytes.length < 25 || + bytes.toString('ascii', 0, 4) !== 'RIFF' || + bytes.toString('ascii', 8, 12) !== 'WEBP' + ) { + throw new Error('Invalid WebP header'); + } + const declaredSize = bytes.readUInt32LE(4) + 8; + if (declaredSize !== bytes.length) throw new Error('Invalid WebP container size'); + + const chunkType = bytes.toString('ascii', 12, 16); + const chunkSize = bytes.readUInt32LE(16); + if (20 + chunkSize > bytes.length) throw new Error('Truncated WebP image chunk'); + if (chunkType === 'VP8X') { + if (chunkSize < 10 || bytes.length < 30) throw new Error('Invalid VP8X chunk'); + return { + width: 1 + readUInt24LE(bytes, 24), + height: 1 + readUInt24LE(bytes, 27), + }; + } + if (chunkType === 'VP8 ') { + if ( + chunkSize < 10 || + bytes.length < 30 || + bytes[23] !== 0x9d || + bytes[24] !== 0x01 || + bytes[25] !== 0x2a + ) { + throw new Error('Invalid VP8 key frame'); + } + return { + width: bytes.readUInt16LE(26) & 0x3fff, + height: bytes.readUInt16LE(28) & 0x3fff, + }; + } + if (chunkType === 'VP8L') { + if (chunkSize < 5 || bytes.length < 25 || bytes[20] !== 0x2f) { + throw new Error('Invalid VP8L frame'); + } + const bits = bytes.readUInt32LE(21); + return { + width: 1 + (bits & 0x3fff), + height: 1 + ((bits >>> 14) & 0x3fff), + }; + } + throw new Error(`Unsupported WebP image chunk ${chunkType}`); +} + +function readUInt24LE(bytes: Buffer, offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return false; + throw error; + } +} + +function corruptPack(petId: string, message: string, cause?: unknown): PetPackStoreError { + return new PetPackStoreError('corrupt_pack', message, { petId, cause }); +} + +function ioFailed(message: string, cause: unknown, petId?: string): PetPackStoreError { + return new PetPackStoreError('io_failed', message, { petId, cause }); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1944219a0709bf42189b4215def150784e3020786d67120221f6204c4583f45b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1944219a0709bf42189b4215def150784e3020786d67120221f6204c4583f45b.source new file mode 100644 index 0000000000..2bdc3d6cb0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1944219a0709bf42189b4215def150784e3020786d67120221f6204c4583f45b.source @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { + ReadImageSnapshotStoreError, + type ContextOffloadReadResult, + type ReadImageSnapshotReader, + type ReadImageSnapshotStore, + type SessionContextRef, +} from '@maka/core/context-offload'; +import { + authenticateInteractiveContextOffloadReader, + authenticateInteractiveContextOffloadWriter, + createInteractiveContextOffloadReader, + type InteractiveContextOffloadReader, + type InteractiveContextOffloadWriter, +} from './context-offload-store.js'; + +/** Derives the Read image hydration contract from an authenticated reader. */ +export function createReadImageSnapshotReader( + reader: InteractiveContextOffloadReader, + sessionId: string, +): ReadImageSnapshotReader { + const store = authenticateInteractiveContextOffloadReader(reader); + if (!sessionId) throw new Error('Read image snapshot Session id is required'); + return Object.freeze({ + async read(input: SessionContextRef): Promise { + if (input.sessionId !== sessionId) return { ok: false, reason: 'session_mismatch' }; + const result = await store.read({ + sessionId, + refId: input.refId, + maxBytes: MAX_READ_IMAGE_BYTES, + }); + if (!result.ok) return result; + if ( + result.record.owner.kind !== 'read_image_snapshot' || + !result.record.mediaType.toLowerCase().startsWith('image/') + ) { + return { ok: false, reason: 'corrupt' }; + } + return result; + }, + }); +} + +/** Derives the Read image domain contract from the authenticated byte authority. */ +export function createReadImageSnapshotStore( + writer: InteractiveContextOffloadWriter, + sessionId: string, +): ReadImageSnapshotStore { + const store = authenticateInteractiveContextOffloadWriter(writer); + if (!sessionId) throw new Error('Read image snapshot Session id is required'); + const reader = createReadImageSnapshotReader( + createInteractiveContextOffloadReader(store), + sessionId, + ); + const facade: ReadImageSnapshotStore = { + async snapshot(input) { + const accepted = Object.freeze({ + sessionId, + ownerId: input.ownerId, + bytes: new Uint8Array(input.bytes), + mimeType: input.mimeType, + }); + if (!accepted.mimeType.toLowerCase().startsWith('image/')) { + throw new Error('Read image snapshot media type must be an image'); + } + if (accepted.bytes.byteLength > MAX_READ_IMAGE_BYTES) { + throw new ReadImageSnapshotStoreError('too_large'); + } + const result = await store.put({ + sessionId: accepted.sessionId, + owner: { kind: 'read_image_snapshot', ownerId: accepted.ownerId }, + bytes: accepted.bytes, + mediaType: accepted.mimeType, + }); + if (!result.ok) throw new ReadImageSnapshotStoreError(result.reason); + const { record } = result; + if ( + record.sessionId !== accepted.sessionId || + record.owner.kind !== 'read_image_snapshot' || + record.owner.ownerId !== accepted.ownerId || + record.mediaType !== accepted.mimeType || + record.sizeBytes !== accepted.bytes.byteLength + ) { + throw new Error('Read image snapshot authority returned an inconsistent reference'); + } + return Object.freeze({ + kind: 'session_context', + sessionId: record.sessionId, + refId: record.refId, + }); + }, + + read: (input) => reader.read(input), + }; + return Object.freeze(facade); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/19da778283a22193925d576747ce39a388a618ba5e34ec3be83fba618acf6c5c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/19da778283a22193925d576747ce39a388a618ba5e34ec3be83fba618acf6c5c.source new file mode 100644 index 0000000000..bdb276b9f5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/19da778283a22193925d576747ce39a388a618ba5e34ec3be83fba618acf6c5c.source @@ -0,0 +1,570 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { createRunCompositionSnapshot } from '@maka/core/run-composition'; +import { decodeRuntimeEvent } from '@maka/core/runtime-event'; +import type { LegacyRunHeader } from '../legacy-run-header.js'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; +import { + migrateSqliteRuntimeDatabase, + SQLITE_RUNTIME_SCHEMA_VERSION, +} from '../sqlite-runtime-schema.js'; +describe('invocation opening fact backfill', () => { + test('gives every header-only run the opening fact it never wrote', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + // One run already owns an immutable sequence; the backfill must leave it + // alone rather than rewrite its position one. + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', '{}', 1) + `).run(); + migrateSqliteRuntimeDatabase(db); + assert.equal(readUserVersion(db), SQLITE_RUNTIME_SCHEMA_VERSION); + + const rows = db + .prepare(` + SELECT event_id, invocation_id, run_id, turn_id, event_seq, payload_json + FROM runtime_events + WHERE event_kind = 'invocation_opened' + ORDER BY run_id ASC + `) + .all() as Array<{ + event_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + event_seq: number; + payload_json: string; + }>; + + assert.deepEqual( + rows.map((row) => row.run_id), + ['run-legacy-route', 'run-scheduled'], + 'only the header-only runs are backfilled', + ); + assert.deepEqual( + rows.map((row) => row.event_seq), + [1, 1], + 'a synthesized opening fact is event one of an otherwise empty invocation', + ); + + const legacy = decodeRuntimeEvent(JSON.parse(rows[0]!.payload_json)); + assert.equal(legacy.content?.kind, 'invocation_opened'); + if (legacy.content?.kind !== 'invocation_opened') throw new Error('unreachable'); + assert.equal( + legacy.content.route.provenance, + 'unknown', + 'a header with no Connection identity must not claim an authenticated route', + ); + assert.equal(legacy.content.route.modelId, 'legacy-model'); + assert.equal(legacy.content.source.kind, 'fresh'); + assert.equal(legacy.invocationId, 'run-legacy-route'); + + const scheduled = decodeRuntimeEvent(JSON.parse(rows[1]!.payload_json)); + if (scheduled.content?.kind !== 'invocation_opened') throw new Error('unreachable'); + assert.deepEqual(scheduled.content.root, { + kind: 'scheduled_task', + scheduledTaskId: 'task-9', + }); + assert.equal(scheduled.content.route.provenance, 'runtime'); + + // Both header-only runs were marked completed, so each gets the ending + // its header recorded, right after its opening. + const backfilled = db + .prepare(` + SELECT run_id, event_seq, event_kind FROM runtime_events + WHERE run_id IN ('run-legacy-route', 'run-scheduled') + ORDER BY run_id ASC, event_seq ASC + `) + .all() as Array<{ run_id: string; event_seq: number; event_kind: string }>; + assert.deepEqual( + backfilled.map(({ run_id, event_seq, event_kind }) => ({ + run_id, + event_seq, + event_kind, + })), + [ + { run_id: 'run-legacy-route', event_seq: 1, event_kind: 'invocation_opened' }, + { run_id: 'run-legacy-route', event_seq: 2, event_kind: 'completed' }, + { run_id: 'run-scheduled', event_seq: 1, event_kind: 'invocation_opened' }, + { run_id: 'run-scheduled', event_seq: 2, event_kind: 'completed' }, + ], + ); + const ordinals = db + .prepare('SELECT COUNT(*) AS total FROM runtime_session_event_ordinals') + .get() as { total: number }; + assert.equal( + ordinals.total, + backfilled.length, + 'every backfilled event joins the Session ordinal stream', + ); + + // The run that already owns an immutable sequence keeps it untouched: + // rewriting its position one would break digests other facts signed. + const withEvents = db + .prepare( + "SELECT event_id FROM runtime_events WHERE run_id = 'run-with-events' ORDER BY event_seq", + ) + .all() as Array<{ event_id: string }>; + assert.deepEqual( + withEvents.map((row) => row.event_id), + ['existing-1'], + ); + + // Its opening is not lost, though: it goes on the legacy shelf, keyed by + // the invocation id its own events already carry. + const legacyRows = db + .prepare(` + SELECT invocation_id, session_id, run_id, turn_id, opened_at, opening_json, + anchor_event_id + FROM runtime_legacy_invocation_openings + ORDER BY invocation_id + `) + .all() as Array<{ + invocation_id: string; + session_id: string; + run_id: string; + turn_id: string; + opened_at: number; + opening_json: string; + anchor_event_id: string; + }>; + assert.deepEqual( + legacyRows.map((row) => row.invocation_id), + ['run-with-events'], + 'only a run whose sequence is already immutable takes the legacy shelf', + ); + assert.equal(legacyRows[0]!.run_id, 'run-with-events'); + assert.equal(legacyRows[0]!.turn_id, 'turn-with-events'); + assert.equal(legacyRows[0]!.opened_at, 1); + assert.equal( + (JSON.parse(legacyRows[0]!.opening_json) as { kind: string }).kind, + 'invocation_opened', + ); + // The shelved opening describes that ledger, so it is anchored to the + // ledger's first event and cannot outlive it. + assert.equal( + legacyRows[0]!.anchor_event_id, + 'existing-1', + 'the shelved opening is anchored to the first event of the run it describes', + ); + } finally { + db.close(); + } + }); + }); + + test('enumerates event openings and migrated ones as one inventory', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const { json } = encodeCanonicalRuntimeEvent({ + id: 'existing-1', + invocationId: 'run-with-events', + runId: 'run-with-events', + sessionId: 'session-1', + turnId: 'turn-with-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', ?, 1) + `).run(json); + migrateSqliteRuntimeDatabase(db); + } finally { + db.close(); + } + + const store = createSqliteRuntimeStore(databasePath); + try { + const invocations = await store.listSessionInvocations('session-1'); + assert.deepEqual( + invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route', 'run-scheduled', 'run-with-events'], + 'a migrated opening is enumerated beside the ones the events carry', + ); + for (const invocation of invocations) { + assert.equal(invocation.opening.kind, 'invocation_opened'); + assert.equal(invocation.sessionId, 'session-1'); + } + const migrated = invocations.find( + (invocation) => invocation.invocationId === 'run-with-events', + ); + assert.equal(migrated?.turnId, 'turn-with-events'); + assert.equal(migrated?.terminalEvent, undefined); + } finally { + store.close(); + } + }); + }); + + test('purging a migrated Session takes its shelved openings with it', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const { json } = encodeCanonicalRuntimeEvent({ + id: 'existing-1', + invocationId: 'run-with-events', + runId: 'run-with-events', + sessionId: 'session-1', + turnId: 'turn-with-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', ?, 1) + `).run(json); + migrateSqliteRuntimeDatabase(db); + } finally { + db.close(); + } + + // What purging a conversation does to this database: delete the Session's + // events. `conversation-operational-state.ts` runs exactly this statement + // on a lease that has `PRAGMA foreign_keys = ON`, which is also how + // `runtime_session_event_ordinals` is cleaned up today. + const purge = new DatabaseSync(databasePath); + try { + purge.exec('PRAGMA foreign_keys = ON'); + purge.prepare('DELETE FROM runtime_events WHERE session_id = ?').run('session-1'); + } finally { + purge.close(); + } + + // The shelved opening is only read when its invocation has no opening + // event, so a purge that deleted the events but left the shelf would make + // a completed run reappear as an active one. + const store = createSqliteRuntimeStore(databasePath); + try { + assert.deepEqual(await store.listSessionInvocations('session-1'), []); + assert.equal(await store.readRunInvocation('session-1', 'run-with-events'), undefined); + } finally { + store.close(); + } + + const check = new DatabaseSync(databasePath); + try { + assert.equal( + ( + check + .prepare('SELECT COUNT(*) AS count FROM runtime_legacy_invocation_openings') + .get() as { count: number } + ).count, + 0, + 'the shelf is empty, not merely unreadable', + ); + } finally { + check.close(); + } + }); + }); + + test('bounds, pages and addresses the same inventory', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + migrateSqliteRuntimeDatabase(db); + } finally { + db.close(); + } + + const store = createSqliteRuntimeStore(databasePath); + try { + const bounded = await store.listSessionInvocationsBounded('session-1', 2); + assert.deepEqual( + bounded.invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route', 'run-scheduled'], + ); + assert.equal(bounded.truncated, true, 'the extra row read past the limit reports the rest'); + + const first = await store.listSessionInvocationsPage('session-1', { limit: 2 }); + assert.deepEqual( + first.invocations.map((invocation) => invocation.invocationId), + ['run-with-events', 'run-scheduled'], + 'a page runs newest first', + ); + const second = await store.listSessionInvocationsPage('session-1', { + limit: 2, + ...(first.nextCursor ? { before: first.nextCursor } : {}), + }); + assert.deepEqual( + second.invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route'], + 'the cursor resumes without repeating or skipping a tied opening time', + ); + assert.equal(second.nextCursor, null); + + const one = await store.readInvocation('session-1', 'run-scheduled'); + assert.equal(one.turnId, 'turn-scheduled'); + assert.deepEqual(one.opening.root, { kind: 'scheduled_task', scheduledTaskId: 'task-9' }); + + await assert.rejects( + () => store.listSessionInvocationsPage('session-1', { limit: 0 }), + /between 1 and 256/, + ); + await assert.rejects( + () => + store.listSessionInvocationsPage('session-1', { + limit: 1, + before: { openedAt: Number.NaN, invocationId: 'run-scheduled' }, + }), + /Invalid invocation page cursor/, + ); + } finally { + store.close(); + } + }); + }); + + // Built from what the header era actually wrote, not from what the decoder + // accepts: every run that reached a provider carried the composition snapshot. + test('migrates a header exactly as the header era wrote it, composition included', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const insert = db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ); + for (const record of [ + header({ + runId: 'run-composed', + turnId: 'turn-composed', + status: 'failed', + failureClass: 'provider_error', + failureMessage: 'the provider said no', + completedAt: 7, + runComposition: headerEraComposition(), + }), + header({ + runId: 'run-composed-events', + turnId: 'turn-composed-events', + runComposition: headerEraComposition(), + }), + ]) { + insert.run(record.sessionId, record.runId, record.createdAt, JSON.stringify(record)); + } + const { json } = encodeCanonicalRuntimeEvent({ + id: 'composed-1', + invocationId: 'run-composed-events', + runId: 'run-composed-events', + sessionId: 'session-1', + turnId: 'turn-composed-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('composed-1', 'session-1', 'run-composed-events', 'run-composed-events', + 'turn-composed-events', 1, 'text', ?, 1) + `).run(json); + migrateSqliteRuntimeDatabase(db); + migrateSqliteCoreExecutionDatabase(db); + } finally { + db.close(); + } + + const store = createSqliteRuntimeStore(databasePath); + try { + const invocations = await store.listSessionInvocations('session-1'); + assert.deepEqual( + invocations.map((invocation) => invocation.invocationId).sort(), + [ + 'run-composed', + 'run-composed-events', + 'run-legacy-route', + 'run-scheduled', + 'run-with-events', + ], + 'a run whose header carried a composition snapshot is still a run', + ); + const composed = await store.readInvocation('session-1', 'run-composed'); + assert.equal(composed.terminalEvent?.status, 'failed'); + assert.equal(composed.terminalEvent?.ts, 7); + assert.equal(composed.terminalEvent?.actions?.stateDelta?.failureClass, 'provider_error'); + assert.equal( + composed.terminalEvent?.content?.kind === 'error' + ? composed.terminalEvent.content.message + : undefined, + 'the provider said no', + ); + } finally { + store.close(); + } + }); + }); + + test('refuses to migrate a header it cannot read, and drops nothing', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + // A graph wake with no delivery attempt is corruption. Inventing a root + // authority for it would be worse than refusing, and dropping the header + // would be worse still: the migration stops, and the database stays as + // the header era left it. + const corrupt = header({ + runId: 'run-corrupt-root', + turnId: 'turn-corrupt', + agentGraphWakeId: 'wake-1', + }); + db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ).run(corrupt.sessionId, corrupt.runId, corrupt.createdAt, JSON.stringify(corrupt)); + assert.throws(() => migrateSqliteRuntimeDatabase(db), /session-1\/run-corrupt-root/); + assert.equal(readUserVersion(db), 15); + const openings = db + .prepare( + "SELECT COUNT(*) AS total FROM runtime_events WHERE event_kind = 'invocation_opened'", + ) + .get() as { total: number }; + assert.equal(openings.total, 0, 'the transaction rolled every other run back too'); + const headers = db + .prepare('SELECT COUNT(*) AS total FROM core_agent_runs WHERE record_json IS NOT NULL') + .get() as { total: number }; + assert.equal(headers.total, 4, 'every header is still there to be read by a fixed build'); + } finally { + db.close(); + } + }); + }); +}); + +function headerEraComposition() { + return createRunCompositionSnapshot({ + composerId: 'maka.default', + composerRevision: '1', + sourceRevisions: [{ id: 'system-prompt', revision: '1' }], + baseSystemPromptHash: `sha256:${'a'.repeat(64)}`, + toolCatalogHash: `sha256:${'b'.repeat(64)}`, + toolAvailabilityHash: `sha256:${'c'.repeat(64)}`, + baseProviderOptionsHash: `sha256:${'d'.repeat(64)}`, + toolNames: ['read_file'], + contextWindow: 200_000, + }); +} + +/** + * Put the database back the way the header era left it: runtime schema v15, + * no opening facts, and a `core_agent_runs` row that still carries the + * header the migration under test has to read. + */ +function rewindToHeaderEra(db: DatabaseSync): void { + db.exec('DROP INDEX IF EXISTS runtime_events_by_session_kind'); + db.exec('DROP INDEX IF EXISTS runtime_events_one_opening_per_invocation'); + db.exec('DROP INDEX IF EXISTS runtime_legacy_invocation_openings_by_session'); + db.exec('DROP TABLE IF EXISTS runtime_legacy_invocation_openings'); + db.exec("DELETE FROM runtime_events WHERE event_kind = 'invocation_opened'"); + db.exec( + 'ALTER TABLE runtime_continuation_claims RENAME COLUMN target_opening_json TO target_run_header_json', + ); + db.exec('ALTER TABLE core_agent_runs ADD COLUMN record_json TEXT'); + // Opening facts were introduced in v16, regardless of the current version. + db.exec('PRAGMA user_version = 15'); +} + +function readUserVersion(db: DatabaseSync): number { + return (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; +} + +async function withHeaderOnlyRuns(run: (databasePath: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-opening-backfill-')); + try { + const databasePath = join(root, OPERATIONAL_STATE_DATABASE_NAME); + const db = new DatabaseSync(databasePath); + try { + migrateSqliteRuntimeDatabase(db); + migrateSqliteCoreExecutionDatabase(db); + rewindToHeaderEra(db); + const insert = db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ); + for (const record of [ + header({ runId: 'run-legacy-route', turnId: 'turn-legacy', modelId: 'legacy-model' }), + header({ + runId: 'run-scheduled', + turnId: 'turn-scheduled', + llmConnectionId: 'connection-1', + scheduledTaskId: 'task-9', + }), + header({ runId: 'run-with-events', turnId: 'turn-with-events' }), + ]) { + insert.run(record.sessionId, record.runId, record.createdAt, JSON.stringify(record)); + } + } finally { + db.close(); + } + + await run(databasePath); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function header(overrides: Partial): LegacyRunHeader { + return { + runId: 'run-1', + invocationId: overrides.runId ?? 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/tmp/cwd', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + ...overrides, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1abafe213980bd360a0a3b7e79ff6d71c4e7f9ec6e7527d416f08daff8cfb164.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1abafe213980bd360a0a3b7e79ff6d71c4e7f9ec6e7527d416f08daff8cfb164.source new file mode 100644 index 0000000000..3c843f5302 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1abafe213980bd360a0a3b7e79ff6d71c4e7f9ec6e7527d416f08daff8cfb164.source @@ -0,0 +1,1393 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 39; +export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; +export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; + +export const SQLITE_AGENT_GRAPH_CONTROL_TABLES = [ + 'agent_graph_epochs', + 'agent_graph_intent_claims', + 'agent_graph_schedule_updates', + 'agent_graph_operator_provisions', + 'agent_graph_client_projections', + 'agent_graph_client_operator_projections', + 'agent_graph_client_terminal_activity', + 'agent_graph_client_applied_records', + 'agent_graph_supervisor_wakes', + 'agent_graph_supervisor_wake_attempts', +] as const; + +const MIGRATIONS: ReadonlyMap = new Map([ + [ + 39, + ` + CREATE TABLE IF NOT EXISTS coordination_transcript_index ( + sequence INTEGER PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('legacy', 'runtime')), + source_sequence INTEGER NOT NULL CHECK (source_sequence >= 0), + UNIQUE (source, source_sequence) + ); + `, + ], + [ + 1, + ` + CREATE TABLE session_metadata ( + session_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL, + last_message_at INTEGER, + name TEXT NOT NULL, + is_flagged INTEGER NOT NULL CHECK (is_flagged IN (0, 1)), + is_archived INTEGER NOT NULL CHECK (is_archived IN (0, 1)), + status TEXT NOT NULL, + status_updated_at INTEGER, + parent_session_id TEXT, + revision_root_session_id TEXT, + revision_index INTEGER, + has_unread INTEGER NOT NULL CHECK (has_unread IN (0, 1)), + backend TEXT NOT NULL, + llm_connection_slug TEXT NOT NULL, + model TEXT NOT NULL, + metadata_version INTEGER NOT NULL CHECK (metadata_version > 0), + committed_at INTEGER NOT NULL + ); + + CREATE INDEX session_metadata_by_recency + ON session_metadata(is_archived, last_message_at DESC, last_used_at DESC, session_id); + + CREATE INDEX session_metadata_by_flag + ON session_metadata(is_flagged, is_archived, session_id); + + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + + CREATE INDEX session_metadata_by_parent + ON session_metadata(parent_session_id, session_id); + + CREATE INDEX session_metadata_by_revision + ON session_metadata(revision_root_session_id, revision_index, session_id); + + CREATE TABLE session_metadata_labels ( + session_id TEXT NOT NULL, + label_index INTEGER NOT NULL CHECK (label_index >= 0), + label TEXT NOT NULL, + PRIMARY KEY(session_id, label_index), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX session_metadata_labels_by_label + ON session_metadata_labels(label, session_id); + + `, + ], + [ + 2, + ` + CREATE TABLE session_metadata_tombstones ( + session_id TEXT PRIMARY KEY, + deleted_at INTEGER NOT NULL + ); + `, + ], + [ + 3, + ` + ALTER TABLE session_metadata ADD COLUMN subagent_parent_session_id TEXT; + + UPDATE session_metadata + SET subagent_parent_session_id = + json_extract(payload_json, '$.subagentParent.parentSessionId') + WHERE json_type(payload_json, '$.subagentParent.parentSessionId') = 'text'; + + CREATE INDEX session_metadata_by_subagent_parent + ON session_metadata(subagent_parent_session_id, session_id); + `, + ], + [ + 4, + ` + ALTER TABLE session_metadata ADD COLUMN subagent_parent_run_id TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_tool_call_id TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_swarm_id TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_item_id TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_request_fingerprint TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_initial_turn_id TEXT; + ALTER TABLE session_metadata ADD COLUMN subagent_initial_run_id TEXT; + + UPDATE session_metadata + SET + subagent_parent_run_id = + json_extract(payload_json, '$.subagentParent.spawnedBy.parentRunId'), + subagent_tool_call_id = + json_extract(payload_json, '$.subagentParent.spawnedBy.toolCallId'), + subagent_swarm_id = + json_extract(payload_json, '$.subagentParent.swarm.swarmId'), + subagent_item_id = + json_extract(payload_json, '$.subagentParent.swarm.itemId'), + subagent_request_fingerprint = + json_extract(payload_json, '$.subagentSpawn.requestFingerprint'), + subagent_initial_turn_id = + json_extract(payload_json, '$.subagentSpawn.initialTurnId'), + subagent_initial_run_id = + json_extract(payload_json, '$.subagentSpawn.initialRunId') + WHERE subagent_parent_session_id IS NOT NULL; + + CREATE UNIQUE INDEX session_metadata_by_subagent_spawn + ON session_metadata( + subagent_parent_session_id, + subagent_parent_run_id, + subagent_tool_call_id, + COALESCE(subagent_swarm_id, ''), + COALESCE(subagent_item_id, '') + ) + WHERE + subagent_parent_session_id IS NOT NULL + AND subagent_parent_run_id IS NOT NULL + AND subagent_tool_call_id IS NOT NULL + AND subagent_request_fingerprint IS NOT NULL; + `, + ], + [ + 5, + ` + CREATE TABLE subagent_spawns ( + parent_session_id TEXT NOT NULL, + parent_run_id TEXT NOT NULL, + tool_call_id TEXT NOT NULL, + swarm_id TEXT NOT NULL, + item_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + child_session_id TEXT NOT NULL UNIQUE, + initial_turn_id TEXT NOT NULL, + initial_run_id TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY(parent_session_id, parent_run_id, tool_call_id, swarm_id, item_id) + ); + + INSERT INTO subagent_spawns( + parent_session_id, + parent_run_id, + tool_call_id, + swarm_id, + item_id, + request_fingerprint, + child_session_id, + initial_turn_id, + initial_run_id, + claimed_at + ) + SELECT + subagent_parent_session_id, + subagent_parent_run_id, + subagent_tool_call_id, + COALESCE(subagent_swarm_id, ''), + COALESCE(subagent_item_id, ''), + subagent_request_fingerprint, + session_id, + subagent_initial_turn_id, + subagent_initial_run_id, + committed_at + FROM session_metadata + WHERE + subagent_parent_session_id IS NOT NULL + AND subagent_parent_run_id IS NOT NULL + AND subagent_tool_call_id IS NOT NULL + AND subagent_request_fingerprint IS NOT NULL + AND subagent_initial_turn_id IS NOT NULL + AND subagent_initial_run_id IS NOT NULL; + + DROP INDEX session_metadata_by_subagent_spawn; + `, + ], + [ + 6, + ` + CREATE TABLE agent_graph_intent_claims ( + claim_id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + graph_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + intent_fingerprint TEXT NOT NULL, + readiness_context_fingerprint TEXT NOT NULL, + target_operator_id TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_turn_id TEXT NOT NULL, + target_run_id TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + UNIQUE(graph_id, intent_id), + UNIQUE(target_session_id, target_turn_id), + UNIQUE(target_session_id, target_run_id) + ); + + CREATE INDEX agent_graph_intent_claims_by_graph + ON agent_graph_intent_claims(graph_id, claimed_at, intent_id); + `, + ], + [ + 7, + ` + CREATE TABLE agent_graph_schedule_updates ( + graph_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + update_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + update_fingerprint TEXT NOT NULL, + source_session_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_tool_call_id TEXT NOT NULL, + closes_graph INTEGER NOT NULL CHECK (closes_graph IN (0, 1)), + payload_json TEXT NOT NULL, + committed_at INTEGER NOT NULL CHECK (committed_at >= 0), + PRIMARY KEY(graph_id, revision), + UNIQUE(source_session_id, source_run_id, source_tool_call_id) + ); + + CREATE INDEX agent_graph_schedule_updates_by_graph + ON agent_graph_schedule_updates(graph_id, committed_at, update_id); + `, + ], + [ + 8, + ` + ALTER TABLE agent_graph_intent_claims + ADD COLUMN admission_status TEXT NOT NULL DEFAULT 'executing' + CHECK (admission_status IN ('claimed', 'executing', 'cancelled')); + ALTER TABLE agent_graph_intent_claims + ADD COLUMN admission_updated_at INTEGER NOT NULL DEFAULT 0 + CHECK (admission_updated_at >= 0); + ALTER TABLE agent_graph_intent_claims + ADD COLUMN cancellation_reason TEXT; + + UPDATE agent_graph_intent_claims + SET admission_updated_at = claimed_at; + `, + ], + [ + 9, + ` + CREATE TABLE agent_graph_operator_provisions ( + graph_id TEXT NOT NULL, + work_id TEXT NOT NULL, + provision_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + provision_fingerprint TEXT NOT NULL, + agent_id TEXT NOT NULL, + operator_id TEXT NOT NULL, + target_session_id TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + provisioned_at INTEGER NOT NULL CHECK (provisioned_at >= 0), + PRIMARY KEY(graph_id, work_id), + UNIQUE(graph_id, operator_id) + ); + + CREATE INDEX agent_graph_operator_provisions_by_graph + ON agent_graph_operator_provisions(graph_id, provisioned_at, operator_id); + `, + ], + [ + 10, + ` + CREATE TABLE agent_graph_client_projections ( + graph_id TEXT PRIMARY KEY, + root_session_id TEXT NOT NULL, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + snapshot_version TEXT NOT NULL, + payload_json TEXT NOT NULL, + materialized_at INTEGER NOT NULL CHECK (materialized_at >= 0) + ); + + CREATE TABLE agent_graph_client_operator_projections ( + graph_id TEXT NOT NULL, + operator_id TEXT NOT NULL, + snapshot_version TEXT NOT NULL, + payload_json TEXT NOT NULL, + materialized_at INTEGER NOT NULL CHECK (materialized_at >= 0), + PRIMARY KEY(graph_id, operator_id) + ); + + CREATE TABLE agent_graph_client_terminal_activity ( + graph_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_time INTEGER NOT NULL CHECK (event_time >= 0), + payload_json TEXT NOT NULL, + PRIMARY KEY(graph_id, record_id) + ); + + CREATE TABLE agent_graph_client_applied_records ( + graph_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_time INTEGER NOT NULL CHECK (event_time >= 0), + PRIMARY KEY(graph_id, record_id) + ); + + CREATE INDEX agent_graph_client_terminal_activity_page + ON agent_graph_client_terminal_activity( + graph_id, + event_time DESC, + record_id DESC + ); + `, + ], + [ + 11, + ` + CREATE TABLE agent_graph_supervisor_wakes ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + snapshot_version TEXT NOT NULL, + root_session_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('pending', 'running', 'delivered', 'retryable_failed')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + current_attempt_id TEXT, + current_turn_id TEXT, + failure_reason TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY(graph_id, wake_id) + ); + + CREATE TABLE agent_graph_supervisor_wake_attempts ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + attempt_id TEXT NOT NULL UNIQUE, + turn_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('running', 'delivered', 'retryable_failed')), + failure_reason TEXT, + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + PRIMARY KEY(graph_id, wake_id, attempt_id), + FOREIGN KEY(graph_id, wake_id) + REFERENCES agent_graph_supervisor_wakes(graph_id, wake_id) + ON DELETE CASCADE + ); + + CREATE INDEX agent_graph_supervisor_wakes_by_status + ON agent_graph_supervisor_wakes(status, updated_at, graph_id, wake_id); + `, + ], + [ + 12, + ` + DROP INDEX agent_graph_supervisor_wakes_by_status; + + CREATE TABLE agent_graph_supervisor_wakes_v12 ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + snapshot_version TEXT NOT NULL, + root_session_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK ( + status IN ( + 'pending', + 'running', + 'waiting_permission', + 'delivered', + 'retryable_failed' + ) + ), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + current_attempt_id TEXT, + current_turn_id TEXT, + failure_reason TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY(graph_id, wake_id) + ); + + CREATE TABLE agent_graph_supervisor_wake_attempts_v12 ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + attempt_id TEXT NOT NULL UNIQUE, + turn_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK ( + status IN ( + 'running', + 'waiting_permission', + 'delivered', + 'retryable_failed' + ) + ), + failure_reason TEXT, + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + PRIMARY KEY(graph_id, wake_id, attempt_id), + FOREIGN KEY(graph_id, wake_id) + REFERENCES agent_graph_supervisor_wakes_v12(graph_id, wake_id) + ON DELETE CASCADE + ); + + INSERT INTO agent_graph_supervisor_wakes_v12 + SELECT * FROM agent_graph_supervisor_wakes; + + INSERT INTO agent_graph_supervisor_wake_attempts_v12 + SELECT * FROM agent_graph_supervisor_wake_attempts; + + DROP TABLE agent_graph_supervisor_wake_attempts; + DROP TABLE agent_graph_supervisor_wakes; + + ALTER TABLE agent_graph_supervisor_wakes_v12 + RENAME TO agent_graph_supervisor_wakes; + ALTER TABLE agent_graph_supervisor_wake_attempts_v12 + RENAME TO agent_graph_supervisor_wake_attempts; + + CREATE INDEX agent_graph_supervisor_wakes_by_status + ON agent_graph_supervisor_wakes(status, updated_at, graph_id, wake_id); + `, + ], + [ + 13, + ` + CREATE TABLE sandbox_boundary_log ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + entry_kind TEXT NOT NULL + CHECK (entry_kind IN ('genesis', 'expansion_request', 'user_change')), + request_id TEXT, + status TEXT NOT NULL + CHECK (status IN ('applied', 'pending', 'approved', 'denied', 'conflict')), + base_revision INTEGER CHECK (base_revision >= 0), + applied_revision INTEGER CHECK (applied_revision >= 0), + boundary_json TEXT, + expansion_json TEXT, + justification TEXT, + outcome_reason TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + settled_at INTEGER CHECK (settled_at >= 0), + PRIMARY KEY(session_id, entry_id), + UNIQUE(session_id, request_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX sandbox_boundary_log_applied_revision + ON sandbox_boundary_log(session_id, applied_revision) + WHERE applied_revision IS NOT NULL; + + CREATE INDEX sandbox_boundary_log_pending_requests + ON sandbox_boundary_log(session_id, status, created_at, entry_id); + `, + ], + [ + 14, + ` + ALTER TABLE sandbox_boundary_log ADD COLUMN turn_id TEXT; + ALTER TABLE sandbox_boundary_log ADD COLUMN run_id TEXT; + + CREATE INDEX sandbox_boundary_log_settled_closures + ON sandbox_boundary_log(session_id, outcome_reason, created_at, entry_id) + WHERE outcome_reason IS NOT NULL; + `, + ], + [ + 15, + ` + CREATE TABLE session_create_claims ( + session_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0) + ); + `, + ], + [ + 16, + ` + CREATE TABLE IF NOT EXISTS session_catalog_state ( + scope TEXT PRIMARY KEY CHECK (scope = 'catalog'), + epoch TEXT NOT NULL CHECK (length(epoch) = 32), + generation INTEGER NOT NULL CHECK (generation >= 0), + pending_writes INTEGER NOT NULL CHECK (pending_writes >= 0) + ); + + INSERT OR IGNORE INTO session_catalog_state(scope, epoch, generation, pending_writes) + SELECT + 'catalog', + lower(hex(randomblob(16))), + 0, + CASE WHEN EXISTS (SELECT 1 FROM session_metadata) THEN 1 ELSE 0 END; + + CREATE TABLE IF NOT EXISTS session_catalog_projection ( + session_id TEXT PRIMARY KEY, + activity_at INTEGER NOT NULL CHECK (activity_at >= 0), + last_message_at INTEGER, + last_message_preview TEXT + CHECK (last_message_preview IS NULL OR length(last_message_preview) <= 96), + is_archived INTEGER NOT NULL CHECK (is_archived IN (0, 1)), + is_flagged INTEGER NOT NULL CHECK (is_flagged IN (0, 1)), + subagent_parent_session_id TEXT, + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + INSERT OR IGNORE INTO session_catalog_projection( + session_id, + activity_at, + last_message_at, + last_message_preview, + is_archived, + is_flagged, + subagent_parent_session_id + ) + SELECT + session_id, + COALESCE(last_message_at, last_used_at, created_at), + last_message_at, + NULL, + is_archived, + is_flagged, + subagent_parent_session_id + FROM session_metadata; + + CREATE INDEX IF NOT EXISTS session_catalog_by_activity + ON session_catalog_projection(activity_at DESC, session_id ASC); + + CREATE INDEX IF NOT EXISTS session_catalog_by_archived_activity + ON session_catalog_projection(is_archived, activity_at DESC, session_id ASC); + + CREATE INDEX IF NOT EXISTS session_catalog_by_flagged_activity + ON session_catalog_projection(is_flagged, activity_at DESC, session_id ASC); + + CREATE INDEX IF NOT EXISTS session_catalog_by_archived_flagged_activity + ON session_catalog_projection( + is_archived, + is_flagged, + activity_at DESC, + session_id ASC + ); + + CREATE INDEX IF NOT EXISTS session_catalog_by_subagent_activity + ON session_catalog_projection( + subagent_parent_session_id, + activity_at DESC, + session_id ASC + ); + + CREATE TABLE IF NOT EXISTS session_catalog_label_projection ( + session_id TEXT NOT NULL, + label TEXT NOT NULL, + activity_at INTEGER NOT NULL CHECK (activity_at >= 0), + PRIMARY KEY(session_id, label), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + INSERT OR IGNORE INTO session_catalog_label_projection(session_id, label, activity_at) + SELECT labels.session_id, labels.label, projection.activity_at + FROM session_metadata_labels labels + JOIN session_catalog_projection projection + ON projection.session_id = labels.session_id; + + CREATE INDEX IF NOT EXISTS session_catalog_labels_by_label_activity + ON session_catalog_label_projection(label, activity_at DESC, session_id ASC); + + CREATE TRIGGER IF NOT EXISTS session_catalog_after_insert + AFTER INSERT ON session_metadata + BEGIN + INSERT INTO session_catalog_projection( + session_id, + activity_at, + last_message_at, + last_message_preview, + is_archived, + is_flagged, + subagent_parent_session_id + ) VALUES ( + NEW.session_id, + COALESCE(NEW.last_message_at, NEW.last_used_at, NEW.created_at), + NEW.last_message_at, + NULL, + NEW.is_archived, + NEW.is_flagged, + NEW.subagent_parent_session_id + ); + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER IF NOT EXISTS session_catalog_after_update + AFTER UPDATE ON session_metadata + BEGIN + UPDATE session_catalog_projection + SET + activity_at = COALESCE(NEW.last_message_at, NEW.last_used_at, NEW.created_at), + last_message_at = NEW.last_message_at, + is_archived = NEW.is_archived, + is_flagged = NEW.is_flagged, + subagent_parent_session_id = NEW.subagent_parent_session_id + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_label_projection + SET activity_at = COALESCE(NEW.last_message_at, NEW.last_used_at, NEW.created_at) + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER IF NOT EXISTS session_catalog_after_delete + AFTER DELETE ON session_metadata + BEGIN + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER IF NOT EXISTS session_catalog_label_after_insert + AFTER INSERT ON session_metadata_labels + BEGIN + INSERT OR IGNORE INTO session_catalog_label_projection(session_id, label, activity_at) + SELECT NEW.session_id, NEW.label, projection.activity_at + FROM session_catalog_projection projection + WHERE projection.session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER IF NOT EXISTS session_catalog_label_after_delete + AFTER DELETE ON session_metadata_labels + BEGIN + DELETE FROM session_catalog_label_projection + WHERE + session_id = OLD.session_id + AND label = OLD.label + AND NOT EXISTS ( + SELECT 1 + FROM session_metadata_labels labels + WHERE labels.session_id = OLD.session_id + AND labels.label = OLD.label + ); + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + `, + ], + [ + 17, + ` + DROP TRIGGER IF EXISTS session_catalog_label_after_insert; + DROP TRIGGER IF EXISTS session_catalog_label_after_delete; + + CREATE TRIGGER session_catalog_label_after_insert + AFTER INSERT ON session_metadata_labels + BEGIN + INSERT OR IGNORE INTO session_catalog_label_projection(session_id, label, activity_at) + SELECT NEW.session_id, NEW.label, projection.activity_at + FROM session_catalog_projection projection + WHERE projection.session_id = NEW.session_id; + END; + + CREATE TRIGGER session_catalog_label_after_delete + AFTER DELETE ON session_metadata_labels + BEGIN + DELETE FROM session_catalog_label_projection + WHERE + session_id = OLD.session_id + AND label = OLD.label + AND NOT EXISTS ( + SELECT 1 + FROM session_metadata_labels labels + WHERE labels.session_id = OLD.session_id + AND labels.label = OLD.label + ); + END; + `, + ], + [ + 18, + ` + ALTER TABLE session_metadata_tombstones ADD COLUMN retirement_unit_id TEXT; + ALTER TABLE session_metadata_tombstones + ADD COLUMN cleanup_pending INTEGER NOT NULL DEFAULT 0 + CHECK (cleanup_pending IN (0, 1)); + + UPDATE session_metadata_tombstones + SET retirement_unit_id = session_id, cleanup_pending = 1; + + CREATE INDEX session_metadata_tombstones_by_retirement_unit + ON session_metadata_tombstones(retirement_unit_id, cleanup_pending, session_id); + `, + ], + [ + 19, + ` + DROP INDEX agent_graph_supervisor_wakes_by_status; + + CREATE TABLE agent_graph_supervisor_wakes_v19 ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + snapshot_version TEXT NOT NULL, + root_session_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK ( + status IN ( + 'pending', + 'running', + 'waiting_permission', + 'delivered', + 'superseded', + 'retryable_failed' + ) + ), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + current_attempt_id TEXT, + current_turn_id TEXT, + failure_reason TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY(graph_id, wake_id) + ); + + CREATE TABLE agent_graph_supervisor_wake_attempts_v19 ( + graph_id TEXT NOT NULL, + wake_id TEXT NOT NULL, + attempt_id TEXT NOT NULL UNIQUE, + turn_id TEXT NOT NULL, + status TEXT NOT NULL + CHECK ( + status IN ( + 'running', + 'waiting_permission', + 'delivered', + 'superseded', + 'retryable_failed' + ) + ), + failure_reason TEXT, + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + PRIMARY KEY(graph_id, wake_id, attempt_id), + FOREIGN KEY(graph_id, wake_id) + REFERENCES agent_graph_supervisor_wakes_v19(graph_id, wake_id) + ON DELETE CASCADE + ); + + INSERT INTO agent_graph_supervisor_wakes_v19 + SELECT * FROM agent_graph_supervisor_wakes; + + INSERT INTO agent_graph_supervisor_wake_attempts_v19 + SELECT * FROM agent_graph_supervisor_wake_attempts; + + DROP TABLE agent_graph_supervisor_wake_attempts; + DROP TABLE agent_graph_supervisor_wakes; + + ALTER TABLE agent_graph_supervisor_wakes_v19 + RENAME TO agent_graph_supervisor_wakes; + ALTER TABLE agent_graph_supervisor_wake_attempts_v19 + RENAME TO agent_graph_supervisor_wake_attempts; + + CREATE INDEX agent_graph_supervisor_wakes_by_status + ON agent_graph_supervisor_wakes(status, updated_at, graph_id, wake_id); + `, + ], + [ + 20, + ` + CREATE TABLE session_messages ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + message_id TEXT NOT NULL, + message_type TEXT NOT NULL, + message_ts INTEGER NOT NULL CHECK (message_ts >= 0), + record_json TEXT NOT NULL, + PRIMARY KEY(session_id, sequence), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX session_messages_by_identity + ON session_messages(session_id, message_id); + + CREATE INDEX session_messages_by_time + ON session_messages(session_id, message_ts, sequence); + `, + ], + [ + 30, + ` + CREATE TABLE IF NOT EXISTS message_admissions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + run_id TEXT NOT NULL, + message_id TEXT NOT NULL, + content_json TEXT NOT NULL, + submitted_content_digest TEXT NOT NULL, + submitted_placement TEXT NOT NULL + CHECK (submitted_placement IN ('current_turn', 'next_turn')), + placement TEXT NOT NULL CHECK (placement IN ('current_turn', 'next_turn')), + disposition TEXT NOT NULL CHECK (disposition IN ('steering', 'followup')), + queue_order INTEGER NOT NULL CHECK (queue_order >= 0), + admitted_at INTEGER NOT NULL CHECK (admitted_at >= 0), + UNIQUE (session_id, message_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS message_admissions_by_session_order + ON message_admissions(session_id, queue_order, sequence); + + CREATE TABLE IF NOT EXISTS cancelled_message_admissions ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + submitted_content_digest TEXT NOT NULL, + submitted_placement TEXT NOT NULL + CHECK (submitted_placement IN ('current_turn', 'next_turn')), + PRIMARY KEY (session_id, message_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + `, + ], + [ + 21, + ` + CREATE TABLE projects ( + project_id TEXT PRIMARY KEY, + identity TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + last_used_at INTEGER NOT NULL, + archived_at INTEGER + ); + + CREATE TABLE project_locations ( + project_id TEXT NOT NULL, + path TEXT NOT NULL, + is_worktree INTEGER NOT NULL CHECK (is_worktree IN (0, 1)), + last_used_at INTEGER NOT NULL, + PRIMARY KEY(project_id, path), + FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + + CREATE TABLE project_aliases ( + alias TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + + CREATE INDEX project_aliases_by_project + ON project_aliases(project_id, alias); + `, + ], + [ + 22, + ` + UPDATE session_metadata + SET + payload_json = json_set(payload_json, '$.connectionLocked', json('true')), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ) + WHERE + json_extract(payload_json, '$.connectionLocked') = 0 + AND EXISTS ( + SELECT 1 + FROM session_messages messages + WHERE + messages.session_id = session_metadata.session_id + AND messages.message_type = 'user' + ); + `, + ], + [ + 23, + ` + CREATE TABLE session_message_payloads ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + record_bytes INTEGER NOT NULL CHECK (record_bytes > ${SQLITE_SESSION_MESSAGE_CHUNK_BYTES}), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + PRIMARY KEY(session_id, sequence), + FOREIGN KEY(session_id, sequence) + REFERENCES session_messages(session_id, sequence) + ON DELETE CASCADE + ) WITHOUT ROWID; + + CREATE TABLE session_message_chunks ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + chunk_index INTEGER NOT NULL CHECK (chunk_index >= 0), + data BLOB NOT NULL CHECK (length(data) BETWEEN 1 AND ${SQLITE_SESSION_MESSAGE_CHUNK_BYTES}), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + PRIMARY KEY(session_id, sequence, chunk_index), + FOREIGN KEY(session_id, sequence) + REFERENCES session_message_payloads(session_id, sequence) + ON DELETE CASCADE + ) WITHOUT ROWID; + + `, + ], + [ + 24, + ` + CREATE TABLE agent_graph_epochs ( + root_session_id TEXT NOT NULL, + epoch INTEGER NOT NULL CHECK (epoch > 0), + graph_id TEXT NOT NULL UNIQUE, + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + PRIMARY KEY(root_session_id, epoch) + ); + + CREATE INDEX agent_graph_epochs_current + ON agent_graph_epochs(root_session_id, epoch DESC); + `, + ], + [ + 25, + ` + UPDATE session_metadata + SET + status = 'active', + payload_json = json_set(payload_json, '$.status', 'active'), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) + ) + WHERE + status IN ('review', 'done') + OR json_extract(payload_json, '$.status') IN ('review', 'done'); + `, + ], + [ + 26, + ` + DROP TRIGGER IF EXISTS session_catalog_label_after_insert; + DROP TRIGGER IF EXISTS session_catalog_label_after_delete; + DROP TRIGGER IF EXISTS session_catalog_after_update; + DROP INDEX IF EXISTS session_catalog_labels_by_label_activity; + DROP TABLE IF EXISTS session_catalog_label_projection; + DROP INDEX IF EXISTS session_catalog_by_archived_activity; + DROP INDEX IF EXISTS session_catalog_by_flagged_activity; + DROP INDEX IF EXISTS session_catalog_by_archived_flagged_activity; + DROP INDEX IF EXISTS session_metadata_by_flag; + + CREATE TRIGGER session_catalog_after_update + AFTER UPDATE ON session_metadata + BEGIN + UPDATE session_catalog_projection + SET + activity_at = COALESCE(NEW.last_message_at, NEW.last_used_at, NEW.created_at), + last_message_at = NEW.last_message_at, + is_archived = NEW.is_archived, + is_flagged = NEW.is_flagged, + subagent_parent_session_id = NEW.subagent_parent_session_id + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + DROP INDEX IF EXISTS session_metadata_labels_by_label; + DROP TABLE IF EXISTS session_metadata_labels; + `, + ], + [ + 27, + ` + UPDATE session_metadata + SET + payload_json = json_set( + CASE + WHEN json_extract(payload_json, '$.status') = 'archived' + THEN json_remove( + json_set(payload_json, '$.status', 'active'), + '$.archivedAt', + '$.blockedReason', + '$.statusUpdatedAt' + ) + ELSE json_remove(payload_json, '$.archivedAt') + END, + '$.isArchived', + CASE + WHEN + json_type(payload_json, '$.isArchived') = 'true' + OR json_extract(payload_json, '$.status') = 'archived' + OR is_archived = 1 + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + THEN json('true') + ELSE json('false') + END + ), + is_archived = CASE + WHEN + json_type(payload_json, '$.isArchived') = 'true' + OR json_extract(payload_json, '$.status') = 'archived' + OR is_archived = 1 + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + THEN 1 + ELSE 0 + END, + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) + ) + WHERE + json_extract(payload_json, '$.status') = 'archived' + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + OR ( + ( + json_type(payload_json, '$.isArchived') = 'true' + OR is_archived = 1 + ) + AND ( + json_type(payload_json, '$.isArchived') IS NOT 'true' + OR is_archived != 1 + ) + ) + OR ( + json_type(payload_json, '$.isArchived') IS NOT 'true' + AND is_archived != 1 + AND ( + json_type(payload_json, '$.isArchived') IS NOT 'false' + OR is_archived != 0 + ) + ); + + DROP INDEX session_metadata_by_status; + ALTER TABLE session_metadata DROP COLUMN status; + ALTER TABLE session_metadata DROP COLUMN status_updated_at; + `, + ], + [ + 28, + ` + ALTER TABLE session_metadata ADD COLUMN external_adapter_id TEXT; + ALTER TABLE session_metadata ADD COLUMN external_source_session_id TEXT; + + CREATE INDEX session_metadata_by_external_origin + ON session_metadata( + external_adapter_id, + external_source_session_id, + created_at DESC, + session_id + ) + WHERE external_adapter_id IS NOT NULL + AND external_source_session_id IS NOT NULL; + `, + ], + [ + 29, + ` + DROP TRIGGER session_catalog_after_insert; + DROP TRIGGER session_catalog_after_update; + DROP INDEX IF EXISTS session_metadata_by_recency; + + UPDATE session_metadata + SET + payload_json = json_remove(payload_json, '$.lastUsedAt'), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) + ) + WHERE json_type(payload_json, '$.lastUsedAt') IS NOT NULL; + + CREATE TRIGGER session_catalog_after_insert + AFTER INSERT ON session_metadata + BEGIN + INSERT INTO session_catalog_projection( + session_id, + activity_at, + last_message_at, + last_message_preview, + is_archived, + is_flagged, + subagent_parent_session_id + ) VALUES ( + NEW.session_id, + COALESCE(NEW.last_message_at, NEW.created_at), + NEW.last_message_at, + NULL, + NEW.is_archived, + NEW.is_flagged, + NEW.subagent_parent_session_id + ); + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + + CREATE TRIGGER session_catalog_after_update + AFTER UPDATE ON session_metadata + BEGIN + UPDATE session_catalog_projection + SET + activity_at = CASE + WHEN NEW.last_message_at IS NOT OLD.last_message_at + THEN COALESCE(NEW.last_message_at, OLD.created_at) + ELSE activity_at + END, + last_message_at = NEW.last_message_at, + is_archived = NEW.is_archived, + is_flagged = NEW.is_flagged, + subagent_parent_session_id = NEW.subagent_parent_session_id + WHERE session_id = NEW.session_id; + + UPDATE session_catalog_state + SET generation = generation + 1 + WHERE scope = 'catalog'; + END; + `, + ], + [ + 31, + ` + CREATE TABLE IF NOT EXISTS message_admissions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + run_id TEXT NOT NULL, + message_id TEXT NOT NULL, + content_json TEXT NOT NULL, + submitted_content_digest TEXT NOT NULL, + submitted_placement TEXT NOT NULL + CHECK (submitted_placement IN ('current_turn', 'next_turn')), + placement TEXT NOT NULL CHECK (placement IN ('current_turn', 'next_turn')), + disposition TEXT NOT NULL CHECK (disposition IN ('steering', 'followup')), + queue_order INTEGER NOT NULL CHECK (queue_order >= 0), + admitted_at INTEGER NOT NULL CHECK (admitted_at >= 0), + UNIQUE (session_id, message_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS message_admissions_by_session_order + ON message_admissions(session_id, queue_order, sequence); + + CREATE TABLE IF NOT EXISTS cancelled_message_admissions ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + submitted_content_digest TEXT NOT NULL, + submitted_placement TEXT NOT NULL + CHECK (submitted_placement IN ('current_turn', 'next_turn')), + PRIMARY KEY (session_id, message_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS session_metadata_one_workhub_coordination_session + ON session_metadata(json_extract(payload_json, '$.role')) + WHERE json_extract(payload_json, '$.role') = 'workhub_coordination'; + `, + ], + [ + 32, + ` + ALTER TABLE message_admissions ADD COLUMN submitted_intent_json TEXT; + `, + ], + [ + 33, + ` + UPDATE session_metadata + SET + payload_json = json_set(payload_json, '$.connectionLocked', json('true')), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ) + WHERE + json_extract(payload_json, '$.connectionLocked') = 0 + AND json_extract(payload_json, '$.subagentParent') IS NOT NULL; + `, + ], + [ + 34, + ` + -- WorkHub delegation_assigned records are decoded by schema-aware builds. + -- Advancing the profile schema prevents an older build from opening a + -- transcript containing this new canonical message type. + SELECT 1; + `, + ], + [ + 35, + ` + ALTER TABLE message_admissions + ADD COLUMN skill_invocation_json TEXT NOT NULL + DEFAULT '{"loaded":[],"failed":[],"receipts":[]}'; + `, + ], + [ + 36, + ` + -- WorkHub replacement intent and atomic supersession records require the + -- schema-v2 canonical message decoder. Prevent older builds from opening + -- a profile after either record has been committed. + SELECT 1; + `, + ], + [ + 37, + ` + ALTER TABLE cancelled_message_admissions + ADD COLUMN cancellation_claim_id TEXT; + `, + ], + [ + 38, + ` + -- The one global owner of a WorkHub action identity. It deliberately has no + -- Session foreign key: the claim must outlive removal of the target Session + -- so a committed destructive claim still converges after that removal. + CREATE TABLE IF NOT EXISTS workhub_action_claims ( + action_id TEXT PRIMARY KEY, + operation TEXT NOT NULL CHECK ( + operation IN ( + 'answer_here', 'clarify', 'delegate_existing', 'create_new', 'replace', 'stop' + ) + ), + action_fingerprint TEXT NOT NULL, + subject TEXT NOT NULL, + claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0) + ); + `, + ], +]); + +if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { + throw new Error('SQLite session metadata migrations contain a duplicate or missing version'); +} +for (let version = 1; version <= SQLITE_SESSION_METADATA_SCHEMA_VERSION; version += 1) { + if (!MIGRATIONS.has(version)) { + throw new Error(`Missing SQLite session metadata migration ${version}`); + } +} + +export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); +} + +export function migrateSqliteSessionMetadataDatabase( + db: DatabaseSync, + options: { transaction?: 'self' | 'caller' } = {}, +): void { + db.exec(` + CREATE TABLE IF NOT EXISTS session_metadata_schema ( + scope TEXT PRIMARY KEY, + version INTEGER NOT NULL CHECK (version >= 0) + ) + `); + const ownsTransaction = options.transaction !== 'caller'; + if (ownsTransaction) db.exec('BEGIN IMMEDIATE'); + try { + const current = readSqliteSessionMetadataSchemaVersion(db); + if ( + current > 0 && + current < 29 && + hasColumn(db, 'session_metadata', 'session_id') && + !hasColumn(db, 'session_metadata', 'last_used_at') + ) { + db.exec(` + ALTER TABLE session_metadata + ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0; + UPDATE session_metadata + SET last_used_at = COALESCE(last_message_at, created_at); + `); + } + if (current > SQLITE_SESSION_METADATA_SCHEMA_VERSION) { + throw new Error( + `SQLite session metadata schema ${current} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, + ); + } + for ( + let version = current + 1; + version <= SQLITE_SESSION_METADATA_SCHEMA_VERSION; + version += 1 + ) { + const sql = MIGRATIONS.get(version); + if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); + // Versions 32, 35, and 37 each add one column, and the post-merge convergence + // path can replay them onto a database that already carries the current + // table shape. SQLite has no `ADD COLUMN IF NOT EXISTS`, so the guards + // live here. + const columnAlreadyPresent = + (version === 32 && hasColumn(db, 'message_admissions', 'submitted_intent_json')) || + (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json')) || + (version === 37 && hasColumn(db, 'cancelled_message_admissions', 'cancellation_claim_id')); + if (!columnAlreadyPresent) { + db.exec(sql); + } + if (version === 29 && hasColumn(db, 'session_metadata', 'last_used_at')) { + db.exec('ALTER TABLE session_metadata DROP COLUMN last_used_at'); + } + db.prepare(` + INSERT INTO session_metadata_schema(scope, version) + VALUES ('session_metadata', ?) + ON CONFLICT(scope) DO UPDATE SET version = excluded.version + `).run(version); + } + if (ownsTransaction) db.exec('COMMIT'); + } catch (error) { + if (ownsTransaction) rollback(db); + throw error; + } +} + +function hasColumn(db: DatabaseSync, table: string, column: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return rows.some((row) => row.name === column); +} + +export function readSqliteSessionMetadataSchemaVersion(db: DatabaseSync): number { + const row = db + .prepare(` + SELECT version + FROM session_metadata_schema + WHERE scope = 'session_metadata' + `) + .get() as { version?: unknown } | undefined; + if (!row) return 0; + const value = row.version; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid SQLite session metadata schema version'); + } + return value; +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the migration failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ae1446df7b17bede6791f516c3c522981394bf16f03434679fd2209ff2a3b94.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ae1446df7b17bede6791f516c3c522981394bf16f03434679fd2209ff2a3b94.source new file mode 100644 index 0000000000..c5534a258f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ae1446df7b17bede6791f516c3c522981394bf16f03434679fd2209ff2a3b94.source @@ -0,0 +1,694 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { createSessionCopyCleanupAuthority } from '../session-copy-cleanup.js'; +import type { + ProcessLifetimeOwner, + ProcessLifetimeRecoveryClaim, +} from '../process-lifetime-owner.js'; + +const roots: string[] = []; + +async function createWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-session-copy-cleanup-')); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('session copy cleanup authority', () => { + it('forgets a known rejected creation without trying to resume or remove it', async () => { + const workspaceRoot = await createWorkspace(); + let resumes = 0; + let removals = 0; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + resumeSessionCopy: async () => { + resumes += 1; + }, + removeSession: async () => { + removals += 1; + }, + }); + const creation = { + sessionId: 'fork-rejected', + kind: 'branch' as const, + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + intent: 'side_conversation' as const, + ownerId: 'web-contents:1', + }; + + await assert.rejects( + authority.ownCreation(creation, async () => { + throw new Error('session busy'); + }), + /session busy/, + ); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-rejected']); + + await authority.rejectCreation('fork-rejected'); + + assert.deepEqual(await readPendingIds(workspaceRoot), []); + assert.equal(resumes, 0); + assert.equal(removals, 0); + assert.equal(await authority.ownCreation(creation, async () => 'retried'), 'retried'); + }); + + it('releases a rejected creation lease so the same identity can retry', async () => { + const workspaceRoot = await createWorkspace(); + let removalFails = true; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => { + if (removalFails) throw new Error('temporary removal failure'); + }, + }); + await assert.rejects(authority.cleanup('fork-retry'), /temporary removal failure/); + + const creation = { + sessionId: 'fork-retry', + kind: 'branch' as const, + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'web-contents:1', + }; + await assert.rejects( + authority.ownCreation(creation, async () => 'unreachable'), + /scheduled for cleanup/, + ); + + removalFails = false; + await authority.cleanup('fork-retry'); + assert.equal(await authority.ownCreation(creation, async () => 'created'), 'created'); + }); + + it('orders cancellation after an in-flight copy reaches a known outcome', async () => { + const workspaceRoot = await createWorkspace(); + let releaseCreation!: () => void; + const creationGate = new Promise((resolve) => { + releaseCreation = resolve; + }); + const events: string[] = []; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'process-current', + resumeSessionCopy: async () => { + events.push('resume'); + }, + removeSession: async () => { + events.push('remove'); + }, + }); + const creation = authority.ownCreation( + { + sessionId: 'fork-racing-create', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'web-contents:1', + }, + async () => { + events.push('create'); + await creationGate; + return 'created'; + }, + ); + + await authority.schedule('fork-racing-create'); + assert.deepEqual(events, ['create']); + releaseCreation(); + assert.equal(await creation, 'created'); + await authority.cleanup('fork-racing-create'); + + assert.deepEqual(events, ['create', 'remove']); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('resolves an unknown creating lease before removing it after restart', async () => { + const workspaceRoot = await createWorkspace(); + const first = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'process-before-crash', + removeSession: async () => { + throw new Error('remove should belong to the successor'); + }, + }); + await assert.rejects( + first.ownCreation( + { + sessionId: 'fork-unknown-create', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + intent: 'side_conversation', + ownerId: 'web-contents:2', + }, + async () => { + throw new Error('response lost'); + }, + ), + /response lost/, + ); + + const events: string[] = []; + const successor = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'process-after-crash', + resumeSessionCopy: async (creation) => { + events.push( + `resume:${creation.sessionId}:${creation.sourceTurnId ?? 'empty'}:${creation.intent}`, + ); + }, + removeSession: async (sessionId) => { + events.push(`remove:${sessionId}`); + }, + }); + + assert.deepEqual(await successor.recover(), { + removed: ['fork-unknown-create'], + failed: [], + }); + assert.deepEqual(events, [ + 'resume:fork-unknown-create:source-turn:side_conversation', + 'remove:fork-unknown-create', + ]); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('does not recover a live copy whose owning process is still active', async () => { + const workspaceRoot = await createWorkspace(); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:101', + removeSession: async () => {}, + }); + await owner.ownCreation( + { + sessionId: 'fork-live-owner', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + const removed: string[] = []; + const concurrent = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:202', + isOwnerProcessActive: (ownerProcessId) => ownerProcessId === 'tui:101', + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + + assert.deepEqual(await concurrent.recover(), { removed: [], failed: [] }); + assert.deepEqual(removed, []); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-live-owner']); + }); + + it('claims one released process incarnation across all of its copies', async () => { + const workspaceRoot = await createWorkspace(); + const original = fakeLifetimeOwner('lock-v1:11111111-1111-4111-8111-111111111111'); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:101', + processLifetimeOwner: original.owner, + removeSession: async () => {}, + }); + for (const sessionId of ['fork-lifetime-a', 'fork-lifetime-b']) { + await owner.ownCreation( + { + sessionId, + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + } + + const successor = fakeLifetimeOwner('lock-v1:22222222-2222-4222-8222-222222222222'); + const removed: string[] = []; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:202', + processLifetimeOwner: successor.owner, + removeSession: async (sessionId) => { + assert.equal(successor.claimClosed, false); + removed.push(sessionId); + }, + }); + + assert.deepEqual(await authority.recover(), { + removed: ['fork-lifetime-a', 'fork-lifetime-b'], + failed: [], + }); + assert.deepEqual(removed, ['fork-lifetime-a', 'fork-lifetime-b']); + assert.equal(successor.claimAttempts, 1); + assert.equal(successor.claimRetired, true); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('holds the released owner claim while recovering a cleanup-phase copy', async () => { + const workspaceRoot = await createWorkspace(); + const original = fakeLifetimeOwner('lock-v1:77777777-7777-4777-8777-777777777777'); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:707', + processLifetimeOwner: original.owner, + removeSession: async () => {}, + }); + await owner.ownCreation( + { + sessionId: 'fork-cleanup-phase', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + updatePendingLease(workspaceRoot, 'fork-cleanup-phase', (record) => ({ + ...record, + phase: 'cleanup', + cancelRequested: true, + })); + + const successor = fakeLifetimeOwner('lock-v1:88888888-8888-4888-8888-888888888888'); + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:808', + processLifetimeOwner: successor.owner, + removeSession: async () => { + assert.equal(successor.claimClosed, false); + }, + }); + + assert.deepEqual(await authority.recover(), { + removed: ['fork-cleanup-phase'], + failed: [], + }); + assert.equal(successor.claimAttempts, 1); + assert.equal(successor.claimRetired, true); + }); + + it('falls back to process liveness for an unsupported owner reference version', async () => { + const workspaceRoot = await createWorkspace(); + const original = fakeLifetimeOwner('lock-v1:99999999-9999-4999-8999-999999999999'); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:909', + processLifetimeOwner: original.owner, + removeSession: async () => {}, + }); + await owner.ownCreation( + { + sessionId: 'fork-future-owner', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + updatePendingLease(workspaceRoot, 'fork-future-owner', (record) => ({ + ...record, + ownerLifetimeRef: 'lock-v2:future-owner', + })); + + let claimAttempts = 0; + const removed: string[] = []; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:1001', + processLifetimeOwner: { + reference: 'lock-v1:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + tryClaimReleased: async () => { + claimAttempts += 1; + throw new Error('unsupported owner reference must use the PID fallback'); + }, + retireUnreferencedReleasedOwners: async () => {}, + close: async () => {}, + }, + isOwnerProcessActive: () => false, + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + + assert.deepEqual(await authority.recover(), { + removed: ['fork-future-owner'], + failed: [], + }); + assert.equal(claimAttempts, 0); + assert.deepEqual(removed, ['fork-future-owner']); + }); + + it('asks the process owner to retire released files with no database references', async () => { + const workspaceRoot = await createWorkspace(); + let referenced: ReadonlySet | undefined; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processLifetimeOwner: { + reference: 'lock-v1:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + tryClaimReleased: async () => undefined, + retireUnreferencedReleasedOwners: async (current) => { + referenced = current; + }, + close: async () => {}, + }, + removeSession: async () => {}, + }); + + assert.deepEqual(await authority.recover(), { removed: [], failed: [] }); + assert.ok(referenced); + assert.deepEqual([...referenced], []); + }); + + it('fails closed when process-incarnation ownership cannot be inspected', async () => { + const workspaceRoot = await createWorkspace(); + const original = fakeLifetimeOwner('lock-v1:33333333-3333-4333-8333-333333333333'); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:303', + processLifetimeOwner: original.owner, + removeSession: async () => {}, + }); + await owner.ownCreation( + { + sessionId: 'fork-unknown-owner', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + + const failure = new Error('native lock unavailable'); + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:404', + processLifetimeOwner: { + reference: 'lock-v1:44444444-4444-4444-8444-444444444444', + tryClaimReleased: async () => { + throw failure; + }, + retireUnreferencedReleasedOwners: async () => {}, + close: async () => {}, + }, + removeSession: async () => { + throw new Error('must not remove an indeterminate owner'); + }, + }); + + const recovery = await authority.recover(); + assert.deepEqual(recovery.removed, []); + assert.deepEqual(recovery.failed, [{ sessionId: 'fork-unknown-owner', error: failure }]); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-unknown-owner']); + }); + + it('abandons every live copy owned by a renderer that exits', async () => { + const workspaceRoot = await createWorkspace(); + const removed: string[] = []; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'process-current', + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + await authority.ownCreation( + { + sessionId: 'fork-owned-renderer', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'web-contents:7', + }, + async () => 'created', + ); + + await authority.abandonOwner('web-contents:7'); + await authority.cleanup('fork-owned-renderer'); + + assert.deepEqual(removed, ['fork-owned-renderer']); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('abandons only copies owned by the current process incarnation', async () => { + const workspaceRoot = await createWorkspace(); + const firstLifetime = fakeLifetimeOwner('lock-v1:55555555-5555-4555-8555-555555555555'); + const secondLifetime = fakeLifetimeOwner('lock-v1:66666666-6666-4666-8666-666666666666'); + const first = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:505', + processLifetimeOwner: firstLifetime.owner, + removeSession: async () => {}, + }); + const removed: string[] = []; + const second = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:505', + processLifetimeOwner: secondLifetime.owner, + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + for (const [authority, sessionId] of [ + [first, 'fork-first-incarnation'], + [second, 'fork-second-incarnation'], + ] as const) { + await authority.ownCreation( + { + sessionId, + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + } + + await second.abandonOwner('tui-side'); + await second.cleanup('fork-second-incarnation'); + + assert.deepEqual(removed, ['fork-second-incarnation']); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-first-incarnation']); + }); + + it('acknowledges abandon after the intent is durable without waiting for removal', async () => { + const workspaceRoot = await createWorkspace(); + let releaseRemoval: (() => void) | undefined; + const removal = new Promise((resolve) => { + releaseRemoval = resolve; + }); + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => removal, + }); + + await authority.schedule('fork-scheduled'); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-scheduled']); + + releaseRemoval?.(); + await authority.cleanup('fork-scheduled'); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + it('persists a failed removal and recovers it through a new authority instance', async () => { + const workspaceRoot = await createWorkspace(); + const first = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => { + throw new Error('temporary removal failure'); + }, + }); + + await assert.rejects(first.cleanup('fork-1'), /temporary removal failure/); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-1']); + + const removed: string[] = []; + const afterRestart = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + const recovery = await afterRestart.recover(); + + assert.deepEqual(removed, ['fork-1']); + assert.deepEqual(recovery, { removed: ['fork-1'], failed: [] }); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('clears the durable intent only after the complete removal succeeds', async () => { + const workspaceRoot = await createWorkspace(); + let pendingDuringRemoval: string[] = []; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => { + pendingDuringRemoval = await readPendingIds(workspaceRoot); + }, + }); + + await authority.cleanup('fork-2'); + + assert.deepEqual(pendingDuringRemoval, ['fork-2']); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); + + it('continues recovering other companions when one removal still fails', async () => { + const workspaceRoot = await createWorkspace(); + const seed = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => { + throw new Error('offline'); + }, + }); + await assert.rejects(seed.cleanup('fork-a')); + await assert.rejects(seed.cleanup('fork-b')); + + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async (sessionId) => { + if (sessionId === 'fork-a') throw new Error('still offline'); + }, + }); + const recovery = await authority.recover(); + + assert.deepEqual(recovery.removed, ['fork-b']); + assert.deepEqual( + recovery.failed.map(({ sessionId }) => sessionId), + ['fork-a'], + ); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-a']); + }); + + it('forgets a terminal retained Revision without reporting deletion', async () => { + const workspaceRoot = await createWorkspace(); + const seed = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => { + throw new Error('offline'); + }, + }); + await assert.rejects(seed.cleanup('retained-revision')); + + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + removeSession: async () => 'retained', + }); + assert.deepEqual(await authority.recover(), { removed: [], failed: [] }); + assert.deepEqual(await readPendingIds(workspaceRoot), []); + }); +}); + +async function readPendingIds(workspaceRoot: string): Promise { + const database = new DatabaseSync(join(workspaceRoot, 'runtime.sqlite'), { readOnly: true }); + try { + return ( + database + .prepare(` + SELECT session_id AS sessionId + FROM workflow_quote_companion_cleanup + ORDER BY tracked_at, session_id + `) + .all() as Array<{ sessionId: string }> + ).map(({ sessionId }) => sessionId); + } finally { + database.close(); + } +} + +function updatePendingLease( + workspaceRoot: string, + sessionId: string, + update: (record: Record) => Record, +): void { + const database = new DatabaseSync(join(workspaceRoot, 'runtime.sqlite')); + try { + const row = database + .prepare( + 'SELECT record_json AS recordJson FROM workflow_quote_companion_cleanup WHERE session_id = ?', + ) + .get(sessionId) as { recordJson: string } | undefined; + assert.ok(row); + database + .prepare('UPDATE workflow_quote_companion_cleanup SET record_json = ? WHERE session_id = ?') + .run( + JSON.stringify(update(JSON.parse(row.recordJson) as Record)), + sessionId, + ); + } finally { + database.close(); + } +} + +function fakeLifetimeOwner(reference: string): { + owner: ProcessLifetimeOwner; + readonly claimAttempts: number; + readonly claimClosed: boolean; + readonly claimRetired: boolean; +} { + let claimAttempts = 0; + let claimClosed = false; + let claimRetired = false; + const claim: ProcessLifetimeRecoveryClaim = { + retire: async () => { + claimRetired = true; + claimClosed = true; + }, + close: async () => { + claimClosed = true; + }, + }; + return { + owner: { + reference, + tryClaimReleased: async () => { + claimAttempts += 1; + return claim; + }, + retireUnreferencedReleasedOwners: async () => {}, + close: async () => {}, + }, + get claimAttempts() { + return claimAttempts; + }, + get claimClosed() { + return claimClosed; + }, + get claimRetired() { + return claimRetired; + }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1b44f827b405d4fb44fbdeec5cec87368bfa5bd187dd0c6345eaa4916c09be6c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1b44f827b405d4fb44fbdeec5cec87368bfa5bd187dd0c6345eaa4916c09be6c.source new file mode 100644 index 0000000000..6ec943fd9b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1b44f827b405d4fb44fbdeec5cec87368bfa5bd187dd0c6345eaa4916c09be6c.source @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { normalizeRootTurnAdmissionPayload } from '../agent-run-store.js'; + +test('root admission preserves an explicit empty inline-reference marker from its sources', () => { + const content = { text: 'plain', inlineReferences: [] } as const; + const normalized = normalizeRootTurnAdmissionPayload(content, [ + { + messageId: 'message-1', + content, + placement: 'next_turn', + disposition: 'followup', + }, + ]); + + assert.deepEqual(normalized.normalizedInput, content); + assert.deepEqual(normalized.sourceMessages[0]?.content, content); +}); + +test('root admission preserves and validates each source submission digest', () => { + const digest = `sha256:${'a'.repeat(64)}` as const; + const source = { + messageId: 'message-1', + content: { text: 'prepared', displayText: 'submitted' }, + submittedContentDigest: digest, + placement: 'next_turn' as const, + disposition: 'followup' as const, + }; + const normalized = normalizeRootTurnAdmissionPayload(source.content, [source]); + + assert.equal(normalized.sourceMessages[0]?.submittedContentDigest, digest); + assert.throws(() => + normalizeRootTurnAdmissionPayload(source.content, [ + { ...source, submittedContentDigest: 'sha256:not-a-digest' }, + ]), + ); +}); + +test('root admission preserves and validates each source submitted placement', () => { + const content = { text: 'promoted follow-up' } as const; + const source = { + messageId: 'promoted-message', + content, + submittedPlacement: 'next_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + }; + + assert.equal( + normalizeRootTurnAdmissionPayload(content, [source]).sourceMessages[0]?.submittedPlacement, + 'next_turn', + ); + assert.throws(() => + normalizeRootTurnAdmissionPayload(content, [ + { ...source, submittedPlacement: 'invalid-placement' }, + ]), + ); +}); + +test('root admission preserves and validates each source Skill outcome', () => { + const content = { text: 'prepared', displayText: '/skill:writer draft' } as const; + const skillInvocation = { + loaded: [{ id: 'writer', name: 'Writer' }], + failed: [{ request: 'typo', reason: 'not_found' as const }], + receipts: [], + }; + const source = { + messageId: 'message-skill', + content, + skillInvocation, + placement: 'next_turn' as const, + disposition: 'followup' as const, + }; + + assert.deepEqual( + normalizeRootTurnAdmissionPayload(content, [source]).sourceMessages[0]?.skillInvocation, + skillInvocation, + ); + assert.throws(() => + normalizeRootTurnAdmissionPayload(content, [ + { ...source, skillInvocation: { loaded: [], failed: [], receipts: 'invalid' } }, + ]), + ); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ba2cca36caba115903e9e0f58483482ee810349d47e6fc492b9e45de3993ec7.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ba2cca36caba115903e9e0f58483482ee810349d47e6fc492b9e45de3993ec7.source new file mode 100644 index 0000000000..4f577f8b98 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1ba2cca36caba115903e9e0f58483482ee810349d47e6fc492b9e45de3993ec7.source @@ -0,0 +1,336 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + CREDENTIAL_SCHEMA_VERSION, + createFileCredentialStore, + withCredentialFileLock, + type CredentialCasResult, + type CredentialKind, + type CredentialStore, +} from '../credential-store.js'; + +const isPosix = process.platform !== 'win32'; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'maka-cred-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('FileCredentialStore', () => { + test('round-trips secrets and returns null for missing ones', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + assert.equal(await store.getSecret('openai-prod', 'api_key'), null); + + await store.setSecret('openai-prod', 'api_key', 'sk-test-123'); + assert.equal(await store.getSecret('openai-prod', 'api_key'), 'sk-test-123'); + + await store.deleteSecret('openai-prod', 'api_key'); + assert.equal(await store.getSecret('openai-prod', 'api_key'), null); + }); + }); + + test('deleteSecret(slug) with no kind clears every kind for that slug only', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('a', 'api_key', 'key-a'); + await store.setSecret('a', 'oauth_token', 'tok-a'); + await store.setSecret('b', 'api_key', 'key-b'); + + await store.deleteSecret('a'); + + assert.equal(await store.getSecret('a', 'api_key'), null); + assert.equal(await store.getSecret('a', 'oauth_token'), null); + assert.equal(await store.getSecret('b', 'api_key'), 'key-b'); + }); + }); + + test('writes a versioned, plaintext (file-first) file', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('openai-prod', 'api_key', 'sk-plain'); + + const raw = JSON.parse(await readFile(join(dir, 'credentials.json'), 'utf8')) as { + version: number; + values: Record; + }; + assert.equal(raw.version, CREDENTIAL_SCHEMA_VERSION); + // File-first: the value is stored as plaintext, not encoded. + assert.equal(raw.values['openai-prod:apiKey'], 'sk-plain'); + }); + }); + + test('reading an unknown / pre-migration schema fails closed', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'credentials.json'); + + // Legacy file: no `version` field (the safeStorage-era shape). + await writeFile(path, JSON.stringify({ values: { 'x:apiKey': 'enc' } }), 'utf8'); + const legacy = createFileCredentialStore(dir); + await assert.rejects(legacy.getSecret('x', 'api_key'), /schema version/); + + // Future version we don't understand. + await writeFile(path, JSON.stringify({ version: 999, values: {} }), 'utf8'); + const future = createFileCredentialStore(dir); + await assert.rejects(future.getSecret('x', 'api_key'), /schema version/); + }); + }); + + test('leaves no temp file behind after a write', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('a', 'api_key', 'k'); + const entries = await readdir(dir); + assert.deepEqual(entries, ['credentials.json']); + }); + }); + + test('creates the file 0600 on POSIX', { skip: !isPosix }, async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('a', 'api_key', 'k'); + const mode = (await stat(join(dir, 'credentials.json'))).mode & 0o777; + assert.equal(mode, 0o600); + }); + }); + + test('re-chmods a pre-existing world-readable file to 0600 on write', { + skip: !isPosix, + }, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'credentials.json'); + // A valid v1 file that was created with a loose mode. + await writeFile(path, JSON.stringify({ version: CREDENTIAL_SCHEMA_VERSION, values: {} }), { + encoding: 'utf8', + mode: 0o644, + }); + const store = createFileCredentialStore(dir); + await store.setSecret('a', 'api_key', 'k'); + const mode = (await stat(path)).mode & 0o777; + assert.equal(mode, 0o600); + }); + }); + + test('hardens a pre-existing world-accessible workspace dir to 0700 on write', { + skip: !isPosix, + }, async () => { + await withTempDir(async (dir) => { + await chmod(dir, 0o777); // a loose dir that predates the hardening + const store = createFileCredentialStore(dir); + await store.setSecret('a', 'api_key', 'k'); + // hardenDirectory re-chmods an existing dir (mkdir's mode only applies + // on creation); the writer and the lock share it, so the lock can't + // leave the dir loose either. + assert.equal((await stat(dir)).mode & 0o777, 0o700); + }); + }); + + test('serializes concurrent writes across slugs without clobbering', async () => { + await withTempDir(async (dir) => { + // With no in-instance queue, these contend directly on the file lock — + // this proves the lock alone serializes a read-modify-write so no slug is + // dropped. A handful is enough; more just adds lock-poll wall-clock. + const store = createFileCredentialStore(dir); + const count = 8; + await Promise.all( + Array.from({ length: count }, (_unused, i) => + store.setSecret(`conn-${i}`, 'api_key', `key-${i}`), + ), + ); + for (let i = 0; i < count; i++) { + assert.equal(await store.getSecret(`conn-${i}`, 'api_key'), `key-${i}`); + } + }); + }); + + test('two independent store instances writing concurrently both survive (cross-process lock)', async () => { + await withTempDir(async (dir) => { + // Separate instances => separate in-instance queues; only the file + // lock can stop a read-modify-write lost update between them. + const a = createFileCredentialStore(dir); + const b = createFileCredentialStore(dir); + await Promise.all([ + a.setSecret('slug-a', 'api_key', 'AAA'), + b.setSecret('slug-b', 'api_key', 'BBB'), + ]); + + const reader = createFileCredentialStore(dir); + assert.equal(await reader.getSecret('slug-a', 'api_key'), 'AAA'); + assert.equal(await reader.getSecret('slug-b', 'api_key'), 'BBB'); + }); + }); + + test('a held lock is waited on, never stolen (no lost update)', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'credentials.json'); + // Hold the lock as another process (or a crashed one) would: the lock is + // the `${path}.lock` directory. The store must wait for it, never steal it. + const lockPath = `${path}.lock`; + await mkdir(lockPath); + + const store = createFileCredentialStore(dir); + let settled = false; + const write = store.setSecret('a', 'api_key', 'V').then(() => { + settled = true; + }); + + // The lock is held, so the write is blocked before its critical section: + // it must not steal the lock and must not have written the file yet. + await assert.rejects(stat(path)); // file absent — proven blocked, not stolen + assert.equal(settled, false); + + await rm(lockPath, { recursive: true, force: true }); // release + await write; + assert.equal(await store.getSecret('a', 'api_key'), 'V'); // proceeds only once the lock frees + }); + }); + + test('a never-released lock fails loud with the lock path and recovery hint', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'credentials.json'); + await mkdir(`${path}.lock`); // a crashed holder's lock that never releases + // A small timeout drives the fail-loud path without the production wait. + // The error must name the lock dir AND how to recover, so the guidance + // can't silently regress. + await assert.rejects( + withCredentialFileLock(path, async () => 'unreachable', 60), + (error: Error) => + error.message.includes(`${path}.lock`) && + /remove that directory and retry/.test(error.message), + ); + }); + }); +}); + +describe('FileCredentialStore compareAndSetSecret', () => { + // Optional capability: exercise it through a guarded helper so a store that + // does not expose CAS is a caller's own fallback problem, not a test crash. + function cas( + store: CredentialStore, + slug: string, + kind: CredentialKind, + expected: string | null, + value: string, + ): Promise { + assert.ok(store.compareAndSetSecret, 'file store exposes the CAS capability'); + return store.compareAndSetSecret(slug, kind, expected, value); + } + + test('commits when the basis still matches, and the write persists', async () => { + await withTempDir(async (dir) => { + const store = createFileCredentialStore(dir); + await store.setSecret('acct', 'oauth_token', 'tok-basis'); + + const result = await cas(store, 'acct', 'oauth_token', 'tok-basis', 'tok-next'); + assert.deepEqual(result, { committed: true }); + assert.equal(await store.getSecret('acct', 'oauth_token'), 'tok-next'); + }); + }); + + test('commits a write-if-absent (expected null) only while the entry is absent', async () => { + await withTempDir(async (dir) => { + // Decide "store has no token" and write in one serialized step. A value + // written between the check and the write must make this writer lose. + const importer = createFileCredentialStore(dir); + const other = createFileCredentialStore(dir); + + // Someone writes a token after the importer would have read "absent". + await other.setSecret('acct', 'oauth_token', 'tok-live'); + + const blocked = await cas(importer, 'acct', 'oauth_token', null, 'tok-import'); + assert.deepEqual(blocked, { committed: false, current: 'tok-live' }); + assert.equal(await other.getSecret('acct', 'oauth_token'), 'tok-live'); + + // With a genuinely absent entry the same write-if-absent commits. + const committed = await cas(importer, 'other-acct', 'oauth_token', null, 'tok-import'); + assert.deepEqual(committed, { committed: true }); + assert.equal(await importer.getSecret('other-acct', 'oauth_token'), 'tok-import'); + }); + }); + + test('two store instances racing on the same basis: loser sees the entry changed and adopts it', async () => { + await withTempDir(async (dir) => { + // Two separate instances share the file (a cross-process refresh). Both + // read the same basis; only the file lock decides the winner. + const a = createFileCredentialStore(dir); + const b = createFileCredentialStore(dir); + await a.setSecret('acct', 'oauth_token', 'tok-basis'); + + // A commits first, then B tries with the now-stale basis. + const winner = await cas(a, 'acct', 'oauth_token', 'tok-basis', 'tok-A'); + const loser = await cas(b, 'acct', 'oauth_token', 'tok-basis', 'tok-B'); + + assert.deepEqual(winner, { committed: true }); + // Entry changed (not gone): current is a string the loser adopts. + assert.deepEqual(loser, { committed: false, current: 'tok-A' }); + const reader = createFileCredentialStore(dir); + assert.equal(await reader.getSecret('acct', 'oauth_token'), 'tok-A'); + }); + }); + + test('a basis whose entry was deleted (logout) is distinguishable from a changed one', async () => { + await withTempDir(async (dir) => { + const a = createFileCredentialStore(dir); + const b = createFileCredentialStore(dir); + await a.setSecret('acct', 'oauth_token', 'tok-basis'); + + // A terminal logout removes the entry after B read its basis. + await a.deleteSecret('acct', 'oauth_token'); + + const result = await cas(b, 'acct', 'oauth_token', 'tok-basis', 'tok-resurrect'); + // Entry gone (current === null): the caller must NOT resurrect it, and the + // write did not commit. + assert.deepEqual(result, { committed: false, current: null }); + const reader = createFileCredentialStore(dir); + assert.equal(await reader.getSecret('acct', 'oauth_token'), null); + }); + }); + + test('concurrent CAS from the same basis: exactly one wins, the loser returns the winner value', async () => { + await withTempDir(async (dir) => { + const a = createFileCredentialStore(dir); + const b = createFileCredentialStore(dir); + await a.setSecret('acct', 'oauth_token', 'tok-basis'); + + const [ra, rb] = await Promise.all([ + cas(a, 'acct', 'oauth_token', 'tok-basis', 'tok-A'), + cas(b, 'acct', 'oauth_token', 'tok-basis', 'tok-B'), + ]); + + const winners = [ra, rb].filter((r) => r.committed); + assert.equal(winners.length, 1, 'exactly one CAS commits'); + const winnerValue = ra.committed ? 'tok-A' : 'tok-B'; + const loser = ra.committed ? rb : ra; + assert.deepEqual(loser, { committed: false, current: winnerValue }); + + const reader = createFileCredentialStore(dir); + assert.equal(await reader.getSecret('acct', 'oauth_token'), winnerValue); + }); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1c8116448911a87e7b8c4e17dbfc64feceee84617f77d6750d280a889ed414f6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1c8116448911a87e7b8c4e17dbfc64feceee84617f77d6750d280a889ed414f6.source new file mode 100644 index 0000000000..55d0655d21 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1c8116448911a87e7b8c4e17dbfc64feceee84617f77d6750d280a889ed414f6.source @@ -0,0 +1,2589 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, it } from 'node:test'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { RunSealedError } from '@maka/core/runtime-event-store'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; +import { readLogicalRuntimeExecution } from '@maka/core/runtime-logical-execution'; +import { + RuntimeTranscriptOversizedTurnError, + RuntimeTranscriptQuery, +} from '../runtime-transcript-query.js'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { + buildImmutableRuntimePrefix, + createRuntimeBoundaryCursor, + runtimePrefixSegment, + type ContinuationClaimV1, + type ImmutableRuntimePrefixV1, +} from '@maka/core/runtime-boundary'; +import { + ToolLedgerCorruptionError, + ToolLedgerRejectionError, +} from '@maka/core/tool-ledger-scanner'; +import { + SQLITE_RUNTIME_SCHEMA_VERSION, + createSqliteRuntimeStore, + type SqliteRuntimeStoreFailpoint, +} from '../sqlite-runtime-store.js'; + +describe('SqliteRuntimeStore', () => { + it('applies versioned migrations and reopens the same database without rewriting schema', async () => { + await withStore(async (store, dbPath) => { + assert.equal(store.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); + assert.equal(store.journalMode(), 'wal'); + assert.equal(store.foreignKeysEnabled(), true); + store.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.equal(reopened.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); + assert.deepEqual(await reopened.readRuntimeEvents('session-1', 'run-1'), []); + } finally { + reopened.close(); + } + }); + }); + + it('refuses every post-terminal append as the typed sealed-run boundary', async () => { + await withStore(async (store) => { + const opening = functionCallEvent({ + id: 'sealed-run-opening', + content: { kind: 'text', text: 'hello' }, + }); + await store.appendRuntimeEvent(opening.sessionId, opening.runId, opening); + const terminal: RuntimeEvent = { + id: 'sealed-run-terminal', + invocationId: 'invocation-1', + runId: opening.runId, + sessionId: opening.sessionId, + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'aborted', + actions: { endInvocation: true, stateDelta: { abortSource: 'user_stop' } }, + }; + await store.appendRuntimeEvent(terminal.sessionId, terminal.runId, terminal); + + // A plain straggler and a tool-bearing one refuse identically: the + // seal is checked before tool-ledger semantics (#2311), so a late + // function_call cannot surface as a producer bug or as corruption. + await assert.rejects( + store.appendRuntimeEvent(opening.sessionId, opening.runId, { + ...opening, + id: 'late-plain-straggler', + ts: 3, + }), + (error: unknown) => error instanceof RunSealedError, + ); + await assert.rejects( + store.appendRuntimeEvent( + opening.sessionId, + opening.runId, + functionCallEvent({ + id: 'late-tool-straggler', + ts: 4, + }), + ), + (error: unknown) => error instanceof RunSealedError, + ); + // Exact-id retry of an already-stored event keeps its dedup answer. + await store.appendRuntimeEvent(terminal.sessionId, terminal.runId, terminal); + }); + }); + + it('bounds a transcript Turn by the bytes it stores, not by its JSON string length', async () => { + await withStore(async (store) => { + const run = { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + }; + await store.appendRuntimeEvent( + run.sessionId, + run.runId, + buildInvocationOpenedEvent({ + id: 'oversized-opening', + run, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), + ); + // Every character here is three stored bytes, so a budget read as UTF-16 + // code units admits a Turn three times the size it was asked to bound. + const text = '本'.repeat(4_000); + await store.appendRuntimeEvent(run.sessionId, run.runId, { + id: 'oversized-prompt', + ...run, + ts: 2, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text }, + }); + await store.appendRuntimeEvent(run.sessionId, run.runId, { + id: 'oversized-terminal', + ...run, + ts: 3, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }); + + const request = { + direction: 'newer' as const, + throughOrdinal: Number.MAX_SAFE_INTEGER, + position: 1, + limit: 8, + maxEvents: 64, + }; + await assert.rejects( + store.readTranscriptInvocations(run.sessionId, { ...request, maxBytes: 6_000 }), + (error: unknown) => error instanceof RuntimeTranscriptOversizedTurnError, + ); + const served = await store.readTranscriptInvocations(run.sessionId, { + ...request, + maxBytes: 64_000, + }); + assert.equal(served.length, 1); + }); + }); + + it('pages the transcript without reading rows the page does not contain', async () => { + await withStore(async (store, dbPath) => { + for (let turn = 0; turn < 4; turn += 1) await appendSettledTurn(store, turn); + store.close(); + const db = new DatabaseSync(dbPath); + try { + const executed: { sql: string; bind: unknown[] }[] = []; + const query = new RuntimeTranscriptQuery( + watchStatements(db, executed), + () => + ({ + sessionId: 'session-1', + }) as unknown as RuntimeInvocationRecord, + ); + const request = { + throughOrdinal: Number.MAX_SAFE_INTEGER, + position: 6, + limit: 1, + maxEvents: 64, + maxBytes: 64_000, + }; + query.highWater('session-1'); + query.invocations('session-1', { ...request, direction: 'older' }); + query.invocations('session-1', { ...request, direction: 'newer' }); + // A full scan is how a page starts costing the Session it sits in: the + // rows it walks are every Turn's, not the page's. + for (const { sql, bind } of executed) { + const plan = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...(bind as [])) as unknown as { + detail: string; + }[]; + const scans = plan.filter((step) => step.detail.startsWith('SCAN')); + assert.deepEqual(scans, [], `${scans[0]?.detail} in ${sql}`); + } + } finally { + db.close(); + } + }); + }); + + it('assigns stable Session ordinals in commit order across Runs', async () => { + await withStore(async (store, dbPath) => { + const first = functionCallEvent({ id: 'ordinal-1', ts: 20 }); + const second = functionCallEvent({ + id: 'ordinal-2', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + ts: 10, + }); + await store.appendRuntimeEvent(first.sessionId, first.runId, first); + await store.appendRuntimeEvent(second.sessionId, second.runId, second); + await store.appendRuntimeEvent(first.sessionId, first.runId, first); + + assert.deepEqual( + (await store.readSessionRuntimeEventEntries('session-1')).map(({ ordinal, event }) => ({ + ordinal, + eventId: event.id, + })), + [ + { ordinal: 1, eventId: 'ordinal-1' }, + { ordinal: 2, eventId: 'ordinal-2' }, + ], + ); + + store.close(); + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual( + (await reopened.readSessionRuntimeEventEntries('session-1')).map( + ({ ordinal, event }) => ({ ordinal, eventId: event.id }), + ), + [ + { ordinal: 1, eventId: 'ordinal-1' }, + { ordinal: 2, eventId: 'ordinal-2' }, + ], + ); + } finally { + reopened.close(); + } + }); + }); + it('makes a raw canonical-equivalent terminal durability retry idempotent', async () => { + await withStore(async (store) => { + const terminal: RuntimeEvent = { + id: 'terminal-event-1', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 5, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + content: { + kind: 'text', + text: 'done', + displayText: 'done', + attachments: [], + quotes: [], + }, + actions: { endInvocation: true }, + }; + + await store.appendRuntimeEvent('session-1', 'run-1', terminal); + await store.ensureTerminalRuntimeEventDurable('session-1', 'run-1', terminal); + + const events = await store.readImmutableRuntimeEvents('session-1', 'run-1'); + assert.equal(events.length, 1); + assert.deepEqual(events[0]?.content, { kind: 'text', text: 'done' }); + }); + }); + + it('imports a conversation-copy tool ledger with its derived projections', async () => { + await withStore(async (store) => { + const events = [functionCallEvent(), toolDispatchEvent(), functionResponseEvent({ ts: 11 })]; + + await store.importConversationCopyRuntimeEvents('session-1', [{ runId: 'run-1', events }]); + await store.importConversationCopyRuntimeEvents('session-1', [{ runId: 'run-1', events }]); + + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), events); + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'outcome_committed', + ); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared', 'outcome_committed'], + ); + }); + }); + + // These two pin the ERROR CLASS, not the message. AgentRun exempts exactly + // one class from the store-unavailable latch (`ToolLedgerRejectionError`), so + // the class is a behavioural contract between storage and runtime — and both + // messages are byte-identical to the plain `Error` strings they replaced, so + // a regression to `throw new Error(...)` would leave every message-matching + // assertion in this suite green while the exemption silently stopped working. + it('rejects an inadmissible candidate with ToolLedgerRejectionError, naming the code', async () => { + await withStore(async (store) => { + // Untagged, so it takes the generic lane — a tagged response is a + // reserved boundary fact and never reaches the transition check. This is + // the exact shape #2234 produced: a result with no call to answer. + const orphan = functionResponseEvent({ + id: 'orphan-response-event', + ts: 11, + refs: { toolCallId: 'provider-call-1' }, + }); + await assert.rejects( + store.appendRuntimeEvent(orphan.sessionId, orphan.runId, orphan), + (error: unknown) => + error instanceof ToolLedgerRejectionError && + error.code === 'orphan_response' && + error.eventId === 'orphan-response-event', + ); + }); + }); + + it('reports pre-existing damage as ToolLedgerCorruptionError, even from another session', async () => { + await withStore(async (store, dbPath) => { + store.close(); + + // Seed damage the store would never have written itself, in a session + // this run never touches: the health scan has no WHERE clause, so one + // damaged operation anywhere in the workspace is what a later append meets. + const raw = new DatabaseSync(dbPath); + const stranded = functionResponseEvent({ + id: 'stranded-response', + sessionId: 'some-other-session', + invocationId: 'some-other-invocation', + runId: 'some-other-run', + ts: 5, + }); + raw + .prepare(` + INSERT INTO runtime_events + (event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, + payload_json, committed_at) + VALUES (?, ?, ?, ?, ?, 1, 'function_response', ?, 5) + `) + .run( + stranded.id, + stranded.sessionId, + stranded.invocationId, + stranded.runId, + stranded.turnId, + JSON.stringify(stranded), + ); + raw.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + const healthy = functionCallEvent(); + await assert.rejects( + reopened.appendRuntimeEvent(healthy.sessionId, healthy.runId, healthy), + (error: unknown) => + error instanceof ToolLedgerCorruptionError && + !(error instanceof ToolLedgerRejectionError) && + error.code === 'orphan_response', + ); + } finally { + reopened.close(); + } + }); + }); + + it('commits function_call, dispatch fact, and operation projection atomically in T1', async () => { + await withStore(async (store) => { + const call = functionCallEvent(); + const dispatch = toolDispatchEvent(); + + const input = { + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: call, + dispatchRuntimeEvent: dispatch, + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + committedAt: 10, + } as const; + const result = await store.commitToolPrepared(input); + + assert.equal(result.created, true); + assert.equal(result.runtimeEventSeq, 2); + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), [call, dispatch]); + assert.deepEqual(await store.readToolOperation('operation-1'), { + operationId: 'operation-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + currentState: 'prepared', + callEventId: 'call-event-1', + dispatchEventId: 'dispatch-event-1', + version: 1, + }); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared'], + ); + assert.equal((await store.readToolJournal('operation-1'))[0]?.runtimeEventId, dispatch.id); + assert.deepEqual( + (await store.listUnsettledToolOperations()).map((operation) => operation.operationId), + ['operation-1'], + ); + }); + }); + + it('commits nested T1 events with parent operation linkage', async () => { + await withStore(async (store) => { + const parentRefs = { + parentToolCallId: 'exec-call-1', + parentOperationId: 'exec-operation-1', + } as const; + const call = functionCallEvent({ + refs: { + operationId: 'operation-1', + toolCallId: 'provider-call-1', + ...parentRefs, + }, + }); + const dispatch = toolDispatchEvent({ + refs: { + operationId: 'operation-1', + toolCallId: 'provider-call-1', + ...parentRefs, + }, + }); + + const result = await store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: call, + dispatchRuntimeEvent: dispatch, + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + committedAt: 10, + }); + + assert.equal(result.created, true); + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), [call, dispatch]); + }); + }); + + it('claims an exact function_call that was committed while permission was pending', async () => { + await withStore(async (store) => { + const call = functionCallEvent(); + await store.appendRuntimeEvent('session-1', 'run-1', call); + + const result = await commitPrepared(store); + + assert.equal(result.created, true); + assert.equal(result.runtimeEventSeq, 2); + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), [ + call, + toolDispatchEvent(), + ]); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + + it('rolls back every T1 row when failure occurs after the RuntimeEvent insert', async () => { + await withStore(async (store, _dbPath, setFailpoint) => { + setFailpoint('after_runtime_event_insert'); + + await assert.rejects( + store.commitToolPrepared({ + operationId: 'operation-t1-failure', + journalEventId: 'operation-t1-failure_prepared', + runtimeEvent: functionCallEvent({ id: 'call-t1-failure' }), + dispatchRuntimeEvent: toolDispatchEvent({ + id: 'dispatch-t1-failure', + refs: { operationId: 'operation-t1-failure', toolCallId: 'provider-call-1' }, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-t1-failure', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + }, + }, + }), + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + committedAt: 11, + }), + /sqlite runtime failpoint: after_runtime_event_insert/, + ); + + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), []); + assert.equal(await store.readToolOperation('operation-t1-failure'), undefined); + assert.deepEqual(await store.readToolJournal('operation-t1-failure'), []); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 0); + }); + }); + + it('commits function_response, outcome journal fact, and projection atomically in T2', async () => { + await withStore(async (store) => { + await commitPrepared(store, { resultProjectionVersion: 1 }); + const outcome = functionResponseEvent({ + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: 'private execution contents', + modelProjection: { version: 1, kind: 'text', text: 'bounded model contents' }, + }, + }); + + const result = await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: outcome, + committedAt: 20, + }); + + assert.equal(result.created, true); + assert.equal(result.runtimeEventSeq, 3); + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), [ + functionCallEvent(), + toolDispatchEvent({ + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + resultProjectionVersion: 1, + }, + }, + }), + outcome, + ]); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 3); + assert.deepEqual(await store.readToolOperation('operation-1'), { + operationId: 'operation-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + currentState: 'outcome_committed', + callEventId: 'call-event-1', + dispatchEventId: 'dispatch-event-1', + resultEventId: 'response-event-1', + version: 2, + }); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared', 'outcome_committed'], + ); + assert.deepEqual(await store.listUnsettledToolOperations(), []); + }); + }); + + it('keeps projected T2 prepared when its atomic model projection is missing', async () => { + await withStore(async (store) => { + await commitPrepared(store, { resultProjectionVersion: 1 }); + + await assert.rejects( + store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 20, + }), + /requires its durable model projection/, + ); + + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared'], + ); + }); + }); + + it('rolls back T2 without hiding the previously committed prepared boundary', async () => { + await withStore(async (store, _dbPath, setFailpoint) => { + await commitPrepared(store); + setFailpoint('after_runtime_event_insert'); + + await assert.rejects( + store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent({ id: 'response-t2-failure' }), + committedAt: 21, + }), + /sqlite runtime failpoint: after_runtime_event_insert/, + ); + + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared'], + ); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 2); + }); + }); + + it('deduplicates exact T1/T2 retries and rejects operation identity drift', async () => { + await withStore(async (store) => { + const firstPrepared = await commitPrepared(store); + const duplicatePrepared = await commitPrepared(store); + assert.equal(firstPrepared.created, true); + assert.equal(duplicatePrepared.created, false); + + const firstOutcome = await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 20, + }); + const duplicateOutcome = await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 20, + }); + assert.equal(firstOutcome.created, true); + assert.equal(duplicateOutcome.created, false); + assert.equal((await store.readToolJournal('operation-1')).length, 2); + assert.equal((await store.readRuntimeEvents('session-1', 'run-1')).length, 3); + + await assert.rejects( + store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: functionCallEvent({ + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Read', + args: { path: '/workspace/repo/OTHER.md' }, + }, + }), + dispatchRuntimeEvent: toolDispatchEvent({ + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: DIFFERENT_READ_ARGS_HASH, + recoveryMode: 'replay_safe', + }, + }, + }), + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: DIFFERENT_READ_ARGS_HASH, + recoveryMode: 'replay_safe', + committedAt: 30, + }), + /duplicate_event_id/, + ); + }); + }); + + it('validates a tool transition after unrelated invocation history', async () => { + await withStore(async (store) => { + const unrelated = functionCallEvent({ + id: 'unrelated-event', + sessionId: 'session-2', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + content: { kind: 'text', text: 'unrelated history' }, + }); + await store.appendRuntimeEvent(unrelated.sessionId, unrelated.runId, unrelated); + + const result = await commitPrepared(store); + + assert.equal(result.created, true); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + + it('rebuilds disposable tool projections from RuntimeEvent facts', async () => { + await withStore(async (store) => { + await commitPrepared(store); + await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 20, + }); + + const result = await store.rebuildToolProjectionsFromRuntimeEvents(); + + assert.deepEqual(result, { operations: 1, journalEvents: 2 }); + assert.equal( + (await store.readToolOperation('operation-1'))?.dispatchEventId, + 'dispatch-event-1', + ); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => ({ + state: event.state, + runtimeEventId: event.runtimeEventId, + })), + [ + { state: 'prepared', runtimeEventId: 'dispatch-event-1' }, + { state: 'outcome_committed', runtimeEventId: 'response-event-1' }, + ], + ); + }); + }); + + it('coalesces stream chunks outside the immutable high-water ledger', async () => { + await withStore(async (store) => { + for (const [index, text] of ['hel', 'lo', '!'].entries()) { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: `partial-${index}`, + ts: index + 1, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + refs: { providerEventId: 'message-1' }, + }), + ); + } + + const visible = await store.readRuntimeEvents('session-1', 'run-1'); + const scanned: RuntimeEvent[] = []; + const scan = await store.scanRuntimeEvents( + 'session-1', + 'run-1', + { + maxBatchBytes: 1024, + maxRecordBytes: 1024, + maxImmutableRecords: 10, + maxImmutableBytes: 1024, + maxPartialRecords: 10, + maxPartialBytes: 1024, + }, + (events) => scanned.push(...events), + ); + assert.equal(scan.status, 'complete'); + assert.equal(visible.length, 1); + assert.deepEqual(scanned, visible); + assert.deepEqual(visible[0]?.content, { kind: 'text', text: 'hello!' }); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 0); + }); + }); + + it('rejects an oversized scan record before visiting its decoded body', async () => { + await withStore(async (store) => { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'large-event', + content: { kind: 'text', text: 'x'.repeat(4096) }, + }), + ); + let visits = 0; + const result = await store.scanRuntimeEvents( + 'session-1', + 'run-1', + { + maxBatchBytes: 128, + maxRecordBytes: 128, + maxImmutableRecords: 10, + maxImmutableBytes: 1024, + maxPartialRecords: 10, + maxPartialBytes: 1024, + }, + () => { + visits += 1; + }, + ); + assert.equal(result.status, 'limit_exceeded'); + assert.equal(visits, 0); + }); + }); + + it('rejects an immutable ledger that exceeds its cumulative scan budget before decoding it', async () => { + await withStore(async (store) => { + for (const index of [1, 2]) { + const event: RuntimeEvent = { + id: `event-${index}`, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: index, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: `message-${index}` }, + }; + await store.appendRuntimeEvent('session-1', 'run-1', event); + } + let visits = 0; + const result = await store.scanRuntimeEvents( + 'session-1', + 'run-1', + { + maxBatchBytes: 16 * 1024, + maxRecordBytes: 16 * 1024, + maxImmutableRecords: 1, + maxImmutableBytes: 16 * 1024, + maxPartialRecords: 10, + maxPartialBytes: 16 * 1024, + }, + () => { + visits += 1; + }, + ); + + assert.equal(result.status, 'limit_exceeded'); + assert.equal(visits, 0); + }); + }); + + it('streams fragmented legacy partial segments without retaining their row set', async () => { + await withStore(async (store, dbPath) => { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'partial-segment-seed', + partial: true, + content: { kind: 'text', text: '' }, + refs: { providerEventId: 'message-1' }, + }), + ); + const inspect = new DatabaseSync(dbPath); + try { + const { stream_key: streamKey } = inspect + .prepare('SELECT stream_key FROM runtime_partial_snapshots') + .get() as { stream_key: string }; + const insert = inspect.prepare(` + INSERT INTO runtime_partial_segments(stream_key, segment_seq, text_content, updated_at) + VALUES (?, ?, 'x', ?) + `); + inspect.exec('BEGIN IMMEDIATE'); + for (let sequence = 1; sequence <= 9_000; sequence += 1) { + insert.run(streamKey, sequence, sequence); + } + inspect.exec('COMMIT'); + } finally { + inspect.close(); + } + const scanned: RuntimeEvent[] = []; + const result = await store.scanRuntimeEvents( + 'session-1', + 'run-1', + { + maxBatchBytes: 1024, + maxRecordBytes: 16 * 1024, + maxImmutableRecords: 10, + maxImmutableBytes: 16 * 1024, + maxPartialRecords: 10, + maxPartialBytes: 16 * 1024, + }, + (events) => scanned.push(...events), + ); + assert.equal(result.status, 'complete'); + assert.equal(scanned.length, 1); + assert.equal(scanned[0]?.content?.kind, 'text'); + assert.equal( + scanned[0]?.content?.kind === 'text' ? scanned[0].content.text : undefined, + 'x'.repeat(9_000), + ); + }); + }); + + it('atomically claims a source with exactly one terminal RuntimeEvent at its tail', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + + const acquired = await store.claimContinuation({ claim }); + const existing = await store.claimContinuation({ claim: { ...claim } }); + + assert.equal(acquired.kind, 'acquired'); + assert.equal(existing.kind, 'existing'); + assert.deepEqual(existing.claim, claim); + assert.deepEqual(await store.readContinuationClaimByBoundary(claim.boundaryDigest), claim); + }); + }); + + it('refuses a continuation target opening it cannot read, stored or submitted', async () => { + await withStore(async (store, dbPath) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + await assert.rejects( + () => + store.claimContinuation({ + claim: { + ...claim, + targetOpening: { + ...claim.targetOpening, + configuration: { + ...claim.targetOpening.configuration, + permissionMode: 'execute', + }, + } as unknown as ContinuationClaimV1['targetOpening'], + }, + }), + /Invalid RuntimeEvent invocation_opened schema/, + ); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + const database = new DatabaseSync(dbPath); + try { + database.exec(` + UPDATE runtime_continuation_claims + SET target_opening_json = json_set( + target_opening_json, + '$.configuration.permissionMode', + 'execute' + ) + WHERE claim_id = 'claim-1'; + `); + } finally { + database.close(); + } + + // A persisted Run header used to be widened on read. The opening fact has + // no legacy layer and none is wanted: a claim whose frozen opening cannot + // be read cannot authenticate the start event it exists to authenticate, + // and admitting one against a guessed opening would be the failure this + // record is meant to prevent. + await assert.rejects( + store.readContinuationClaimByBoundary(claim.boundaryDigest), + /Invalid RuntimeEvent invocation_opened schema/, + ); + }); + }); + + for (const initialKind of ['fresh', 'continuation'] as const) { + it(`authenticates repeated handoff under one ${initialKind} logical admission across reopen`, async () => { + await withStore(async (store, dbPath) => { + const manual = continuationClaim(); + let source: RuntimeEvent; + const segments: ReturnType[] = []; + if (initialKind === 'continuation') { + const ancestor = continuationSourcePrefix(); + await persistImmutablePrefix(store, ancestor); + segments.push(runtimePrefixSegment(ancestor)); + await store.claimContinuation({ claim: manual }); + source = continuationStartEvent(manual); + await store.commitContinuationStart({ claim: manual, event: source }); + } else { + source = { + ...continuationStartEvent(manual), + id: 'root-opening', + actions: undefined, + content: { + ...manual.targetOpening, + source: { kind: 'fresh' }, + root: { kind: 'goal', goalId: 'original-goal' }, + lineage: { parentRunId: 'owning-agent', parentTurnId: 'owning-turn' }, + }, + }; + await store.appendRuntimeEvent(source.sessionId, source.runId, source); + } + const rootRunId = source.runId; + const logicalIdentity = { + sessionId: source.sessionId, + turnId: source.turnId, + runId: rootRunId, + }; + for (let index = 0; index < 2; index += 1) { + const target = { + sessionId: source.sessionId, + turnId: source.turnId, + runId: `handoff-run-${index}`, + invocationId: `handoff-invocation-${index}`, + }; + const claimId = `handoff-claim-${index}`; + const seal: RuntimeEvent = { + ...source, + id: `pause-${index}`, + content: undefined, + ts: 15 + index, + actions: { + endInvocation: true, + handoffPause: { + protocol: 'runtime_handoff_pause_v1', + handoffId: `handoff-${index}`, + remainingSteps: null, + hostEpoch: 'old-host', + rootRunId, + successorRunId: target.runId, + successorInvocationId: target.invocationId, + claimId, + }, + }, + }; + await store.appendRuntimeEvent(seal.sessionId, seal.runId, seal); + assert.equal( + (await readLogicalRuntimeExecution(store, logicalIdentity))?.pendingHandoff?.claimId, + claimId, + ); + segments.push( + runtimePrefixSegment( + await store.readImmutableRuntimePrefix({ + sessionId: source.sessionId, + runId: source.runId, + }), + ), + ); + const boundary = createRuntimeBoundaryCursor( + segments as [(typeof segments)[number], ...typeof segments], + ); + const proposed = continuationClaimForBoundary(boundary, { claimId, target }); + assert.equal(source.content?.kind, 'invocation_opened'); + const opening = source.content as ContinuationClaimV1['targetOpening']; + assert.equal(proposed.targetOpening.source.kind, 'continuation'); + const claim: ContinuationClaimV1 = { + ...proposed, + targetOpening: { + ...opening, + source: { + ...(proposed.targetOpening.source as Extract< + ContinuationClaimV1['targetOpening']['source'], + { kind: 'continuation' } + >), + kind: 'handoff', + rootRunId, + claimId, + boundaryDigest: boundary.manifestDigest, + }, + }, + }; + for (const targetOpening of [ + { ...claim.targetOpening, root: { kind: 'user' as const } }, + { ...claim.targetOpening, lineage: { parentRunId: 'stolen-owner' } }, + { ...claim.targetOpening, configuration: { ...opening.configuration, cwd: '/other' } }, + ]) { + if (JSON.stringify(targetOpening) === JSON.stringify(claim.targetOpening)) continue; + await assert.rejects( + store.claimContinuation({ claim: { ...claim, targetOpening } }), + /sealed source authority/, + ); + } + await assert.rejects( + store.claimContinuation({ + claim: { + ...claim, + target: { ...claim.target, invocationId: 'unauthorized-physical-target' }, + }, + }), + /sealed source authority/, + ); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + assert.equal((await store.claimContinuation({ claim })).kind, 'existing'); + assert.equal( + (await readLogicalRuntimeExecution(store, logicalIdentity))?.pendingHandoff?.claimId, + claimId, + ); + source = continuationStartEvent(claim, { id: `handoff-start-${index}` }); + await store.commitContinuationStart({ claim, event: source }); + await store.commitContinuationStart({ claim, event: source }); + const live = await readLogicalRuntimeExecution(store, logicalIdentity); + assert.equal(live?.root.runId, rootRunId); + assert.equal(live?.tip.runId, source.runId); + assert.equal(live?.pendingHandoff, undefined); + await assert.rejects( + store.appendRuntimeEvent(source.sessionId, 'rogue', { + ...source, + id: `rogue-${index}`, + runId: 'rogue', + invocationId: 'rogue', + content: { kind: 'text', text: 'unauthorized' }, + actions: undefined, + }), + /target identity conflict/, + ); + } + const terminal: RuntimeEvent = { + ...source, + id: 'logical-completion', + content: undefined, + status: 'completed', + actions: { endInvocation: true }, + }; + await store.appendRuntimeEvent(terminal.sessionId, terminal.runId, terminal); + store.close(); + const reopened = createSqliteRuntimeStore(dbPath); + try { + const claims = await reopened.listContinuationClaimsForRecovery(source.sessionId); + assert.equal(claims.length, initialKind === 'fresh' ? 2 : 3); + assert.equal(claims.at(-1)?.claim.target.turnId, source.turnId); + assert.deepEqual( + (await reopened.readRuntimeEvents(source.sessionId, source.runId)).at(-1), + encodeCanonicalRuntimeEvent(terminal).event, + ); + assert.equal( + (await readLogicalRuntimeExecution(reopened, logicalIdentity))?.tip.terminalEvent + ?.status, + 'completed', + ); + } finally { + reopened.close(); + } + }); + }); + } + + it('rejects a continuation claim whose immediate source boundary is not durable', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + + await assert.rejects(store.claimContinuation({ claim }), /source boundary is missing/i); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + }); + }); + + it('rejects a non-terminal continuation source without sealing the active Run', async () => { + await withStore(async (store) => { + const source = activeContinuationSourcePrefix(); + const claim = continuationClaimForBoundary( + createRuntimeBoundaryCursor([runtimePrefixSegment(source)]), + ); + await persistImmutablePrefix(store, source); + + await assert.rejects( + store.claimContinuation({ claim }), + /source boundary must end with exactly one terminal RuntimeEvent/i, + ); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + + const terminal: RuntimeEvent = functionCallEvent({ + id: 'source-terminal-after-rejected-claim', + ts: 2, + content: undefined, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }); + await store.ensureTerminalRuntimeEventDurable(terminal.sessionId, terminal.runId, terminal); + assert.deepEqual( + (await store.readImmutableRuntimeEvents(terminal.sessionId, terminal.runId)).map( + (event) => event.id, + ), + [source.events[0]!.id, terminal.id], + ); + }); + }); + + it('rejects a continuation source whose terminal RuntimeEvent has a corrupt suffix', async () => { + await withStore(async (store, dbPath) => { + const source = continuationSourcePrefix(); + const suffix = functionCallEvent({ + id: 'corrupt-source-suffix', + ts: 3, + content: { kind: 'text', text: 'must not follow the terminal fact' }, + }); + await persistImmutablePrefix(store, source); + + const raw = new DatabaseSync(dbPath); + try { + raw + .prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, 3, 'text', ?, ?) + `) + .run( + suffix.id, + suffix.sessionId, + suffix.invocationId, + suffix.runId, + suffix.turnId, + JSON.stringify(suffix), + suffix.ts, + ); + } finally { + raw.close(); + } + + const corruptedPrefix = buildImmutableRuntimePrefix(source.identity, [ + ...source.events.map((event, index) => ({ eventSeq: index + 1, event })), + { eventSeq: 3, event: suffix }, + ]); + const claim = continuationClaimForBoundary( + createRuntimeBoundaryCursor([runtimePrefixSegment(corruptedPrefix)]), + ); + + await assert.rejects( + store.claimContinuation({ claim }), + /source boundary must end with exactly one terminal RuntimeEvent/i, + ); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + }); + }); + + it('rejects a stale claim after the source advances beyond its planned boundary', async () => { + await withStore(async (store) => { + const source = activeContinuationSourcePrefix(); + const claim = continuationClaimForBoundary( + createRuntimeBoundaryCursor([runtimePrefixSegment(source)]), + ); + await persistImmutablePrefix(store, source); + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'source-event-2', + ts: 2, + role: 'system', + author: 'system', + content: undefined, + status: 'failed', + actions: { endInvocation: true }, + }), + ); + + await assert.rejects(store.claimContinuation({ claim }), /source boundary changed/i); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + }); + }); + + it('seals the claimed source against later immutable RuntimeEvents', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + await assert.rejects( + store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'source-event-2', + ts: 2, + role: 'system', + author: 'system', + content: undefined, + status: 'failed', + actions: { endInvocation: true }, + }), + ), + /sealed by continuation claim/i, + ); + }); + }); + + it('rolls back a continuation claim when the process fails after its insert', async () => { + await withStore(async (store, _dbPath, setFailpoint) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + setFailpoint('after_continuation_claim_insert'); + + await assert.rejects(store.claimContinuation({ claim }), /after_continuation_claim_insert/); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + + setFailpoint(undefined); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + }); + }); + + it('rejects a second boundary that tries to reuse an acquired target identity', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const source = continuationSourcePrefix(); + const otherSource = buildImmutableRuntimePrefix( + { + sessionId: 'session-1', + invocationId: 'invocation-source-2', + runId: 'run-source-2', + turnId: 'turn-source-2', + }, + [ + { + eventSeq: 1, + event: functionCallEvent({ + id: 'source-2-event', + invocationId: 'invocation-source-2', + runId: 'run-source-2', + turnId: 'turn-source-2', + content: { kind: 'text', text: 'source request' }, + role: 'user', + author: 'user', + }), + }, + { + eventSeq: 2, + event: functionCallEvent({ + id: 'source-2-terminal', + invocationId: 'invocation-source-2', + runId: 'run-source-2', + turnId: 'turn-source-2', + ts: 2, + content: undefined, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + }, + ], + ); + const otherBoundary = createRuntimeBoundaryCursor([runtimePrefixSegment(otherSource)]); + await persistImmutablePrefix(store, source); + await persistImmutablePrefix(store, otherSource); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + const conflict = await store.claimContinuation({ + claim: continuationClaimForBoundary(otherBoundary, { + claimId: 'claim-2', + claimedAt: 11, + target: claim.target, + }), + }); + + assert.equal(conflict.kind, 'conflict'); + assert.deepEqual(conflict.claim, claim); + }); + }); + + it('lets a started continuation target be purged instead of refusing the delete', async () => { + await withStore(async (store, dbPath) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + const db = new DatabaseSync(dbPath); + try { + db.exec('PRAGMA foreign_keys = ON'); + // Stand the claim up the way starting a continuation does: its start + // event is event one of the target Session's run. + const start = db + .prepare('SELECT event_id, session_id FROM runtime_events ORDER BY event_seq ASC LIMIT 1') + .get() as { event_id: string; session_id: string }; + db.prepare( + "UPDATE runtime_continuation_claims SET start_event_id = ?, start_kind = 'runtime_admission' WHERE claim_id = ?", + ).run(start.event_id, claim.claimId); + + // Purging a conversation deletes its events. The claim used to have no + // ON DELETE clause, so the constraint refused this and rolled the whole + // purge back — for the user's delete, a copy rollback, an import + // discard and Session retirement alike. + db.prepare('DELETE FROM runtime_events WHERE session_id = ?').run(start.session_id); + + assert.equal( + ( + db + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_continuation_claims WHERE claim_id = ?', + ) + .get(claim.claimId) as { count: number } + ).count, + 0, + 'a continuation whose target was deleted no longer names anything, so it goes too', + ); + } finally { + db.close(); + } + }); + }); + + it('fails closed when continuation claim columns disagree with canonical payload', async () => { + await withStore(async (store, dbPath) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + const tamper = new DatabaseSync(dbPath); + try { + tamper + .prepare('UPDATE runtime_continuation_claims SET source_run_id = ? WHERE claim_id = ?') + .run('forged-source-run', claim.claimId); + } finally { + tamper.close(); + } + + await assert.rejects( + store.readContinuationClaimByBoundary(claim.boundaryDigest), + /row\/payload identity mismatch/, + ); + await assert.rejects( + store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'source-write-after-claim-corruption', + ts: 2, + content: { kind: 'text', text: 'must remain sealed' }, + }), + ), + /row\/payload identity mismatch/, + ); + }); + }); + + it('requires a continuation claim target to have an empty RuntimeEvent ledger', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + await store.appendRuntimeEvent( + claim.target.sessionId, + claim.target.runId, + functionCallEvent({ + id: 'unexpected-target-event', + ...claim.target, + ts: 9, + content: { kind: 'text', text: 'not a continuation start' }, + }), + ); + + await assert.rejects( + store.claimContinuation({ claim }), + /target RuntimeEvent ledger is not empty/i, + ); + assert.equal(await store.readContinuationClaimByBoundary(claim.boundaryDigest), undefined); + }); + }); + + it('reserves a claimed target first event for its dedicated continuation-start writer', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const start = continuationStartEvent(claim); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + await assert.rejects( + store.appendRuntimeEvent( + claim.target.sessionId, + claim.target.runId, + functionCallEvent({ + id: 'racing-target-event', + ...claim.target, + ts: 11, + content: { kind: 'text', text: 'must not steal event sequence one' }, + }), + ), + /reserved for continuation-start/i, + ); + assert.deepEqual(await store.commitContinuationStart({ claim, event: start }), { + created: true, + runtimeEventSeq: 1, + }); + + const afterStart: RuntimeEvent = { + id: 'continued-model-event', + ...claim.target, + ts: 13, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'provider dispatch is now admitted' }, + }; + await store.appendRuntimeEvent(claim.target.sessionId, claim.target.runId, afterStart); + assert.deepEqual( + (await store.readImmutableRuntimeEvents(claim.target.sessionId, claim.target.runId)).map( + (event) => event.id, + ), + [start.id, afterStart.id], + ); + }); + }); + + it('commits continuation-start exactly once through its dedicated authority writer', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const event = continuationStartEvent(claim); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + assert.deepEqual(await store.readContinuationClaimStateByBoundary(claim.boundaryDigest), { + claim, + }); + assert.deepEqual(await store.listContinuationClaimsForRecovery(claim.target.sessionId), [ + { claim }, + ]); + + await assert.rejects( + store.appendRuntimeEvent(claim.target.sessionId, claim.target.runId, event), + /continuation authority writer/i, + ); + assert.deepEqual(await store.commitContinuationStart({ claim, event }), { + created: true, + runtimeEventSeq: 1, + }); + assert.deepEqual(await store.commitContinuationStart({ claim, event }), { + created: false, + runtimeEventSeq: 1, + }); + assert.deepEqual( + await store.readImmutableRuntimeEvents(claim.target.sessionId, claim.target.runId), + [event], + ); + assert.deepEqual(await store.readContinuationClaimStateByBoundary(claim.boundaryDigest), { + claim, + startEventId: event.id, + startKind: 'runtime_admission', + }); + assert.deepEqual(await store.listContinuationClaimsForRecovery(claim.target.sessionId), [ + { claim, startEventId: event.id, startKind: 'runtime_admission' }, + ]); + }); + }); + + it('stores continuation start provenance through separate admission and repair commands', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const repairEvent = continuationStartEvent(claim, { + id: 'repair-start-event', + provenance: 'claim_repair', + }); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + await assert.rejects( + store.commitContinuationStart({ claim, event: repairEvent }), + /invalid continuation-start authority event/i, + ); + assert.deepEqual(await store.commitContinuationRepairStart({ claim, event: repairEvent }), { + created: true, + runtimeEventSeq: 1, + }); + assert.deepEqual(await store.readContinuationClaimStateByBoundary(claim.boundaryDigest), { + claim, + startEventId: repairEvent.id, + startKind: 'claim_repair', + }); + }); + }); + + it('binds the durable tool boundary marker to a live continuation start only', async () => { + await withStore(async (store) => { + const liveClaim = continuationClaim(); + const liveEvent = continuationStartEvent(liveClaim, { + toolBoundaryProtocol: 't1_after_preflight_v1', + }); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim: liveClaim })).kind, 'acquired'); + assert.deepEqual( + await store.commitContinuationStart({ claim: liveClaim, event: liveEvent }), + { + created: true, + runtimeEventSeq: 1, + }, + ); + + const repairSource = buildImmutableRuntimePrefix( + { + sessionId: 'session-1', + invocationId: 'invocation-repair-source', + runId: 'run-repair-source', + turnId: 'turn-repair-source', + }, + [ + { + eventSeq: 1, + event: functionCallEvent({ + id: 'repair-source-event', + sessionId: 'session-1', + invocationId: 'invocation-repair-source', + runId: 'run-repair-source', + turnId: 'turn-repair-source', + content: { kind: 'text', text: 'repair source request' }, + role: 'user', + author: 'user', + }), + }, + { + eventSeq: 2, + event: functionCallEvent({ + id: 'repair-source-terminal', + sessionId: 'session-1', + invocationId: 'invocation-repair-source', + runId: 'run-repair-source', + turnId: 'turn-repair-source', + ts: 2, + content: undefined, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + }, + ], + ); + const repairClaim = continuationClaimForBoundary( + createRuntimeBoundaryCursor([runtimePrefixSegment(repairSource)]), + { + claimId: 'continuation-claim-repair-protocol', + target: { + sessionId: 'session-1', + invocationId: 'invocation-repair-protocol', + runId: 'run-repair-protocol', + turnId: 'turn-repair-protocol', + }, + }, + ); + const repairEvent = continuationStartEvent(repairClaim, { + id: 'repair-start-with-protocol', + provenance: 'claim_repair', + toolBoundaryProtocol: 't1_after_preflight_v1', + }); + await persistImmutablePrefix(store, repairSource); + assert.equal((await store.claimContinuation({ claim: repairClaim })).kind, 'acquired'); + await assert.rejects( + store.commitContinuationRepairStart({ claim: repairClaim, event: repairEvent }), + /invalid continuation-start authority event/i, + ); + }); + }); + + it('seals a continuation invocation after its terminal fact while allowing exact retry', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const start = continuationStartEvent(claim); + const terminal: RuntimeEvent = { + id: 'continuation-terminal-1', + ...claim.target, + ts: 13, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { + endInvocation: true, + stateDelta: { failureClass: 'continuation_test_terminal' }, + }, + }; + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + await store.commitContinuationStart({ claim, event: start }); + await store.ensureTerminalRuntimeEventDurable( + claim.target.sessionId, + claim.target.runId, + terminal, + ); + await store.ensureTerminalRuntimeEventDurable( + claim.target.sessionId, + claim.target.runId, + terminal, + ); + + await assert.rejects( + store.appendRuntimeEvent(claim.target.sessionId, claim.target.runId, { + id: 'post-terminal-model-event', + ...claim.target, + ts: 14, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'must not be appended' }, + }), + /sealed by its terminal fact/i, + ); + await assert.rejects( + store.appendRuntimeEvent(claim.target.sessionId, claim.target.runId, { + id: 'post-terminal-fresh-invocation', + sessionId: claim.target.sessionId, + invocationId: 'fresh-invocation-after-terminal', + runId: claim.target.runId, + turnId: claim.target.turnId, + ts: 15, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'must not bypass the run terminal seal' }, + }), + /run identity conflict|sealed by its terminal fact/i, + ); + assert.deepEqual( + (await store.readImmutableRuntimeEvents(claim.target.sessionId, claim.target.runId)).map( + (event) => event.id, + ), + [start.id, terminal.id], + ); + }); + }); + + it('does not bless an exact terminal retry when a corrupt suffix follows it', async () => { + await withStore(async (store, dbPath) => { + const terminal: RuntimeEvent = { + id: 'terminal-before-corrupt-suffix', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }; + await store.appendRuntimeEvent('session-1', 'run-1', terminal); + store.close(); + + const suffix: RuntimeEvent = { + id: 'corrupt-post-terminal-suffix', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 3, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'must make the ledger invalid' }, + }; + const raw = new DatabaseSync(dbPath); + try { + raw + .prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, 2, 'text', ?, ?) + `) + .run( + suffix.id, + suffix.sessionId, + suffix.invocationId, + suffix.runId, + suffix.turnId, + JSON.stringify(suffix), + suffix.ts, + ); + } finally { + raw.close(); + } + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.ensureTerminalRuntimeEventDurable('session-1', 'run-1', terminal), + /terminal RuntimeEvent must be the immutable ledger tail/i, + ); + await assert.rejects( + reopened.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'append-after-corrupt-terminal-suffix', + ts: 4, + content: { kind: 'text', text: 'must remain sealed' }, + }), + ), + /sealed by its terminal fact/i, + ); + } finally { + reopened.close(); + } + }); + }); + + it('rejects a continuation-start whose provider replay identity differs from its claim', async () => { + await withStore(async (store) => { + const claim = continuationClaim(); + const event = continuationStartEvent(claim); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + await assert.rejects( + store.commitContinuationStart({ + claim, + event: { + ...event, + actions: { + continuationStart: { + ...event.actions!.continuationStart!, + providerReplayDigest: `sha256:${'c'.repeat(64)}`, + }, + }, + }, + }), + /invalid continuation-start authority event/i, + ); + await assert.rejects( + store.commitContinuationStart({ + claim, + event: { ...event, ts: claim.claimedAt - 1 }, + }), + /invalid continuation-start authority event/i, + ); + assert.deepEqual(await store.readContinuationClaimStateByBoundary(claim.boundaryDigest), { + claim, + }); + }); + }); + + it('rolls back continuation-start when failure occurs after the event insert', async () => { + await withStore(async (store, _dbPath, setFailpoint) => { + const claim = continuationClaim(); + const event = continuationStartEvent(claim); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + setFailpoint('after_continuation_start_insert'); + + await assert.rejects( + store.commitContinuationStart({ claim, event }), + /after_continuation_start_insert/, + ); + assert.deepEqual( + await store.readImmutableRuntimeEvents(claim.target.sessionId, claim.target.runId), + [], + ); + assert.deepEqual(await store.readContinuationClaimByBoundary(claim.boundaryDigest), claim); + + setFailpoint(undefined); + assert.deepEqual(await store.commitContinuationStart({ claim, event }), { + created: true, + runtimeEventSeq: 1, + }); + }); + }); + + it('pins a physical immutable prefix independently of mutable partial snapshots', async () => { + await withStore(async (store) => { + const first = functionCallEvent({ + id: 'user-event-1', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + }); + await store.appendRuntimeEvent('session-1', 'run-1', first); + const beforePartial = await store.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: 'run-1', + }); + + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'partial-1', + ts: 2, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'working' }, + refs: { providerEventId: 'message-1' }, + }), + ); + const afterPartial = await store.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: 'run-1', + }); + + assert.equal((await store.readRuntimeEvents('session-1', 'run-1')).length, 2); + assert.deepEqual(afterPartial.position, { + lastEventSeq: 1, + eventCount: 1, + lastEventId: 'user-event-1', + }); + assert.equal(afterPartial.prefixDigest, beforePartial.prefixDigest); + + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'model-event-2', + ts: 3, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'done' }, + }), + ); + const pinned = await store.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: 'run-1', + upToEventSeq: 1, + }); + const latest = await store.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: 'run-1', + }); + + assert.equal(pinned.prefixDigest, beforePartial.prefixDigest); + assert.equal(latest.position.lastEventSeq, 2); + assert.notEqual(latest.prefixDigest, beforePartial.prefixDigest); + }); + }); + + it('stores a partial batch as one append-only segment and reconstructs the same text', async () => { + await withStore(async (store, dbPath) => { + const partial = (id: string, ts: number, text: string): RuntimeEvent => + functionCallEvent({ + id, + ts, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + refs: { providerEventId: 'message-1' }, + }); + await store.appendRuntimeEvent('session-1', 'run-1', partial('partial-1', 1, 'a')); + await store.appendRuntimePartialBatch('session-1', 'run-1', [ + partial('partial-2', 2, 'b'), + partial('partial-3', 3, 'c'), + ]); + + const events = await store.readRuntimeEvents('session-1', 'run-1'); + assert.equal(events.length, 1); + assert.equal(events[0]?.content?.kind, 'text'); + assert.equal(events[0]?.content?.kind === 'text' ? events[0].content.text : undefined, 'abc'); + + const inspect = new DatabaseSync(dbPath); + try { + assert.deepEqual( + inspect + .prepare(` + SELECT segment_seq, text_content + FROM runtime_partial_segments + ORDER BY segment_seq ASC + `) + .all() + .map((row) => ({ ...row })), + [{ segment_seq: 1, text_content: 'abc' }], + ); + } finally { + inspect.close(); + } + }); + }); + + it('coalesces partial text into fixed-size tail segments', async () => { + await withStore(async (store, dbPath) => { + const chunks = ['x'.repeat(40 * 1024), 'y'.repeat(40 * 1024), 'z']; + for (const [index, text] of chunks.entries()) { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: `partial-${index}`, + ts: index + 1, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + refs: { providerEventId: 'message-1' }, + }), + ); + } + + const events = await store.readRuntimeEvents('session-1', 'run-1'); + assert.equal(events[0]?.content?.kind, 'text'); + assert.equal( + events[0]?.content?.kind === 'text' ? events[0].content.text : undefined, + chunks.join(''), + ); + + const inspect = new DatabaseSync(dbPath); + try { + const snapshot = inspect + .prepare('SELECT text_content FROM runtime_partial_snapshots') + .get() as { text_content?: unknown }; + const segments = inspect + .prepare(` + SELECT segment_seq, length(CAST(text_content AS BLOB)) AS stored_bytes + FROM runtime_partial_segments + ORDER BY segment_seq ASC + `) + .all() + .map((row) => ({ ...row })); + assert.equal(snapshot.text_content, ''); + assert.deepEqual(segments, [ + { segment_seq: 1, stored_bytes: 40 * 1024 }, + { segment_seq: 2, stored_bytes: 40 * 1024 + 1 }, + ]); + } finally { + inspect.close(); + } + }); + }); + + it('rejects a partial batch that crosses presentation streams atomically', async () => { + await withStore(async (store) => { + const partial = (id: string, providerEventId: string, text: string): RuntimeEvent => + functionCallEvent({ + id, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + refs: { providerEventId }, + }); + await assert.rejects( + store.appendRuntimePartialBatch('session-1', 'run-1', [ + partial('partial-1', 'message-1', 'a'), + partial('partial-2', 'message-2', 'b'), + ]), + /exactly one presentation stream/, + ); + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('rejects a physical immutable prefix with an event-seq gap', async () => { + await withStore(async (store, dbPath) => { + for (let eventSeq = 1; eventSeq <= 3; eventSeq += 1) { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: `event-${eventSeq}`, + ts: eventSeq, + role: 'user', + author: 'user', + content: { kind: 'text', text: String(eventSeq) }, + }), + ); + } + store.close(); + const raw = new DatabaseSync(dbPath); + try { + raw.prepare('DELETE FROM runtime_events WHERE event_seq = 2').run(); + } finally { + raw.close(); + } + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: 'run-1', + }), + /event_seq gap/, + ); + } finally { + reopened.close(); + } + }); + }); + + it('replaces text and tool partial snapshots when their durable final arrives', async () => { + await withStore(async (store, dbPath) => { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'text-partial', + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'working' }, + refs: { providerEventId: 'message-1' }, + }), + ); + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'tool-partial', + partial: true, + role: 'tool', + author: 'tool', + content: undefined, + refs: { toolCallId: 'provider-call-1' }, + }), + ); + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionCallEvent({ + id: 'text-final', + ts: 2, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'done' }, + refs: { providerEventId: 'message-1' }, + }), + ); + await store.appendRuntimeEvent('session-1', 'run-1', functionCallEvent()); + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + functionResponseEvent({ + refs: { toolCallId: 'provider-call-1' }, + }), + ); + + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['text-final', 'call-event-1', 'response-event-1'], + ); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 3); + const inspect = new DatabaseSync(dbPath); + try { + assert.equal( + ( + inspect.prepare('SELECT count(*) AS count FROM runtime_partial_segments').get() as { + count: number; + } + ).count, + 0, + ); + } finally { + inspect.close(); + } + }); + }); + + it('uses the immutable SQLite event as the steering-message recovery proof', async () => { + await withStore(async (store) => { + const steering = functionCallEvent({ + id: 'steering-event-1', + content: { kind: 'text', text: 'steer', steering: true }, + refs: { providerEventId: 'message-steering' }, + }); + + await store.appendRuntimeEvent('session-1', 'run-1', steering, { durable: true }); + await store.appendRuntimeEvent('session-1', 'run-1', steering, { durable: true }); + + assert.deepEqual( + await store.readImmutableSteeringMessageProof('session-1', 'message-steering'), + { event: steering }, + ); + await store.repairImmutableSteeringMessageProofsForRecovery('session-1'); + await assert.rejects( + store.appendRuntimeEvent( + 'session-1', + 'run-2', + functionCallEvent({ + id: 'steering-event-conflict', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + content: { kind: 'text', text: 'different', steering: true }, + refs: { providerEventId: 'message-steering' }, + }), + ), + /Immutable steering message identity conflict: message-steering/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-2'), []); + }); + }); +}); + +type Store = ReturnType; + +async function withStore( + run: ( + store: Store, + dbPath: string, + setFailpoint: (point: SqliteRuntimeStoreFailpoint | undefined) => void, + ) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-runtime-')); + const dbPath = join(root, 'runtime.sqlite'); + let failpoint: SqliteRuntimeStoreFailpoint | undefined; + const store = createSqliteRuntimeStore(dbPath, { + failpoint: (point) => { + if (failpoint === point) throw new Error(`sqlite runtime failpoint: ${point}`); + }, + }); + try { + await run(store, dbPath, (point) => { + failpoint = point; + }); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +function continuationClaim( + input: Parameters[1] = {}, +): ContinuationClaimV1 { + const boundary = createRuntimeBoundaryCursor([runtimePrefixSegment(continuationSourcePrefix())]); + return continuationClaimForBoundary(boundary, input); +} + +function continuationClaimForBoundary( + boundary: ContinuationClaimV1['boundary'], + input: { + claimId?: string; + claimedAt?: number; + target?: ContinuationClaimV1['target']; + } = {}, +): ContinuationClaimV1 { + const source = boundary.segments.at(-1)!; + const target = + input.target ?? + ({ + sessionId: 'session-1', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + } satisfies ContinuationClaimV1['target']); + const claimId = input.claimId ?? 'claim-1'; + const claimedAt = input.claimedAt ?? 10; + return { + protocol: 'continuation_claim_v1', + claimId, + boundaryDigest: boundary.manifestDigest, + boundary, + providerProjectionVersion: 1, + providerReplayDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + target, + targetOpening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'connection-1', + modelId: 'model-1', + }, + configuration: { + cwd: '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', + sourceInvocationId: source.identity.invocationId, + sourceRunId: source.identity.runId, + sourceTurnId: source.identity.turnId, + sourceRuntimeEventHighWater: source.position.lastEventSeq, + claimId, + boundaryDigest: boundary.manifestDigest, + }, + lineage: { + parentRunId: source.identity.runId, + parentTurnId: source.identity.turnId, + }, + }, + claimedAt, + }; +} + +function continuationSourcePrefix(): ImmutableRuntimePrefixV1 { + return buildImmutableRuntimePrefix( + { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + }, + [ + ...activeContinuationSourcePrefix().events.map((event, index) => ({ + eventSeq: index + 1, + event, + })), + { + eventSeq: 2, + event: functionCallEvent({ + id: 'source-terminal-1', + ts: 2, + content: undefined, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + }, + ], + ); +} + +function activeContinuationSourcePrefix(): ImmutableRuntimePrefixV1 { + return buildImmutableRuntimePrefix( + { + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + }, + [ + { + eventSeq: 1, + event: functionCallEvent({ + id: 'source-user-1', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue this interrupted Run' }, + }), + }, + ], + ); +} + +async function persistImmutablePrefix( + store: Store, + prefix: ImmutableRuntimePrefixV1, +): Promise { + for (const runtimeEvent of prefix.events) { + await store.appendRuntimeEvent(prefix.identity.sessionId, prefix.identity.runId, runtimeEvent); + } +} + +function continuationStartEvent( + claim: ContinuationClaimV1, + overrides: { + id?: string; + provenance?: 'runtime_admission' | 'claim_repair'; + toolBoundaryProtocol?: 't1_after_preflight_v1'; + } = {}, +): RuntimeEvent { + const source = claim.boundary.segments.at(-1)!; + return { + id: overrides.id ?? 'continuation-start-1', + ...claim.target, + ts: 12, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: claim.targetOpening, + actions: { + ...(overrides.toolBoundaryProtocol + ? { runtimeProtocol: { toolBoundary: overrides.toolBoundaryProtocol } } + : {}), + continuationStart: { + protocol: 'continuation_start_v2', + provenance: overrides.provenance ?? 'runtime_admission', + claimId: claim.claimId, + boundaryDigest: claim.boundaryDigest, + immediateSource: { + sessionId: source.identity.sessionId, + invocationId: source.identity.invocationId, + runId: source.identity.runId, + turnId: source.identity.turnId, + highWater: source.position.lastEventSeq, + prefixDigest: source.prefixDigest, + }, + replayManifestDigest: claim.boundary.manifestDigest, + providerProjectionVersion: claim.providerProjectionVersion, + providerReplayDigest: claim.providerReplayDigest, + }, + }, + }; +} + +/** A DatabaseSync that records what each statement was actually run with. */ +function watchStatements( + db: DatabaseSync, + executed: { sql: string; bind: unknown[] }[], +): DatabaseSync { + return { + prepare(sql: string) { + const statement = db.prepare(sql); + const record = + (call: (...bind: unknown[]) => T) => + (...bind: unknown[]) => { + executed.push({ sql, bind }); + return call(...bind); + }; + return { + all: record((...bind) => statement.all(...(bind as []))), + get: record((...bind) => statement.get(...(bind as []))), + iterate: record((...bind) => statement.iterate(...(bind as []))), + }; + }, + } as unknown as DatabaseSync; +} + +async function appendSettledTurn(store: Store, index: number): Promise { + const run = { + sessionId: 'session-1', + invocationId: `invocation-${index}`, + runId: `run-${index}`, + turnId: `turn-${index}`, + }; + await store.appendRuntimeEvent( + run.sessionId, + run.runId, + buildInvocationOpenedEvent({ + id: `opened-${index}`, + run, + openedAt: index * 10, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), + ); + await store.appendRuntimeEvent(run.sessionId, run.runId, { + id: `prompt-${index}`, + ...run, + ts: index * 10 + 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: `turn ${index}` }, + }); + await store.appendRuntimeEvent(run.sessionId, run.runId, { + id: `terminal-${index}`, + ...run, + ts: index * 10 + 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }); +} + +function functionCallEvent(overrides: Partial = {}): RuntimeEvent { + return { + id: 'call-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Read', + args: { path: '/workspace/repo/README.md' }, + }, + ...overrides, + }; +} + +function functionResponseEvent(overrides: Partial = {}): RuntimeEvent { + return { + id: 'response-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: 'contents', + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + ...overrides, + }; +} + +function toolDispatchEvent(overrides: Partial = {}): RuntimeEvent { + return { + id: 'dispatch-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + ...overrides, + }; +} + +function commitPrepared(store: Store, options: { resultProjectionVersion?: 1 } = {}) { + return store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: functionCallEvent(), + dispatchRuntimeEvent: toolDispatchEvent({ + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + ...(options.resultProjectionVersion !== undefined + ? { resultProjectionVersion: options.resultProjectionVersion } + : {}), + }, + }, + }), + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: READ_ARGS_HASH, + recoveryMode: 'replay_safe', + committedAt: 10, + }); +} + +const READ_ARGS_HASH = canonicalToolArgsHash('Read', { + path: '/workspace/repo/README.md', +}); +const DIFFERENT_READ_ARGS_HASH = canonicalToolArgsHash('Read', { + path: '/workspace/repo/OTHER.md', +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1cf66a95bca4ff2f0542df4f4d949a4d7faaa9115237a66193730f8157219c9a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1cf66a95bca4ff2f0542df4f4d949a4d7faaa9115237a66193730f8157219c9a.source new file mode 100644 index 0000000000..4939126fd7 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1cf66a95bca4ff2f0542df4f4d949a4d7faaa9115237a66193730f8157219c9a.source @@ -0,0 +1,2522 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Buffer } from 'node:buffer'; +import { createHash, randomUUID, type Hash } from 'node:crypto'; +import { constants as fsConstants, type BigIntStats } from 'node:fs'; +import { + chmod, + link, + lstat, + mkdir, + open, + readdir, + rename, + rm, + stat, + type FileHandle, +} from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { PassThrough, Readable, Transform, Writable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { constants as zlibConstants, createZstdCompress, createZstdDecompress } from 'node:zlib'; +import { unlock, waitForLock } from 'fs-native-extensions'; +import { + compareSessionBundleCanonicalPaths, + computeSessionBundleCanonicalTreeDigest, + SessionBundleCanonicalLayoutValidator, + SessionBundleCanonicalTreeDigestBuilder, + type SessionBundleCanonicalTreeEntry, +} from './session-bundle-canonical-tree.js'; +import { + assertSessionBundleLimits, + copyOpaqueStateIdentityDescriptor, + isNonEmptyUnicodeString, + isSha256Digest, + isValidUnicodeString, + SESSION_BUNDLE_ARCHIVE_FORMAT, + SESSION_BUNDLE_CANONICALIZATION_VERSION, + SESSION_BUNDLE_CODEC_NAME, + SESSION_BUNDLE_CODEC_VERSION, + SESSION_BUNDLE_COMPRESSION_FORMAT, + SESSION_BUNDLE_COMPRESSION_LEVEL, + SESSION_BUNDLE_MANIFEST_PATH, + SESSION_BUNDLE_SCHEMA_VERSION, + SESSION_BUNDLE_STATE_IDENTITY_PATH, + SESSION_BUNDLE_STATE_PATH, + SESSION_BUNDLE_WORKSPACE_PATH, + SessionBundleFileError, + type OpaqueStateIdentityDescriptor, + type SessionBundleArtifact, + type SessionBundleFileOperation, + type SessionBundleFileService, + type SessionBundleHydrationCleanupInput, + type SessionBundleHydrationCleanupResult, + type SessionBundleHydrateInput, + type SessionBundleHydration, + type SessionBundleInspection, + type SessionBundleLimits, + type SessionBundleManifestV1, + type SessionBundlePackInput, + type SessionBundleQuotaName, + type SessionBundleReadInput, + type Sha256Digest, +} from './session-bundle-contract.js'; +import { + decodeSessionBundleManifestV1, + encodeSessionBundleManifestV1, +} from './session-bundle-manifest.js'; +import { + decodeSessionBundleUstarHeaderV1, + encodeSessionBundleUstarHeaderV1, + isSessionBundleUstarZeroBlock, + SESSION_BUNDLE_USTAR_BLOCK_BYTES, + sessionBundleUstarPaddingBytes, + type SessionBundleUstarHeader, +} from './session-bundle-ustar.js'; +import { syncDirectory } from './stable-storage.js'; + +const ARCHIVE_TERMINATOR = Buffer.alloc(SESSION_BUNDLE_USTAR_BLOCK_BYTES * 2); +const HYDRATION_STAGING_MARKER = '.maka-session-bundle-staging-'; +const HYDRATION_OWNERSHIP_SUFFIX = '.owner.json'; +const HYDRATION_OWNERSHIP_KIND = 'maka-session-bundle-hydration-staging' as const; +const HYDRATION_OWNERSHIP_BINDING_KIND = 'maka-session-bundle-hydration-staging-binding' as const; +const HYDRATION_OWNERSHIP_ANCHOR = '.maka-session-bundle-owner'; +const HYDRATION_OWNERSHIP_MAX_BYTES = 2 * 1024; +const HYDRATION_CLEANUP_SUFFIX = '.cleanup'; +const HYDRATION_TOKEN_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const FILESYSTEM_ID_PATTERN = /^(0|[1-9][0-9]*)$/; +const PACK_TEMP_MARKER = '.maka-session-bundle-pack-'; +const PUBLICATION_LOCK_MARKER = '.maka-session-bundle-publish-'; +const PACK_FILE_CHUNK_BYTES = 64 * 1024; +const SESSION_BUNDLE_ZSTD_WINDOW_LOG = 21; +const SESSION_BUNDLE_ZSTD_WINDOW_DESCRIPTOR = 0x58; +const NODE_ZSTD_TRAILING_EMPTY_FRAME = Buffer.from('28b52ffd040001000099e9d851', 'hex'); +const publicationLockGates = new Map>(); + +interface FilesystemNodeIdentity { + dev: bigint; + ino: bigint; +} + +interface FileFingerprint extends FilesystemNodeIdentity { + nlink: bigint; + size: bigint; + mtimeNs: bigint; + ctimeNs: bigint; +} + +interface PackPublicationState { + linkedFingerprint?: FileFingerprint; +} + +interface HydrationStagingBinding { + stagingRoot: string; + stagingFingerprint: FileFingerprint; + ownershipPath: string; + ownershipFingerprint: FileFingerprint; +} + +interface HydrationStagingOwnershipV1 { + schemaVersion: 1; + kind: typeof HYDRATION_OWNERSHIP_KIND; + destinationName: string; + stagingName: string; + token: string; +} + +interface HydrationStagingOwnershipBindingV1 { + schemaVersion: 1; + kind: typeof HYDRATION_OWNERSHIP_BINDING_KIND; + stagingDev: string; + stagingIno: string; +} + +interface DecodedHydrationStagingOwnership { + ownership: HydrationStagingOwnershipV1; + binding?: HydrationStagingOwnershipBindingV1; +} + +interface PackDirectoryEntry { + canonical: Extract; + fingerprint: FileFingerprint; + sourcePath: string; +} + +interface PackDiskFileEntry { + canonical: Extract; + fingerprint: FileFingerprint; + sourcePath: string; +} + +interface PackMemoryFileEntry { + canonical: Extract; + bytes: Buffer; +} + +type PackEntry = PackDirectoryEntry | PackDiskFileEntry | PackMemoryFileEntry; + +interface PackScanBudget { + payloadBytes: number; +} + +interface ReadValidationResult extends SessionBundleInspection { + decompressedTarBytes: number; +} + +interface ParsedTarResult { + manifest: SessionBundleManifestV1; + stateIdentity: OpaqueStateIdentityDescriptor; +} + +export function createSessionBundleFileService(): SessionBundleFileService { + return new NodeSessionBundleFileService(); +} + +export class NodeSessionBundleFileService implements SessionBundleFileService { + async pack(input: SessionBundlePackInput): Promise { + try { + return await packSessionBundle(input); + } catch (error) { + throw normalizeOperationError(error, 'pack'); + } + } + + async inspect(input: SessionBundleReadInput): Promise { + const validated = validateReadInput(input); + try { + const result = await readAndValidateSessionBundle({ + ...validated, + operation: 'inspect', + }); + return inspectionFromReadResult(result); + } catch (error) { + throw normalizeOperationError(error, 'inspect'); + } + } + + async hydrate(input: SessionBundleHydrateInput): Promise { + const validated = validateHydrateInput(input); + try { + return await withDestinationPublicationLock(validated.destinationRoot, 'hydrate', () => + hydrateSessionBundle(validated), + ); + } catch (error) { + throw normalizeOperationError(error, 'hydrate'); + } + } + + async cleanupHydrationStaging( + input: SessionBundleHydrationCleanupInput, + ): Promise { + const destinationRoot = validateHydrationCleanupInput(input); + try { + return await withDestinationPublicationLock(destinationRoot, 'cleanup', async () => ({ + destinationRoot, + ...(await cleanupHydrationStagingForDestination(destinationRoot)), + })); + } catch (error) { + throw normalizeOperationError(error, 'cleanup'); + } + } +} + +async function hydrateSessionBundle(validated: { + sourcePath: string; + expectedArchiveDigest?: Sha256Digest; + limits: SessionBundleLimits; + expectedSessionId: string; + destinationRoot: string; +}): Promise { + const parent = dirname(validated.destinationRoot); + const prefix = `.${basename(validated.destinationRoot)}${HYDRATION_STAGING_MARKER}`; + let staging: HydrationStagingBinding | undefined; + let published = false; + + try { + await cleanupHydrationStagingForDestination(validated.destinationRoot, 'hydrate'); + await assertDestinationMissing(validated.destinationRoot, 'hydrate'); + const parentMetadata = await stat(parent, { bigint: true }); + if (!parentMetadata.isDirectory()) throw ioError('hydrate'); + staging = await createHydrationStaging(validated.destinationRoot, parentMetadata); + + const result = await readAndValidateSessionBundle({ + sourcePath: validated.sourcePath, + expectedArchiveDigest: validated.expectedArchiveDigest, + limits: validated.limits, + operation: 'hydrate', + expectedSessionId: validated.expectedSessionId, + stagingRoot: staging.stagingRoot, + }); + + await assertDestinationMissing(validated.destinationRoot, 'hydrate'); + const currentStaging = await lstat(staging.stagingRoot, { bigint: true }); + if ( + !currentStaging.isDirectory() || + !sameFilesystemNode(staging.stagingFingerprint, fingerprint(currentStaging)) + ) { + throw new SessionBundleFileError( + 'source_changed', + 'Session bundle hydration staging root changed before publication', + ); + } + // Codec participants hold the destination lifecycle lock from cleanup through + // publication. A non-cooperating writer can still create an empty destination + // here because Node does not expose rename-without-replacement for directories. + await rename(staging.stagingRoot, validated.destinationRoot); + published = true; + await removeOwnedPackFile(staging.ownershipPath, staging.ownershipFingerprint); + await syncDirectory(parent); + return { + ...inspectionFromReadResult(result), + destinationRoot: validated.destinationRoot, + stateRoot: join(validated.destinationRoot, 'state'), + workspaceRoot: join(validated.destinationRoot, 'workspace'), + }; + } finally { + if (!published) { + if (staging !== undefined) { + await removeOwnedHydrationStaging(staging, parent, prefix).catch(() => {}); + } else { + await cleanupHydrationStagingForDestination(validated.destinationRoot, 'hydrate').catch( + () => {}, + ); + } + } + } +} + +async function packSessionBundle(input: SessionBundlePackInput): Promise { + const validated = validatePackInput(input); + await assertDestinationMissing(validated.destination, 'pack'); + assertQuota( + 'pack', + validated.limits, + 'maxPathBytes', + Buffer.byteLength(SESSION_BUNDLE_MANIFEST_PATH), + ); + assertQuota('pack', validated.limits, 'maxPathDepth', 1); + assertPathQuota('pack', validated.limits, SESSION_BUNDLE_STATE_IDENTITY_PATH); + + const identityBytes = Buffer.from(validated.stateIdentity.bytes); + assertQuota('pack', validated.limits, 'maxStateIdentityBytes', identityBytes.byteLength); + assertQuota('pack', validated.limits, 'maxFileBytes', identityBytes.byteLength); + const entries: PackEntry[] = [ + { + canonical: { + kind: 'file', + path: SESSION_BUNDLE_STATE_IDENTITY_PATH, + mode: 0o644, + size: identityBytes.byteLength, + contentDigest: digestBytes(identityBytes), + }, + bytes: identityBytes, + }, + ]; + const budget: PackScanBudget = { payloadBytes: identityBytes.byteLength }; + assertQuota('pack', validated.limits, 'maxEntryCount', entries.length); + assertQuota('pack', validated.limits, 'maxPayloadBytes', budget.payloadBytes); + + await scanPackRoot( + validated.stateRoot, + SESSION_BUNDLE_STATE_PATH, + entries, + budget, + validated.limits, + ); + await scanPackRoot( + validated.workspaceRoot, + SESSION_BUNDLE_WORKSPACE_PATH, + entries, + budget, + validated.limits, + ); + entries.sort((left, right) => + compareSessionBundleCanonicalPaths(left.canonical.path, right.canonical.path), + ); + assertQuota('pack', validated.limits, 'maxEntryCount', entries.length); + + const tree = computeSessionBundleCanonicalTreeDigest(entries.map((entry) => entry.canonical)); + assertQuota('pack', validated.limits, 'maxPayloadBytes', tree.payloadBytes); + const manifest: SessionBundleManifestV1 = { + schemaVersion: SESSION_BUNDLE_SCHEMA_VERSION, + codec: { + name: SESSION_BUNDLE_CODEC_NAME, + version: SESSION_BUNDLE_CODEC_VERSION, + canonicalizationVersion: SESSION_BUNDLE_CANONICALIZATION_VERSION, + archive: SESSION_BUNDLE_ARCHIVE_FORMAT, + compression: SESSION_BUNDLE_COMPRESSION_FORMAT, + compressionLevel: SESSION_BUNDLE_COMPRESSION_LEVEL, + }, + envelope: validated.envelope, + stateIdentity: { + path: SESSION_BUNDLE_STATE_IDENTITY_PATH, + mediaType: validated.stateIdentity.mediaType, + }, + payload: { + statePath: SESSION_BUNDLE_STATE_PATH, + workspacePath: SESSION_BUNDLE_WORKSPACE_PATH, + treeDigest: tree.treeDigest, + payloadBytes: tree.payloadBytes, + entryCount: tree.entryCount, + }, + }; + const manifestBytes = Buffer.from(encodeSessionBundleManifestV1(manifest)); + assertQuota('pack', validated.limits, 'maxManifestBytes', manifestBytes.byteLength); + const decompressedTarBytes = calculateTarBytes(manifestBytes.byteLength, entries); + assertQuota('pack', validated.limits, 'maxDecompressedTarBytes', decompressedTarBytes); + + const parent = dirname(validated.destination); + const parentMetadata = await stat(parent, { bigint: true }); + if (!parentMetadata.isDirectory()) throw ioError('pack'); + const temporaryPath = join( + parent, + `.${basename(validated.destination)}${PACK_TEMP_MARKER}${randomUUID()}.tmp`, + ); + let handle: FileHandle | undefined; + let temporaryFingerprint: FileFingerprint | undefined; + let published = false; + const publicationState: PackPublicationState = {}; + const archiveHash = createHash('sha256'); + const compressedMeter = new HashingQuotaTransform( + archiveHash, + validated.limits, + 'maxCompressedBytes', + 'pack', + ); + let generatedTarBytes = 0; + let output: OpenFileHandleWritable | undefined; + + try { + handle = await open(temporaryPath, 'wx+', 0o600); + const createdMetadata = await handle.stat({ bigint: true }); + if (!createdMetadata.isFile()) throw ioError('pack'); + temporaryFingerprint = fingerprint(createdMetadata); + output = new OpenFileHandleWritable(handle, 'pack'); + const tar = Readable.from( + meterGeneratedTar( + generateSessionBundleTar(manifestBytes, entries, validated.limits), + (bytes) => { + generatedTarBytes += bytes; + }, + ), + ); + await pipeline( + tar, + createSessionBundleZstdCompressor(), + new CanonicalZstdOutputTransform(), + compressedMeter, + output, + ); + if (generatedTarBytes !== decompressedTarBytes) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle USTAR byte count changed during pack', + ); + } + await assertPackDirectoriesUnchanged(entries); + await handle.sync(); + output.destroy(); + output = undefined; + const archiveDigest = `sha256:${archiveHash.digest('hex')}` as Sha256Digest; + const hashedFingerprint = await assertPackTemporaryBound( + handle, + temporaryPath, + temporaryFingerprint, + compressedMeter.bytes, + ); + await publishPackFileNoReplace( + handle, + temporaryPath, + validated.destination, + hashedFingerprint, + archiveDigest, + compressedMeter.bytes, + publicationState, + ); + await syncDirectory(parent); + published = true; + return { + path: validated.destination, + archiveDigest, + compressedBytes: compressedMeter.bytes, + decompressedTarBytes, + payloadBytes: tree.payloadBytes, + entryCount: tree.entryCount, + }; + } finally { + output?.destroy(); + if (!published && publicationState.linkedFingerprint !== undefined) { + await removeOwnedPackPublication( + validated.destination, + parent, + publicationState.linkedFingerprint, + ).catch(() => {}); + } + if (temporaryFingerprint !== undefined) { + await removeOwnedPackTemporary(temporaryPath, parent, temporaryFingerprint).catch(() => {}); + } + await handle?.close().catch(() => {}); + } +} + +async function scanPackRoot( + sourceRoot: string, + archiveRoot: 'state/' | 'workspace/', + entries: PackEntry[], + budget: PackScanBudget, + limits: SessionBundleLimits, +): Promise { + const metadata = await lstat(sourceRoot, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle source root must be a real directory', + ); + } + const rootFingerprint = fingerprint(metadata); + validatePackPath(archiveRoot, limits); + entries.push({ + canonical: { kind: 'directory', path: archiveRoot }, + sourcePath: sourceRoot, + fingerprint: rootFingerprint, + }); + assertQuota('pack', limits, 'maxEntryCount', entries.length); + await scanPackDirectory(sourceRoot, archiveRoot, entries, budget, limits); + const after = await lstat(sourceRoot, { bigint: true }); + if (!after.isDirectory() || !sameFingerprint(rootFingerprint, fingerprint(after))) { + throw sourceChanged(); + } +} + +async function scanPackDirectory( + sourceDirectory: string, + archiveDirectory: string, + entries: PackEntry[], + budget: PackScanBudget, + limits: SessionBundleLimits, +): Promise { + const children = await readdir(sourceDirectory, { encoding: 'buffer', withFileTypes: true }); + for (const child of children) { + const childName = decodeFilesystemName(child.name); + const sourcePath = join(sourceDirectory, childName); + const metadata = await lstat(sourcePath, { bigint: true }); + if (metadata.isSymbolicLink()) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle source contains a symbolic link', + ); + } + if (metadata.isDirectory()) { + const path = `${archiveDirectory}${childName}/`; + validatePackPath(path, limits); + const directoryFingerprint = fingerprint(metadata); + entries.push({ + canonical: { kind: 'directory', path }, + sourcePath, + fingerprint: directoryFingerprint, + }); + assertQuota('pack', limits, 'maxEntryCount', entries.length); + await scanPackDirectory(sourcePath, path, entries, budget, limits); + const after = await lstat(sourcePath, { bigint: true }); + if (!after.isDirectory() || !sameFingerprint(directoryFingerprint, fingerprint(after))) { + throw sourceChanged(); + } + continue; + } + if (!metadata.isFile()) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle source contains an unsupported filesystem entry', + ); + } + if (metadata.nlink !== 1n) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle source contains a hard-linked file', + ); + } + + const path = `${archiveDirectory}${childName}`; + validatePackPath(path, limits); + const size = safeBigIntSize(metadata.size); + budget.payloadBytes = safeAdd(budget.payloadBytes, size, 'quota_exceeded'); + assertQuota('pack', limits, 'maxPayloadBytes', budget.payloadBytes); + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path, + mode: hasExecutableBit(metadata.mode) ? 0o755 : 0o644, + size, + }); + const file = await inspectPackFile(sourcePath, metadata, limits); + entries.push({ + canonical: { + kind: 'file', + path, + mode: hasExecutableBit(metadata.mode) ? 0o755 : 0o644, + size: file.size, + contentDigest: file.contentDigest, + }, + sourcePath, + fingerprint: file.fingerprint, + }); + assertQuota('pack', limits, 'maxEntryCount', entries.length); + } +} + +async function inspectPackFile( + sourcePath: string, + scannedMetadata: BigIntStats, + limits: SessionBundleLimits, +): Promise<{ contentDigest: Sha256Digest; fingerprint: FileFingerprint; size: number }> { + const scannedFingerprint = fingerprint(scannedMetadata); + const size = safeBigIntSize(scannedMetadata.size); + assertQuota('pack', limits, 'maxFileBytes', size); + const handle = await openPackSourceFile(sourcePath); + try { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() || + before.nlink !== 1n || + !sameFingerprint(scannedFingerprint, fingerprint(before)) + ) { + throw sourceChanged(); + } + const hash = createHash('sha256'); + let observed = 0; + for await (const value of handle.createReadStream({ + autoClose: false, + emitClose: false, + highWaterMark: PACK_FILE_CHUNK_BYTES, + })) { + const chunk = Buffer.from(value); + observed = safeAdd(observed, chunk.byteLength, 'source_changed'); + assertQuota('pack', limits, 'maxFileBytes', observed); + hash.update(chunk); + } + const after = await handle.stat({ bigint: true }); + if ( + observed !== size || + !after.isFile() || + after.nlink !== 1n || + !sameFingerprint(scannedFingerprint, fingerprint(after)) + ) { + throw sourceChanged(); + } + return { + contentDigest: `sha256:${hash.digest('hex')}` as Sha256Digest, + fingerprint: scannedFingerprint, + size, + }; + } finally { + await handle.close().catch(() => {}); + } +} + +async function* generateSessionBundleTar( + manifestBytes: Buffer, + entries: readonly PackEntry[], + limits: SessionBundleLimits, +): AsyncGenerator { + yield Buffer.from( + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: SESSION_BUNDLE_MANIFEST_PATH, + mode: 0o644, + size: manifestBytes.byteLength, + }), + ); + yield manifestBytes; + yield padding(manifestBytes.byteLength); + + for (const entry of entries) { + yield Buffer.from( + encodeSessionBundleUstarHeaderV1({ + kind: entry.canonical.kind, + path: entry.canonical.path, + mode: entry.canonical.kind === 'directory' ? 0o755 : entry.canonical.mode, + size: entry.canonical.kind === 'directory' ? 0 : entry.canonical.size, + }), + ); + if (entry.canonical.kind === 'file') { + if ('bytes' in entry) { + yield entry.bytes; + } else if (isPackDiskFileEntry(entry)) { + yield* readVerifiedPackFile(entry, limits); + } else { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle pack entry lost its source binding', + ); + } + yield padding(entry.canonical.size); + } + } + yield ARCHIVE_TERMINATOR; +} + +function isPackDiskFileEntry(entry: PackEntry): entry is PackDiskFileEntry { + return entry.canonical.kind === 'file' && 'sourcePath' in entry; +} + +async function* readVerifiedPackFile( + entry: PackDiskFileEntry, + limits: SessionBundleLimits, +): AsyncGenerator { + const handle = await openPackSourceFile(entry.sourcePath); + try { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() || + before.nlink !== 1n || + !sameFingerprint(entry.fingerprint, fingerprint(before)) + ) { + throw sourceChanged(); + } + const hash = createHash('sha256'); + let observed = 0; + if (entry.canonical.size > 0) { + for await (const value of handle.createReadStream({ + autoClose: false, + emitClose: false, + start: 0, + end: entry.canonical.size - 1, + highWaterMark: PACK_FILE_CHUNK_BYTES, + })) { + const chunk = Buffer.from(value); + observed = safeAdd(observed, chunk.byteLength, 'source_changed'); + if (observed > entry.canonical.size) throw sourceChanged(); + assertQuota('pack', limits, 'maxFileBytes', observed); + hash.update(chunk); + yield chunk; + } + } + const after = await handle.stat({ bigint: true }); + const digest = `sha256:${hash.digest('hex')}`; + if ( + observed !== entry.canonical.size || + digest !== entry.canonical.contentDigest || + !after.isFile() || + after.nlink !== 1n || + !sameFingerprint(entry.fingerprint, fingerprint(after)) + ) { + throw sourceChanged(); + } + } finally { + await handle.close().catch(() => {}); + } +} + +async function assertPackDirectoriesUnchanged(entries: readonly PackEntry[]): Promise { + for (const entry of entries) { + if (entry.canonical.kind !== 'directory' || !('sourcePath' in entry)) continue; + const metadata = await lstat(entry.sourcePath, { bigint: true }); + if (!metadata.isDirectory() || !sameFingerprint(entry.fingerprint, fingerprint(metadata))) { + throw sourceChanged(); + } + } +} + +async function readAndValidateSessionBundle(input: { + sourcePath: string; + expectedArchiveDigest?: Sha256Digest; + limits: SessionBundleLimits; + operation: 'inspect' | 'hydrate'; + expectedSessionId?: string; + stagingRoot?: string; +}): Promise { + const handle = await open(input.sourcePath, 'r'); + const archiveHash = createHash('sha256'); + const compressedMeter = new HashingQuotaTransform( + archiveHash, + input.limits, + 'maxCompressedBytes', + input.operation, + ); + const decompressedMeter = new CountingQuotaTransform( + input.limits, + 'maxDecompressedTarBytes', + input.operation, + ); + const output = new PassThrough(); + let pump: Promise | undefined; + let source: ReturnType | undefined; + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile()) throw ioError(input.operation); + const sourceFingerprint = fingerprint(before); + const sourceBytes = safeBigIntSize(before.size); + assertQuota(input.operation, input.limits, 'maxCompressedBytes', sourceBytes); + + source = handle.createReadStream({ autoClose: false, emitClose: false }); + const decompressor = createZstdDecompress({ + params: { + [zlibConstants.ZSTD_d_windowLogMax]: SESSION_BUNDLE_ZSTD_WINDOW_LOG, + }, + }); + pump = pipeline( + source, + compressedMeter, + new CanonicalZstdFrameTransform(), + decompressor, + decompressedMeter, + output, + ); + const parsed = await parseAndValidateTar(output, input); + await pump; + const after = await handle.stat({ bigint: true }); + if (!after.isFile() || !sameFingerprint(sourceFingerprint, fingerprint(after))) { + throw sourceChanged(); + } + if (compressedMeter.bytes !== sourceBytes) throw sourceChanged(); + + const archiveDigest = `sha256:${archiveHash.digest('hex')}` as Sha256Digest; + if ( + input.expectedArchiveDigest !== undefined && + input.expectedArchiveDigest !== archiveDigest + ) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle archive digest does not match the expected digest', + ); + } + return { + manifest: parsed.manifest, + stateIdentity: parsed.stateIdentity, + archiveDigest, + verified: true, + decompressedTarBytes: decompressedMeter.bytes, + }; + } catch (error) { + output.destroy(); + await pump?.catch(() => {}); + throw normalizeReadError(error, input.operation); + } finally { + source?.destroy(); + await handle.close().catch(() => {}); + } +} + +async function parseAndValidateTar( + source: AsyncIterable, + input: { + limits: SessionBundleLimits; + operation: 'inspect' | 'hydrate'; + expectedSessionId?: string; + stagingRoot?: string; + }, +): Promise { + const reader = new AsyncByteReader(source); + const manifestHeaderBytes = await reader.readExactly(SESSION_BUNDLE_USTAR_BLOCK_BYTES); + if (isSessionBundleUstarZeroBlock(manifestHeaderBytes)) { + throw new SessionBundleFileError('invalid_manifest', 'Session bundle manifest is missing'); + } + const manifestHeader = decodeSessionBundleUstarHeaderV1(manifestHeaderBytes); + if ( + manifestHeader.kind !== 'file' || + manifestHeader.path !== SESSION_BUNDLE_MANIFEST_PATH || + manifestHeader.mode !== 0o644 + ) { + throw new SessionBundleFileError( + 'invalid_manifest', + 'Session bundle manifest is not the first canonical USTAR entry', + ); + } + assertPathQuota(input.operation, input.limits, manifestHeader.path); + assertQuota(input.operation, input.limits, 'maxManifestBytes', manifestHeader.size); + const manifestBytes = await reader.readExactly(manifestHeader.size); + await reader.readZeroPadding(sessionBundleUstarPaddingBytes(manifestHeader.size)); + const manifest = decodeSessionBundleManifestV1(manifestBytes); + assertQuota(input.operation, input.limits, 'maxEntryCount', manifest.payload.entryCount); + assertQuota(input.operation, input.limits, 'maxPayloadBytes', manifest.payload.payloadBytes); + if ( + input.expectedSessionId !== undefined && + manifest.envelope.sessionId !== input.expectedSessionId + ) { + throw new SessionBundleFileError( + 'identity_mismatch', + 'Session bundle Cloud Session identity does not match the expected identity', + ); + } + + const builder = new SessionBundleCanonicalTreeDigestBuilder(manifest.payload.entryCount); + const layout = new SessionBundleCanonicalLayoutValidator(manifest.payload.entryCount); + let declaredPayloadBytes = 0; + let identityBytes: Buffer | undefined; + + for (let entryIndex = 0; entryIndex < manifest.payload.entryCount; entryIndex += 1) { + const headerBytes = await reader.readExactly(SESSION_BUNDLE_USTAR_BLOCK_BYTES); + if (isSessionBundleUstarZeroBlock(headerBytes)) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle ended before every declared payload entry', + { details: { operation: input.operation, entryIndex } }, + ); + } + const header = decodeSessionBundleUstarHeaderV1(headerBytes); + validateArchiveEntryBeforeContent(header, layout, input, entryIndex); + if (header.kind === 'file') { + assertQuota(input.operation, input.limits, 'maxFileBytes', header.size, entryIndex); + if (header.path === SESSION_BUNDLE_STATE_IDENTITY_PATH) { + assertQuota( + input.operation, + input.limits, + 'maxStateIdentityBytes', + header.size, + entryIndex, + ); + } + declaredPayloadBytes = safeAdd(declaredPayloadBytes, header.size, 'integrity_mismatch'); + assertQuota( + input.operation, + input.limits, + 'maxPayloadBytes', + declaredPayloadBytes, + entryIndex, + ); + if (declaredPayloadBytes > manifest.payload.payloadBytes) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle payload exceeds its manifest declaration', + ); + } + } + + const result = await consumeArchiveEntry(reader, header, input); + builder.add( + header.kind === 'directory' + ? { kind: 'directory', path: header.path } + : { + kind: 'file', + path: header.path, + mode: header.mode, + size: header.size, + contentDigest: result.contentDigest, + }, + ); + if (result.identityBytes !== undefined) identityBytes = result.identityBytes; + } + + const firstTerminator = await reader.readExactly(SESSION_BUNDLE_USTAR_BLOCK_BYTES); + const secondTerminator = await reader.readExactly(SESSION_BUNDLE_USTAR_BLOCK_BYTES); + if ( + !isSessionBundleUstarZeroBlock(firstTerminator) || + !isSessionBundleUstarZeroBlock(secondTerminator) + ) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle does not end with exactly two canonical zero blocks', + ); + } + await reader.assertEof(); + + layout.finish(); + const tree = builder.finish(); + if ( + tree.treeDigest !== manifest.payload.treeDigest || + tree.payloadBytes !== manifest.payload.payloadBytes || + tree.entryCount !== manifest.payload.entryCount + ) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle payload tree does not match its manifest', + ); + } + if (identityBytes === undefined) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle state identity descriptor is missing', + ); + } + return { + manifest, + stateIdentity: { + mediaType: manifest.stateIdentity.mediaType, + bytes: Uint8Array.from(identityBytes), + }, + }; +} + +function validateArchiveEntryBeforeContent( + header: SessionBundleUstarHeader, + layout: SessionBundleCanonicalLayoutValidator, + input: { limits: SessionBundleLimits; operation: 'inspect' | 'hydrate' }, + entryIndex: number, +): void { + assertPathQuota(input.operation, input.limits, header.path, entryIndex); + try { + layout.add( + header.kind === 'directory' + ? { kind: 'directory', path: header.path } + : { kind: 'file', path: header.path, mode: header.mode }, + ); + } catch (error) { + if (!(error instanceof SessionBundleFileError)) throw error; + throw new SessionBundleFileError(error.code, error.message, { + cause: error, + details: { operation: input.operation, entryIndex }, + }); + } +} + +async function consumeArchiveEntry( + reader: AsyncByteReader, + header: SessionBundleUstarHeader, + input: { + operation: 'inspect' | 'hydrate'; + stagingRoot?: string; + }, +): Promise<{ contentDigest: Sha256Digest; identityBytes?: Buffer }> { + if (header.kind === 'directory') { + if (input.stagingRoot !== undefined) { + const path = hydrationPath(input.stagingRoot, header.path); + await mkdir(path, { mode: 0o755 }); + await chmod(path, 0o755); + } + return { contentDigest: digestBytes(Buffer.alloc(0)) }; + } + + const hash = createHash('sha256'); + const identityChunks: Buffer[] | undefined = + header.path === SESSION_BUNDLE_STATE_IDENTITY_PATH ? [] : undefined; + let output: FileHandle | undefined; + try { + if (input.stagingRoot !== undefined) { + output = await open(hydrationPath(input.stagingRoot, header.path), 'wx', header.mode); + } + await reader.consumeExactly(header.size, async (chunk) => { + hash.update(chunk); + if (identityChunks !== undefined) identityChunks.push(Buffer.from(chunk)); + if (output !== undefined) await writeAll(output, chunk); + }); + await reader.readZeroPadding(sessionBundleUstarPaddingBytes(header.size)); + if (output !== undefined) { + await output.sync(); + await output.chmod(header.mode); + } + } finally { + await output?.close().catch(() => {}); + } + return { + contentDigest: `sha256:${hash.digest('hex')}` as Sha256Digest, + ...(identityChunks === undefined ? {} : { identityBytes: Buffer.concat(identityChunks) }), + }; +} + +class AsyncByteReader { + readonly #iterator: AsyncIterator; + #chunk = Buffer.alloc(0); + #offset = 0; + #ended = false; + + constructor(source: AsyncIterable) { + this.#iterator = source[Symbol.asyncIterator](); + } + + async readExactly(size: number): Promise { + if (!Number.isSafeInteger(size) || size < 0) throw integrityError(); + const output = Buffer.alloc(size); + let written = 0; + await this.consumeExactly(size, (chunk) => { + chunk.copy(output, written); + written += chunk.byteLength; + }); + return output; + } + + async consumeExactly( + size: number, + consume: (chunk: Buffer) => void | Promise, + ): Promise { + let remaining = size; + while (remaining > 0) { + if (!(await this.#ensureChunk())) throw integrityError(); + const available = this.#chunk.byteLength - this.#offset; + const length = Math.min(remaining, available); + const slice = this.#chunk.subarray(this.#offset, this.#offset + length); + await consume(slice); + this.#offset += length; + remaining -= length; + } + } + + async readZeroPadding(size: number): Promise { + await this.consumeExactly(size, (chunk) => { + if (chunk.some((byte) => byte !== 0)) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle USTAR padding is not canonical', + ); + } + }); + } + + async assertEof(): Promise { + if (await this.#ensureChunk()) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle contains bytes after its canonical terminator', + ); + } + } + + async #ensureChunk(): Promise { + while (this.#offset >= this.#chunk.byteLength && !this.#ended) { + const next = await this.#iterator.next(); + if (next.done) { + this.#ended = true; + this.#chunk = Buffer.alloc(0); + this.#offset = 0; + break; + } + this.#chunk = Buffer.from(next.value); + this.#offset = 0; + } + return this.#offset < this.#chunk.byteLength; + } +} + +class CountingQuotaTransform extends Transform { + bytes = 0; + + constructor( + private readonly limits: SessionBundleLimits, + private readonly quota: SessionBundleQuotaName, + private readonly operation: SessionBundleFileOperation, + ) { + super(); + } + + override _transform( + value: Buffer | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error | null, data?: Buffer) => void, + ): void { + const chunk = Buffer.from(value); + try { + this.bytes = safeAdd(this.bytes, chunk.byteLength, 'quota_exceeded'); + assertQuota(this.operation, this.limits, this.quota, this.bytes); + callback(null, chunk); + } catch (error) { + callback(error as Error); + } + } +} + +class HashingQuotaTransform extends CountingQuotaTransform { + constructor( + private readonly hash: Hash, + limits: SessionBundleLimits, + quota: SessionBundleQuotaName, + operation: SessionBundleFileOperation, + ) { + super(limits, quota, operation); + } + + override _transform( + value: Buffer | Uint8Array, + encoding: BufferEncoding, + callback: (error?: Error | null, data?: Buffer) => void, + ): void { + const chunk = Buffer.from(value); + super._transform(chunk, encoding, (error, data) => { + if (error !== undefined && error !== null) { + callback(error); + return; + } + this.hash.update(chunk); + callback(null, data); + }); + } +} + +class OpenFileHandleWritable extends Writable { + constructor( + private readonly handle: FileHandle, + private readonly operation: 'pack' | 'hydrate', + ) { + super(); + } + + override _write( + value: Buffer | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + writeAll(this.handle, Buffer.from(value), this.operation).then( + () => callback(), + (error: unknown) => callback(error as Error), + ); + } +} + +/** + * Node 24/26's bundled encoder appends this empty frame after multi-chunk input. + * This is undocumented: if Node stops appending it, `_flush` emits the retained + * tail unchanged; any other encoder drift fails the golden archive test. + */ +class CanonicalZstdOutputTransform extends Transform { + #tail = Buffer.alloc(0); + + override _transform( + value: Buffer | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error | null, data?: Buffer) => void, + ): void { + const combined = Buffer.concat([this.#tail, Buffer.from(value)]); + if (combined.byteLength <= NODE_ZSTD_TRAILING_EMPTY_FRAME.byteLength) { + this.#tail = combined; + callback(); + return; + } + const retainedBytes = NODE_ZSTD_TRAILING_EMPTY_FRAME.byteLength; + const emittedBytes = combined.byteLength - retainedBytes; + this.#tail = combined.subarray(emittedBytes); + callback(null, combined.subarray(0, emittedBytes)); + } + + override _flush(callback: (error?: Error | null) => void): void { + if (!this.#tail.equals(NODE_ZSTD_TRAILING_EMPTY_FRAME)) this.push(this.#tail); + this.#tail = Buffer.alloc(0); + callback(); + } +} + +type ZstdFramePhase = 'block-header' | 'block-payload' | 'checksum' | 'frame-header'; + +/** + * Node's native decoder accepts arbitrary bytes after its last complete frame. + * Validate the framing independently while passing the same bytes downstream. + */ +class CanonicalZstdFrameTransform extends Transform { + #buffer = Buffer.alloc(0); + #frameCount = 0; + #lastBlock = false; + #payloadBytes = 0; + #phase: ZstdFramePhase = 'frame-header'; + + override _transform( + value: Buffer | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error | null, data?: Buffer) => void, + ): void { + const chunk = Buffer.from(value); + try { + this.#buffer = this.#buffer.byteLength === 0 ? chunk : Buffer.concat([this.#buffer, chunk]); + this.#consumeAvailableBytes(); + callback(null, chunk); + } catch (error) { + callback(error as Error); + } + } + + override _flush(callback: (error?: Error | null) => void): void { + if (this.#phase !== 'frame-header' || this.#buffer.byteLength !== 0 || this.#frameCount !== 1) { + callback( + new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle Zstandard framing is truncated or contains trailing bytes', + ), + ); + return; + } + callback(); + } + + #consumeAvailableBytes(): void { + while (true) { + if (this.#phase === 'frame-header') { + if (this.#frameCount !== 0 && this.#buffer.byteLength !== 0) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle must contain exactly one canonical Zstandard frame', + ); + } + if (this.#buffer.byteLength < 6) return; + if ( + this.#buffer.readUInt32LE(0) !== 0xfd2fb528 || + this.#buffer[4] !== 0x04 || + this.#buffer[5] !== SESSION_BUNDLE_ZSTD_WINDOW_DESCRIPTOR + ) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle Zstandard frame settings are not canonical', + ); + } + this.#discard(6); + this.#phase = 'block-header'; + continue; + } + + if (this.#phase === 'block-header') { + if (this.#buffer.byteLength < 3) return; + const header = this.#buffer[0] | (this.#buffer[1] << 8) | (this.#buffer[2] << 16); + this.#lastBlock = (header & 1) === 1; + const blockType = (header >>> 1) & 0x03; + const blockSize = header >>> 3; + if (blockType === 0x03 || blockSize > 128 * 1024) { + throw new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle Zstandard block is malformed', + ); + } + this.#payloadBytes = blockType === 0x01 ? 1 : blockSize; + this.#discard(3); + this.#phase = 'block-payload'; + continue; + } + + if (this.#phase === 'block-payload') { + if (this.#buffer.byteLength < this.#payloadBytes) { + this.#payloadBytes -= this.#buffer.byteLength; + this.#buffer = Buffer.alloc(0); + return; + } + this.#discard(this.#payloadBytes); + this.#payloadBytes = 0; + this.#phase = this.#lastBlock ? 'checksum' : 'block-header'; + continue; + } + + if (this.#buffer.byteLength < 4) return; + this.#discard(4); + this.#frameCount += 1; + this.#phase = 'frame-header'; + } + } + + #discard(bytes: number): void { + this.#buffer = this.#buffer.subarray(bytes); + } +} + +function createSessionBundleZstdCompressor(): Transform { + return createZstdCompress({ + params: { + [zlibConstants.ZSTD_c_compressionLevel]: SESSION_BUNDLE_COMPRESSION_LEVEL, + [zlibConstants.ZSTD_c_checksumFlag]: 1, + [zlibConstants.ZSTD_c_contentSizeFlag]: 0, + [zlibConstants.ZSTD_c_dictIDFlag]: 0, + [zlibConstants.ZSTD_c_nbWorkers]: 0, + [zlibConstants.ZSTD_c_windowLog]: SESSION_BUNDLE_ZSTD_WINDOW_LOG, + }, + }); +} + +async function* meterGeneratedTar( + source: AsyncIterable, + observe: (bytes: number) => void, +): AsyncGenerator { + for await (const chunk of source) { + observe(chunk.byteLength); + if (chunk.byteLength > 0) yield chunk; + } +} + +function calculateTarBytes(manifestBytes: number, entries: readonly PackEntry[]): number { + let total = tarEntryBytes(manifestBytes); + for (const entry of entries) { + total = safeAdd( + total, + tarEntryBytes(entry.canonical.kind === 'directory' ? 0 : entry.canonical.size), + 'unsupported_entry', + ); + } + return safeAdd(total, ARCHIVE_TERMINATOR.byteLength, 'unsupported_entry'); +} + +function tarEntryBytes(size: number): number { + return safeAdd( + SESSION_BUNDLE_USTAR_BLOCK_BYTES, + safeAdd(size, sessionBundleUstarPaddingBytes(size), 'unsupported_entry'), + 'unsupported_entry', + ); +} + +function padding(size: number): Buffer { + const bytes = sessionBundleUstarPaddingBytes(size); + return bytes === 0 ? Buffer.alloc(0) : Buffer.alloc(bytes); +} + +function validatePackPath(path: string, limits: SessionBundleLimits): void { + assertPathQuota('pack', limits, path); + if ( + !isValidUnicodeString(path) || + path.includes('\0') || + path.includes('\\') || + path.startsWith('/') || + /^[A-Za-z]:/.test(path) + ) { + throw new SessionBundleFileError('unsafe_path', 'Session bundle source path is unsafe'); + } + const logicalPath = path.endsWith('/') ? path.slice(0, -1) : path; + if ( + logicalPath.length === 0 || + logicalPath + .split('/') + .some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new SessionBundleFileError('unsafe_path', 'Session bundle source path is unsafe'); + } + encodeSessionBundleUstarHeaderV1({ + kind: path.endsWith('/') ? 'directory' : 'file', + path, + mode: path.endsWith('/') ? 0o755 : 0o644, + size: 0, + }); +} + +function assertPathQuota( + operation: SessionBundleFileOperation, + limits: SessionBundleLimits, + path: string, + entryIndex?: number, +): void { + const pathBytes = Buffer.byteLength(path, 'utf8'); + const logicalPath = path.endsWith('/') ? path.slice(0, -1) : path; + const depth = logicalPath.length === 0 ? 0 : logicalPath.split('/').length; + assertQuota(operation, limits, 'maxPathBytes', pathBytes, entryIndex); + assertQuota(operation, limits, 'maxPathDepth', depth, entryIndex, depth); +} + +function assertQuota( + operation: SessionBundleFileOperation, + limits: SessionBundleLimits, + quota: SessionBundleQuotaName, + observed: number, + entryIndex?: number, + pathDepth?: number, +): void { + const limit = limits[quota]; + if (observed <= limit) return; + throw new SessionBundleFileError('quota_exceeded', 'Session bundle quota was exceeded', { + details: { + operation, + quota, + limit, + observed, + ...(entryIndex === undefined ? {} : { entryIndex }), + ...(pathDepth === undefined ? {} : { pathDepth }), + }, + }); +} + +function validatePackInput(input: SessionBundlePackInput): { + stateRoot: string; + workspaceRoot: string; + stateIdentity: OpaqueStateIdentityDescriptor; + envelope: SessionBundleManifestV1['envelope']; + destination: string; + limits: SessionBundleLimits; +} { + if (!isRecord(input) || !isRecord(input.snapshot) || !isRecord(input.envelope)) { + throw new TypeError('Session bundle pack input must be an object'); + } + assertSessionBundleLimits(input.limits); + const stateRoot = requiredFilesystemPath(input.snapshot.stateRoot, 'stateRoot'); + const workspaceRoot = requiredFilesystemPath(input.snapshot.workspaceRoot, 'workspaceRoot'); + const destination = requiredFilesystemPath(input.destination, 'destination'); + const stateIdentity = copyOpaqueStateIdentityDescriptor(input.snapshot.stateIdentity); + if (!isNonEmptyUnicodeString(input.envelope.sessionId)) { + throw new TypeError('Session bundle sessionId must be a non-empty Unicode string'); + } + let lastCommittedActivationId: string | undefined; + if (Object.hasOwn(input.envelope, 'lastCommittedActivationId')) { + if (!isNonEmptyUnicodeString(input.envelope.lastCommittedActivationId)) { + throw new TypeError( + 'Session bundle lastCommittedActivationId must be a non-empty Unicode string', + ); + } + lastCommittedActivationId = input.envelope.lastCommittedActivationId; + } + return { + stateRoot, + workspaceRoot, + stateIdentity, + envelope: { + sessionId: input.envelope.sessionId, + ...(lastCommittedActivationId === undefined ? {} : { lastCommittedActivationId }), + }, + destination, + limits: input.limits, + }; +} + +function validateReadInput(input: SessionBundleReadInput): { + sourcePath: string; + expectedArchiveDigest?: Sha256Digest; + limits: SessionBundleLimits; +} { + if (!isRecord(input) || !isRecord(input.source)) { + throw new TypeError('Session bundle read input must be an object'); + } + assertSessionBundleLimits(input.limits); + const sourcePath = requiredFilesystemPath(input.source.path, 'source.path'); + let expectedArchiveDigest: Sha256Digest | undefined; + if (Object.hasOwn(input.source, 'expectedArchiveDigest')) { + if (!isSha256Digest(input.source.expectedArchiveDigest)) { + throw new TypeError('Session bundle expected archive digest must be lowercase SHA-256'); + } + expectedArchiveDigest = input.source.expectedArchiveDigest; + } + return { + sourcePath, + ...(expectedArchiveDigest === undefined ? {} : { expectedArchiveDigest }), + limits: input.limits, + }; +} + +function validateHydrateInput(input: SessionBundleHydrateInput): { + sourcePath: string; + expectedArchiveDigest?: Sha256Digest; + limits: SessionBundleLimits; + expectedSessionId: string; + destinationRoot: string; +} { + const read = validateReadInput(input); + if (!isNonEmptyUnicodeString(input.expectedSessionId)) { + throw new TypeError('Session bundle expectedSessionId must be a non-empty Unicode string'); + } + return { + ...read, + expectedSessionId: input.expectedSessionId, + destinationRoot: requiredFilesystemPath(input.destinationRoot, 'destinationRoot'), + }; +} + +function validateHydrationCleanupInput(input: SessionBundleHydrationCleanupInput): string { + if (!isRecord(input)) { + throw new TypeError('Session bundle hydration cleanup input must be an object'); + } + return requiredFilesystemPath(input.destinationRoot, 'destinationRoot'); +} + +function requiredFilesystemPath(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + throw new TypeError(`Session bundle ${label} must be a non-empty filesystem path`); + } + return resolve(value); +} + +function decodeFilesystemName(value: Buffer): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(value); + } catch { + throw new SessionBundleFileError( + 'unsafe_path', + 'Session bundle source name is not valid UTF-8', + ); + } +} + +async function assertDestinationMissing( + path: string, + operation: 'pack' | 'hydrate', +): Promise { + try { + await lstat(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) return; + throw error; + } + throw destinationExists(operation); +} + +async function assertPackTemporaryBound( + handle: FileHandle, + temporaryPath: string, + expectedIdentity: FileFingerprint, + expectedBytes: number, +): Promise { + const [handleMetadata, pathMetadata] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(temporaryPath, { bigint: true }), + ]); + const handleFingerprint = fingerprint(handleMetadata); + if ( + !handleMetadata.isFile() || + !pathMetadata.isFile() || + handleMetadata.nlink !== 1n || + pathMetadata.nlink !== 1n || + handleMetadata.size !== BigInt(expectedBytes) || + !sameFilesystemNode(expectedIdentity, handleFingerprint) || + !sameFingerprint(handleFingerprint, fingerprint(pathMetadata)) + ) { + throw packPublicationChanged(); + } + return handleFingerprint; +} + +async function publishPackFileNoReplace( + handle: FileHandle, + temporaryPath: string, + destination: string, + hashedFingerprint: FileFingerprint, + expectedDigest: Sha256Digest, + expectedBytes: number, + state: PackPublicationState, +): Promise { + try { + // Both paths are siblings, so a hard link is an atomic no-replace publication. + await link(temporaryPath, destination); + } catch (error) { + if (isErrno(error, 'EEXIST')) throw destinationExists('pack'); + throw error; + } + + const [beforeHandle, beforeDestination] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(destination, { bigint: true }), + ]); + const verificationFingerprint = fingerprint(beforeHandle); + const destinationFingerprint = fingerprint(beforeDestination); + if ( + beforeHandle.isFile() && + beforeDestination.isFile() && + !beforeDestination.isSymbolicLink() && + beforeHandle.size === BigInt(expectedBytes) && + sameFilesystemNode(hashedFingerprint, verificationFingerprint) && + sameFilesystemNode(verificationFingerprint, destinationFingerprint) + ) { + // The open, hashed file proves that this is the inode link() published. + // Do not claim an arbitrary destination merely because link() returned. + state.linkedFingerprint = destinationFingerprint; + } + + const beforeTemporary = await lstat(temporaryPath, { bigint: true }); + const temporaryFingerprint = fingerprint(beforeTemporary); + if ( + state.linkedFingerprint === undefined && + beforeTemporary.isFile() && + !beforeTemporary.isSymbolicLink() && + beforeDestination.isFile() && + !beforeDestination.isSymbolicLink() && + sameFilesystemNode(temporaryFingerprint, destinationFingerprint) + ) { + // The pathname may have been swapped before link(). In that case the + // current temporary inode still proves which unwanted inode we published. + state.linkedFingerprint = destinationFingerprint; + } + if ( + !beforeHandle.isFile() || + !beforeTemporary.isFile() || + !beforeDestination.isFile() || + beforeTemporary.isSymbolicLink() || + beforeDestination.isSymbolicLink() || + beforeHandle.size !== BigInt(expectedBytes) || + state.linkedFingerprint === undefined || + !sameFilesystemNode(state.linkedFingerprint, destinationFingerprint) || + !sameFilesystemNode(temporaryFingerprint, destinationFingerprint) || + !sameFilesystemNode(hashedFingerprint, verificationFingerprint) || + !sameFingerprint(verificationFingerprint, fingerprint(beforeDestination)) + ) { + throw packPublicationChanged(); + } + + const actualDigest = await digestOpenPackFile(handle, expectedBytes); + const [afterHandle, afterDestination] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(destination, { bigint: true }), + ]); + if ( + actualDigest !== expectedDigest || + !afterHandle.isFile() || + !afterDestination.isFile() || + !sameFingerprint(verificationFingerprint, fingerprint(afterHandle)) || + !sameFingerprint(fingerprint(afterHandle), fingerprint(afterDestination)) + ) { + throw packPublicationChanged(); + } +} + +async function digestOpenPackFile( + handle: FileHandle, + expectedBytes: number, +): Promise { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(Math.min(PACK_FILE_CHUNK_BYTES, Math.max(expectedBytes, 1))); + let position = 0; + while (position < expectedBytes) { + const length = Math.min(buffer.byteLength, expectedBytes - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead <= 0) throw packPublicationChanged(); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return `sha256:${hash.digest('hex')}` as Sha256Digest; +} + +async function withDestinationPublicationLock( + destination: string, + operation: 'hydrate' | 'cleanup', + action: () => Promise, +): Promise { + const lockPath = join( + dirname(destination), + `${PUBLICATION_LOCK_MARKER}${createHash('sha256') + .update(basename(destination).normalize('NFC').toLocaleLowerCase('en-US')) + .digest('hex')}.lock`, + ); + return runWithPublicationLockGate(lockPath, async () => { + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + const handle = await open(lockPath, fsConstants.O_CREAT | fsConstants.O_RDWR | noFollow, 0o600); + let acquired = false; + try { + if (process.platform !== 'win32') await handle.chmod(0o600); + await assertStablePublicationLock(handle, lockPath, operation); + await waitForLock(handle.fd); + acquired = true; + await assertStablePublicationLock(handle, lockPath, operation); + return await action(); + } finally { + if (acquired) { + try { + unlock(handle.fd); + } catch { + // Closing the handle is the final OS-level release path. + } + } + await handle.close().catch(() => {}); + } + }); +} + +async function runWithPublicationLockGate( + lockPath: string, + action: () => Promise, +): Promise { + const previous = publicationLockGates.get(lockPath); + let release!: () => void; + const current = new Promise((resolveGate) => { + release = resolveGate; + }); + publicationLockGates.set(lockPath, current); + await previous?.catch(() => {}); + try { + return await action(); + } finally { + release(); + if (publicationLockGates.get(lockPath) === current) publicationLockGates.delete(lockPath); + } +} + +async function assertStablePublicationLock( + handle: FileHandle, + lockPath: string, + operation: 'hydrate' | 'cleanup', +): Promise { + const [handleMetadata, pathMetadata] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(lockPath, { bigint: true }), + ]); + if ( + !handleMetadata.isFile() || + !pathMetadata.isFile() || + handleMetadata.dev !== pathMetadata.dev || + handleMetadata.ino !== pathMetadata.ino + ) { + throw ioError(operation); + } +} + +async function openPackSourceFile(path: string): Promise { + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + try { + return await open(path, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (isErrno(error, 'ELOOP')) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle source changed to a symbolic link', + ); + } + throw error; + } +} + +function hydrationPath(stagingRoot: string, archivePath: string): string { + const logical = archivePath.endsWith('/') ? archivePath.slice(0, -1) : archivePath; + return join(stagingRoot, ...logical.split('/')); +} + +async function writeAll( + handle: FileHandle, + chunk: Buffer, + operation: 'pack' | 'hydrate' = 'hydrate', +): Promise { + let offset = 0; + while (offset < chunk.byteLength) { + const result = await handle.write(chunk, offset, chunk.byteLength - offset, null); + if (result.bytesWritten <= 0) throw ioError(operation); + offset += result.bytesWritten; + } +} + +async function appendOpenFileContents( + handle: FileHandle, + contents: Buffer, + start: number, + operation: 'pack' | 'hydrate', +): Promise { + let offset = 0; + while (offset < contents.byteLength) { + const result = await handle.write( + contents, + offset, + contents.byteLength - offset, + start + offset, + ); + if (result.bytesWritten <= 0) throw ioError(operation); + offset += result.bytesWritten; + } + await handle.sync(); +} + +async function createHydrationStaging( + destinationRoot: string, + parentMetadata: BigIntStats, +): Promise { + const parent = dirname(destinationRoot); + const destinationName = basename(destinationRoot); + const prefix = `.${destinationName}${HYDRATION_STAGING_MARKER}`; + const token = randomUUID(); + const stagingName = `${prefix}${token}`; + const stagingRoot = join(parent, stagingName); + const ownershipPath = `${stagingRoot}${HYDRATION_OWNERSHIP_SUFFIX}`; + const ownershipAnchorPath = join(stagingRoot, HYDRATION_OWNERSHIP_ANCHOR); + const ownership = encodeHydrationStagingOwnership({ + schemaVersion: 1, + kind: HYDRATION_OWNERSHIP_KIND, + destinationName, + stagingName, + token, + }); + let ownershipHandle: FileHandle | undefined; + let createdOwnershipFingerprint: FileFingerprint | undefined; + let stagingFingerprint: FileFingerprint | undefined; + let initialized = false; + try { + ownershipHandle = await open(ownershipPath, 'wx', 0o600); + const createdOwnershipMetadata = await ownershipHandle.stat({ + bigint: true, + }); + if ( + !createdOwnershipMetadata.isFile() || + createdOwnershipMetadata.nlink !== 1n || + createdOwnershipMetadata.size !== 0n + ) { + throw ioError('hydrate'); + } + createdOwnershipFingerprint = fingerprint(createdOwnershipMetadata); + await writeAll(ownershipHandle, ownership, 'hydrate'); + await ownershipHandle.sync(); + await syncDirectory(parent); + + await mkdir(stagingRoot, { mode: 0o700 }); + await chmod(stagingRoot, 0o700); + const stagingMetadata = await lstat(stagingRoot, { bigint: true }); + if ( + !stagingMetadata.isDirectory() || + stagingMetadata.isSymbolicLink() || + stagingMetadata.dev !== parentMetadata.dev + ) { + throw ioError('hydrate'); + } + stagingFingerprint = fingerprint(stagingMetadata); + + // Keep the first canonical record intact and hard-link it into the staging + // directory before appending the inode binding. If the process dies during + // that append, cleanup can still authenticate the directory through this + // exact owner-file inode instead of trusting a partial second JSON line. + await link(ownershipPath, ownershipAnchorPath); + const [anchoredOwnershipMetadata, ownershipAnchorMetadata] = await Promise.all([ + ownershipHandle.stat({ bigint: true }), + lstat(ownershipAnchorPath, { bigint: true }), + ]); + if ( + !anchoredOwnershipMetadata.isFile() || + !ownershipAnchorMetadata.isFile() || + ownershipAnchorMetadata.isSymbolicLink() || + anchoredOwnershipMetadata.nlink !== 2n || + !sameFilesystemNode( + fingerprint(anchoredOwnershipMetadata), + fingerprint(ownershipAnchorMetadata), + ) + ) { + throw ioError('hydrate'); + } + await syncDirectory(stagingRoot); + await syncDirectory(parent); + + const binding = encodeHydrationStagingOwnershipBinding({ + schemaVersion: 1, + kind: HYDRATION_OWNERSHIP_BINDING_KIND, + stagingDev: stagingMetadata.dev.toString(), + stagingIno: stagingMetadata.ino.toString(), + }); + await appendOpenFileContents(ownershipHandle, binding, ownership.byteLength, 'hydrate'); + if (!(await removeOwnedPackFile(ownershipAnchorPath, createdOwnershipFingerprint))) { + throw ioError('hydrate'); + } + await syncDirectory(stagingRoot); + const [ownershipMetadata, ownershipPathMetadata] = await Promise.all([ + ownershipHandle.stat({ bigint: true }), + lstat(ownershipPath, { bigint: true }), + ]); + if ( + !ownershipMetadata.isFile() || + !ownershipPathMetadata.isFile() || + ownershipPathMetadata.isSymbolicLink() || + ownershipMetadata.nlink !== 1n || + ownershipMetadata.size !== BigInt(ownership.byteLength + binding.byteLength) || + !sameFilesystemNode(createdOwnershipFingerprint, fingerprint(ownershipMetadata)) || + !sameFingerprint(fingerprint(ownershipMetadata), fingerprint(ownershipPathMetadata)) + ) { + throw ioError('hydrate'); + } + const ownershipFingerprint = fingerprint(ownershipMetadata); + await ownershipHandle.close(); + ownershipHandle = undefined; + initialized = true; + return { + stagingRoot, + stagingFingerprint, + ownershipPath, + ownershipFingerprint, + }; + } finally { + await ownershipHandle?.close().catch(() => {}); + if (!initialized) { + if (stagingFingerprint !== undefined) { + await removeOwnedDirectory(stagingRoot, parent, stagingFingerprint).catch(() => {}); + } + if (createdOwnershipFingerprint !== undefined) { + await removeOwnedPackFile(ownershipPath, createdOwnershipFingerprint).catch(() => {}); + } + await syncDirectory(parent).catch(() => {}); + } + } +} + +async function cleanupHydrationStagingForDestination( + destinationRoot: string, + operation: 'hydrate' | 'cleanup' = 'cleanup', +): Promise<{ + removedStagingDirectories: number; + removedOwnershipRecords: number; +}> { + const parent = dirname(destinationRoot); + const destinationName = basename(destinationRoot); + const prefix = `.${destinationName}${HYDRATION_STAGING_MARKER}`; + const parentMetadata = await stat(parent, { bigint: true }); + if (!parentMetadata.isDirectory()) throw ioError(operation); + let removedStagingDirectories = 0; + let removedOwnershipRecords = 0; + let changed = false; + + for (const ownershipName of (await readdir(parent)).sort()) { + if (!ownershipName.startsWith(prefix) || !ownershipName.endsWith(HYDRATION_OWNERSHIP_SUFFIX)) { + continue; + } + const owned = await readHydrationStagingOwnership( + parent, + destinationName, + prefix, + ownershipName, + ); + if (owned === undefined) continue; + if ( + owned.stagingIdentity !== undefined && + (await removeOwnedDirectory(owned.stagingRoot, parent, owned.stagingIdentity)) + ) { + removedStagingDirectories += 1; + changed = true; + if (await removeOwnedPackFile(owned.ownershipPath, owned.ownershipFingerprint)) { + removedOwnershipRecords += 1; + } + continue; + } + + const [stagingMetadata, quarantineMetadata] = await Promise.all([ + lstat(owned.stagingRoot).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }), + lstat(hydrationCleanupRoot(owned.stagingRoot)).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }), + ]); + if ( + stagingMetadata === undefined && + quarantineMetadata === undefined && + (await removeOwnedPackFile(owned.ownershipPath, owned.ownershipFingerprint)) + ) { + removedOwnershipRecords += 1; + changed = true; + } + } + if (changed) await syncDirectory(parent); + return { removedStagingDirectories, removedOwnershipRecords }; +} + +async function readHydrationStagingOwnership( + parent: string, + destinationName: string, + prefix: string, + ownershipName: string, +): Promise< + | { + stagingRoot: string; + stagingIdentity?: FilesystemNodeIdentity; + ownershipPath: string; + ownershipFingerprint: FileFingerprint; + } + | undefined +> { + const ownershipPath = join(parent, ownershipName); + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + let handle: FileHandle | undefined; + try { + handle = await open(ownershipPath, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ELOOP')) return undefined; + throw error; + } + try { + const [handleMetadata, pathMetadata] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(ownershipPath, { bigint: true }), + ]); + const ownershipFingerprint = fingerprint(handleMetadata); + if ( + !handleMetadata.isFile() || + !pathMetadata.isFile() || + pathMetadata.isSymbolicLink() || + handleMetadata.nlink < 1n || + handleMetadata.nlink > 2n || + handleMetadata.size > BigInt(HYDRATION_OWNERSHIP_MAX_BYTES) || + !sameFingerprint(ownershipFingerprint, fingerprint(pathMetadata)) + ) { + return undefined; + } + const bytes = await readBoundedFileHandle(handle, HYDRATION_OWNERSHIP_MAX_BYTES); + if (bytes === undefined) return undefined; + const after = await handle.stat({ bigint: true }); + if (!sameFingerprint(ownershipFingerprint, fingerprint(after))) return undefined; + const decoded = decodeHydrationStagingOwnership(bytes); + if ( + decoded === undefined || + decoded.ownership.destinationName !== destinationName || + decoded.ownership.stagingName !== `${prefix}${decoded.ownership.token}` || + ownershipName !== `${decoded.ownership.stagingName}${HYDRATION_OWNERSHIP_SUFFIX}` + ) { + return undefined; + } + const stagingRoot = join(parent, decoded.ownership.stagingName); + const stagingIdentity = + decoded.binding === undefined + ? await readAnchoredHydrationStagingIdentity(stagingRoot, ownershipFingerprint) + : { + dev: BigInt(decoded.binding.stagingDev), + ino: BigInt(decoded.binding.stagingIno), + }; + return { + stagingRoot, + stagingIdentity, + ownershipPath, + ownershipFingerprint, + }; + } finally { + await handle.close().catch(() => {}); + } +} + +function encodeHydrationStagingOwnership(value: HydrationStagingOwnershipV1): Buffer { + return Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); +} + +function encodeHydrationStagingOwnershipBinding(value: HydrationStagingOwnershipBindingV1): Buffer { + return Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); +} + +function decodeHydrationStagingOwnership( + value: Buffer, +): DecodedHydrationStagingOwnership | undefined { + const firstLineEnd = value.indexOf(0x0a); + if (firstLineEnd < 0) return undefined; + const ownership = decodeHydrationStagingOwnershipRecord(value.subarray(0, firstLineEnd + 1)); + if (ownership === undefined) return undefined; + const bindingBytes = value.subarray(firstLineEnd + 1); + if (bindingBytes.byteLength === 0) return { ownership }; + const binding = decodeHydrationStagingOwnershipBinding(bindingBytes); + // An incomplete final line is an interrupted append, not a reason to lose + // the valid first record. The caller requires the hard-link anchor before it + // trusts such a reservation. + return binding === undefined ? { ownership } : { ownership, binding }; +} + +function decodeHydrationStagingOwnershipRecord( + value: Buffer, +): HydrationStagingOwnershipV1 | undefined { + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(value)); + } catch { + return undefined; + } + if ( + !isRecord(decoded) || + Object.keys(decoded).length !== 5 || + decoded.schemaVersion !== 1 || + decoded.kind !== HYDRATION_OWNERSHIP_KIND || + typeof decoded.destinationName !== 'string' || + typeof decoded.stagingName !== 'string' || + typeof decoded.token !== 'string' || + !HYDRATION_TOKEN_PATTERN.test(decoded.token) + ) { + return undefined; + } + const ownership: HydrationStagingOwnershipV1 = { + schemaVersion: 1, + kind: HYDRATION_OWNERSHIP_KIND, + destinationName: decoded.destinationName, + stagingName: decoded.stagingName, + token: decoded.token, + }; + return encodeHydrationStagingOwnership(ownership).equals(value) ? ownership : undefined; +} + +function decodeHydrationStagingOwnershipBinding( + value: Buffer, +): HydrationStagingOwnershipBindingV1 | undefined { + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(value)); + } catch { + return undefined; + } + if ( + !isRecord(decoded) || + Object.keys(decoded).length !== 4 || + decoded.schemaVersion !== 1 || + decoded.kind !== HYDRATION_OWNERSHIP_BINDING_KIND || + typeof decoded.stagingDev !== 'string' || + !FILESYSTEM_ID_PATTERN.test(decoded.stagingDev) || + typeof decoded.stagingIno !== 'string' || + !FILESYSTEM_ID_PATTERN.test(decoded.stagingIno) + ) { + return undefined; + } + const binding: HydrationStagingOwnershipBindingV1 = { + schemaVersion: 1, + kind: HYDRATION_OWNERSHIP_BINDING_KIND, + stagingDev: decoded.stagingDev, + stagingIno: decoded.stagingIno, + }; + return encodeHydrationStagingOwnershipBinding(binding).equals(value) ? binding : undefined; +} + +async function readAnchoredHydrationStagingIdentity( + stagingRoot: string, + ownershipFingerprint: FileFingerprint, +): Promise { + for (const candidate of [stagingRoot, hydrationCleanupRoot(stagingRoot)]) { + const identity = await readAnchoredHydrationDirectoryIdentity(candidate, ownershipFingerprint); + if (identity !== undefined) return identity; + } + return undefined; +} + +async function readAnchoredHydrationDirectoryIdentity( + directory: string, + ownershipFingerprint: FileFingerprint, +): Promise { + const anchorPath = join(directory, HYDRATION_OWNERSHIP_ANCHOR); + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + let anchorHandle: FileHandle | undefined; + try { + anchorHandle = await open(anchorPath, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ELOOP') || isErrno(error, 'ENOTDIR')) { + return undefined; + } + throw error; + } + try { + const [stagingMetadata, anchorMetadata, anchorPathMetadata] = await Promise.all([ + lstat(directory, { bigint: true }), + anchorHandle.stat({ bigint: true }), + lstat(anchorPath, { bigint: true }), + ]); + if ( + !stagingMetadata.isDirectory() || + stagingMetadata.isSymbolicLink() || + !anchorMetadata.isFile() || + !anchorPathMetadata.isFile() || + anchorPathMetadata.isSymbolicLink() || + ownershipFingerprint.nlink !== 2n || + !sameFingerprint(ownershipFingerprint, fingerprint(anchorMetadata)) || + !sameFingerprint(fingerprint(anchorMetadata), fingerprint(anchorPathMetadata)) + ) { + return undefined; + } + return fingerprint(stagingMetadata); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return undefined; + throw error; + } finally { + await anchorHandle.close().catch(() => {}); + } +} + +async function readBoundedFileHandle( + handle: FileHandle, + maxBytes: number, +): Promise { + const buffer = Buffer.alloc(maxBytes + 1); + let offset = 0; + while (offset < buffer.byteLength) { + const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + return offset > maxBytes ? undefined : buffer.subarray(0, offset); +} + +async function removeOwnedDirectory( + path: string, + parent: string, + expectedIdentity: FilesystemNodeIdentity, +): Promise { + if (dirname(path) !== parent) return false; + const quarantineRoot = hydrationCleanupRoot(path); + const existingQuarantine = await lstat(quarantineRoot, { + bigint: true, + }).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }); + if (existingQuarantine !== undefined) { + if ( + !existingQuarantine.isDirectory() || + existingQuarantine.isSymbolicLink() || + !sameFilesystemNode(expectedIdentity, fingerprint(existingQuarantine)) + ) { + return false; + } + await rm(quarantineRoot, { recursive: true }); + return true; + } + + const initial = await lstat(path, { bigint: true }).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }); + if ( + initial === undefined || + !initial.isDirectory() || + initial.isSymbolicLink() || + !sameFilesystemNode(expectedIdentity, fingerprint(initial)) + ) { + return false; + } + + const current = await lstat(path, { bigint: true }).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }); + if ( + current === undefined || + !current.isDirectory() || + current.isSymbolicLink() || + !sameFilesystemNode(expectedIdentity, fingerprint(current)) + ) { + return false; + } + + // The quarantine path itself retains the staging inode, so a crash at any + // later point remains recoverable from the persisted ownership binding. The + // lifecycle lock excludes cooperating writers; Node does not expose a + // rename-without-replacement primitive for non-cooperating directory writers. + try { + await rename(path, quarantineRoot); + } catch (error) { + if ( + isErrno(error, 'ENOENT') || + isErrno(error, 'EEXIST') || + isErrno(error, 'ENOTEMPTY') || + isErrno(error, 'EISDIR') || + isErrno(error, 'ENOTDIR') + ) { + return false; + } + throw error; + } + const moved = await lstat(quarantineRoot, { bigint: true }); + if ( + !moved.isDirectory() || + moved.isSymbolicLink() || + !sameFilesystemNode(expectedIdentity, fingerprint(moved)) + ) { + return false; + } + await rm(quarantineRoot, { recursive: true }); + return true; +} + +function hydrationCleanupRoot(stagingRoot: string): string { + return `${stagingRoot}${HYDRATION_CLEANUP_SUFFIX}`; +} + +async function removeOwnedHydrationStaging( + staging: HydrationStagingBinding, + parent: string, + prefix: string, +): Promise { + if ( + dirname(staging.stagingRoot) !== parent || + !basename(staging.stagingRoot).startsWith(prefix) || + dirname(staging.ownershipPath) !== parent || + basename(staging.ownershipPath) !== + `${basename(staging.stagingRoot)}${HYDRATION_OWNERSHIP_SUFFIX}` + ) { + return; + } + const removed = await removeOwnedDirectory( + staging.stagingRoot, + parent, + staging.stagingFingerprint, + ); + if (removed || (await hydrationStagingPathsAbsent(staging.stagingRoot))) { + await removeOwnedPackFile(staging.ownershipPath, staging.ownershipFingerprint); + } + await syncDirectory(parent); +} + +async function hydrationStagingPathsAbsent(stagingRoot: string): Promise { + const paths = [stagingRoot, hydrationCleanupRoot(stagingRoot)]; + const metadata = await Promise.all( + paths.map((path) => + lstat(path).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }), + ), + ); + return metadata.every((value) => value === undefined); +} + +async function removeOwnedPackTemporary( + path: string, + parent: string, + expectedFingerprint: FileFingerprint, +): Promise { + if (dirname(path) !== parent || !basename(path).includes(PACK_TEMP_MARKER)) return; + await removeOwnedPackFile(path, expectedFingerprint); +} + +async function removeOwnedPackPublication( + path: string, + parent: string, + expectedFingerprint: FileFingerprint, +): Promise { + if (dirname(path) !== parent) return; + await removeOwnedPackFile(path, expectedFingerprint); +} + +async function removeOwnedPackFile( + path: string, + expectedFingerprint: FileFingerprint, +): Promise { + const metadata = await lstat(path, { bigint: true }).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + }); + if ( + metadata === undefined || + !metadata.isFile() || + metadata.isSymbolicLink() || + !sameFilesystemNode(expectedFingerprint, fingerprint(metadata)) + ) { + return false; + } + await rm(path); + return true; +} + +function inspectionFromReadResult(result: ReadValidationResult): SessionBundleInspection { + return { + manifest: result.manifest, + stateIdentity: copyOpaqueStateIdentityDescriptor(result.stateIdentity), + archiveDigest: result.archiveDigest, + verified: true, + }; +} + +function fingerprint(metadata: BigIntStats): FileFingerprint { + return { + dev: metadata.dev, + ino: metadata.ino, + nlink: metadata.nlink, + size: metadata.size, + mtimeNs: metadata.mtimeNs, + ctimeNs: metadata.ctimeNs, + }; +} + +function sameFingerprint(left: FileFingerprint, right: FileFingerprint): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function sameFilesystemNode(left: FilesystemNodeIdentity, right: FilesystemNodeIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function safeBigIntSize(value: bigint): number { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle filesystem entry size is outside the safe integer range', + ); + } + return Number(value); +} + +function safeAdd( + left: number, + right: number, + code: 'integrity_mismatch' | 'quota_exceeded' | 'source_changed' | 'unsupported_entry', +): number { + const value = left + right; + if (!Number.isSafeInteger(value)) { + throw new SessionBundleFileError(code, 'Session bundle byte count exceeds safe integers'); + } + return value; +} + +function hasExecutableBit(mode: bigint): boolean { + return (mode & 0o111n) !== 0n; +} + +function digestBytes(value: Uint8Array): Sha256Digest { + return `sha256:${createHash('sha256').update(value).digest('hex')}` as Sha256Digest; +} + +function sourceChanged(): SessionBundleFileError { + return new SessionBundleFileError( + 'source_changed', + 'Session bundle source changed while it was being consumed', + ); +} + +function packPublicationChanged(): SessionBundleFileError { + return new SessionBundleFileError( + 'source_changed', + 'Session bundle pack output changed before publication completed', + ); +} + +function destinationExists(operation: 'pack' | 'hydrate'): SessionBundleFileError { + return new SessionBundleFileError( + 'destination_exists', + 'Session bundle destination already exists', + { details: { operation } }, + ); +} + +function unsafePath( + operation: SessionBundleFileOperation, + entryIndex: number, +): SessionBundleFileError { + return new SessionBundleFileError('unsafe_path', 'Session bundle contains an unsafe path', { + details: { operation, entryIndex }, + }); +} + +function unsupportedEntry( + operation: SessionBundleFileOperation, + entryIndex: number, +): SessionBundleFileError { + return new SessionBundleFileError( + 'unsupported_entry', + 'Session bundle contains unsupported entry metadata', + { details: { operation, entryIndex } }, + ); +} + +function integrityError(): SessionBundleFileError { + return new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle archive is truncated or malformed', + ); +} + +function ioError(operation: SessionBundleFileOperation): SessionBundleFileError { + return new SessionBundleFileError('io_failure', 'Session bundle filesystem operation failed', { + details: { operation }, + }); +} + +function normalizeReadError( + error: unknown, + operation: 'inspect' | 'hydrate', +): SessionBundleFileError { + if (error instanceof SessionBundleFileError) return error; + if (isFilesystemError(error)) return ioError(operation); + return new SessionBundleFileError( + 'integrity_mismatch', + 'Session bundle compressed stream is invalid or truncated', + ); +} + +function normalizeOperationError(error: unknown, operation: SessionBundleFileOperation): Error { + if ( + error instanceof SessionBundleFileError || + error instanceof TypeError || + error instanceof RangeError + ) { + return error; + } + return ioError(operation); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && typeof (error as NodeJS.ErrnoException).code === 'string'; +} + +function isFilesystemError(error: unknown): error is NodeJS.ErrnoException { + return isErrnoException(error) && typeof error.syscall === 'string'; +} + +function isErrno(error: unknown, code: string): boolean { + return isErrnoException(error) && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1f3eacfd12ac4859d4cd0be49b335c71efcc9c08b5d70f4cde6edd3e484d9e8b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1f3eacfd12ac4859d4cd0be49b335c71efcc9c08b5d70f4cde6edd3e484d9e8b.source new file mode 100644 index 0000000000..b76aee156d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/1f3eacfd12ac4859d4cd0be49b335c71efcc9c08b5d70f4cde6edd3e484d9e8b.source @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface EvidenceReadBudget { + readonly maxRecords: number; + readonly maxBytes: number; +} + +export type BoundedEvidenceReadResult = + | { + readonly status: 'complete'; + readonly records: readonly T[]; + readonly sourceRecordCount: number; + readonly storedBytes: number; + } + | { readonly status: 'limit_exceeded' }; + +export function assertEvidenceReadBudget(budget: EvidenceReadBudget): void { + if (!Number.isSafeInteger(budget.maxRecords) || budget.maxRecords < 0) { + throw new RangeError('Evidence record limit must be a non-negative integer'); + } + if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 0) { + throw new RangeError('Evidence byte limit must be a non-negative integer'); + } +} + +export function measureEvidenceRows( + rows: readonly { stored_bytes?: unknown }[], + budget: EvidenceReadBudget, + invalidRowMessage: string, +): { readonly sourceRecordCount: number; readonly storedBytes: number } | undefined { + if (rows.length > budget.maxRecords) return undefined; + let storedBytes = 0; + for (const row of rows) { + if ( + typeof row.stored_bytes !== 'number' || + !Number.isSafeInteger(row.stored_bytes) || + row.stored_bytes < 0 + ) { + throw new Error(invalidRowMessage); + } + storedBytes += row.stored_bytes; + if (!Number.isSafeInteger(storedBytes) || storedBytes > budget.maxBytes) return undefined; + } + return { sourceRecordCount: rows.length, storedBytes }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/228536d30624e5e32f773a0753c6c897499bf3ee993eef78c92f976bf670264e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/228536d30624e5e32f773a0753c6c897499bf3ee993eef78c92f976bf670264e.source new file mode 100644 index 0000000000..5ef1a84ab8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/228536d30624e5e32f773a0753c6c897499bf3ee993eef78c92f976bf670264e.source @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + createExternalSessionAdapterRegistry, + type ExternalSessionAdapterOptions, +} from './external-session-adapters.js'; +export { + ExternalSessionImporter, + type ExternalSessionImportRequest, + type ExternalSessionImportTarget, +} from './external-session-importer.js'; diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ae3e27970156eebbbccdce5d9db0a933efa9f95219e6c8da2b93c79a5c3c7e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ae3e27970156eebbbccdce5d9db0a933efa9f95219e6c8da2b93c79a5c3c7e.source new file mode 100644 index 0000000000..660d86e091 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ae3e27970156eebbbccdce5d9db0a933efa9f95219e6c8da2b93c79a5c3c7e.source @@ -0,0 +1,4820 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { lstat, mkdtemp, open, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { describe, mock, test } from 'node:test'; +import { + createDefaultRuntimePolicy, + type ConnectionCatalogEntry, + type ConnectionCatalogEntryDraft, + type ConnectionVersionBasis, + type CredentialLocator, + type CredentialStatus, + type CredentialVersionBasis, + type MutateRuntimePolicyInput, + type RuntimePolicy, +} from '@maka/core/runtime-policy'; +import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + tryAcquireInteractiveRootReader, + type StorageRootLease, +} from '../root-authority.js'; +import { + authenticateRuntimePolicyStoresReader, + authenticateRuntimePolicyStoresWriter, + openInteractiveRuntimePolicyStoresForRead, + openInteractiveRuntimePolicyStoresForWrite, + RuntimePolicyStoreError, +} from '../runtime-policy-stores.js'; +import { ConnectionCatalogDocumentOwner } from '../runtime-policy/connection-catalog-document.js'; +import { CredentialVaultDocumentOwner } from '../runtime-policy/credential-vault-document.js'; +import { + prepareInteractiveOAuthEnrollmentIntent, + writeConnectionOnboardingIntent, +} from '../runtime-policy/onboarding-transaction.js'; +import { upsertInteractiveOAuthLoginReceipt } from '../runtime-policy/oauth-login-receipt-document.js'; +import { removeControlDirectory } from './fixtures/control-directory-hygiene.js'; + +const execFileAsync = promisify(execFile); + +describe('runtime policy stores', () => { + test('upgrades schema v2 with the automatic Host shell default', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const { shell: _shell, ...policyV2 } = createDefaultRuntimePolicy(); + await writeFile( + join(root, 'runtime-policy.json'), + `${JSON.stringify({ schemaVersion: 2, revision: 4, policy: policyV2 })}\n`, + ); + + const snapshot = await stores.runtimePolicy.getSnapshot(); + assert.equal(snapshot.revision, 4); + assert.deepEqual(snapshot.policy.shell, { preference: 'auto', executable: '' }); + const committed = await stores.runtimePolicy.mutate({ + expectedRevision: 4, + operation: { kind: 'set_shell', value: snapshot.policy.shell }, + }); + assert.equal(committed.kind, 'committed'); + const persisted = JSON.parse(await readFile(join(root, 'runtime-policy.json'), 'utf8')) as { + schemaVersion: number; + }; + assert.equal(persisted.schemaVersion, 3); + }); + }); + + test('persists extra request bodies and resolves custom headers as secret execution material', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection(stores, 0, { + ...connectionDraft('customized-openai', 'openai', 'Customized OpenAI'), + requestBodyOverlay: { provider: { order: ['primary'] } }, + }); + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'provider-secret', + }); + assert.deepEqual( + await stores.operations.replaceConnectionRequestHeaders(connection.connectionId, [ + { name: 'X-Tenant', value: 'tenant-a' }, + ]), + { kind: 'committed', names: ['X-Tenant'] }, + ); + assert.deepEqual( + await stores.operations.replaceConnectionRequestHeaders(connection.connectionId, [ + { name: 'x-tenant' }, + { name: 'X-Title', value: 'Maka' }, + ]), + { kind: 'committed', names: ['x-tenant', 'X-Title'] }, + ); + assert.deepEqual( + await stores.operations.getConnectionRequestHeaders(connection.connectionId), + { names: ['x-tenant', 'X-Title'] }, + ); + + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind !== 'ready') return; + assert.deepEqual(resolved.connection.requestBodyOverlay, { + provider: { order: ['primary'] }, + }); + assert.equal( + resolved.secretMaterial.requestHeaders?.secret, + JSON.stringify({ 'x-tenant': 'tenant-a', 'X-Title': 'Maka' }), + ); + + const updated = await stores.connectionCatalog.update({ + expected: connectionBasis(resolved.connection), + changes: { + name: resolved.connection.name, + enabled: resolved.connection.enabled, + enabledModelIds: resolved.connection.enabledModelIds, + requestBodyOverlay: null, + }, + }); + assert.equal(updated.kind, 'committed'); + if (updated.kind === 'committed') { + assert.equal(updated.snapshot.connections[0]?.requestBodyOverlay, undefined); + } + }); + }); + + test('carries, replaces, clears, and endpoint-retires the typed capability table', async () => { + await withInteractiveOwner(async ({ stores }) => { + const declared = { + 'relay-model': { + thinkingLevels: ['minimal', 'low'] as const, + vision: true, + contextWindow: 128_000, + }, + }; + // Create persists the typed projection — and only the projection + // (the extras bag never entered the picture). + const connection = await createConnection(stores, 0, { + ...connectionDraft('my-relay', 'openai-compatible', 'My Relay'), + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['relay-model'], + relayModelProfiles: declared, + }); + assert.deepEqual(connection.relayModelProfiles, declared); + + // Replacement is total: a new table swaps in, null clears. + const replaced = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['relay-model'], + relayModelProfiles: { 'relay-model': { vision: false } }, + }, + }); + assert.equal(replaced.kind, 'committed'); + if (replaced.kind !== 'committed') return; + const afterReplace = replaced.snapshot.connections[0]; + assert.deepEqual(afterReplace?.relayModelProfiles, { 'relay-model': { vision: false } }); + + const cleared = await stores.connectionCatalog.update({ + expected: connectionBasis(afterReplace!), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['relay-model'], + relayModelProfiles: null, + }, + }); + assert.equal(cleared.kind, 'committed'); + if (cleared.kind !== 'committed') return; + const afterClear = cleared.snapshot.connections[0]; + assert.equal(afterClear?.relayModelProfiles, undefined); + + // An absent key leaves the table untouched (name-only saves stay + // capability-blind), and an UNANNOUNCED endpoint change retires the + // table along with the fetched inventory: the new baseUrl fronts + // different models, and the old declarations must not outlive them. + const retained = await stores.connectionCatalog.update({ + expected: connectionBasis(afterClear!), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['relay-model'], + relayModelProfiles: declared, + }, + }); + assert.equal(retained.kind, 'committed'); + if (retained.kind !== 'committed') return; + const nameOnly = await stores.connectionCatalog.update({ + expected: connectionBasis(retained.snapshot.connections[0]!), + changes: { + name: 'Renamed Relay', + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['relay-model'], + }, + }); + assert.equal(nameOnly.kind, 'committed'); + if (nameOnly.kind !== 'committed') return; + assert.deepEqual(nameOnly.snapshot.connections[0]?.relayModelProfiles, declared); + + const endpointMoved = await stores.connectionCatalog.update({ + expected: connectionBasis(nameOnly.snapshot.connections[0]!), + changes: { + name: 'Renamed Relay', + baseUrl: 'https://other-relay.example/v1', + enabled: true, + enabledModelIds: ['relay-model'], + }, + }); + assert.equal(endpointMoved.kind, 'committed'); + if (endpointMoved.kind !== 'committed') return; + assert.equal(endpointMoved.snapshot.connections[0]?.relayModelProfiles, undefined); + assert.deepEqual(endpointMoved.snapshot.connections[0]?.models, []); + + // …unless the same update submits a table of its own — then the table + // belongs to the NEW endpoint and is stored. Config import relies on + // this exact single-call shape. + const movedWithTable = await stores.connectionCatalog.update({ + expected: connectionBasis(endpointMoved.snapshot.connections[0]!), + changes: { + name: 'Renamed Relay', + baseUrl: 'https://third-relay.example/v1', + enabled: true, + enabledModelIds: ['relay-model'], + relayModelProfiles: declared, + }, + }); + assert.equal(movedWithTable.kind, 'committed'); + if (movedWithTable.kind !== 'committed') return; + assert.deepEqual(movedWithTable.snapshot.connections[0]?.relayModelProfiles, declared); + assert.deepEqual(movedWithTable.snapshot.connections[0]?.models, []); + }); + }); + + test('an untouched profile table is pruned to the new enabled-model selection', async () => { + await withInteractiveOwner(async ({ stores }) => { + const declared = { + 'relay-model': { vision: true as const }, + 'relay-model-2': { contextWindow: 64_000 as const }, + }; + const connection = await createConnection(stores, 0, { + ...connectionDraft('prune-relay', 'openai-compatible', 'Prune Relay'), + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['relay-model', 'relay-model-2'], + relayModelProfiles: declared, + }); + assert.deepEqual(connection.relayModelProfiles, declared); + + const disabled = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['relay-model'], + }, + }); + assert.equal(disabled.kind, 'committed'); + if (disabled.kind !== 'committed') return; + // No profile instruction rode along, so the ⊆ enabledModelIds rule is + // the store's job: the disabled model's declaration is gone, never + // stranded as a stale key the settings page cannot see. + assert.deepEqual(disabled.snapshot.connections[0]?.relayModelProfiles, { + 'relay-model': { vision: true }, + }); + + // Pruning everything degrades to "no table" — never a stored `{}`. + const allDisabled = await stores.connectionCatalog.update({ + expected: connectionBasis(disabled.snapshot.connections[0]!), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: [], + }, + }); + assert.equal(allDisabled.kind, 'committed'); + if (allDisabled.kind !== 'committed') return; + assert.equal(allDisabled.snapshot.connections[0]?.relayModelProfiles, undefined); + }); + }); + + // The migrating half of this behaviour is covered in @maka/core: seeding an + // OAuth credential for the provider that declares aliases is refused here, + // since the vault only accepts client-supplied OAuth for GitHub Copilot. + test('a relay keeps its own ids opaque through a model refresh', async () => { + await withInteractiveOwner(async ({ stores }) => { + // Same ids, different provider. A relay may serve `claude-*` names as its + // own identifiers, so nothing here may be rewritten on Anthropic's behalf. + const connection = await createConnection(stores, 0, { + ...connectionDraft('alias-relay', 'openai-compatible', 'Alias Relay'), + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['claude-haiku-4-5-20251001'], + relayModelProfiles: { 'claude-haiku-4-5-20251001': { vision: true } }, + }); + + const credential = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'sk-relay', + }); + assert.equal(credential.kind, 'committed'); + + const fetch = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + + const discovered = await stores.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_000, + }); + assert.equal(discovered.kind, 'committed'); + if (discovered.kind !== 'committed') return; + // Left exactly as the user set it. A refresh migrates only ids it can + // prove were renamed, and a relay supplies no rename table — so neither + // `claude-opus-5` nor `claude-haiku-4-5` may replace this one. + assert.deepEqual(discovered.snapshot.connections[0]?.enabledModelIds, [ + 'claude-haiku-4-5-20251001', + ]); + }); + }); + + test('a model refresh keeps the selection, and an explicit change prunes it', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection(stores, 0, { + ...connectionDraft('refresh-relay', 'openai-compatible', 'Refresh Relay'), + baseUrl: 'https://relay.example/v1', + enabledModelIds: ['model-a', 'model-b'], + relayModelProfiles: { + 'model-a': { vision: true }, + 'model-b': { contextWindow: 64_000 }, + }, + }); + assert.deepEqual(connection.relayModelProfiles, { + 'model-a': { vision: true }, + 'model-b': { contextWindow: 64_000 }, + }); + + // A /models fetch needs a credential first — discovery is refused for + // credential-less connections before any of this runs. + const credential = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'sk-refresh', + }); + assert.equal(credential.kind, 'committed'); + + // The /models refresh no longer lists model-a. One response is not + // grounds for deleting a model the user picked (#1584), so the selection + // and its declaration both stand — and the subset invariant holds + // because neither side moved. + const fetch = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + const discovered = await stores.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'model-b' }], + source: 'fetched', + fetchedAt: 43, + }); + assert.equal(discovered.kind, 'committed'); + if (discovered.kind !== 'committed') return; + const after = discovered.snapshot.connections[0]; + assert.deepEqual(after?.enabledModelIds, ['model-a', 'model-b']); + assert.deepEqual(after?.relayModelProfiles, { + 'model-a': { vision: true }, + 'model-b': { contextWindow: 64_000 }, + }); + + // Unchecking model-a IS a decision, and the update path prunes its + // declaration with it. The document must also survive a canonical + // reload: the next mutation re-decodes persisted state, and a stranding + // here would have raised invalid_document instead of committing. + const roundtrip = await stores.connectionCatalog.update({ + expected: connectionBasis(after!), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['model-b'], + }, + }); + assert.equal(roundtrip.kind, 'committed'); + if (roundtrip.kind !== 'committed') return; + assert.deepEqual(roundtrip.snapshot.connections[0]?.relayModelProfiles, { + 'model-b': { contextWindow: 64_000 }, + }); + }); + }); + + test('commits closed policy mutations, canonicalizes proxy hosts, and preserves connection identity', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const policy = await stores.runtimePolicy.mutate(personalizationMutation(0)); + assert.equal(policy.kind, 'committed'); + assert.deepEqual( + await stores.runtimePolicy.mutate({ + expectedRevision: 0, + operation: { + kind: 'set_memory', + value: { enabled: false, agentReadEnabled: false }, + }, + }), + { + kind: 'revision_conflict', + expectedRevision: 0, + actualRevision: 1, + }, + ); + await assert.rejects( + () => + stores.runtimePolicy.mutate({ + expectedRevision: 1, + operation: { kind: 'replace_everything', value: {} }, + } as unknown as MutateRuntimePolicyInput), + isStoreError('invalid_policy_input'), + ); + for (const host of [' ', 'proxy\u0000.internal']) { + await assert.rejects( + () => stores.runtimePolicy.mutate(networkProxyMutation(1, { host })), + isStoreError('invalid_policy_input'), + ); + } + const proxy = await stores.runtimePolicy.mutate( + networkProxyMutation(1, { + host: ' proxy.internal ', + authEnabled: false, + username: '', + }), + ); + assert.equal(proxy.kind, 'committed'); + if (proxy.kind === 'committed') + assert.equal(proxy.snapshot.policy.networkProxy.host, 'proxy.internal'); + + const connection = await createConnection(stores, 0, { + ...connectionDraft('openai-main', 'openai', 'OpenAI'), + baseUrl: 'HTTPS://API.OPENAI.COM:443/v1', + }); + assert.match(connection.connectionId, UUID_PATTERN); + assert.equal(connection.baseUrl, undefined); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.baseUrl, + undefined, + ); + assert.deepEqual(connectionBasis(connection), { + connectionId: connection.connectionId, + revision: 1, + }); + + const target = { connectionId: connection.connectionId, modelId: 'gpt-5' }; + assert.equal( + ( + await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: 1, + target, + }) + ).kind, + 'committed', + ); + + const changes = { + name: 'Renamed', + baseUrl: ' https://Gateway.EXAMPLE:443/v1 ', + enabled: true, + enabledModelIds: ['gpt-5'], + relayModelProfiles: null, + }; + await assert.rejects( + () => + stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { ...changes, slug: 'replacement', providerType: 'anthropic' }, + } as never), + isStoreError('invalid_connection_input'), + ); + const updated = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes, + }); + assert.equal(updated.kind, 'committed'); + if (updated.kind !== 'committed') return; + const current = updated.snapshot.connections[0]; + assert.ok(current); + assert.equal(current.connectionId, connection.connectionId); + assert.equal(current.slug, 'openai-main'); + assert.equal(current.providerType, 'openai'); + assert.equal(current.name, 'Renamed'); + assert.equal(current.baseUrl, 'https://gateway.example/v1'); + assert.deepEqual(updated.snapshot.defaultTarget, target); + + const persisted = JSON.parse( + await readFile(join(root, 'connection-catalog.json'), 'utf8'), + ) as { + connections: Array>; + }; + assert.equal(persisted.connections[0]?.baseUrl, 'https://gateway.example/v1'); + }); + }); + + test('rejects a valid mutation whose combined policy document exceeds its byte limit', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const committed = await stores.runtimePolicy.mutate(personalizationMutation(0)); + assert.equal(committed.kind, 'committed'); + if (committed.kind !== 'committed') return; + const path = join(root, 'runtime-policy.json'); + const persistedBefore = await readFile(path); + + const entries = Array.from( + { length: 64 }, + (_, index) => `domain-${index}-${'x'.repeat(480)}`, + ); + await assert.rejects( + () => + stores.runtimePolicy.mutate( + networkProxyMutation(1, { + bypassList: entries.map((entry) => `bypass-${entry}`), + autoBypassDomains: entries.map((entry) => `auto-${entry}`), + }), + ), + isStoreError('invalid_policy_input'), + ); + + assert.deepEqual(await stores.runtimePolicy.getSnapshot(), committed.snapshot); + assert.deepEqual(await readFile(path), persistedBefore); + }); + }); + + test('rejects a valid create when the aggregate catalog exceeds its byte limit', async () => { + await withInteractiveOwner(async ({ stores }) => { + const enabledModelIds = Array.from({ length: 512 }, (_, index) => { + const prefix = index.toString(36).padStart(4, '0'); + return `${prefix}-${'m'.repeat(507)}`; + }); + let revision = 0; + let rejected = false; + + for (let index = 0; index < 32; index += 1) { + try { + const result = await stores.connectionCatalog.create({ + expectedCatalogRevision: revision, + connection: { + ...connectionDraft(`catalog-cap-${index}`, 'openai', `Catalog cap ${index}`), + enabledModelIds, + }, + }); + assert.equal(result.kind, 'committed'); + if (result.kind !== 'committed') { + throw new Error('catalog capacity setup did not commit'); + } + revision = result.snapshot.revision; + } catch (error) { + assert.ok(isStoreError('invalid_connection_input')(error)); + rejected = true; + break; + } + } + + assert.equal(rejected, true); + const snapshot = await stores.connectionCatalog.getSnapshot(); + assert.equal(snapshot.revision, revision); + assert.equal(snapshot.connections.length, revision); + }); + }); + + test('rejects a valid secret when the aggregate vault exceeds its byte limit', async () => { + await withInteractiveOwner(async ({ stores }) => { + const secret = 's'.repeat(64 * 1024); + let catalogRevision = 0; + let vaultRevision = 0; + let rejected = false; + + for (let index = 0; index < 40; index += 1) { + const connection = await createConnection( + stores, + catalogRevision, + connectionDraft(`vault-cap-${index}`, 'openai', `Vault cap ${index}`), + ); + catalogRevision += 1; + try { + const result = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret, + }); + assert.equal(result.kind, 'committed'); + if (result.kind !== 'committed') { + throw new Error('vault capacity setup did not commit'); + } + vaultRevision = result.snapshot.revision; + } catch (error) { + assert.ok(isStoreError('invalid_credential_input')(error)); + rejected = true; + break; + } + } + + assert.equal(rejected, true); + const snapshot = await stores.credentialVault.getSnapshot(); + assert.equal(snapshot.revision, vaultRevision); + assert.equal(snapshot.entries.length, vaultRevision); + }); + }); + + test('owns endpoints and fails closed on unsafe or unreachable persisted connection state', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const rejected: ConnectionCatalogEntryDraft[] = [ + { ...connectionDraft('ftp', 'openai', 'FTP'), baseUrl: 'ftp://example.com/v1' }, + { + ...connectionDraft('userinfo', 'openai', 'Userinfo'), + baseUrl: 'https://user:pass@example.com/v1', + }, + { + ...connectionDraft('query', 'openai', 'Query'), + baseUrl: 'https://example.com/v1?tenant=a', + }, + { + ...connectionDraft('fragment', 'openai', 'Fragment'), + baseUrl: 'https://example.com/v1#models', + }, + { + ...connectionDraft('oauth', 'github-copilot', 'OAuth'), + baseUrl: 'https://example.com/copilot', + }, + ]; + for (const connection of rejected) { + await assert.rejects( + () => stores.connectionCatalog.create({ expectedCatalogRevision: 0, connection }), + isStoreError('invalid_connection_input'), + ); + } + + const canonical = await createConnection(stores, 0, { + ...connectionDraft('canonical', 'openai', 'Canonical'), + baseUrl: 'HTTPS://Gateway.EXAMPLE:443/v1', + }); + assert.equal(canonical.baseUrl, 'https://gateway.example/v1'); + + const path = join(root, 'connection-catalog.json'); + const document = JSON.parse(await readFile(path, 'utf8')) as { + connections: Array>; + }; + document.connections[0]!.models = [{ id: 'persisted-but-unreachable' }]; + const unreachable = `${JSON.stringify(document)}\n`; + await writeFile(path, unreachable, 'utf8'); + await assert.rejects( + () => stores.connectionCatalog.getSnapshot(), + isStoreError('invalid_document'), + ); + assert.equal(await readFile(path, 'utf8'), unreachable); + }); + }); + + test('migrates a persisted Gemini CLI default to the remaining supported catalog', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const retiredConnectionId = '11111111-1111-4111-8111-111111111111'; + const googleConnectionId = '22222222-2222-4222-8222-222222222222'; + await writeFile( + join(root, 'connection-catalog.json'), + `${JSON.stringify({ + schemaVersion: 1, + revision: 2, + defaultTarget: { + connectionId: retiredConnectionId, + modelId: 'gemini-2.5-pro', + }, + connections: [ + { + connectionId: retiredConnectionId, + revision: 1, + slug: 'gemini-account', + name: 'Gemini account', + providerType: 'gemini-cli', + enabled: true, + enabledModelIds: ['gemini-2.5-pro'], + models: [], + }, + { + connectionId: googleConnectionId, + revision: 1, + slug: 'google-api', + name: 'Google API', + providerType: 'google', + enabled: true, + enabledModelIds: ['gemini-2.5-pro'], + models: [], + }, + ], + })}\n`, + 'utf8', + ); + + const snapshot = await stores.connectionCatalog.getSnapshot(); + + assert.equal(snapshot.revision, 2); + assert.equal(snapshot.defaultTarget, null); + assert.deepEqual( + snapshot.connections.map(({ connectionId, providerType }) => ({ + connectionId, + providerType, + })), + [{ connectionId: googleConnectionId, providerType: 'google' }], + ); + const persisted = JSON.parse( + await readFile(join(root, 'connection-catalog.json'), 'utf8'), + ) as { + connections: Array<{ providerType: string }>; + }; + assert.deepEqual( + persisted.connections.map(({ providerType }) => providerType), + ['gemini-cli', 'google'], + ); + }); + }); + + /** + * A connection that predates its provider's retirement. `create` refuses to + * author one now, which is the point — such rows can only arrive by having + * been written before retirement, so tests reproduce them the same way: + * appended to the persisted catalog rather than through the mutation API. + */ + async function seedRetiredConnection( + root: string, + stores: Awaited>, + slug: string, + connectionId: string, + // A retired row that was tested before its provider was retired is the + // case global invalidation reaches, so seeding one is how that path gets + // exercised at all. + lastTest?: { status: 'verified'; checkedAt: string }, + ): Promise { + const path = join(root, 'connection-catalog.json'); + const document = existsSync(path) + ? (JSON.parse(await readFile(path, 'utf8')) as { + revision: number; + connections: Record[]; + }) + : { + schemaVersion: 1, + revision: 0, + defaultTarget: null, + connections: [] as Record[], + }; + document.revision += 1; + document.connections.push({ + connectionId, + revision: 1, + slug, + name: slug, + providerType: 'claude-subscription', + enabled: true, + enabledModelIds: ['claude-opus-5'], + models: [], + ...(lastTest ? { lastTest } : {}), + }); + await writeFile(path, `${JSON.stringify(document)}\n`, 'utf8'); + const snapshot = await stores.connectionCatalog.getSnapshot(); + const seeded = snapshot.connections.find( + (item: ConnectionCatalogEntry) => item.connectionId === connectionId, + ); + assert.ok(seeded, `${slug} was not readable after seeding`); + return seeded; + } + + test('refuses to author or default to a retired provider', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // Decode and delete are the only paths a retired provider may still take. + // Authoring one would create a row that can never execute, and committing + // it as the default would succeed only for the next read to rewrite it to + // null — which reads to the caller as a lost write, not a refusal. + await assert.rejects( + () => + stores.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: connectionDraft('new-claude', 'claude-subscription', 'New Claude'), + }), + isStoreError('invalid_connection_input'), + ); + + const kept = await seedRetiredConnection( + root, + stores, + 'kept-claude', + '55555555-5555-4555-8555-555555555555', + ); + const snapshot = await stores.connectionCatalog.getSnapshot(); + const target = { connectionId: kept.connectionId, modelId: 'claude-opus-5' }; + assert.deepEqual( + await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: snapshot.revision, + target, + }), + { kind: 'invalid_default_target', target }, + ); + }); + }); + + test('an OAuth refresh cannot rotate a retained retired credential', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // The credential this retirement deliberately keeps was written before + // the provider was retired, so it is seeded the same way the row is. + // `compareAndSetOAuthCredential` validated only the auth kind, which a + // retired provider still declares — so a refresh committed and advanced + // the credential revision. No production caller reaches it today, since + // execution resolution refuses first; that is precisely why it would + // have stayed open. + const retired = await seedRetiredConnection( + root, + stores, + 'refresh-claude', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ); + const locator = { + scope: 'connection' as const, + connectionId: retired.connectionId, + kind: 'oauth_token' as const, + }; + const vaultPath = join(root, 'credential-vault.json'); + await writeFile( + vaultPath, + `${JSON.stringify({ + schemaVersion: 1, + revision: 1, + entries: [ + { + locator, + credentialId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + revision: 1, + secret: JSON.stringify({ + access_token: 'legacy', + expires_at: Number.MAX_SAFE_INTEGER, + }), + updatedAt: 1, + }, + ], + })}\n`, + 'utf8', + ); + const seeded = await getCredentialStatus(stores.credentialVault, locator); + assert.equal(credentialBasis(seeded).revision, 1); + + await assert.rejects( + () => + stores.operations.compareAndSetOAuthCredential({ + locator, + expected: credentialExpectation(seeded), + secret: JSON.stringify({ + access_token: 'rotated', + expires_at: Number.MAX_SAFE_INTEGER, + }), + }), + isStoreError('invalid_connection_input'), + ); + + // The credential is unchanged, which is what keeps it deletable as the + // thing the user came to remove rather than something Maka rewrote. + const after = await getCredentialStatus(stores.credentialVault, locator); + assert.equal(credentialBasis(after).revision, 1); + }); + }); + + test('deleting a retained retired credential leaves its row byte-stable', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // Deleting the credential is the one write a tombstone must still accept + // — it is how a user removes the retained token. The deletion itself was + // never the problem: it invalidated the row's verification on the way + // out, bumping the revision of a row nothing may rewrite. The global + // sweep was taught to skip retired rows; this single-connection path is + // the same invariant reached one connection at a time. + const retired = await seedRetiredConnection( + root, + stores, + 'delete-claude', + '88888888-8888-4888-8888-888888888888', + { status: 'verified', checkedAt: '2026-08-01T00:00:00.000Z' }, + ); + assert.ok(retired.lastTest, 'the seeded retired row must carry a verification'); + + // Seeded the same way the row is: a retired connection's credential + // cannot be written through the API that now refuses it. + const locator = { + scope: 'connection' as const, + connectionId: retired.connectionId, + kind: 'oauth_token' as const, + }; + await writeFile( + join(root, 'credential-vault.json'), + `${JSON.stringify({ + schemaVersion: 1, + revision: 1, + entries: [ + { + locator, + credentialId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + revision: 1, + secret: JSON.stringify({ + access_token: 'legacy', + expires_at: Number.MAX_SAFE_INTEGER, + }), + updatedAt: 1, + }, + ], + })}\n`, + 'utf8', + ); + const seeded = await getCredentialStatus(stores.credentialVault, locator); + assert.equal(credentialBasis(seeded).revision, 1); + + const deleted = await stores.credentialVault.delete({ + expected: credentialBasis(seeded), + }); + assert.equal(deleted.kind, 'committed'); + + const after = await stores.connectionCatalog.getSnapshot(); + const row = after.connections.find((item) => item.slug === 'delete-claude'); + // The credential is gone and the row is exactly as it was — same + // revision, same verification. A concurrent deletion of the row itself + // must not have been made stale by removing its credential. + assert.equal(row?.revision, retired.revision); + assert.deepEqual(row?.lastTest, retired.lastTest); + + // Control: the same deletion against a live connection must still + // invalidate it. Without this the test would pass just as well if the + // guard stopped every invalidation rather than only the retired one. + const live = await createConnection( + stores, + after.revision, + connectionDraft('delete-live', 'openai', 'Delete Live'), + ); + const liveLocator = connectionCredential(live, 'api_key'); + await stores.credentialVault.set({ + locator: liveLocator, + expected: null, + secret: 'live-key', + }); + const ticket = await stores.operations.beginConnectionTest(live.connectionId, 'gpt-5'); + assert.equal(ticket.kind, 'ready'); + if (ticket.kind !== 'ready') return; + await stores.operations.completeConnectionTest(ticket.ticket, { + status: 'verified', + checkedAt: '2026-08-03T00:00:00.000Z', + }); + const liveBefore = (await stores.connectionCatalog.getSnapshot()).connections.find( + (item) => item.slug === 'delete-live', + ); + assert.ok(liveBefore?.lastTest, 'the live row must be verified before its credential goes'); + + const liveStatus = await getCredentialStatus(stores.credentialVault, liveLocator); + const liveDeleted = await stores.credentialVault.delete({ + expected: credentialBasis(liveStatus), + }); + assert.equal(liveDeleted.kind, 'committed'); + + const liveAfter = (await stores.connectionCatalog.getSnapshot()).connections.find( + (item) => item.slug === 'delete-live', + ); + assert.equal(liveAfter?.lastTest, undefined); + assert.equal(liveAfter?.revision, liveBefore.revision + 1); + }); + }); + + test('a global proxy change leaves a retained retired row byte-stable', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // Refusing direct writes was not enough: an indirect one reached the + // tombstone. Editing the network proxy invalidates every tested + // connection's verification, which bumped the retired row's revision + // too — enough to make a deletion started elsewhere fail as stale, from + // a global setting that has nothing to do with this connection. Its + // `lastTest` describes a provider that can no longer be tested, so there + // is nothing there to invalidate either. + const live = await createConnection( + stores, + 0, + connectionDraft('proxy-live', 'openai', 'Proxy Live'), + ); + const retired = await seedRetiredConnection( + root, + stores, + 'proxy-claude', + '99999999-9999-4999-8999-999999999999', + { status: 'verified', checkedAt: '2026-08-01T00:00:00.000Z' }, + ); + assert.ok(retired.lastTest, 'the seeded retired row must carry a verification to invalidate'); + + await stores.credentialVault.set({ + locator: connectionCredential(live, 'api_key'), + expected: null, + secret: 'live-key', + }); + const ticket = await stores.operations.beginConnectionTest(live.connectionId, 'gpt-5'); + assert.equal(ticket.kind, 'ready'); + if (ticket.kind !== 'ready') return; + await stores.operations.completeConnectionTest(ticket.ticket, { + status: 'verified', + checkedAt: '2026-08-02T00:00:00.000Z', + }); + const before = await stores.connectionCatalog.getSnapshot(); + const liveBefore = before.connections.find((item) => item.slug === 'proxy-live'); + assert.ok(liveBefore?.lastTest, 'the live row must be verified before the proxy change'); + + const policy = await stores.runtimePolicy.getSnapshot(); + const proxied = await stores.runtimePolicy.mutate( + networkProxyMutation(policy.revision, { host: 'proxy.example' }), + ); + assert.equal(proxied.kind, 'committed'); + + const after = await stores.connectionCatalog.getSnapshot(); + const retiredAfter = after.connections.find((item) => item.slug === 'proxy-claude'); + const liveAfter = after.connections.find((item) => item.slug === 'proxy-live'); + // The live row is invalidated — without this the test would pass by the + // invalidation doing nothing at all. + assert.equal(liveAfter?.lastTest, undefined); + assert.equal(liveAfter?.revision, (liveBefore?.revision ?? 0) + 1); + // The tombstone is untouched, revision included. + assert.deepEqual(retiredAfter?.lastTest, retired.lastTest); + assert.equal(retiredAfter?.revision, retired.revision); + }); + }); + + test('refuses every connection-owned write against a retained retired row', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // The catalog update guard alone did not establish the tombstone: the + // credential vault and the request-header replacement are sibling writes + // that reach the same connection, and both committed against a retired + // row until they shared one refusal. Each is exercised here because each + // is separately reachable from a remote client. + const kept = await seedRetiredConnection( + root, + stores, + 'sealed-claude', + '77777777-7777-4777-8777-777777777777', + ); + + await assert.rejects( + () => + stores.operations.replaceConnectionRequestHeaders(kept.connectionId, [ + { name: 'X-Tenant', value: 'tenant-a' }, + ]), + isStoreError('invalid_connection_input'), + ); + + await assert.rejects( + () => + stores.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: kept.connectionId, + kind: 'request_headers', + }, + expected: null, + secret: JSON.stringify({ 'X-Tenant': 'tenant-a' }), + }), + isStoreError('invalid_connection_input'), + ); + + await assert.rejects( + () => + stores.credentialVault.set({ + locator: { scope: 'connection', connectionId: kept.connectionId, kind: 'oauth_token' }, + expected: null, + secret: 'refreshed-token', + }), + isStoreError('invalid_connection_input'), + ); + + // Reading and deleting stay open — that is the whole point of retaining + // the row, and a refusal that strands the credential would be worse than + // the writes it prevents. + const snapshot = await stores.connectionCatalog.getSnapshot(); + const still = snapshot.connections.find((item) => item.slug === 'sealed-claude'); + assert.ok(still, 'refused writes must leave the row readable'); + assert.equal( + ( + await stores.connectionCatalog.remove({ + expected: { connectionId: kept.connectionId, revision: still.revision }, + }) + ).kind, + 'committed', + ); + }); + }); + + test('refuses to edit a retained retired connection back toward usable', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + // The row is kept so its credential stays visible and deletable, and + // create and set-default already refuse. Update was the way back in: a + // retired connection could be re-enabled and become a default candidate + // again, at which point every downstream refusal is the only thing left + // between it and a Session. Read and delete remain the exceptions. + const kept = await seedRetiredConnection( + root, + stores, + 'editable-claude', + '66666666-6666-4666-8666-666666666666', + ); + + await assert.rejects( + () => + stores.connectionCatalog.update({ + expected: { connectionId: kept.connectionId, revision: kept.revision }, + changes: { + name: kept.name, + enabled: true, + enabledModelIds: [...kept.enabledModelIds], + }, + }), + isStoreError('invalid_connection_input'), + ); + + // Still readable and still deletable afterwards — refusing the edit must + // not strand the credential this retirement deliberately retains. + const snapshot = await stores.connectionCatalog.getSnapshot(); + const still = snapshot.connections.find((item) => item.slug === 'editable-claude'); + assert.ok(still, 'a refused edit must leave the row readable'); + assert.equal( + ( + await stores.connectionCatalog.remove({ + expected: { connectionId: kept.connectionId, revision: still.revision }, + }) + ).kind, + 'committed', + ); + }); + }); + + test('releases a default target that points at a retained retired connection', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const retiredConnectionId = '33333333-3333-4333-8333-333333333333'; + const openaiConnectionId = '44444444-4444-4444-8444-444444444444'; + await writeFile( + join(root, 'connection-catalog.json'), + `${JSON.stringify({ + schemaVersion: 1, + revision: 2, + defaultTarget: { connectionId: retiredConnectionId, modelId: 'claude-opus-5' }, + connections: [ + { + connectionId: retiredConnectionId, + revision: 1, + slug: 'claude-subscription', + name: 'Claude Subscription', + providerType: 'claude-subscription', + enabled: true, + enabledModelIds: ['claude-opus-5'], + models: [], + }, + { + connectionId: openaiConnectionId, + revision: 1, + slug: 'openai', + name: 'OpenAI', + providerType: 'openai', + enabled: true, + enabledModelIds: ['gpt-4.1'], + models: [], + }, + ], + })}\n`, + 'utf8', + ); + + const snapshot = await stores.connectionCatalog.getSnapshot(); + + // The connection stays — the user needs it visible to delete it and + // clear the credential — but it stops being what new Sessions default to. + assert.equal(snapshot.defaultTarget, null); + assert.deepEqual( + snapshot.connections.map(({ connectionId, providerType }) => ({ + connectionId, + providerType, + })), + [ + { connectionId: retiredConnectionId, providerType: 'claude-subscription' }, + { connectionId: openaiConnectionId, providerType: 'openai' }, + ], + ); + }); + }); + + test('rejects a retired Gemini CLI record that collides with a maintained connection identity', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const duplicateConnectionId = '11111111-1111-4111-8111-111111111111'; + await writeFile( + join(root, 'connection-catalog.json'), + `${JSON.stringify({ + schemaVersion: 1, + revision: 2, + defaultTarget: null, + connections: [ + { + connectionId: duplicateConnectionId, + revision: 1, + slug: 'gemini-account', + name: 'Gemini account', + providerType: 'gemini-cli', + enabled: true, + enabledModelIds: ['gemini-2.5-pro'], + models: [], + }, + { + connectionId: duplicateConnectionId, + revision: 1, + slug: 'google-api', + name: 'Google API', + providerType: 'google', + enabled: true, + enabledModelIds: ['gemini-2.5-pro'], + models: [], + }, + ], + })}\n`, + 'utf8', + ); + + await assert.rejects( + () => stores.connectionCatalog.getSnapshot(), + isStoreError('invalid_document'), + ); + }); + }); + + test('validates credential locators and redacts credential status', async () => { + await withInteractiveOwner(async ({ stores }) => { + const required = await createConnection( + stores, + 0, + connectionDraft('required', 'openai', 'Required key'), + ); + + assert.deepEqual( + await stores.credentialVault.getStatus({ + scope: 'connection', + connectionId: '00000000-0000-4000-8000-000000000001', + kind: 'api_key', + }), + { kind: 'connection_not_found' }, + ); + await assert.rejects( + () => stores.credentialVault.getStatus(connectionCredential(required, 'oauth_token')), + isStoreError('invalid_credential_input'), + ); + + const apiSecret = 'api-secret-never-redacted-back'; + const proxySecret = 'proxy-secret-never-redacted-back'; + const apiSet = await stores.credentialVault.set({ + locator: connectionCredential(required, 'api_key'), + expected: null, + secret: apiSecret, + }); + assert.equal(apiSet.kind, 'committed'); + const proxySet = await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: proxySecret, + }); + assert.equal(proxySet.kind, 'committed'); + + const requiredStatus = await getCredentialStatus( + stores.credentialVault, + connectionCredential(required, 'api_key'), + ); + const proxyStatus = await getCredentialStatus(stores.credentialVault, proxyCredential()); + const publicViews = JSON.stringify([ + apiSet, + proxySet, + requiredStatus, + proxyStatus, + await stores.credentialVault.getSnapshot(), + ]); + assert.equal(publicViews.includes(apiSecret), false); + assert.equal(publicViews.includes(proxySecret), false); + }); + }); + + test('resolves execution connection material from one mutation cut', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const disabled = await createConnection(stores, 0, { + ...connectionDraft('execution-disabled', 'openai', 'Disabled'), + enabled: false, + }); + const required = await createConnection( + stores, + 1, + connectionDraft('execution-required', 'openai', 'Required'), + ); + const optional = await createConnection( + stores, + 2, + connectionDraft('execution-optional', 'localai', 'Optional'), + ); + const none = await createConnection( + stores, + 3, + connectionDraft('execution-none', 'ollama', 'None'), + ); + + assert.deepEqual(await stores.operations.resolveExecutionConnection(catalogSlug('missing')), { + kind: 'not_found', + }); + await assert.rejects( + stores.operations.resolveExecutionConnection({ + kind: 'unexpected', + connectionSlug: 'missing', + } as never), + /Invalid execution Connection reference kind/, + ); + assert.deepEqual( + await stores.operations.resolveExecutionConnection(catalogSlug(disabled.slug)), + { + kind: 'disabled', + }, + ); + + const missingRequired = await stores.operations.resolveExecutionConnection( + catalogSlug(required.slug), + ); + assert.equal(missingRequired.kind, 'credential_not_configured'); + if (missingRequired.kind === 'credential_not_configured') { + assert.deepEqual(missingRequired.status.locator, connectionCredential(required, 'api_key')); + } + for (const connection of [optional, none]) { + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind === 'ready') assert.deepEqual(resolved.secretMaterial, {}); + } + + // A connection stored before its provider was retired keeps its + // credential, so every readiness signal below `provider_retired` is + // satisfied and the resolver used to answer `ready` — which is what let a + // Session be committed against it. + const retired = await seedRetiredConnection( + root, + stores, + 'execution-retired', + '66666666-6666-4666-8666-666666666666', + ); + const retiredLogin = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'retired-login', + target: { kind: 'existing', connectionId: retired.connectionId }, + }); + assert.equal(retiredLogin.kind, 'provider_action_unavailable'); + assert.deepEqual( + await stores.operations.resolveExecutionConnection(catalogSlug(retired.slug)), + { + kind: 'provider_retired', + }, + ); + + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(required, 'api_key'), + expected: null, + secret: 'execution-connection-secret', + }) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(0, { host: 'execution.proxy.internal' }), + ) + ).kind, + 'committed', + ); + const missingProxy = await stores.operations.resolveExecutionConnection( + catalogSlug(required.slug), + ); + assert.equal(missingProxy.kind, 'credential_not_configured'); + if (missingProxy.kind === 'credential_not_configured') { + assert.deepEqual(missingProxy.status.locator, proxyCredential()); + } + + const [proxySet, resolved] = await Promise.all([ + stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'execution-proxy-secret', + }), + stores.operations.resolveExecutionConnection(catalogSlug(required.slug)), + ]); + assert.equal(proxySet.kind, 'committed'); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind !== 'ready') return; + assert.deepEqual(resolved.connection, required); + assert.equal(resolved.networkProxy.host, 'execution.proxy.internal'); + assert.equal(resolved.secretMaterial.connection?.secret, 'execution-connection-secret'); + assert.equal(resolved.secretMaterial.networkProxy?.secret, 'execution-proxy-secret'); + }); + }); + + test('bound execution never follows a reused connection slug', async () => { + await withInteractiveOwner(async ({ stores }) => { + const original = await createConnection( + stores, + 0, + connectionDraft('reused-execution-slug', 'openai', 'Original'), + ); + await stores.credentialVault.set({ + locator: connectionCredential(original, 'api_key'), + expected: null, + secret: 'original-secret', + }); + + const bound = { + kind: 'bound' as const, + connectionId: original.connectionId, + connectionSlug: original.slug, + }; + assert.equal((await stores.operations.resolveExecutionConnection(bound)).kind, 'ready'); + assert.deepEqual( + await stores.operations.resolveExecutionConnection({ + ...bound, + connectionSlug: 'different-slug', + }), + { kind: 'identity_mismatch' }, + ); + + assert.equal( + (await stores.connectionCatalog.remove({ expected: connectionBasis(original) })).kind, + 'committed', + ); + const replacement = await createConnection( + stores, + 2, + connectionDraft(original.slug, 'openai', 'Replacement'), + ); + await stores.credentialVault.set({ + locator: connectionCredential(replacement, 'api_key'), + expected: null, + secret: 'replacement-secret', + }); + + assert.deepEqual(await stores.operations.resolveExecutionConnection(bound), { + kind: 'not_found', + }); + const current = await stores.operations.resolveExecutionConnection( + catalogSlug(original.slug), + ); + assert.equal(current.kind, 'ready'); + if (current.kind === 'ready') { + assert.equal(current.connection.connectionId, replacement.connectionId); + assert.equal(current.secretMaterial.connection?.secret, 'replacement-secret'); + } + }); + }); + + test('refreshes only the matching OAuth credential generation without invalidating verification', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('oauth-refresh', 'github-copilot', 'OAuth refresh'), + ); + const locator = connectionCredential(connection, 'oauth_token'); + const originalSecret = JSON.stringify({ + access_token: 'github-access-v1', + refresh_token: 'github-refresh-v1', + expires_at: 1, + }); + const configured = await stores.credentialVault.set({ + locator, + expected: null, + secret: originalSecret, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + await verifyConnection(stores, connection.connectionId, '2026-08-01T00:00:00.000Z'); + const status = await getCredentialStatus(stores.credentialVault, locator); + const initialRevision = credentialBasis(status).revision; + const replacementSecret = JSON.stringify({ + access_token: 'github-access-v2', + refresh_token: 'github-refresh-v2', + expires_at: Number.MAX_SAFE_INTEGER, + }); + + const refreshed = await stores.operations.compareAndSetOAuthCredential({ + locator: { ...locator, kind: 'oauth_token' }, + expected: credentialExpectation(status), + secret: replacementSecret, + }); + + assert.equal(refreshed.kind, 'committed'); + if (refreshed.kind !== 'committed') return; + assert.equal(refreshed.credentialId, credentialBasis(status).credentialId); + assert.equal(refreshed.revision, initialRevision + 1); + const refreshedStatus = await getCredentialStatus(stores.credentialVault, locator); + assert.equal(credentialBasis(refreshedStatus).revision, initialRevision + 1); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind === 'ready') { + assert.equal(resolved.secretMaterial.connection?.secret, replacementSecret); + } + + const stale = await stores.operations.compareAndSetOAuthCredential({ + locator: { ...locator, kind: 'oauth_token' }, + expected: credentialExpectation(status), + secret: 'stale-refresh-must-not-commit', + }); + assert.equal(stale.kind, 'superseded'); + const stillResolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); + assert.equal(stillResolved.kind, 'ready'); + if (stillResolved.kind === 'ready') { + assert.equal(stillResolved.secretMaterial.connection?.secret, replacementSecret); + } + + const deleted = await stores.credentialVault.delete({ + expected: credentialBasis(refreshedStatus), + }); + assert.equal(deleted.kind, 'committed'); + const resurrection = await stores.operations.compareAndSetOAuthCredential({ + locator: { ...locator, kind: 'oauth_token' }, + expected: credentialExpectation(refreshedStatus), + secret: 'refresh-must-not-recreate-a-deleted-credential', + }); + assert.equal(resurrection.kind, 'superseded'); + assert.equal((await getCredentialStatus(stores.credentialVault, locator)).configured, false); + }); + }); + + test('conditionally commits discovery and test facts from the latest admitted state with one-shot tickets', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-success', 'openai', 'Effects success'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'effect-secret', + }) + ).kind, + 'committed', + ); + + assert.deepEqual( + await stores.operations.beginModelFetch('00000000-0000-4000-8000-000000000001'), + { kind: 'connection_not_found' }, + ); + + const fetch = await stores.operations.beginModelFetch(connection.connectionId); + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ + schemaVersion: 1, + overrides: { 'openai:gpt-5': { apiProtocol: 'openai-responses' } }, + }), + 'utf8', + ); + const testTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + 'gpt-5', + ); + assert.equal(fetch.kind, 'ready'); + assert.equal(testTicket.kind, 'ready'); + if (fetch.kind !== 'ready' || testTicket.kind !== 'ready') return; + assert.equal(testTicket.modelId, 'gpt-5'); + assert.equal(testTicket.connection.models?.[0]?.apiProtocol, 'openai-responses'); + assert.equal(fetch.secretMaterial.connection?.secret, 'effect-secret'); + + await assert.rejects( + () => + stores.operations.completeModelFetch(testTicket.ticket as never, { + models: [{ id: 'wrong-ticket-must-not-write' }], + source: 'fetched', + fetchedAt: 10, + }), + isStoreError('invalid_connection_input'), + ); + + const tested = await stores.operations.completeConnectionTest(testTicket.ticket, { + status: 'needs_reauth', + checkedAt: '2026-07-29T12:00:00.000Z', + errorClass: 'auth', + }); + assert.equal(tested.kind, 'committed'); + if (tested.kind !== 'committed') return; + assert.deepEqual(tested.snapshot.connections[0]?.lastTest, { + status: 'needs_reauth', + checkedAt: '2026-07-29T12:00:00.000Z', + errorClass: 'auth', + }); + + const discovered = await stores.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'gpt-5.1' }, { id: 'gpt-5.2' }], + source: 'fetched', + fetchedAt: 42, + }); + assert.equal(discovered.kind, 'committed'); + if (discovered.kind !== 'committed') return; + const afterDiscovery = discovered.snapshot.connections[0]; + assert.ok(afterDiscovery); + assert.deepEqual(afterDiscovery.models, [ + { id: 'gpt-5.1' }, + { id: 'gpt-5.2' }, + { + id: 'gpt-5', + apiProtocol: 'openai-responses', + factOverriddenFields: ['apiProtocol'], + }, + ]); + // Discovery records what the provider reported while retaining the + // selected fact-backed model for selectors and execution. + assert.deepEqual(afterDiscovery.enabledModelIds, ['gpt-5']); + assert.equal(afterDiscovery.modelSource, 'fetched'); + assert.equal(afterDiscovery.modelsFetchedAt, 42); + + assert.equal(JSON.stringify([tested, discovered]).includes('effect-secret'), false); + + await assert.rejects( + () => + stores.operations.completeModelFetch(fetch.ticket, { + models: [{ id: 'replay-must-not-write' }], + source: 'fetched', + fetchedAt: 43, + }), + isStoreError('invalid_connection_input'), + ); + await assert.rejects( + () => + stores.operations.completeConnectionTest(testTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:01:00.000Z', + }), + isStoreError('invalid_connection_input'), + ); + }); + }); + + test('replaces Copilot bootstrap ids with the account-authorized model catalog', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('copilot-models', 'github-copilot', 'Copilot models'), + ); + const configured = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'oauth_token'), + expected: null, + secret: JSON.stringify({ + access_token: 'github-access', + refresh_token: 'github-refresh', + expires_at: Number.MAX_SAFE_INTEGER, + }), + }); + assert.equal(configured.kind, 'committed'); + + const prepared = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const completed = await stores.operations.completeModelFetch(prepared.ticket, { + models: [{ id: 'account-available' }, { id: 'account-preview' }], + source: 'fetched', + fetchedAt: 43, + }); + assert.equal(completed.kind, 'committed'); + if (completed.kind !== 'committed') return; + + const updated = completed.snapshot.connections[0]; + assert.deepEqual(updated?.models, [{ id: 'account-available' }, { id: 'account-preview' }]); + assert.deepEqual(updated?.enabledModelIds, ['account-available', 'account-preview']); + assert.equal(updated?.enabledModelIds.includes('gpt-5'), false); + }); + }); + + test('clears a withdrawn Copilot default when authoritative discovery leaves no enabled models', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('copilot-withdrawn-default', 'github-copilot', 'Copilot withdrawn default'), + ); + const configured = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'oauth_token'), + expected: null, + secret: JSON.stringify({ + access_token: 'github-access', + refresh_token: 'github-refresh', + expires_at: Number.MAX_SAFE_INTEGER, + }), + }); + assert.equal(configured.kind, 'committed'); + + const firstFetch = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(firstFetch.kind, 'ready'); + if (firstFetch.kind !== 'ready') return; + const firstCompleted = await stores.operations.completeModelFetch(firstFetch.ticket, { + models: [{ id: 'old-model' }], + source: 'fetched', + fetchedAt: 42, + }); + assert.equal(firstCompleted.kind, 'committed'); + if (firstCompleted.kind !== 'committed') return; + + const defaulted = await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: firstCompleted.snapshot.revision, + target: { connectionId: connection.connectionId, modelId: 'old-model' }, + }); + assert.equal(defaulted.kind, 'committed'); + + const secondFetch = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(secondFetch.kind, 'ready'); + if (secondFetch.kind !== 'ready') return; + const refreshed = await stores.operations.completeModelFetch(secondFetch.ticket, { + models: [{ id: 'replacement-model' }], + source: 'fetched', + fetchedAt: 43, + }); + assert.equal(refreshed.kind, 'committed'); + if (refreshed.kind !== 'committed') return; + + assert.deepEqual(refreshed.snapshot.connections[0]?.enabledModelIds, []); + assert.equal(refreshed.snapshot.defaultTarget, null); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).defaultTarget, null); + }); + }); + + test('keeps the canonical default target when discovery stops listing its model', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-default', 'ollama', 'Effects default'), + ); + const defaultTarget = await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: 1, + target: { connectionId: connection.connectionId, modelId: 'gpt-5' }, + }); + assert.equal(defaultTarget.kind, 'committed'); + + const prepared = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const completed = await stores.operations.completeModelFetch(prepared.ticket, { + models: [{ id: 'llama3.3' }, { id: 'qwen3' }], + source: 'fetched', + fetchedAt: 43, + }); + assert.equal(completed.kind, 'committed'); + if (completed.kind !== 'committed') return; + // Silently pointing the workspace default at a different model is its own + // surprise, and the response that omitted `gpt-5` is one observation of + // an account it does not fully describe (#1584). The target stands; the + // picker marks it, and switching stays the user's decision. + const expected = { connectionId: connection.connectionId, modelId: 'gpt-5' }; + assert.deepEqual(completed.snapshot.defaultTarget, expected); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).defaultTarget, expected); + }); + }); + + test('releases the canonical default target when a selection change removes its model', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('selection-default', 'ollama', 'Selection default'), + ); + const widened = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + enabled: true, + enabledModelIds: ['gpt-5', 'llama3.3'], + relayModelProfiles: null, + }, + }); + assert.equal(widened.kind, 'committed'); + if (widened.kind !== 'committed') return; + const widenedEntry = widened.snapshot.connections[0]; + assert.ok(widenedEntry); + assert.equal( + ( + await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: widened.snapshot.revision, + target: { connectionId: connection.connectionId, modelId: 'gpt-5' }, + }) + ).kind, + 'committed', + ); + + const narrowed = await stores.connectionCatalog.update({ + expected: connectionBasis(widenedEntry), + changes: { + name: connection.name, + enabled: true, + enabledModelIds: ['llama3.3'], + relayModelProfiles: null, + }, + }); + assert.equal(narrowed.kind, 'committed'); + if (narrowed.kind !== 'committed') return; + // Not `llama3.3`: a surviving member of the set is not the user's answer. + assert.equal(narrowed.snapshot.defaultTarget, null); + assert.equal((await stores.connectionCatalog.getSnapshot()).defaultTarget, null); + }); + }); + + test('releases the canonical default target when its connection is disabled', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('default-disabled', 'ollama', 'Default disabled'), + ); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.equal( + ( + await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: catalog.revision, + target: { connectionId: connection.connectionId, modelId: 'gpt-5' }, + }) + ).kind, + 'committed', + ); + + const disabled = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + enabled: false, + enabledModelIds: connection.enabledModelIds, + relayModelProfiles: null, + }, + }); + assert.equal(disabled.kind, 'committed'); + if (disabled.kind !== 'committed') return; + assert.equal(disabled.snapshot.defaultTarget, null); + assert.equal((await stores.connectionCatalog.getSnapshot()).defaultTarget, null); + }); + }); + + test('rejects a stated default target that names an unselected model', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('stated-default', 'ollama', 'Stated default'), + ); + const catalog = await stores.connectionCatalog.getSnapshot(); + const rejected = await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: catalog.revision, + target: { connectionId: connection.connectionId, modelId: 'llama3.3' }, + }); + assert.equal(rejected.kind, 'invalid_default_target'); + const unchanged = await stores.connectionCatalog.getSnapshot(); + assert.equal(unchanged.defaultTarget, null); + assert.equal(unchanged.revision, catalog.revision); + }); + }); + + test('keeps an emptied model selection across the next canonical discovery', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-empty-selection', 'ollama', 'Empty selection'), + ); + + const first = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(first.kind, 'ready'); + if (first.kind !== 'ready') return; + const seeded = await stores.operations.completeModelFetch(first.ticket, { + models: [{ id: 'llama3.3' }, { id: 'qwen3' }], + source: 'fetched', + fetchedAt: 1, + }); + assert.equal(seeded.kind, 'committed'); + + const current = (await stores.connectionCatalog.getSnapshot()).connections[0]!; + const emptied = await stores.connectionCatalog.update({ + expected: connectionBasis(current), + changes: { + name: current.name, + baseUrl: current.baseUrl, + enabled: true, + enabledModelIds: [], + relayModelProfiles: null, + }, + }); + assert.equal(emptied.kind, 'committed'); + + // Every catalog entry carries a `models` array from birth, so reading + // "has an inventory" as "the field exists" made this connection look like + // it had never fetched — and each refresh re-seeded `liveIds[0]` over the + // selection the user had just emptied. + const second = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(second.kind, 'ready'); + if (second.kind !== 'ready') return; + const refetched = await stores.operations.completeModelFetch(second.ticket, { + models: [{ id: 'llama3.3' }, { id: 'gemma3' }], + source: 'fetched', + fetchedAt: 2, + }); + assert.equal(refetched.kind, 'committed'); + if (refetched.kind !== 'committed') return; + assert.deepEqual(refetched.snapshot.connections[0]?.enabledModelIds, []); + }); + }); + + test('admits a test model the user selected or the provider listed, and nothing else', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-test-model', 'openai', 'Effects test model'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'test-model-secret', + }) + ).kind, + 'committed', + ); + + assert.equal( + (await stores.operations.beginConnectionTest(connection.connectionId, 'gpt-5')).kind, + 'ready', + ); + await assert.rejects( + () => stores.operations.beginConnectionTest(connection.connectionId, 'injected-model'), + isStoreError('invalid_connection_input'), + ); + + const discovery = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(discovery.kind, 'ready'); + if (discovery.kind !== 'ready') return; + assert.equal( + ( + await stores.operations.completeModelFetch(discovery.ticket, { + models: [{ id: 'canonical-fetched-model' }], + source: 'fetched', + fetchedAt: 1, + }) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.operations.beginConnectionTest( + connection.connectionId, + 'canonical-fetched-model', + ) + ).kind, + 'ready', + ); + // Still selected, so still testable: discovery not mentioning `gpt-5` + // says something about that response, not about the account (#1584). + // Testing it is exactly how the user finds out which is true. + assert.equal( + (await stores.operations.beginConnectionTest(connection.connectionId, 'gpt-5')).kind, + 'ready', + ); + // An id from neither source is still refused — the gate rejects strings + // nobody chose and nobody reported, which is all it ever needed to do. + await assert.rejects( + () => stores.operations.beginConnectionTest(connection.connectionId, 'injected-model'), + isStoreError('invalid_connection_input'), + ); + assert.equal( + (await stores.operations.beginConnectionTest(connection.connectionId, null)).kind, + 'ready', + ); + }); + }); + + test('preserves verified state for equivalent discovery and clears it on model protocol changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-model-protocol', 'openai', 'Effects model protocol'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'model-protocol-secret', + }) + ).kind, + 'committed', + ); + + const initialDiscovery = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(initialDiscovery.kind, 'ready'); + if (initialDiscovery.kind !== 'ready') return; + assert.equal( + ( + await stores.operations.completeModelFetch(initialDiscovery.ticket, { + models: [{ id: 'gpt-5', apiProtocol: 'openai-chat' }], + source: 'fetched', + fetchedAt: 1, + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T11:59:00.000Z'); + + const equivalentDiscovery = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(equivalentDiscovery.kind, 'ready'); + if (equivalentDiscovery.kind !== 'ready') return; + const equivalent = await stores.operations.completeModelFetch(equivalentDiscovery.ticket, { + models: [{ id: 'gpt-5', apiProtocol: 'openai-chat' }], + source: 'fetched', + fetchedAt: 2, + }); + assert.equal(equivalent.kind, 'committed'); + if (equivalent.kind !== 'committed') return; + assert.deepEqual(equivalent.snapshot.connections[0]?.lastTest, { + status: 'verified', + checkedAt: '2026-07-29T11:59:00.000Z', + }); + + const testTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + 'gpt-5', + ); + const rediscovery = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(testTicket.kind, 'ready'); + assert.equal(rediscovery.kind, 'ready'); + if (testTicket.kind !== 'ready' || rediscovery.kind !== 'ready') return; + + assert.equal( + ( + await stores.operations.completeModelFetch(rediscovery.ticket, { + models: [{ id: 'gpt-5', apiProtocol: 'openai-responses' }], + source: 'fetched', + fetchedAt: 3, + }) + ).kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeConnectionTest(testTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:00:00.000Z', + }), + { kind: 'superseded', changed: ['connection'] }, + ); + + const current = (await stores.connectionCatalog.getSnapshot()).connections[0]; + assert.deepEqual(current?.models, [{ id: 'gpt-5', apiProtocol: 'openai-responses' }]); + assert.equal(current?.lastTest, undefined); + }); + }); + + test('clears verified state when enabled model selection changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-selection-invalidation', 'openai', 'Selection invalidation'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'selection-secret', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:10:00.000Z'); + const current = (await stores.connectionCatalog.getSnapshot()).connections[0]!; + + const updated = await stores.connectionCatalog.update({ + expected: connectionBasis(current), + changes: { + name: current.name, + baseUrl: current.baseUrl, + enabled: true, + enabledModelIds: ['gpt-5-mini'], + relayModelProfiles: null, + }, + }); + assert.equal(updated.kind, 'committed'); + if (updated.kind !== 'committed') return; + assert.equal(updated.snapshot.connections[0]?.lastTest, undefined); + }); + }); + + test('clears verified state only for admitted connection credential mutations', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-credential-invalidation', 'openai', 'Credential invalidation'), + ); + const locator = connectionCredential(connection, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: null, + secret: 'credential-v1', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:20:00.000Z'); + const initialStatus = await getCredentialStatus(stores.credentialVault, locator); + + await assert.rejects( + () => + stores.credentialVault.set({ + locator: connectionCredential(connection, 'oauth_token'), + expected: null, + secret: 'invalid-oauth-credential', + }), + isStoreError('invalid_credential_input'), + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + const staleSet = await stores.credentialVault.set({ + locator, + expected: { + credentialId: credentialBasis(initialStatus).credentialId, + revision: credentialBasis(initialStatus).revision + 1, + }, + secret: 'stale-credential', + }); + assert.equal(staleSet.kind, 'credential_stale'); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: credentialExpectation(initialStatus), + secret: 'credential-v2', + }) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:21:00.000Z'); + const rotatedStatus = await getCredentialStatus(stores.credentialVault, locator); + const staleDelete = await stores.credentialVault.delete({ + expected: credentialBasis(initialStatus), + }); + assert.equal(staleDelete.kind, 'credential_stale'); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + assert.equal( + ( + await stores.credentialVault.delete({ + expected: credentialBasis(rotatedStatus), + }) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + }); + }); + + test('a bound connection credential write rejects revision, provider, and endpoint drift', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('bound-import', 'openai', 'Bound import'), + ); + const locator = connectionCredential(connection, 'api_key'); + const seeded = await stores.credentialVault.set({ + locator, + expected: null, + secret: 'existing-target-secret', + }); + assert.equal(seeded.kind, 'committed'); + if (seeded.kind !== 'committed') return; + const status = await getCredentialStatus(stores.credentialVault, locator); + const sourceTarget = { + ...connectionBasis(connection), + slug: connection.slug, + providerType: connection.providerType, + effectiveBaseUrl: new URL(PROVIDER_REGISTRY.openai.baseUrl).toString(), + }; + + const moved = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: 'https://target-relay.example/v1', + enabled: connection.enabled, + enabledModelIds: connection.enabledModelIds, + }, + }); + assert.equal(moved.kind, 'committed'); + if (moved.kind !== 'committed') return; + const current = moved.snapshot.connections.find( + (item) => item.connectionId === connection.connectionId, + ); + assert.ok(current); + if (!current) return; + + const staleRevision = await stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + expectedConnection: sourceTarget, + secret: 'must-not-cross-targets', + } as never); + assert.deepEqual(staleRevision, { + kind: 'connection_stale', + expected: connectionBasis(connection), + actual: connectionBasis(current), + }); + assert.deepEqual(await stores.operations.exportCredentialMaterial(locator, sourceTarget), { + kind: 'connection_stale', + expected: connectionBasis(connection), + actual: connectionBasis(current), + }); + + for (const expectedConnection of [ + { ...sourceTarget, ...connectionBasis(current) }, + { ...sourceTarget, ...connectionBasis(current), providerType: 'deepseek' }, + ]) { + const mismatch = await stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + expectedConnection, + secret: 'must-not-cross-targets', + } as never); + assert.deepEqual(mismatch, { + kind: 'connection_stale', + expected: connectionBasis(current), + actual: connectionBasis(current), + }); + } + + assert.equal( + (await stores.operations.exportCredentialMaterial(locator))?.secret, + 'existing-target-secret', + ); + }); + }); + + test('reports unknown outcome when credential persistence fails after clearing verified state', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to inject a persistence failure' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-credential-failure', 'openai', 'Credential failure'), + ); + const locator = connectionCredential(connection, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: null, + secret: 'credential-before-failure', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:30:00.000Z'); + const status = await getCredentialStatus(stores.credentialVault, locator); + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected credential persistence failure'); + return originalSync.call(this); + }, + ); + try { + await assert.rejects( + () => + stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + secret: 'credential-after-failure', + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + assert.equal( + (await getCredentialStatus(stores.credentialVault, locator)).revision, + status.revision, + ); + }); + }); + + test('invalidates verified state only when the effective network proxy basis changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-proxy-invalidation', 'openai', 'Proxy invalidation'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'proxy-invalidation-secret', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:40:00.000Z'); + + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(0, { + host: 'proxy-one.internal', + authEnabled: false, + username: '', + }), + ) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:41:00.000Z'); + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(1, { + host: 'proxy-two.internal', + authEnabled: false, + username: '', + }), + ) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:42:00.000Z'); + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(2, { + host: 'proxy-two.internal', + authEnabled: false, + username: '', + bypassList: ['127.0.0.1'], + autoBypassDomains: ['localhost'], + }), + ) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + }); + }); + + test('validates proxy policy mutations before clearing and reports failed follow-up commits as unknown', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to inject a persistence failure' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-proxy-policy-failure', 'openai', 'Proxy policy failure'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'proxy-policy-failure-secret', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T12:50:00.000Z'); + + await assert.rejects( + () => stores.runtimePolicy.mutate(networkProxyMutation(0, { host: ' ' })), + isStoreError('invalid_policy_input'), + ); + assert.deepEqual( + await stores.runtimePolicy.mutate( + networkProxyMutation(1, { + host: 'stale.proxy.internal', + authEnabled: false, + username: '', + }), + ), + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 0 }, + ); + const oversizedBypassList = Array.from( + { length: 100 }, + (_value, index) => `${index}-${'x'.repeat(500)}`, + ); + await assert.rejects( + () => + stores.runtimePolicy.mutate( + networkProxyMutation(0, { + authEnabled: false, + username: '', + bypassList: oversizedBypassList, + autoBypassDomains: [], + }), + ), + isStoreError('invalid_policy_input'), + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy policy persistence failure'); + return originalSync.call(this); + }, + ); + try { + await assert.rejects( + () => + stores.runtimePolicy.mutate( + networkProxyMutation(0, { + host: 'failed.proxy.internal', + authEnabled: false, + username: '', + }), + ), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.equal(syncCalls, 3); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + assert.equal((await stores.runtimePolicy.getSnapshot()).revision, 0); + }); + }); + + test('invalidates verified state for active proxy password rotation and deletion', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-proxy-credential', 'openai', 'Proxy credential'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'proxy-credential-connection-secret', + }) + ).kind, + 'committed', + ); + await verifyConnection(stores, connection.connectionId, '2026-07-29T13:00:00.000Z'); + + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'proxy-password-v1', + }) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + 'an inactive proxy credential is not part of the verification basis', + ); + assert.equal((await stores.runtimePolicy.mutate(networkProxyMutation(0))).kind, 'committed'); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + + await verifyConnection(stores, connection.connectionId, '2026-07-29T13:01:00.000Z'); + const initialStatus = await getCredentialStatus(stores.credentialVault, proxyCredential()); + await assert.rejects( + () => + stores.credentialVault.set({ + locator: proxyCredential(), + expected: credentialExpectation(initialStatus), + secret: 'x'.repeat(64 * 1024 + 1), + }), + isStoreError('invalid_credential_input'), + ); + const staleSet = await stores.credentialVault.set({ + locator: proxyCredential(), + expected: { + credentialId: credentialBasis(initialStatus).credentialId, + revision: credentialBasis(initialStatus).revision + 1, + }, + secret: 'stale-proxy-password', + }); + assert.equal(staleSet.kind, 'credential_stale'); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: credentialExpectation(initialStatus), + secret: 'proxy-password-v2', + }) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + + await verifyConnection(stores, connection.connectionId, '2026-07-29T13:02:00.000Z'); + const rotatedStatus = await getCredentialStatus(stores.credentialVault, proxyCredential()); + const staleDelete = await stores.credentialVault.delete({ + expected: credentialBasis(initialStatus), + }); + assert.equal(staleDelete.kind, 'credential_stale'); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + + assert.equal( + ( + await stores.credentialVault.delete({ + expected: credentialBasis(rotatedStatus), + }) + ).kind, + 'committed', + ); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + }); + }); + + test('reports unknown outcome when active proxy password persistence fails after clearing', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to inject a persistence failure' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-proxy-credential-failure', 'openai', 'Proxy credential failure'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'proxy-failure-connection-secret', + }) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'proxy-password-before-failure', + }) + ).kind, + 'committed', + ); + assert.equal((await stores.runtimePolicy.mutate(networkProxyMutation(0))).kind, 'committed'); + await verifyConnection(stores, connection.connectionId, '2026-07-29T13:10:00.000Z'); + const status = await getCredentialStatus(stores.credentialVault, proxyCredential()); + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy credential persistence failure'); + return originalSync.call(this); + }, + ); + try { + await assert.rejects( + () => + stores.credentialVault.set({ + locator: proxyCredential(), + expected: credentialExpectation(status), + secret: 'proxy-password-after-failure', + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.equal(syncCalls, 3); + assert.equal( + (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest, + undefined, + ); + assert.equal( + (await getCredentialStatus(stores.credentialVault, proxyCredential())).revision, + status.revision, + ); + }); + }); + + test('commits effects when proxy representation or GET-irrelevant body settings change', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-proxy-bypass', 'openai', 'Effects proxy bypass'), + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'proxy-bypass-connection-secret', + }) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(0, { + bypassList: ['localhost', 'gateway.example'], + autoBypassDomains: ['127.0.0.1'], + }), + ) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'proxy-bypass-secret', + }) + ).kind, + 'committed', + ); + + const testTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + 'gpt-5', + ); + assert.equal(testTicket.kind, 'ready'); + if (testTicket.kind !== 'ready') return; + assert.equal( + ( + await stores.runtimePolicy.mutate( + networkProxyMutation(1, { + bypassList: ['localhost'], + autoBypassDomains: ['127.0.0.1', 'gateway.example'], + }), + ) + ).kind, + 'committed', + ); + + const completed = await stores.operations.completeConnectionTest(testTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:01:00.000Z', + }); + assert.equal(completed.kind, 'committed'); + if (completed.kind !== 'committed') return; + assert.deepEqual(completed.snapshot.connections[0]?.lastTest, { + status: 'verified', + checkedAt: '2026-07-29T12:01:00.000Z', + }); + + const current = completed.snapshot.connections[0]!; + const modelFetch = await stores.operations.beginModelFetch(current.connectionId); + assert.equal(modelFetch.kind, 'ready'); + if (modelFetch.kind !== 'ready') return; + const bodyUpdate = await stores.connectionCatalog.update({ + expected: connectionBasis(current), + changes: { + name: current.name, + enabled: current.enabled, + enabledModelIds: current.enabledModelIds, + requestBodyOverlay: { provider: { only: ['deepseek'] } }, + }, + }); + assert.equal(bodyUpdate.kind, 'committed'); + assert.equal( + ( + await stores.operations.completeModelFetch(modelFetch.ticket, { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 2, + }) + ).kind, + 'committed', + ); + }); + }); + + test('supersedes effects on connection, credential, proxy, proxy credential, and slug ABA changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + let connection = await createConnection( + stores, + 0, + connectionDraft('effects-fence', 'openai', 'Effects fence'), + ); + const locator = connectionCredential(connection, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: null, + secret: 'connection-v1', + }) + ).kind, + 'committed', + ); + + const endpointTicket = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(endpointTicket.kind, 'ready'); + if (endpointTicket.kind !== 'ready') return; + const endpointUpdate = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: 'https://gateway.example/v1', + enabled: true, + enabledModelIds: connection.enabledModelIds, + relayModelProfiles: null, + }, + }); + assert.equal(endpointUpdate.kind, 'committed'); + if (endpointUpdate.kind !== 'committed') return; + connection = endpointUpdate.snapshot.connections[0]!; + assert.deepEqual( + await stores.operations.completeModelFetch(endpointTicket.ticket, { + models: [{ id: 'endpoint-stale' }], + source: 'fetched', + fetchedAt: 1, + }), + { kind: 'superseded', changed: ['connection'] }, + ); + + const modelSelectionTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + null, + ); + assert.equal(modelSelectionTicket.kind, 'ready'); + if (modelSelectionTicket.kind !== 'ready') return; + const modelSelectionUpdate = await stores.connectionCatalog.update({ + expected: connectionBasis(connection), + changes: { + name: connection.name, + baseUrl: connection.baseUrl, + enabled: true, + enabledModelIds: ['gpt-5-mini'], + relayModelProfiles: null, + }, + }); + assert.equal(modelSelectionUpdate.kind, 'committed'); + if (modelSelectionUpdate.kind !== 'committed') return; + connection = modelSelectionUpdate.snapshot.connections[0]!; + assert.deepEqual( + await stores.operations.completeConnectionTest(modelSelectionTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:01:00.000Z', + }), + { kind: 'superseded', changed: ['connection'] }, + ); + + const credentialTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + null, + ); + assert.equal(credentialTicket.kind, 'ready'); + if (credentialTicket.kind !== 'ready') return; + const status = await getCredentialStatus(stores.credentialVault, locator); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: credentialExpectation(status), + secret: 'connection-v2', + }) + ).kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeConnectionTest(credentialTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:02:00.000Z', + }), + { kind: 'superseded', changed: ['credential'] }, + ); + + assert.equal( + (await stores.runtimePolicy.mutate(networkProxyMutation(0, { host: 'proxy-one.internal' }))) + .kind, + 'committed', + ); + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'proxy-v1', + }) + ).kind, + 'committed', + ); + const proxyTicket = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(proxyTicket.kind, 'ready'); + if (proxyTicket.kind !== 'ready') return; + assert.equal( + (await stores.runtimePolicy.mutate(networkProxyMutation(1, { host: 'proxy-two.internal' }))) + .kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeModelFetch(proxyTicket.ticket, { + models: [{ id: 'proxy-stale' }], + source: 'fetched', + fetchedAt: 2, + }), + { kind: 'superseded', changed: ['network_proxy'] }, + ); + + const proxyCredentialTicket = await stores.operations.beginConnectionTest( + connection.connectionId, + null, + ); + assert.equal(proxyCredentialTicket.kind, 'ready'); + if (proxyCredentialTicket.kind !== 'ready') return; + const proxyStatus = await getCredentialStatus(stores.credentialVault, proxyCredential()); + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: credentialExpectation(proxyStatus), + secret: 'proxy-v2', + }) + ).kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeConnectionTest(proxyCredentialTicket.ticket, { + status: 'verified', + checkedAt: '2026-07-29T12:03:00.000Z', + }), + { kind: 'superseded', changed: ['credential'] }, + ); + + const abaTicket = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(abaTicket.kind, 'ready'); + if (abaTicket.kind !== 'ready') return; + const removed = await stores.connectionCatalog.remove({ + expected: connectionBasis(connection), + }); + assert.equal(removed.kind, 'committed'); + if (removed.kind !== 'committed') return; + const replacement = await createConnection( + stores, + removed.snapshot.revision, + connectionDraft(connection.slug, 'openai', 'Replacement'), + ); + assert.notEqual(replacement.connectionId, connection.connectionId); + assert.deepEqual( + await stores.operations.completeModelFetch(abaTicket.ticket, { + models: [{ id: 'aba-stale' }], + source: 'fetched', + fetchedAt: 3, + }), + { kind: 'superseded', changed: ['connection', 'credential'] }, + ); + assert.deepEqual(replacement.models, []); + }); + }); + + test('preserves unknown commit semantics and consumes the completion ticket', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to inject a persistence failure' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('effects-unknown', 'ollama', 'Effects unknown'), + ); + const prepared = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const result = { + models: [{ id: 'llama3.3' }], + source: 'fetched' as const, + fetchedAt: 99, + }; + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 2) throw new Error('injected post-publication sync failure'); + return originalSync.call(this); + }, + ); + try { + await assert.rejects( + () => stores.operations.completeModelFetch(prepared.ticket, result), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.equal(syncCalls, 2); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections[0]?.models, [ + { id: 'llama3.3' }, + ]); + await assert.rejects( + () => stores.operations.completeModelFetch(prepared.ticket, result), + isStoreError('invalid_connection_input'), + ); + }); + }); + + test('allows only Copilot OAuth tokens through the public credential setter', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const claude = await seedRetiredConnection( + root, + stores, + 'public-claude', + '77777777-7777-4777-8777-777777777777', + ); + const codex = await createConnection( + stores, + 1, + connectionDraft('public-codex', 'openai-codex', 'Public Codex'), + ); + const copilot = await createConnection( + stores, + 2, + connectionDraft('public-copilot', 'github-copilot', 'Public Copilot'), + ); + const apiKey = await createConnection( + stores, + 3, + connectionDraft('public-api-key', 'openai', 'Public API key'), + ); + + for (const connection of [claude, codex]) { + await assert.rejects( + () => + stores.credentialVault.set({ + locator: connectionCredential(connection, 'oauth_token'), + expected: null, + secret: 'public-oauth-must-be-rejected', + }), + // Both are refused, for different reasons and at different points: + // the retired connection is refused as a whole before its credential + // kind is considered, so it reports the connection as the problem. + isStoreError( + connection === claude ? 'invalid_connection_input' : 'invalid_credential_input', + ), + ); + assert.equal( + ( + await getCredentialStatus( + stores.credentialVault, + connectionCredential(connection, 'oauth_token'), + ) + ).configured, + false, + ); + } + assert.equal( + ( + await stores.credentialVault.set({ + locator: connectionCredential(copilot, 'oauth_token'), + expected: null, + secret: 'copilot-import', + }) + ).kind, + 'committed', + ); + for (const input of [ + { + locator: connectionCredential(apiKey, 'api_key'), + expected: null, + secret: 'api-key-input', + }, + { + locator: { + scope: 'web_search' as const, + provider: 'tavily' as const, + kind: 'api_key' as const, + }, + expected: null, + secret: 'web-search-input', + }, + { locator: proxyCredential(), expected: null, secret: 'proxy-input' }, + ]) { + assert.equal((await stores.credentialVault.set(input)).kind, 'committed'); + } + }); + }); + + test('resolves one atomic WebSearch policy, credential, and proxy execution snapshot', async () => { + await withInteractiveOwner(async ({ stores }) => { + assert.deepEqual(await stores.operations.resolveWebSearchExecution(), { + kind: 'disabled', + provider: 'model', + }); + + const enabled = await stores.runtimePolicy.mutate({ + expectedRevision: 0, + operation: { + kind: 'set_web_search', + value: { enabled: true, defaultProvider: 'tavily' }, + }, + }); + assert.equal(enabled.kind, 'committed'); + const missingSearchCredential = await stores.operations.resolveWebSearchExecution(); + assert.equal(missingSearchCredential.kind, 'credential_not_configured'); + if (missingSearchCredential.kind !== 'credential_not_configured') return; + assert.deepEqual(missingSearchCredential.status.locator, { + scope: 'web_search', + provider: 'tavily', + kind: 'api_key', + }); + + assert.equal( + ( + await stores.credentialVault.set({ + locator: missingSearchCredential.status.locator, + expected: null, + secret: 'tavily-execution-secret', + }) + ).kind, + 'committed', + ); + const direct = await stores.operations.resolveWebSearchExecution(); + assert.equal(direct.kind, 'ready'); + if (direct.kind !== 'ready' || direct.provider !== 'tavily') return; + assert.equal(direct.secretMaterial.webSearch.secret, 'tavily-execution-secret'); + assert.equal(direct.secretMaterial.networkProxy, undefined); + assert.equal(direct.networkProxy.enabled, false); + + const proxied = await stores.runtimePolicy.mutate({ + expectedRevision: 1, + operation: { + kind: 'set_network_proxy', + value: { + ...direct.networkProxy, + enabled: true, + host: 'proxy.example', + port: 8443, + authEnabled: true, + username: 'proxy-user', + }, + }, + }); + assert.equal(proxied.kind, 'committed'); + const missingProxyCredential = await stores.operations.resolveWebSearchExecution(); + assert.equal(missingProxyCredential.kind, 'credential_not_configured'); + if (missingProxyCredential.kind !== 'credential_not_configured') return; + assert.deepEqual(missingProxyCredential.status.locator, proxyCredential()); + + assert.equal( + ( + await stores.credentialVault.set({ + locator: proxyCredential(), + expected: null, + secret: 'proxy-execution-secret', + }) + ).kind, + 'committed', + ); + const ready = await stores.operations.resolveWebSearchExecution(); + assert.equal(ready.kind, 'ready'); + if (ready.kind !== 'ready' || ready.provider !== 'tavily') return; + assert.equal(ready.secretMaterial.webSearch.secret, 'tavily-execution-secret'); + assert.equal(ready.secretMaterial.networkProxy?.secret, 'proxy-execution-secret'); + assert.equal(ready.networkProxy.host, 'proxy.example'); + + const privatePolicy = await stores.runtimePolicy.mutate({ + expectedRevision: 2, + operation: { kind: 'set_privacy', value: { incognitoActive: true } }, + }); + assert.equal(privatePolicy.kind, 'committed'); + assert.deepEqual(await stores.operations.resolveWebSearchExecution(), { + kind: 'privacy_mode', + }); + }); + }); + + test('a stale client cannot recreate a proxy credential after another client disables authentication', async () => { + await withInteractiveOwner(async ({ stores }) => { + const initialPolicy = await stores.runtimePolicy.getSnapshot(); + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initialPolicy.revision, + expectedCredential: null, + networkProxy: { + ...initialPolicy.policy.networkProxy, + enabled: true, + authEnabled: true, + username: 'proxy-user', + }, + credential: { kind: 'replace', secret: 'initial-secret' }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + assert.equal(configured.credentialStatus.configured, true); + if (!configured.credentialStatus.configured) return; + + // Both clients observed the same Host-owned policy and credential basis. + const clientAPolicyRevision = configured.snapshot.revision; + const clientACredential = credentialBasis(configured.credentialStatus); + const clientBPolicyRevision = configured.snapshot.revision; + const clientBCredential = credentialBasis(configured.credentialStatus); + + const disabled = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: clientBPolicyRevision, + expectedCredential: clientBCredential, + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }); + assert.equal(disabled.kind, 'committed'); + if (disabled.kind !== 'committed') return; + + const staleReplacement = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: clientAPolicyRevision, + expectedCredential: clientACredential, + networkProxy: configured.snapshot.policy.networkProxy, + credential: { kind: 'replace', secret: 'must-not-return' }, + }); + assert.ok( + staleReplacement.kind === 'revision_conflict' || + staleReplacement.kind === 'credential_stale', + ); + + const finalPolicy = await stores.runtimePolicy.getSnapshot(); + const finalCredential = await getCredentialStatus(stores.credentialVault, proxyCredential()); + assert.equal(finalPolicy.policy.networkProxy.authEnabled, false); + assert.equal(finalCredential.configured, false); + }); + }); + + test('a bound proxy credential import cannot replace the secret after the proxy target changes', async () => { + await withInteractiveOwner(async ({ stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const source = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'proxy-a.example', + port: 8080, + authEnabled: true, + username: 'source-user', + }, + credential: { kind: 'replace', secret: 'existing-secret' }, + }); + assert.equal(source.kind, 'committed'); + if (source.kind !== 'committed' || !source.credentialStatus.configured) return; + + const retargeted = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: source.snapshot.revision, + expectedCredential: credentialBasis(source.credentialStatus), + networkProxy: { + ...source.snapshot.policy.networkProxy, + host: 'proxy-b.example', + username: 'target-user', + }, + credential: { kind: 'keep' }, + }); + assert.equal(retargeted.kind, 'committed'); + if (retargeted.kind !== 'committed') return; + + const outcome = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: retargeted.snapshot.revision, + expectedCredential: credentialBasis(source.credentialStatus), + networkProxy: retargeted.snapshot.policy.networkProxy, + credential: { + kind: 'replace', + secret: 'source-import-secret', + expectedTarget: { + protocol: 'http', + host: 'proxy-a.example', + port: 8080, + username: 'source-user', + }, + }, + } as never); + + assert.deepEqual(outcome, { + kind: 'proxy_target_mismatch', + expected: { + protocol: 'http', + host: 'proxy-a.example', + port: 8080, + username: 'source-user', + }, + actual: { + protocol: 'http', + host: 'proxy-b.example', + port: 8080, + username: 'target-user', + }, + }); + const exported = await stores.operations.exportCredentialMaterial(proxyCredential()); + assert.equal(exported?.secret, 'existing-secret'); + assert.deepEqual(exported?.proxyTarget, { + protocol: 'http', + host: 'proxy-b.example', + port: 8080, + username: 'target-user', + }); + }); + }); + + test('proxy replacement failure before vault publication leaves both stores unchanged', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 1) throw new Error('injected proxy credential pre-publication failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'unchanged.proxy.internal', + authEnabled: true, + username: 'unchanged-user', + }, + credential: { kind: 'replace', secret: 'must-not-persist' }, + }), + isStoreError('io_failed'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.deepEqual(await stores.runtimePolicy.getSnapshot(), initial); + assert.equal( + (await getCredentialStatus(stores.credentialVault, proxyCredential())).configured, + false, + ); + }); + }); + + test('proxy replacement never persists its secret outside the credential vault', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'vault-only-proxy-secret'; + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy policy persistence failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'vault-only.proxy.internal', + port: 7897, + authEnabled: true, + username: 'vault-only-user', + }, + credential: { kind: 'replace', secret }, + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + const filesContainingSecret: string[] = []; + for (const entry of await readdir(root)) { + if (!entry.endsWith('.json')) continue; + if ((await readFile(join(root, entry), 'utf8')).includes(secret)) { + filesContainingSecret.push(entry); + } + } + assert.deepEqual(filesContainingSecret, ['credential-vault.json']); + assert.equal(existsSync(join(root, 'runtime-policy-network-proxy.json')), false); + assert.equal(existsSync(join(root, 'runtime-policy.json')), false); + }); + }); + + test('authentication disable failure before policy publication leaves both stores unchanged', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'unchanged-disabled-proxy-secret'; + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + authEnabled: true, + username: 'unchanged-disable-user', + }, + credential: { kind: 'replace', secret }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 1) throw new Error('injected proxy policy pre-publication failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: configured.snapshot.revision, + expectedCredential: credentialBasis(configured.credentialStatus), + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }), + isStoreError('io_failed'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.deepEqual(await stores.runtimePolicy.getSnapshot(), configured.snapshot); + assert.equal( + (await getCredentialStatus(stores.credentialVault, proxyCredential())).configured, + true, + ); + }); + }); + + test('disabling proxy authentication commits policy before deleting its credential', { + skip: + process.platform === 'win32' + ? 'POSIX file handles are required to inject persistence failures' + : false, + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const initial = await stores.runtimePolicy.getSnapshot(); + const secret = 'retained-disabled-proxy-secret'; + const configured = await stores.operations.updateNetworkProxy({ + expectedPolicyRevision: initial.revision, + expectedCredential: null, + networkProxy: { + ...initial.policy.networkProxy, + enabled: true, + host: 'disable-order.proxy.internal', + port: 7897, + authEnabled: true, + username: 'disable-order-user', + }, + credential: { kind: 'replace', secret }, + }); + assert.equal(configured.kind, 'committed'); + if (configured.kind !== 'committed') return; + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected proxy credential deletion failure'); + return originalSync.call(this); + }, + ); + + try { + await assert.rejects( + stores.operations.updateNetworkProxy({ + expectedPolicyRevision: configured.snapshot.revision, + expectedCredential: credentialBasis(configured.credentialStatus), + networkProxy: { + ...configured.snapshot.policy.networkProxy, + authEnabled: false, + username: '', + }, + credential: { kind: 'delete' }, + }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + const persistedPolicy = JSON.parse( + await readFile(join(root, 'runtime-policy.json'), 'utf8'), + ) as { readonly policy: { readonly networkProxy: RuntimePolicy['networkProxy'] } }; + assert.equal(persistedPolicy.policy.networkProxy.authEnabled, false); + assert.ok((await readFile(join(root, 'credential-vault.json'), 'utf8')).includes(secret)); + }); + }); + + test('blocks WebFetch while privacy mode is active', async () => { + await withInteractiveOwner(async ({ stores }) => { + const policy = await stores.runtimePolicy.mutate({ + expectedRevision: 0, + operation: { kind: 'set_privacy', value: { incognitoActive: true } }, + }); + assert.equal(policy.kind, 'committed'); + + assert.deepEqual(await stores.operations.resolveHostOutboundExecution(), { + kind: 'privacy_mode', + }); + }); + }); + + test('keeps provider-native WebSearch outside the client search credential resolver', async () => { + await withInteractiveOwner(async ({ stores }) => { + const policy = await stores.runtimePolicy.mutate({ + expectedRevision: 0, + operation: { + kind: 'set_web_search', + value: { enabled: true, defaultProvider: 'model' }, + }, + }); + assert.equal(policy.kind, 'committed'); + assert.deepEqual(await stores.operations.resolveWebSearchExecution(), { + kind: 'model_native_only', + provider: 'model', + }); + }); + }); + + test('removes credentials only for a matching connection revision and converges on partial retries', async () => { + await withInteractiveOwner(async ({ stores }) => { + const original = await createConnection( + stores, + 0, + connectionDraft('removable', 'openai', 'Removable'), + ); + const locator = connectionCredential(original, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: null, + secret: 'must-survive-stale-remove', + }) + ).kind, + 'committed', + ); + assert.equal( + ( + await stores.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: 1, + target: { connectionId: original.connectionId, modelId: 'gpt-5' }, + }) + ).kind, + 'committed', + ); + const updatedResult = await stores.connectionCatalog.update({ + expected: connectionBasis(original), + changes: { + name: 'Current revision', + enabled: true, + enabledModelIds: ['gpt-5'], + relayModelProfiles: null, + }, + }); + assert.equal(updatedResult.kind, 'committed'); + if (updatedResult.kind !== 'committed') return; + const updated = updatedResult.snapshot.connections[0]; + assert.ok(updated); + + const stale = await stores.connectionCatalog.remove({ expected: connectionBasis(original) }); + assert.equal(stale.kind, 'connection_stale'); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).defaultTarget, { + connectionId: original.connectionId, + modelId: 'gpt-5', + }); + + const removed = await stores.connectionCatalog.remove({ expected: connectionBasis(updated) }); + assert.equal(removed.kind, 'committed'); + if (removed.kind !== 'committed') return; + assert.deepEqual(removed.snapshot.connections, []); + assert.equal(removed.snapshot.defaultTarget, null); + assert.deepEqual((await stores.credentialVault.getSnapshot()).entries, []); + assert.deepEqual(await stores.credentialVault.getStatus(locator), { + kind: 'connection_not_found', + }); + const retry = await stores.connectionCatalog.remove({ expected: connectionBasis(updated) }); + assert.equal(retry.kind, 'committed'); + if (retry.kind === 'committed') + assert.equal(retry.snapshot.revision, removed.snapshot.revision); + + const recreated = await createConnection( + stores, + removed.snapshot.revision, + connectionDraft('removable', 'openai', 'Recreated'), + ); + assert.notEqual(recreated.connectionId, original.connectionId); + const recreatedLocator = connectionCredential(recreated, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator: recreatedLocator, + expected: null, + secret: 'partial-state-secret', + }) + ).kind, + 'committed', + ); + const recreatedStatus = await getCredentialStatus(stores.credentialVault, recreatedLocator); + assert.equal( + ( + await stores.credentialVault.delete({ + expected: credentialBasis(recreatedStatus), + }) + ).kind, + 'committed', + ); + const converged = await stores.connectionCatalog.remove({ + expected: connectionBasis(recreated), + }); + assert.equal(converged.kind, 'committed'); + if (converged.kind === 'committed') assert.deepEqual(converged.snapshot.connections, []); + assert.deepEqual((await stores.credentialVault.getSnapshot()).entries, []); + }); + }); + + test('successor recovery removes credentials orphaned by an interrupted connection removal', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to inject a persistence failure' + : false, + }, async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const firstOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(firstOwner); + if (!firstOwner) return; + const interrupted = await (async () => { + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(firstOwner.lease); + const connection = await createConnection( + stores, + 0, + connectionDraft('interrupted-remove', 'openai', 'Interrupted remove'), + ); + const locator = connectionCredential(connection, 'api_key'); + assert.equal( + ( + await stores.credentialVault.set({ + locator, + expected: null, + secret: 'cleanup-after-restart', + }) + ).kind, + 'committed', + ); + + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync: typeof probe.sync; + }; + const originalSync = fileHandlePrototype.sync; + await probe.close(); + let syncCalls = 0; + const syncMock = mock.method( + fileHandlePrototype, + 'sync', + async function (this: typeof probe) { + syncCalls += 1; + if (syncCalls === 3) throw new Error('injected credential cleanup failure'); + return originalSync.call(this); + }, + ); + try { + await assert.rejects( + stores.connectionCatalog.remove({ expected: connectionBasis(connection) }), + isStoreError('commit_outcome_unknown'), + ); + } finally { + syncMock.mock.restore(); + } + + assert.equal(syncCalls, 3); + const committedCatalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual(committedCatalog.connections, []); + assert.equal((await stores.credentialVault.getSnapshot()).entries.length, 1); + return { + basis: connectionBasis(connection), + catalogRevision: committedCatalog.revision, + }; + } finally { + if (!firstOwner.closed) await firstOwner.close(); + } + })(); + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + assert.deepEqual((await stores.credentialVault.getSnapshot()).entries, []); + const retry = await stores.connectionCatalog.remove({ + expected: interrupted.basis, + }); + assert.equal(retry.kind, 'committed'); + if (retry.kind === 'committed') { + assert.equal(retry.snapshot.revision, interrupted.catalogRevision); + } + } finally { + if (!successor.closed) await successor.close(); + } + }); + }); + + test('drains every synchronously admitted ordered mutation before owner close completes', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + + const first = stores.runtimePolicy.mutate(personalizationMutation(0)); + const second = stores.runtimePolicy.mutate({ + expectedRevision: 1, + operation: { + kind: 'set_memory', + value: { enabled: false, agentReadEnabled: false }, + }, + }); + const third = stores.runtimePolicy.mutate({ + expectedRevision: 2, + operation: { + kind: 'set_privacy', + value: { incognitoActive: true }, + }, + }); + const closing = owner.close(); + assert.equal(owner.closed, true); + + const results = await Promise.all([first, second, third, closing]); + assert.deepEqual( + results.slice(0, 3).map((result) => result?.kind), + ['committed', 'committed', 'committed'], + ); + + const readerHandle = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerHandle); + if (!readerHandle) return; + try { + const reader = await openInteractiveRuntimePolicyStoresForRead(readerHandle.lease); + const snapshot = await reader.runtimePolicy.getSnapshot(); + assert.equal(snapshot.revision, 3); + assert.deepEqual(snapshot.policy.personalization, { + displayName: 'Maka', + assistantTone: 'concise', + }); + assert.deepEqual(snapshot.policy.memory, { enabled: false, agentReadEnabled: false }); + assert.deepEqual(snapshot.policy.privacy, { incognitoActive: true }); + } finally { + await readerHandle.close(); + } + }); + }); + + test('fails closed on final symlinks, FIFOs, and oversized documents without changing bytes', { + skip: process.platform === 'win32', + }, async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const external = join(root, '..', 'external-policy.json'); + const original = Buffer.from('{"external":true}\n'); + await writeFile(external, original); + await symlink(external, join(root, 'runtime-policy.json')); + await assert.rejects( + () => stores.runtimePolicy.mutate(personalizationMutation(0)), + isStoreError('invalid_document'), + ); + assert.deepEqual(await readFile(external), original); + assert.equal((await lstat(join(root, 'runtime-policy.json'))).isSymbolicLink(), true); + }); + + await withInteractiveOwner(async ({ root, stores }) => { + const path = join(root, 'runtime-policy.json'); + await execFileAsync('mkfifo', [path]); + await assert.rejects( + () => stores.runtimePolicy.getSnapshot(), + isStoreError('invalid_document'), + ); + assert.equal((await lstat(path)).isFIFO(), true); + }); + + await withInteractiveOwner(async ({ root, stores }) => { + const path = join(root, 'runtime-policy.json'); + const original = Buffer.alloc(256 * 1024 + 1, 0x78); + await writeFile(path, original); + await assert.rejects( + () => stores.runtimePolicy.mutate(personalizationMutation(0)), + isStoreError('invalid_document'), + ); + assert.deepEqual(await readFile(path), original); + }); + }); + + test('single-flights writer recovery and preserves credential material across owner reopen', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const firstOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(firstOwner); + if (!firstOwner) return; + let connection!: ConnectionCatalogEntry; + let firstStatus!: CredentialStatus; + const secret = 'persisted-secret-after-recovery'; + const temporaryNames = [ + 'runtime-policy.json.11111111-1111-4111-8111-111111111111.tmp', + 'connection-catalog.json.22222222-2222-4222-8222-222222222222.tmp', + 'credential-vault.json.33333333-3333-4333-8333-333333333333.tmp', + ]; + try { + await Promise.all([ + writeFile(join(root, temporaryNames[0]!), '{"orphan":true}\n', 'utf8'), + writeFile(join(root, temporaryNames[1]!), '{"orphan":true}\n', 'utf8'), + writeFile(join(root, temporaryNames[2]!), 'plaintext-credential-orphan\n', 'utf8'), + ]); + const [first, sameLeaseOpen] = await Promise.all([ + openInteractiveRuntimePolicyStoresForWrite(firstOwner.lease), + openInteractiveRuntimePolicyStoresForWrite(firstOwner.lease), + ]); + assert.equal(first, sameLeaseOpen); + const remaining = new Set(await readdir(root)); + assert.deepEqual( + temporaryNames.filter((name) => remaining.has(name)), + [], + ); + + connection = await createConnection( + first, + 0, + connectionDraft('reopen', 'openai', 'Reopen'), + ); + const locator = connectionCredential(connection, 'api_key'); + assert.equal( + ( + await first.credentialVault.set({ + locator, + expected: null, + secret, + }) + ).kind, + 'committed', + ); + firstStatus = await getCredentialStatus(first.credentialVault, locator); + assert.equal(JSON.stringify(firstStatus).includes(secret), false); + } finally { + if (!firstOwner.closed) await firstOwner.close(); + } + + const secondOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(secondOwner); + if (!secondOwner) return; + try { + const second = await openInteractiveRuntimePolicyStoresForWrite(secondOwner.lease); + const resolved = await second.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); + assert.equal(resolved.kind, 'ready'); + if (resolved.kind !== 'ready') return; + assert.equal(resolved.secretMaterial.connection?.secret, secret); + assert.equal(resolved.secretMaterial.connection?.credentialId, firstStatus.credentialId); + } finally { + if (!secondOwner.closed) await secondOwner.close(); + } + + const readerHandle = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerHandle); + if (!readerHandle) return; + try { + const reader = await openInteractiveRuntimePolicyStoresForRead(readerHandle.lease); + const publicStatus = await getCredentialStatus( + reader.credentialVault, + connectionCredential(connection, 'api_key'), + ); + assert.equal(publicStatus.credentialId, firstStatus.credentialId); + const publicViews = JSON.stringify([ + publicStatus, + await reader.credentialVault.getSnapshot(), + ]); + assert.equal(publicViews.includes(secret), false); + } finally { + await readerHandle.close(); + } + }); + }); + + test('clears a stale onboarding intent when its connection id conflicts with the catalog', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const connection = await createConnection( + stores, + 0, + connectionDraft('openai', 'openai', 'Stale onboarding'), + ); + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 1, + connectionId: '11111111-1111-4111-8111-111111111111', + providerType: connection.providerType, + suppliedSecret: null, + enabledModelIds: connection.enabledModelIds, + discovery: { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_000, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + + const reopened = await tryAcquireInteractiveRootOwner(capability); + assert.ok(reopened); + if (!reopened) return; + try { + await openInteractiveRuntimePolicyStoresForWrite(reopened.lease); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await reopened.close(); + } + }); + }); + + test('recovers a v1 onboarding intent by identity before deriving a canonical slug', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let connectionId = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const connection = await createConnection(stores, 0, { + ...connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + baseUrl: 'https://relay.example.test/v1', + }); + connectionId = connection.connectionId; + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 1, + connectionId, + providerType: connection.providerType, + suppliedSecret: null, + baseUrl: connection.baseUrl, + enabledModelIds: ['relay/new'], + discovery: { + models: [{ id: 'relay/new' }], + source: 'fetched', + fetchedAt: 1_800_000_000_001, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId: id, slug }) => ({ id, slug })), + [{ id: connectionId, slug: 'my-relay' }], + ); + // Recovery follows the ordinary onboarding merge rule: the newly + // selected model is enabled while a declaration the wizard never + // offered remains intact. + assert.deepEqual(catalog.connections[0]?.enabledModelIds, ['relay/new', 'gpt-5']); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + }); + + test('recovers a v2 create intent with its preallocated dynamic identity', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const connectionId = '22222222-2222-4222-8222-222222222222'; + let firstConnectionId = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + firstConnectionId = ( + await createConnection(stores, 0, connectionDraft('openai', 'openai', 'OpenAI')) + ).connectionId; + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 2, + connectionId, + slug: 'openai-2', + providerType: 'openai', + suppliedSecret: 'second-account-secret', + baseUrl: null, + enabledModelIds: ['gpt-5'], + discovery: { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_002, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId: id, slug }) => ({ id, slug })), + [ + { id: firstConnectionId, slug: 'openai' }, + { id: connectionId, slug: 'openai-2' }, + ], + ); + assert.equal( + ( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId, + kind: 'api_key', + }) + )?.secret, + 'second-account-secret', + ); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + }); + + test('fails closed when a v2 onboarding intent rebinds an existing id to another slug', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let vaultBefore = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const connection = await createConnection( + stores, + 0, + connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + ); + const credential = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'original-secret', + }); + assert.equal(credential.kind, 'committed'); + vaultBefore = await readFile(join(root, 'credential-vault.json'), 'utf8'); + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 2, + connectionId: connection.connectionId, + slug: 'openai-compatible', + providerType: connection.providerType, + suppliedSecret: 'must-not-replace-original', + baseUrl: connection.baseUrl, + enabledModelIds: ['gpt-5'], + discovery: { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_002, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + await assert.rejects( + openInteractiveRuntimePolicyStoresForWrite(successor.lease), + isStoreError('commit_outcome_unknown'), + ); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), true); + assert.equal(await readFile(join(root, 'credential-vault.json'), 'utf8'), vaultBefore); + } finally { + await successor.close(); + } + }); + }); + + test('interactive OAuth create allocates distinct entities and keeps attempt identity durable', async () => { + await withInteractiveOwner(async ({ stores }) => { + const target = { kind: 'create' as const, providerType: 'openai-codex' as const }; + const first = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target, + }); + assert.equal(first.kind, 'ready'); + if (first.kind !== 'ready') return; + assert.match(first.identity.connectionId, UUID_PATTERN); + assert.equal(first.identity.slug, 'codex-subscription'); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections, []); + assert.deepEqual( + await stores.credentialVault.getStatus({ + scope: 'connection', + connectionId: first.identity.connectionId, + kind: 'oauth_token', + }), + { kind: 'connection_not_found' }, + ); + + const firstCompletion = await stores.operations.completeInteractiveOAuthLogin( + first.ticket, + 'oauth-create-secret-a', + ); + assert.equal(firstCompletion.kind, 'committed'); + if (firstCompletion.kind !== 'committed') return; + assert.deepEqual(firstCompletion.connection, first.identity); + assert.deepEqual(await stores.operations.queryInteractiveOAuthLogin('oauth-create-first'), { + kind: 'authenticated', + target, + connection: first.identity, + }); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target, + }), + { + kind: 'authenticated', + target, + connection: first.identity, + }, + ); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-first', + target: { kind: 'create', providerType: 'xai-oauth' }, + }), + { kind: 'attempt_conflict' }, + ); + + const second = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-second', + target, + }); + assert.equal(second.kind, 'ready'); + if (second.kind !== 'ready') return; + assert.notEqual(second.identity.connectionId, first.identity.connectionId); + assert.equal(second.identity.slug, 'codex-subscription-2'); + const secondCompletion = await stores.operations.completeInteractiveOAuthLogin( + second.ticket, + 'oauth-create-secret-b', + ); + assert.equal(secondCompletion.kind, 'committed'); + + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId, slug }) => ({ connectionId, slug })), + [first.identity, second.identity].map(({ connectionId, slug }) => ({ + connectionId, + slug, + })), + ); + assert.equal(catalog.defaultTarget, null); + for (const identity of [first.identity, second.identity]) { + assert.equal( + ( + await getCredentialStatus(stores.credentialVault, { + scope: 'connection', + connectionId: identity.connectionId, + kind: 'oauth_token', + }) + ).configured, + true, + ); + } + }); + }); + + test('interactive OAuth existing login re-enables only its frozen entity', async () => { + await withInteractiveOwner(async ({ stores }) => { + const original = await createConnection(stores, 0, { + ...connectionDraft('codex-disabled', 'openai-codex', 'Personal Codex'), + enabled: false, + enabledModelIds: ['gpt-5.1-codex-mini'], + }); + const admitted = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-existing-disabled', + target: { kind: 'existing', connectionId: original.connectionId }, + }); + assert.equal(admitted.kind, 'ready'); + if (admitted.kind !== 'ready') return; + assert.deepEqual(admitted.identity, { + connectionId: original.connectionId, + slug: original.slug, + providerType: original.providerType, + }); + assert.equal((await stores.connectionCatalog.getSnapshot()).connections[0]?.enabled, false); + assert.equal( + ( + await stores.operations.completeInteractiveOAuthLogin( + admitted.ticket, + 'oauth-disabled-secret', + ) + ).kind, + 'committed', + ); + const catalog = await stores.connectionCatalog.getSnapshot(); + const reenabled = catalog.connections[0]; + assert.ok(reenabled); + assert.equal(reenabled.connectionId, original.connectionId); + assert.equal(reenabled.slug, original.slug); + assert.equal(reenabled.name, original.name); + assert.deepEqual(reenabled.enabledModelIds, original.enabledModelIds); + assert.equal(reenabled.enabled, true); + assert.equal(catalog.defaultTarget, null); + }); + }); + + test('OAuth enrollment fails closed when its exact entity or allocated slug drifts', async () => { + await withInteractiveOwner(async ({ stores }) => { + const createAdmission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-create-slug-drift', + target: { kind: 'create', providerType: 'openai-codex' }, + }); + assert.equal(createAdmission.kind, 'ready'); + if (createAdmission.kind !== 'ready') return; + await createConnection(stores, 0, { + ...connectionDraft( + createAdmission.identity.slug, + 'openai-codex', + 'Concurrent Codex entity', + ), + enabledModelIds: [...PROVIDER_REGISTRY['openai-codex'].fallbackModels], + }); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + createAdmission.ticket, + 'must-not-fallback', + ), + { kind: 'superseded', changed: ['connection'] }, + ); + assert.equal( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: createAdmission.identity.connectionId, + kind: 'oauth_token', + }), + null, + ); + + const existing = (await stores.connectionCatalog.getSnapshot()).connections[0]; + assert.ok(existing); + const existingAdmission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-existing-deleted', + target: { kind: 'existing', connectionId: existing.connectionId }, + }); + assert.equal(existingAdmission.kind, 'ready'); + if (existingAdmission.kind !== 'ready') return; + assert.equal( + (await stores.connectionCatalog.remove({ expected: connectionBasis(existing) })).kind, + 'committed', + ); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + existingAdmission.ticket, + 'must-not-rebind', + ), + { kind: 'superseded', changed: ['connection'] }, + ); + }); + }); + + test('OAuth enrollment recovery converges after every durable commit boundary', async () => { + const stages = ['journal', 'vault', 'catalog', 'receipt'] as const; + for (const [index, stage] of stages.entries()) { + await withInteractiveRoot(async ({ root, capability }) => { + const firstOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(firstOwner); + if (!firstOwner) return; + const attemptId = `oauth-recovery-${stage}`; + const secret = `oauth-recovery-secret-${stage}`; + let ready: Extract< + Awaited>, + { kind: 'ready' } + >; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(firstOwner.lease); + const admission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId, + target: { kind: 'create', providerType: 'openai-codex' }, + }); + assert.equal(admission.kind, 'ready'); + if (admission.kind !== 'ready') return; + ready = admission; + } finally { + await firstOwner.close(); + } + + const target = { kind: 'create' as const, providerType: 'openai-codex' as const }; + const intent = prepareInteractiveOAuthEnrollmentIntent({ + attemptId, + target, + connectionBefore: null, + connectionAfter: ready.connection, + credentialBasis: null, + secret, + }); + await writeConnectionOnboardingIntent(root, intent); + let precommittedCredentialId: string | undefined; + + if (index >= 1) { + const vault = new CredentialVaultDocumentOwner(); + const committed = await vault.set(root, { + locator: { + scope: 'connection', + connectionId: ready.identity.connectionId, + kind: 'oauth_token', + }, + expected: null, + secret, + }); + assert.equal(committed.kind, 'committed'); + if (committed.kind !== 'committed') return; + const status = committed.snapshot.entries.find( + ({ locator }) => + locator.scope === 'connection' && + locator.connectionId === ready.identity.connectionId && + locator.kind === 'oauth_token', + ); + assert.equal(status?.configured, true); + precommittedCredentialId = status?.configured ? status.credentialId : undefined; + } + if (index >= 2) { + const catalog = new ConnectionCatalogDocumentOwner(); + const prepared = catalog.prepareOAuthEnrollmentUpsert( + await catalog.read(root), + null, + ready.connection, + ); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + await catalog.commitPreparedOnboarding(root, prepared); + } + if (index >= 3) { + await upsertInteractiveOAuthLoginReceipt(root, { + attemptId, + target, + connection: ready.identity, + }); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + assert.deepEqual(await stores.operations.queryInteractiveOAuthLogin(attemptId), { + kind: 'authenticated', + target, + connection: ready.identity, + }); + const snapshot = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + snapshot.connections.map(({ connectionId, slug }) => ({ connectionId, slug })), + [{ connectionId: ready.identity.connectionId, slug: ready.identity.slug }], + ); + assert.equal(snapshot.defaultTarget, null); + const credential = await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: ready.identity.connectionId, + kind: 'oauth_token', + }); + assert.equal(credential?.secret, secret); + if (precommittedCredentialId) { + assert.equal(credential?.credentialId, precommittedCredentialId); + } + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + } + }); + + test('interactive OAuth login commits only against its frozen connection and credential basis', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const claude = await createConnection( + stores, + 0, + connectionDraft('codex-login', 'openai-codex', 'Codex login'), + ); + const first = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-first', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); + const second = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-second', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); + assert.equal(first.kind, 'ready'); + assert.equal(second.kind, 'ready'); + if (first.kind !== 'ready' || second.kind !== 'ready') return; + assert.equal(first.secretMaterial.networkProxy, undefined); + const committed = await stores.operations.completeInteractiveOAuthLogin( + second.ticket, + 'oauth-secret-v1', + ); + assert.equal(committed.kind, 'committed'); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin(first.ticket, 'stale-secret'), + { kind: 'superseded', changed: ['credential'] }, + ); + const status = await getCredentialStatus( + stores.credentialVault, + connectionCredential(claude, 'oauth_token'), + ); + assert.equal(status.configured, true); + await assert.rejects( + () => stores.operations.completeInteractiveOAuthLogin(second.ticket, 'ticket-replay'), + isStoreError('invalid_credential_input'), + ); + + const beforeUpdate = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'oauth-before-update', + target: { kind: 'existing', connectionId: claude.connectionId }, + }); + assert.equal(beforeUpdate.kind, 'ready'); + if (beforeUpdate.kind !== 'ready') return; + const current = (await stores.connectionCatalog.getSnapshot()).connections.find( + (connection) => connection.connectionId === claude.connectionId, + ); + assert.ok(current); + const updated = await stores.connectionCatalog.update({ + expected: connectionBasis(current), + changes: { + name: 'Claude renamed', + enabled: current.enabled, + enabledModelIds: current.enabledModelIds, + relayModelProfiles: null, + }, + }); + assert.equal(updated.kind, 'committed'); + assert.deepEqual( + await stores.operations.completeInteractiveOAuthLogin( + beforeUpdate.ticket, + 'connection-stale-secret', + ), + { kind: 'superseded', changed: ['connection'] }, + ); + + const copilot = await createConnection( + stores, + (await stores.connectionCatalog.getSnapshot()).revision, + connectionDraft('copilot-import', 'github-copilot', 'Copilot import'), + ); + // GitHub Copilot enrolls through the Host OAuth seam like every other + // account login, so its admission must be a real ticket, not hidden. + const copilotAdmission = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'copilot-login', + target: { kind: 'existing', connectionId: copilot.connectionId }, + }); + assert.equal(copilotAdmission.kind, 'ready'); + if (copilotAdmission.kind === 'ready') { + assert.equal(copilotAdmission.identity.providerType, 'github-copilot'); + } + + // A retired provider keeps its stored connection, so the login entry + // point is reachable and has to refuse on its own. + const retired = await seedRetiredConnection( + root, + stores, + 'claude-retired', + '88888888-8888-4888-8888-888888888888', + ); + assert.deepEqual( + await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'retired-oauth-login', + target: { kind: 'existing', connectionId: retired.connectionId }, + }), + { kind: 'provider_action_unavailable' }, + ); + }); + }); + + test('rejects forged leases, forged facades, and operations after interactive lease close', async () => { + await assert.rejects( + () => + openInteractiveRuntimePolicyStoresForWrite({} as StorageRootLease<'interactive', 'write'>), + isInvalidLease, + ); + + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + assert.equal(authenticateRuntimePolicyStoresWriter(writer), writer); + assert.throws(() => authenticateRuntimePolicyStoresWriter({ ...writer }), isInvalidLease); + await owner.close(); + await assert.rejects(() => writer.runtimePolicy.getSnapshot(), isInvalidLease); + + const readerHandle = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerHandle); + if (!readerHandle) return; + const reader = await openInteractiveRuntimePolicyStoresForRead(readerHandle.lease); + assert.equal(authenticateRuntimePolicyStoresReader(reader), reader); + assert.throws(() => authenticateRuntimePolicyStoresReader({ ...reader }), isInvalidLease); + await readerHandle.close(); + await assert.rejects(() => reader.connectionCatalog.getSnapshot(), isInvalidLease); + }); + }); +}); + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +type Writer = Awaited>; + +async function createConnection( + stores: Writer, + expectedCatalogRevision: number, + connection: ConnectionCatalogEntryDraft, +): Promise { + const result = await stores.connectionCatalog.create({ expectedCatalogRevision, connection }); + assert.equal(result.kind, 'committed'); + if (result.kind !== 'committed') throw new Error('connection creation did not commit'); + const created = result.snapshot.connections.find((item) => item.slug === connection.slug); + assert.ok(created); + return created; +} + +async function verifyConnection( + stores: Writer, + connectionId: string, + checkedAt: string, +): Promise { + const prepared = await stores.operations.beginConnectionTest(connectionId, null); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') throw new Error('connection test preparation did not succeed'); + const completed = await stores.operations.completeConnectionTest(prepared.ticket, { + status: 'verified', + checkedAt, + }); + assert.equal(completed.kind, 'committed'); +} + +function connectionDraft( + slug: string, + providerType: ConnectionCatalogEntryDraft['providerType'], + name: string, +): ConnectionCatalogEntryDraft { + return { + slug, + name, + providerType, + enabled: true, + enabledModelIds: ['gpt-5'], + }; +} + +function connectionBasis(connection: ConnectionCatalogEntry): ConnectionVersionBasis { + return { connectionId: connection.connectionId, revision: connection.revision }; +} + +function connectionCredential( + connection: ConnectionCatalogEntry, + kind: 'api_key' | 'oauth_token' | 'request_headers', +): Extract { + return { scope: 'connection', connectionId: connection.connectionId, kind }; +} + +function proxyCredential(): Extract { + return { scope: 'network_proxy', kind: 'password' }; +} + +async function getCredentialStatus( + vault: Pick, + locator: CredentialLocator, +): Promise { + const result = await vault.getStatus(locator); + assert.equal(result.kind, 'status'); + if (result.kind !== 'status') throw new Error('credential status query did not return a status'); + return result.status; +} + +function credentialBasis(status: CredentialStatus): CredentialVersionBasis { + assert.equal(status.configured, true); + if (!status.configured) throw new Error('credential is not configured'); + return { + locator: status.locator, + credentialId: status.credentialId, + revision: status.revision, + }; +} + +function credentialExpectation(status: CredentialStatus): { + credentialId: string; + revision: number; +} { + const basis = credentialBasis(status); + return { credentialId: basis.credentialId, revision: basis.revision }; +} + +function personalizationMutation(expectedRevision: number): MutateRuntimePolicyInput { + return { + expectedRevision, + operation: { + kind: 'set_personalization', + value: { displayName: 'Maka', assistantTone: 'concise' }, + }, + }; +} + +function networkProxyMutation( + expectedRevision: number, + changes: Partial = {}, +): MutateRuntimePolicyInput { + return { + expectedRevision, + operation: { + kind: 'set_network_proxy', + value: { + enabled: true, + protocol: 'http', + host: '127.0.0.1', + port: 8080, + authEnabled: true, + username: 'proxy-user', + bypassList: ['localhost'], + autoBypassDomains: ['127.0.0.1'], + ...changes, + }, + }, + }; +} + +function catalogSlug(connectionSlug: string) { + return { kind: 'catalog_slug' as const, connectionSlug }; +} + +function isStoreError(code: RuntimePolicyStoreError['code']) { + return (error: unknown) => error instanceof RuntimePolicyStoreError && error.code === code; +} + +function isInvalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} + +async function withInteractiveOwner( + run: (input: { root: string; stores: Writer }) => Promise, +): Promise { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await run({ root, stores: await openInteractiveRuntimePolicyStoresForWrite(owner.lease) }); + } finally { + if (!owner.closed) await owner.close(); + } + }); +} + +async function withInteractiveRoot( + run: (input: { + root: string; + capability: Awaited>>; + }) => Promise, +): Promise { + await withTempDir(async (base) => { + const root = join(base, 'interactive'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + try { + await run({ root, capability }); + } finally { + await removeControlDirectory(capability.rootId); + } + }); +} + +async function withTempDir(run: (base: string) => Promise): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-policy-')); + try { + await run(base); + } finally { + await rm(base, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ed98860025866144d99f185da5cab87e0a9c425eebb9f33c51e0f60a56e8a1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ed98860025866144d99f185da5cab87e0a9c425eebb9f33c51e0f60a56e8a1.source new file mode 100644 index 0000000000..5fd3218de4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/23ed98860025866144d99f185da5cab87e0a9c425eebb9f33c51e0f60a56e8a1.source @@ -0,0 +1,869 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + createInMemoryImmutableObjectStore, + createInMemorySessionRepository, + createSessionCheckpointManifestV1, + encodeSessionCheckpointManifestV1, + materializeSessionCheckpointV1, + publishSessionCheckpointV1, + SESSION_BUNDLE_OBJECT_MEDIA_TYPE, + SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE, + SessionRepositoryError, + type ImmutableObjectInput, + type ImmutableObjectRef, + type ImmutableObjectStore, + type SessionCheckpointManifestV1, + type SessionRepository, + type StoredSessionCheckpoint, +} from '../session-repository.js'; +import type { SessionBundleArtifact, Sha256Digest } from '../session-bundle-contract.js'; + +test('publishes and verifies Bundle then Manifest before creating an exact head', async () => { + await withTemporaryDirectory(async (directory) => { + const base = createInMemoryImmutableObjectStore(); + const events: string[] = []; + const objectStore: ImmutableObjectStore = { + publish: async (input) => { + events.push(`publish:${input.mediaType}`); + return base.publish(input); + }, + assertReadable: async (ref) => { + events.push(`assert:${ref.mediaType}`); + await base.assertReadable(ref); + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const artifact = await writeArtifact(directory, 'initial.tar.zst', 'initial Bundle bytes'); + const checkpoint = await publishCheckpoint(objectStore, artifact); + + assert.deepEqual(events, [ + `publish:${SESSION_BUNDLE_OBJECT_MEDIA_TYPE}`, + `assert:${SESSION_BUNDLE_OBJECT_MEDIA_TYPE}`, + `publish:${SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE}`, + `assert:${SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE}`, + ]); + assert.equal(checkpoint.value.schemaVersion, 1); + assert.equal(checkpoint.value.compatibilityBundle.digest, artifact.archiveDigest); + + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint, + lastCommittedActivationId: 'activation-a', + }); + + assert.equal(repository.forkIdempotencyRetention, 'indefinite'); + assert.equal(created.ref.revision, 'r1'); + assert.deepEqual(created.checkpoint, checkpoint); + assert.deepEqual(await repository.checkoutCurrent('session-a'), created); + assert.deepEqual(await repository.checkoutExact(created.ref), created); + }); +}); + +test('retains only the current Manifest revision and never falls forward', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + const checkpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'next.tar.zst', 'next Bundle bytes'), + ); + const committed = await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint, + }); + + await assert.rejects( + repository.checkoutExact(created.ref), + hasRepositoryCode('revision_not_available'), + ); + assert.deepEqual(await repository.checkoutCurrent(created.ref.sessionId), committed); + assert.deepEqual(await repository.checkoutExact(committed.ref), committed); + }); +}); + +test('a source-head race returns the requested Manifest and Bundle rather than a newer head', async () => { + await withTemporaryDirectory(async (directory) => { + const base = createInMemoryImmutableObjectStore(); + let blockNextBundleRead = false; + let reading: (() => void) | undefined; + let releaseRead: (() => void) | undefined; + const readStarted = new Promise((resolve) => { + reading = resolve; + }); + const readReleased = new Promise((resolve) => { + releaseRead = resolve; + }); + const objectStore: ImmutableObjectStore = { + publish: (input) => base.publish(input), + assertReadable: async (ref) => { + await base.assertReadable(ref); + if (!blockNextBundleRead || ref.mediaType !== SESSION_BUNDLE_OBJECT_MEDIA_TYPE) return; + blockNextBundleRead = false; + reading?.(); + await readReleased; + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const initial = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'initial.tar.zst', 'initial'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'next.tar.zst', 'next'), + ); + + blockNextBundleRead = true; + const exactRead = repository.checkoutExact(created.ref); + await readStarted; + const committed = await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + }); + releaseRead?.(); + + assert.deepEqual(await exactRead, created); + assert.deepEqual(await repository.checkoutExact(committed.ref), committed); + }); +}); + +test('rejects stale concurrent writers without overwriting the winning head', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + const left = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'left.tar.zst', 'left'), + ); + const right = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'right.tar.zst', 'right'), + ); + const results = await Promise.allSettled([ + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: left, + }), + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: right, + }), + ]); + + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + const rejected = results.find((result) => result.status === 'rejected'); + assert.ok(rejected); + if (!rejected || rejected.status !== 'rejected') return; + assert.ok(rejected.reason instanceof SessionRepositoryError); + assert.equal(rejected.reason.code, 'revision_conflict'); + + const winner = results.find( + ( + result, + ): result is PromiseFulfilledResult>> => + result.status === 'fulfilled', + ); + assert.ok(winner); + if (!winner) return; + assert.deepEqual(await repository.checkoutExact(winner.value.ref), winner.value); + }); +}); + +test('reconciles concurrent and later retries of one commit identity', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + const checkpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'first.tar.zst', 'first'), + ); + const input = { + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint, + commitId: 'commit-a', + }; + const [first, concurrentRetry] = await Promise.all([ + repository.commit(input), + repository.commit(input), + ]); + + assert.deepEqual(concurrentRetry, first); + assert.deepEqual(await repository.commit(input), first); + const nextCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'second.tar.zst', 'second'), + ); + const second = await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: first.ref.revision, + checkpoint: nextCheckpoint, + }); + assert.equal(second.ref.revision, 'r3'); + await assert.rejects( + repository.commit({ ...input, checkpoint: nextCheckpoint }), + hasRepositoryCode('idempotency_conflict'), + ); + }); +}); + +test('never makes a head visible for an unpublished Manifest', async () => { + await withReadySession(async ({ repository, created, checkpoint }) => { + const manifestBytes = encodeSessionCheckpointManifestV1(checkpoint.value); + const unpublished: StoredSessionCheckpoint = { + manifest: { + objectRef: 'memory://immutable-objects/not-published', + digest: digest(manifestBytes), + bytes: manifestBytes.byteLength, + mediaType: SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE, + }, + value: checkpoint.value, + }; + await assert.rejects( + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: unpublished, + }), + hasRepositoryCode('object_not_found'), + ); + assert.deepEqual(await repository.checkoutExact(created.ref), created); + }); +}); + +test('never makes a head visible when a Manifest names an unreadable Bundle', async () => { + await withReadySession(async ({ repository, objectStore, created }) => { + const missingBundle: ImmutableObjectRef = { + objectRef: 'memory://immutable-objects/missing-bundle', + digest: digest('missing Bundle bytes'), + bytes: Buffer.byteLength('missing Bundle bytes'), + mediaType: SESSION_BUNDLE_OBJECT_MEDIA_TYPE, + }; + const value = createSessionCheckpointManifestV1(missingBundle); + const checkpoint = await publishManifest(objectStore, value); + + await assert.rejects( + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint, + }), + hasRepositoryCode('object_not_found'), + ); + assert.deepEqual(await repository.checkoutExact(created.ref), created); + }); +}); + +test('fails closed when Bundle bytes do not match their trusted archive digest', async () => { + await withTemporaryDirectory(async (directory) => { + const objectStore = createInMemoryImmutableObjectStore(); + const artifact = await writeArtifact(directory, 'corrupt.tar.zst', 'real bytes'); + await assert.rejects( + publishSessionCheckpointV1({ + objectStore, + compatibilityBundle: { ...artifact, archiveDigest: digest('different bytes') }, + }), + hasRepositoryCode('integrity_mismatch'), + ); + }); +}); + +test('rejects a Manifest value that does not match its immutable reference', async () => { + await withReadySession(async ({ repository, objectStore, directory, created, checkpoint }) => { + const other = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'other.tar.zst', 'other'), + ); + const mismatched: StoredSessionCheckpoint = { + manifest: checkpoint.manifest, + value: other.value, + }; + + await assert.rejects( + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: mismatched, + }), + hasRepositoryCode('integrity_mismatch'), + ); + }); +}); + +test('claims only a retained readable source and captures its Agent binding', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + await assert.rejects( + repository.claimFork({ + forkId: 'missing-source', + source: { sessionId: 'missing-session', revision: 'r1' }, + targetSessionId: 'session-b', + }), + hasRepositoryCode('source_revision_not_available'), + ); + + const next = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'source-next.tar.zst', 'source next'), + ); + const advanced = await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + }); + + await assert.rejects( + repository.claimFork({ + forkId: 'retired-source', + source: created.ref, + targetSessionId: 'session-b', + }), + hasRepositoryCode('source_revision_not_available'), + ); + const pending = await repository.claimFork({ + forkId: 'retired-source', + source: advanced.ref, + targetSessionId: 'session-b', + }); + assert.equal(pending.state, 'pending'); + assert.equal(pending.sourceAgentId, created.agentId); + }); +}); + +test('does not claim a Fork from an unreadable source checkpoint', async () => { + await withTemporaryDirectory(async (directory) => { + const base = createInMemoryImmutableObjectStore(); + let failedObjectRef: string | undefined; + const objectStore: ImmutableObjectStore = { + publish: (input) => base.publish(input), + assertReadable: async (ref) => { + if (ref.objectRef === failedObjectRef) { + throw new SessionRepositoryError('integrity_mismatch', 'Fork source is damaged'); + } + await base.assertReadable(ref); + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const checkpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'source.tar.zst', 'source'), + ); + const source = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint, + }); + + failedObjectRef = checkpoint.manifest.objectRef; + await assert.rejects( + repository.claimFork({ + forkId: 'fork-a', + source: source.ref, + targetSessionId: 'session-b', + }), + hasRepositoryCode('integrity_mismatch'), + ); + + failedObjectRef = undefined; + assert.equal( + ( + await repository.claimFork({ + forkId: 'fork-a', + source: source.ref, + targetSessionId: 'session-b', + }) + ).state, + 'pending', + ); + }); +}); + +test('does not claim a Fork when its source head moves during verification', async () => { + await withTemporaryDirectory(async (directory) => { + const base = createInMemoryImmutableObjectStore(); + let blockNextBundleRead = false; + let reading: (() => void) | undefined; + let releaseRead: (() => void) | undefined; + const readStarted = new Promise((resolve) => { + reading = resolve; + }); + const readReleased = new Promise((resolve) => { + releaseRead = resolve; + }); + const objectStore: ImmutableObjectStore = { + publish: (input) => base.publish(input), + assertReadable: async (ref) => { + await base.assertReadable(ref); + if (!blockNextBundleRead || ref.mediaType !== SESSION_BUNDLE_OBJECT_MEDIA_TYPE) return; + blockNextBundleRead = false; + reading?.(); + await readReleased; + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const initial = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'initial.tar.zst', 'initial'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'next.tar.zst', 'next'), + ); + + blockNextBundleRead = true; + const claim = repository.claimFork({ + forkId: 'fork-a', + source: created.ref, + targetSessionId: 'session-b', + }); + await readStarted; + const advanced = await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + }); + releaseRead?.(); + + await assert.rejects(claim, hasRepositoryCode('source_revision_not_available')); + assert.equal( + ( + await repository.claimFork({ + forkId: 'fork-a', + source: advanced.ref, + targetSessionId: 'session-b', + }) + ).state, + 'pending', + ); + }); +}); + +test('binds a Fork target to its verified source Agent and a distinct Session identity', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + await assert.rejects( + repository.claimFork({ + forkId: 'same-session', + source: created.ref, + targetSessionId: created.ref.sessionId, + }), + hasRepositoryCode('invalid_fork_target'), + ); + + await repository.claimFork({ + forkId: 'fork-a', + source: created.ref, + targetSessionId: 'session-b', + }); + const targetCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-target.tar.zst', 'fork target'), + ); + await assert.rejects( + repository.createSession({ + sessionId: 'session-b', + agentId: 'agent-b', + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: 'fork-a', + }), + hasRepositoryCode('fork_agent_mismatch'), + ); + await assert.rejects( + repository.createSession({ + sessionId: 'session-b', + agentId: created.agentId, + checkpoint: targetCheckpoint, + lastCommittedActivationId: 'source-activation', + forkedFrom: created.ref, + createdByForkId: 'fork-a', + }), + /Fork-created Session must not carry an Activation identity/, + ); + + const target = await repository.createSession({ + sessionId: 'session-b', + agentId: created.agentId, + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: 'fork-a', + }); + assert.deepEqual((await repository.completeFork({ forkId: 'fork-a' })).target, target.ref); + }); +}); + +test('claims Fork identity before target creation and resumes both crash windows', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + const request = { + forkId: 'fork-a', + source: created.ref, + targetSessionId: 'session-b', + }; + const targetCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-target.tar.zst', 'fork target'), + ); + + const pending = await repository.claimFork(request); + assert.equal(pending.state, 'pending'); + assert.equal(pending.sourceAgentId, created.agentId); + assert.deepEqual(pending.sourceCheckpoint, created.checkpoint); + assert.deepEqual(await repository.claimFork(request), pending); + + // V1 no longer retains the source revision as a Session head after it + // advances. A pending Fork must retain the exact checkpoint it admitted so + // recovery can still materialize and repack that source. + const advancedCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'source-advanced.tar.zst', 'source advanced'), + ); + await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: advancedCheckpoint, + }); + await assert.rejects( + repository.checkoutExact(created.ref), + hasRepositoryCode('revision_not_available'), + ); + await objectStore.assertReadable(pending.sourceCheckpoint.manifest); + await objectStore.assertReadable(pending.sourceCheckpoint.value.compatibilityBundle); + assert.deepEqual(await repository.claimFork(request), pending); + + const materialized = await materializeSessionCheckpointV1({ + objectStore, + checkpoint: pending.sourceCheckpoint, + destination: join(directory, 'recovered-source.tar.zst'), + maxBytes: pending.sourceCheckpoint.value.compatibilityBundle.bytes, + }); + assert.equal( + materialized.expectedArchiveDigest, + created.checkpoint.value.compatibilityBundle.digest, + ); + assert.deepEqual(await readFile(materialized.path), Buffer.from('initial Bundle bytes')); + await assert.rejects( + materializeSessionCheckpointV1({ + objectStore, + checkpoint: pending.sourceCheckpoint, + destination: join(directory, 'over-budget-source.tar.zst'), + maxBytes: pending.sourceCheckpoint.value.compatibilityBundle.bytes - 1, + }), + hasRepositoryCode('quota_exceeded'), + ); + + // Simulates a retry after a crash before target creation. + const target = await repository.createSession({ + sessionId: request.targetSessionId, + agentId: created.agentId, + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: request.forkId, + }); + assert.equal(target.ref.revision, 'r1'); + assert.notDeepEqual(target.ref, created.ref); + + // Simulates a retry after target creation but before operation completion. + assert.deepEqual( + await repository.createSession({ + sessionId: request.targetSessionId, + agentId: created.agentId, + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: request.forkId, + }), + target, + ); + const completed = await repository.completeFork({ forkId: request.forkId }); + assert.equal(completed.state, 'completed'); + assert.deepEqual(completed.target, target.ref); + assert.deepEqual(await repository.completeFork({ forkId: request.forkId }), completed); + + await assert.rejects( + repository.claimFork({ ...request, targetSessionId: 'other-session' }), + hasRepositoryCode('idempotency_conflict'), + ); + }); +}); + +test('never adopts a target created by a different Fork operation', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + await repository.claimFork({ + forkId: 'fork-owner', + source: created.ref, + targetSessionId: 'session-b', + }); + await repository.claimFork({ + forkId: 'fork-contender', + source: created.ref, + targetSessionId: 'session-b', + }); + const targetCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-target.tar.zst', 'fork target'), + ); + await repository.createSession({ + sessionId: 'session-b', + agentId: created.agentId, + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: 'fork-owner', + }); + + await assert.rejects( + repository.createSession({ + sessionId: 'session-b', + agentId: created.agentId, + checkpoint: targetCheckpoint, + forkedFrom: created.ref, + createdByForkId: 'fork-contender', + }), + hasRepositoryCode('session_already_exists'), + ); + await assert.rejects( + repository.completeFork({ forkId: 'fork-contender' }), + hasRepositoryCode('idempotency_conflict'), + ); + }); +}); + +test('keeps a Fork pending until its target checkpoint is readable', async () => { + await withTemporaryDirectory(async (directory) => { + const base = createInMemoryImmutableObjectStore(); + let failedObjectRef: string | undefined; + const objectStore: ImmutableObjectStore = { + publish: (input) => base.publish(input), + assertReadable: async (ref) => { + if (ref.objectRef === failedObjectRef) { + throw new SessionRepositoryError('integrity_mismatch', 'Fork target is damaged'); + } + await base.assertReadable(ref); + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const sourceCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-source.tar.zst', 'fork source'), + ); + const source = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: sourceCheckpoint, + }); + await repository.claimFork({ + forkId: 'fork-a', + source: source.ref, + targetSessionId: 'session-b', + }); + const targetCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-target.tar.zst', 'fork target'), + ); + await repository.createSession({ + sessionId: 'session-b', + agentId: source.agentId, + checkpoint: targetCheckpoint, + forkedFrom: source.ref, + createdByForkId: 'fork-a', + }); + + failedObjectRef = targetCheckpoint.manifest.objectRef; + await assert.rejects( + repository.completeFork({ forkId: 'fork-a' }), + hasRepositoryCode('integrity_mismatch'), + ); + failedObjectRef = undefined; + assert.equal((await repository.completeFork({ forkId: 'fork-a' })).state, 'completed'); + }); +}); + +test('uses independent CAS sequences for source and Fork target Sessions', async () => { + await withReadySession(async ({ repository, objectStore, directory, created }) => { + await repository.claimFork({ + forkId: 'fork-a', + source: created.ref, + targetSessionId: 'session-b', + }); + const forkCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'fork-target.tar.zst', 'fork target'), + ); + const target = await repository.createSession({ + sessionId: 'session-b', + agentId: created.agentId, + checkpoint: forkCheckpoint, + forkedFrom: created.ref, + createdByForkId: 'fork-a', + }); + const targetCheckpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'target-next.tar.zst', 'target next'), + ); + const advancedTarget = await repository.commit({ + sessionId: target.ref.sessionId, + expectedRevision: target.ref.revision, + checkpoint: targetCheckpoint, + }); + + assert.equal(advancedTarget.ref.revision, 'r2'); + assert.deepEqual(await repository.checkoutExact(created.ref), created); + }); +}); + +test('fails closed when a published Manifest or Bundle disappears or changes', async () => { + await withTemporaryDirectory(async (directory) => { + for (const target of ['manifest', 'bundle'] as const) { + for (const code of ['object_not_found', 'integrity_mismatch'] as const) { + const base = createInMemoryImmutableObjectStore(); + let failedObjectRef: string | undefined; + const objectStore: ImmutableObjectStore = { + publish: (input) => base.publish(input), + assertReadable: async (ref) => { + if (ref.objectRef === failedObjectRef) { + throw new SessionRepositoryError(code, 'Object changed after publication'); + } + await base.assertReadable(ref); + }, + materialize: (input) => base.materialize(input), + }; + const repository = createInMemorySessionRepository({ objectStore }); + const checkpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, `${target}-${code}.tar.zst`, `${target}-${code}`), + ); + const created = await repository.createSession({ + sessionId: `session-${target}-${code}`, + agentId: 'agent-a', + checkpoint, + }); + failedObjectRef = + target === 'manifest' + ? checkpoint.manifest.objectRef + : checkpoint.value.compatibilityBundle.objectRef; + + await assert.rejects(repository.checkoutExact(created.ref), hasRepositoryCode(code)); + } + } + }); +}); + +async function withReadySession( + operation: (context: { + repository: SessionRepository; + objectStore: ImmutableObjectStore; + directory: string; + checkpoint: StoredSessionCheckpoint; + created: Awaited>; + }) => Promise, +): Promise { + await withTemporaryDirectory(async (directory) => { + const objectStore = createInMemoryImmutableObjectStore(); + const repository = createInMemorySessionRepository({ objectStore }); + const checkpoint = await publishCheckpoint( + objectStore, + await writeArtifact(directory, 'initial.tar.zst', 'initial Bundle bytes'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint, + }); + await operation({ repository, objectStore, directory, checkpoint, created }); + }); +} + +function publishCheckpoint( + objectStore: ImmutableObjectStore, + compatibilityBundle: SessionBundleArtifact, +): Promise { + return publishSessionCheckpointV1({ objectStore, compatibilityBundle }); +} + +async function publishManifest( + objectStore: ImmutableObjectStore, + value: SessionCheckpointManifestV1, +): Promise { + const bytes = encodeSessionCheckpointManifestV1(value); + const input: ImmutableObjectInput = { + digest: digest(bytes), + bytes: bytes.byteLength, + mediaType: SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE, + source: { kind: 'bytes', value: bytes }, + }; + const manifest = await objectStore.publish(input); + await objectStore.assertReadable(manifest); + return { manifest, value }; +} + +async function withTemporaryDirectory( + operation: (directory: string) => Promise, +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'maka-session-repository-')); + try { + await operation(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function writeArtifact( + directory: string, + name: string, + contents: string, +): Promise { + const bytes = Buffer.from(contents); + const path = join(directory, name); + await writeFile(path, bytes); + return { + path, + archiveDigest: digest(bytes), + compressedBytes: bytes.byteLength, + decompressedTarBytes: bytes.byteLength, + payloadBytes: bytes.byteLength, + entryCount: 1, + }; +} + +function digest(value: Uint8Array | string): Sha256Digest { + return `sha256:${createHash('sha256').update(value).digest('hex')}` as Sha256Digest; +} + +function hasRepositoryCode(code: SessionRepositoryError['code']): (error: unknown) => boolean { + return (error: unknown): boolean => + error instanceof SessionRepositoryError && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/250f95dcb17ddd83609d9ccd3b63404084c7a8864b5a05a1bd00ee1d02352c2e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/250f95dcb17ddd83609d9ccd3b63404084c7a8864b5a05a1bd00ee1d02352c2e.source new file mode 100644 index 0000000000..1f87d66307 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/250f95dcb17ddd83609d9ccd3b63404084c7a8864b5a05a1bd00ee1d02352c2e.source @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import { link, rename, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { readStableBoundedFile, type StableBoundedFileHandle } from './stable-storage.js'; + +export interface MarkerFileHandle extends StableBoundedFileHandle { + writeFile(data: string, encoding: 'utf8'): Promise; + sync(): Promise; + close(): Promise; +} + +export interface MarkerFileDependencies { + open(path: string, flags: string | number, mode?: number): Promise; + randomUUID(): string; +} + +const openMarkerFile = fs.promises.open.bind(fs.promises); +const defaultDependencies: MarkerFileDependencies = { + // Capture once so later-loaded code cannot replace the marker authority's + // filesystem primitive. Race fixtures interpose before dynamically importing + // this module and are captured at the same boundary. + open: openMarkerFile, + randomUUID, +}; + +export interface ReadBoundedMarkerFileInput { + path: string; + maxBytes: number; + invalidFile(): Error; +} + +export async function readBoundedMarkerFile( + input: ReadBoundedMarkerFileInput, + dependencies: Partial = {}, +): Promise { + const deps = { ...defaultDependencies, ...dependencies }; + const contents = await readStableBoundedFile(input, { open: deps.open }); + return contents.toString('utf8'); +} + +export interface PublishMarkerFileInput { + root: string; + markerFile: string; + contents: string; + maxBytes: number; + publication: 'create' | 'replace'; + beforePublish?(): Promise; + invalidFile(): Error; +} + +export async function publishMarkerFile( + input: PublishMarkerFileInput, + dependencies: Partial = {}, +): Promise<'published' | 'already_exists'> { + const deps = { ...defaultDependencies, ...dependencies }; + if (Buffer.byteLength(input.contents, 'utf8') > input.maxBytes) { + throw input.invalidFile(); + } + + const markerPath = join(input.root, input.markerFile); + const tempPath = join(input.root, `${input.markerFile}.${process.pid}.${deps.randomUUID()}.tmp`); + let tempCreated = false; + try { + const handle = await deps.open(tempPath, 'wx', 0o600); + tempCreated = true; + try { + await handle.writeFile(input.contents, 'utf8'); + await handle.sync(); + await handle.close(); + } catch (error) { + await handle.close().catch(() => {}); + throw error; + } + + await input.beforePublish?.(); + if (input.publication === 'create') { + try { + await link(tempPath, markerPath); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + return 'already_exists'; + } + } else { + await rename(tempPath, markerPath); + tempCreated = false; + } + await syncDirectory(input.root, deps); + return 'published'; + } finally { + if (tempCreated) await unlinkIfPresent(tempPath); + } +} + +async function unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + } +} + +async function syncDirectory(path: string, deps: MarkerFileDependencies): Promise { + if (process.platform === 'win32') return; + const handle = await deps.open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/258c9aa4a8654c2f32f0db63a2b83d838a7158294c4163d816bfcb9c5416fb65.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/258c9aa4a8654c2f32f0db63a2b83d838a7158294c4163d816bfcb9c5416fb65.source new file mode 100644 index 0000000000..9141585e98 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/258c9aa4a8654c2f32f0db63a2b83d838a7158294c4163d816bfcb9c5416fb65.source @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; +import type { BoundedEvidenceReadResult, EvidenceReadBudget } from './agent-run-store.js'; +import { createSqliteRuntimeStore, type SqliteRuntimeStore } from './sqlite-runtime-store.js'; +import { + acquireOperationalStateDatabase, + OPERATIONAL_STATE_DATABASE_NAME, +} from './operational-state-store.js'; + +export type RuntimeEventPersistence = { + kind: 'sqlite'; + runtimeEventStore: SqliteRuntimeStore; + runtimeCommitStore: SqliteRuntimeStore; + close(): void; +}; + +export type RuntimeEventReadPersistence = { + kind: 'sqlite'; + runtimeEventStore: RuntimeEventReadStore; + close(): void; +}; + +export interface RuntimeEventReadStore { + listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; + readRuntimeEvents(sessionId: string, runId: string): Promise; + readRuntimeEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise>; + readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; + readSessionRuntimeEvents(sessionId: string): Promise; + /** Session-wide events with the ordinal that fixes their transcript order. */ + readSessionRuntimeEventEntries( + sessionId: string, + ): Promise>; +} + +export async function openRuntimeEventPersistence(input: { + workspaceRoot: string; +}): Promise { + const store = createWorkspaceRuntimeStore(input.workspaceRoot); + return { + kind: 'sqlite', + runtimeEventStore: store, + runtimeCommitStore: store, + close: () => store.close(), + }; +} + +export function createWorkspaceRuntimeStore(workspaceRoot: string): SqliteRuntimeStore { + const databaseLease = acquireOperationalStateDatabase(workspaceRoot); + return createSqliteRuntimeStore(join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), { + databaseLease, + }); +} + +export async function openRuntimeEventReadPersistence(input: { + workspaceRoot: string; +}): Promise { + const store = createSqliteRuntimeStore( + join(input.workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), + { readOnly: true }, + ); + return { + kind: 'sqlite', + runtimeEventStore: Object.freeze({ + listSessionInvocations: (sessionId: string) => store.listSessionInvocations(sessionId), + readRunInvocation: (sessionId: string, runId: string) => + store.readRunInvocation(sessionId, runId), + listSessionInvocationsBounded: (sessionId: string, limit: number) => + store.listSessionInvocationsBounded(sessionId, limit), + listSessionInvocationsPage: (sessionId: string, input: RuntimeInvocationPageInput) => + store.listSessionInvocationsPage(sessionId, input), + readInvocation: (sessionId: string, invocationId: string) => + store.readInvocation(sessionId, invocationId), + readRuntimeEvents: (sessionId: string, runId: string) => + store.readRuntimeEvents(sessionId, runId), + readRuntimeEventsBounded: (sessionId: string, runId: string, budget: EvidenceReadBudget) => + store.readRuntimeEventsBounded(sessionId, runId, budget), + readImmutableRuntimeEvents: (sessionId: string, runId: string) => + store.readImmutableRuntimeEvents(sessionId, runId), + readSessionRuntimeEvents: (sessionId: string) => store.readSessionRuntimeEvents(sessionId), + readSessionRuntimeEventEntries: (sessionId: string) => + store.readSessionRuntimeEventEntries(sessionId), + }), + close: () => store.close(), + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/284f55c5cab2947b5454dcd0aa7a03b747bf5f8a5630abe5f088c52ee3b99a28.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/284f55c5cab2947b5454dcd0aa7a03b747bf5f8a5630abe5f088c52ee3b99a28.source new file mode 100644 index 0000000000..7136b7df95 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/284f55c5cab2947b5454dcd0aa7a03b747bf5f8a5630abe5f088c52ee3b99a28.source @@ -0,0 +1,454 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The Run header as builds before the invocation opening fact wrote it. + * + * Nothing writes this shape any more and no live code reads it. It lives here, + * beside the migration that consumes it, because a persisted row still carries + * it: reading old data is the only remaining reason the shape exists, and + * keeping it out of `@maka/core` is what stops it from being a second live + * authority again. + */ + +import { + decodePersistedPermissionMode, + isPermissionMode, + type PermissionMode, +} from '@maka/core/permission'; +import { isCollaborationMode, type CollaborationMode } from '@maka/core/collaboration'; +import { + isAgentSwarmAuthorizationSource, + isEffectiveOrchestrationSource, + isOrchestrationMode, + type AgentSwarmAuthorizationSource, + type EffectiveOrchestrationSource, + type OrchestrationMode, +} from '@maka/core/orchestration'; +import type { PersistedBackendKind } from '@maka/core/session'; +import { + defineObjectShape, + hasExactShape, + isFiniteNumber, + isOptionalString, + isRecord, +} from '@maka/core/record-schema'; +import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; +import type { + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, + RuntimeInvocationOpenSource, + RuntimeInvocationRootAuthority, + RuntimeInvocationRoute, +} from '@maka/core/runtime-event'; + +const LEGACY_RUN_STATUSES = [ + 'created', + 'running', + 'waiting_for_user', + 'completed', + 'failed', + 'cancelled', +] as const; + +type LegacyRunStatus = (typeof LEGACY_RUN_STATUSES)[number]; + +interface LegacyContinuationSourceV1 { + sourceInvocationId: string; + sourceRunId: string; + sourceTurnId: string; + sourceRuntimeEventHighWater: number; +} + +interface LegacyContinuationSourceV2 extends LegacyContinuationSourceV1 { + protocol: 'continuation_source_v2'; + claimId: string; + boundaryDigest: `sha256:${string}`; + sourcePrefixDigest: `sha256:${string}`; + replayManifestDigest: `sha256:${string}`; +} + +type LegacyContinuationSource = LegacyContinuationSourceV1 | LegacyContinuationSourceV2; + +export interface LegacyRunHeader { + runId: string; + invocationId?: string; + sessionId: string; + turnId: string; + status: LegacyRunStatus; + backendKind: PersistedBackendKind; + llmConnectionId?: string; + providerStateIdentity?: `sha256:${string}`; + llmConnectionSlug: string; + modelId: string; + cwd: string; + workspaceIdentity?: string; + permissionMode: PermissionMode; + collaborationMode?: CollaborationMode; + orchestrationMode?: OrchestrationMode; + orchestrationSource?: EffectiveOrchestrationSource; + agentSwarmAuthorization?: AgentSwarmAuthorizationSource; + toolMode?: ToolMode; + createdAt: number; + updatedAt: number; + completedAt?: number; + parentRunId?: string; + resumedFromRunId?: string; + retriedFromRunId?: string; + agentId?: string; + agentName?: string; + parentTurnId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + parentSessionId?: string; + continuationSource?: LegacyContinuationSource; + scheduledTaskId?: string; + legacyAutomationId?: string; + goalId?: string; + agentGraphWakeId?: string; + agentGraphWakeAttemptId?: string; + rootExecutionKind?: 'context_compact'; + failureClass?: string; + failureMessage?: string; + abortSource?: string; + traceWriteError?: string; + /** + * The provider-dispatch snapshot the header era attached to every run that + * reached a provider. Nothing on the spine reads it back, so the migration + * only has to know it is there: a header carrying it is a well-formed + * header, not a corrupt one. + */ + runComposition?: object; +} + +const LEGACY_RUN_HEADER_SHAPE = defineObjectShape()( + [ + 'runId', + 'sessionId', + 'turnId', + 'status', + 'backendKind', + 'llmConnectionSlug', + 'modelId', + 'cwd', + 'permissionMode', + 'createdAt', + 'updatedAt', + ], + [ + 'invocationId', + 'llmConnectionId', + 'providerStateIdentity', + 'completedAt', + 'parentRunId', + 'resumedFromRunId', + 'retriedFromRunId', + 'agentId', + 'agentName', + 'parentTurnId', + 'retriedFromTurnId', + 'regeneratedFromTurnId', + 'branchOfTurnId', + 'parentSessionId', + 'workspaceIdentity', + 'continuationSource', + 'scheduledTaskId', + 'legacyAutomationId', + 'goalId', + 'agentGraphWakeId', + 'agentGraphWakeAttemptId', + 'rootExecutionKind', + 'failureClass', + 'failureMessage', + 'abortSource', + 'traceWriteError', + 'collaborationMode', + 'orchestrationMode', + 'orchestrationSource', + 'agentSwarmAuthorization', + 'toolMode', + 'runComposition', + ], +); + +const LEGACY_CONTINUATION_SOURCE_V1_SHAPE = defineObjectShape()( + ['sourceInvocationId', 'sourceRunId', 'sourceTurnId', 'sourceRuntimeEventHighWater'], + [], +); + +const LEGACY_CONTINUATION_SOURCE_V2_SHAPE = defineObjectShape()( + [ + 'protocol', + 'sourceInvocationId', + 'sourceRunId', + 'sourceTurnId', + 'sourceRuntimeEventHighWater', + 'claimId', + 'boundaryDigest', + 'sourcePrefixDigest', + 'replayManifestDigest', + ], + [], +); + +const RETIRED_RUN_STATUSES: Readonly> = { + waiting_permission: 'waiting_for_user', +}; + +export function decodePersistedLegacyRunHeader(persisted: unknown): LegacyRunHeader { + let value = persisted; + if ( + isRecord(value) && + value.automationId !== undefined && + value.legacyAutomationId === undefined + ) { + const { automationId, ...current } = value; + value = { ...current, legacyAutomationId: automationId }; + } + if (isRecord(value)) { + const status = + typeof value.status === 'string' + ? (RETIRED_RUN_STATUSES[value.status] ?? value.status) + : value.status; + const permissionMode = decodePersistedPermissionMode(value.permissionMode); + if (status !== value.status || permissionMode !== value.permissionMode) { + value = { ...value, status, permissionMode }; + } + } + return decodeLegacyRunHeader(value); +} + +function decodeLegacyRunHeader(value: unknown): LegacyRunHeader { + if (!isRecord(value) || !hasExactShape(value, LEGACY_RUN_HEADER_SHAPE)) { + throw new Error('Invalid AgentRun header schema'); + } + const valid = + typeof value.runId === 'string' && + typeof value.sessionId === 'string' && + typeof value.turnId === 'string' && + (LEGACY_RUN_STATUSES as readonly unknown[]).includes(value.status) && + isPersistedBackendKind(value.backendKind) && + (value.llmConnectionId === undefined || + (typeof value.llmConnectionId === 'string' && value.llmConnectionId.length > 0)) && + (value.providerStateIdentity === undefined || isSha256Digest(value.providerStateIdentity)) && + typeof value.llmConnectionSlug === 'string' && + typeof value.modelId === 'string' && + typeof value.cwd === 'string' && + isPermissionMode(value.permissionMode) && + (value.collaborationMode === undefined || isCollaborationMode(value.collaborationMode)) && + (value.orchestrationMode === undefined || isOrchestrationMode(value.orchestrationMode)) && + (value.orchestrationSource === undefined || + isEffectiveOrchestrationSource(value.orchestrationSource)) && + (value.agentSwarmAuthorization === undefined || + isAgentSwarmAuthorizationSource(value.agentSwarmAuthorization)) && + (value.rootExecutionKind === undefined || value.rootExecutionKind === 'context_compact') && + Number(value.scheduledTaskId !== undefined) + + Number(value.legacyAutomationId !== undefined) + + Number(value.goalId !== undefined) + + Number(value.agentGraphWakeId !== undefined) <= + 1 && + (value.toolMode === undefined || isToolMode(value.toolMode)) && + isFiniteNumber(value.createdAt) && + isFiniteNumber(value.updatedAt) && + isOptionalString(value.invocationId) && + (value.completedAt === undefined || isFiniteNumber(value.completedAt)) && + [ + value.parentRunId, + value.resumedFromRunId, + value.retriedFromRunId, + value.agentId, + value.agentName, + value.parentTurnId, + value.retriedFromTurnId, + value.regeneratedFromTurnId, + value.branchOfTurnId, + value.parentSessionId, + value.workspaceIdentity, + value.scheduledTaskId, + value.legacyAutomationId, + value.goalId, + value.agentGraphWakeId, + value.agentGraphWakeAttemptId, + value.failureClass, + value.failureMessage, + value.abortSource, + value.traceWriteError, + ].every(isOptionalString) && + (value.runComposition === undefined || isRecord(value.runComposition)) && + (value.continuationSource === undefined || + isLegacyContinuationSource(value.continuationSource)); + if (!valid) throw new Error('Invalid AgentRun header schema'); + return value as unknown as LegacyRunHeader; +} + +/** + * Project one legacy Run header onto its invocation opening fact. + * + * Route provenance fails closed. A header with no Connection identity cannot + * prove which endpoint and credential owned the run, so it projects as + * `unknown` rather than as an authenticated route; its transcript and tool + * evidence stay readable either way. + * + * Throws when a root authority marker is present but incomplete — that is + * corruption, and inventing a root would be worse than refusing one. + */ +export function invocationOpeningFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeEventInvocationOpenedContent { + const lineage: RuntimeInvocationLineage = { + ...(header.parentRunId !== undefined ? { parentRunId: header.parentRunId } : {}), + ...(header.resumedFromRunId !== undefined ? { resumedFromRunId: header.resumedFromRunId } : {}), + ...(header.retriedFromRunId !== undefined ? { retriedFromRunId: header.retriedFromRunId } : {}), + ...(header.parentTurnId !== undefined ? { parentTurnId: header.parentTurnId } : {}), + ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}), + ...(header.retriedFromTurnId !== undefined + ? { retriedFromTurnId: header.retriedFromTurnId } + : {}), + ...(header.regeneratedFromTurnId !== undefined + ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + : {}), + ...(header.branchOfTurnId !== undefined ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.agentId !== undefined ? { agentId: header.agentId } : {}), + ...(header.agentName !== undefined ? { agentName: header.agentName } : {}), + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: invocationRouteFromLegacyRunHeader(header), + configuration: { + cwd: header.cwd, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + orchestrationSource: header.orchestrationSource ?? 'session', + toolMode: header.toolMode ?? DEFAULT_TOOL_MODE, + ...(header.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: header.agentSwarmAuthorization } + : {}), + ...(header.workspaceIdentity !== undefined + ? { workspaceIdentity: header.workspaceIdentity } + : {}), + }, + root: invocationRootFromLegacyRunHeader(header), + source: invocationOpenSourceFromLegacyRunHeader(header), + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; +} + +function invocationRouteFromLegacyRunHeader(header: LegacyRunHeader): RuntimeInvocationRoute { + if (header.llmConnectionId === undefined) { + return { + provenance: 'unknown', + backendKind: header.backendKind, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + }; + } + return { + provenance: 'runtime', + backendKind: header.backendKind, + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + ...(header.providerStateIdentity !== undefined + ? { providerStateIdentity: header.providerStateIdentity } + : {}), + }; +} + +function invocationRootFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeInvocationRootAuthority { + if (header.scheduledTaskId !== undefined) { + return { kind: 'scheduled_task', scheduledTaskId: header.scheduledTaskId }; + } + if (header.goalId !== undefined) return { kind: 'goal', goalId: header.goalId }; + if (header.legacyAutomationId !== undefined) { + return { kind: 'legacy_automation', legacyAutomationId: header.legacyAutomationId }; + } + if (header.agentGraphWakeId !== undefined) { + if (header.agentGraphWakeAttemptId === undefined) { + throw new Error(`AgentRun ${header.runId} has a graph wake with no delivery attempt`); + } + return { + kind: 'agent_graph_supervisor_wake', + wakeId: header.agentGraphWakeId, + attemptId: header.agentGraphWakeAttemptId, + }; + } + if (header.rootExecutionKind === 'context_compact') return { kind: 'context_compact' }; + return { kind: 'user' }; +} + +function invocationOpenSourceFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeInvocationOpenSource { + const source = header.continuationSource; + if (!source) return { kind: 'fresh' }; + const v2 = 'protocol' in source ? source : undefined; + return { + kind: 'continuation', + sourceInvocationId: source.sourceInvocationId, + sourceRunId: source.sourceRunId, + sourceTurnId: source.sourceTurnId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + ...(v2 ? { claimId: v2.claimId, boundaryDigest: v2.boundaryDigest } : {}), + }; +} + +function isLegacyContinuationSource(value: unknown): value is LegacyContinuationSource { + if (!isRecord(value)) return false; + const common = + typeof value.sourceInvocationId === 'string' && + typeof value.sourceRunId === 'string' && + typeof value.sourceTurnId === 'string' && + typeof value.sourceRuntimeEventHighWater === 'number' && + Number.isSafeInteger(value.sourceRuntimeEventHighWater) && + value.sourceRuntimeEventHighWater >= 0; + if (!common) return false; + if (hasExactShape(value, LEGACY_CONTINUATION_SOURCE_V1_SHAPE)) return true; + return ( + hasExactShape(value, LEGACY_CONTINUATION_SOURCE_V2_SHAPE) && + value.protocol === 'continuation_source_v2' && + typeof value.claimId === 'string' && + value.claimId.length > 0 && + typeof value.sourceInvocationId === 'string' && + value.sourceInvocationId.length > 0 && + typeof value.sourceRunId === 'string' && + value.sourceRunId.length > 0 && + typeof value.sourceTurnId === 'string' && + value.sourceTurnId.length > 0 && + typeof value.sourceRuntimeEventHighWater === 'number' && + value.sourceRuntimeEventHighWater > 0 && + isSha256Digest(value.boundaryDigest) && + isSha256Digest(value.sourcePrefixDigest) && + isSha256Digest(value.replayManifestDigest) && + value.replayManifestDigest === value.boundaryDigest + ); +} + +/** `'fake'` stays accepted: runs written by builds that shipped FakeBackend must keep decoding (#3211). */ +function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { + return value === 'ai-sdk' || value === 'fake'; +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2ab9a516edf02f01eebb2aab9f1e821de1fae75f04446e6d2e574a80b19ccb7e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2ab9a516edf02f01eebb2aab9f1e821de1fae75f04446e6d2e574a80b19ccb7e.source new file mode 100644 index 0000000000..72945ebd84 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2ab9a516edf02f01eebb2aab9f1e821de1fae75f04446e6d2e574a80b19ccb7e.source @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + DeepResearchArtifactRef, + DeepResearchChecklistItem, + DeepResearchCheckpoint, + DeepResearchChangedEvent, + DeepResearchEvent, + DeepResearchHandoff, + DeepResearchMutationContext, + DeepResearchRun, + DeepResearchScopeLevel, + DeepResearchStep, + DeepResearchStore, +} from '@maka/core/deep-research-run'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { + createSqliteDeepResearchStore, + type SqliteDeepResearchStore, +} from './deep-research-store.js'; + +const writerBrand: unique symbol = Symbol('InteractiveDeepResearchStoreWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveDeepResearchStoreWriter extends DeepResearchStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + purgeSessionState(sessionId: string): Promise; + close(): void; +} + +export function authenticateInteractiveDeepResearchStoreWriter( + writer: InteractiveDeepResearchStoreWriter, +): InteractiveDeepResearchStoreWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive Deep Research Store writer', + ); + } + return writer; +} + +export async function openInteractiveDeepResearchStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let store: SqliteDeepResearchStore | undefined; + try { + store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const opened = createSqliteDeepResearchStore(root); + try { + await opened.ready(); + return opened; + } catch (error) { + opened.close(); + throw error; + } + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: SqliteDeepResearchStore, +): InteractiveDeepResearchStoreWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Deep Research Store writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractiveDeepResearchStoreWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + read: (sessionId) => run(() => store.read(sessionId)), + readEvents: (sessionId) => run(() => store.readEvents(sessionId)), + start: (sessionId, objective, scopeLevel, context) => + run(() => store.start(sessionId, objective, scopeLevel, cloneContext(context))), + recordArtifact: (sessionId, artifact, context) => + run(() => store.recordArtifact(sessionId, cloneArtifact(artifact), cloneContext(context))), + updateChecklist: (sessionId, item, context) => + run(() => store.updateChecklist(sessionId, cloneChecklist(item), cloneContext(context))), + recordStep: (sessionId, step, context) => + run(() => store.recordStep(sessionId, cloneStep(step), cloneContext(context))), + recordCheckpoint: (sessionId, checkpoint, context) => + run(() => + store.recordCheckpoint(sessionId, cloneCheckpoint(checkpoint), cloneContext(context)), + ), + complete: (sessionId, reportArtifactId, handoff, context) => + run(() => + store.complete(sessionId, reportArtifactId, cloneHandoff(handoff), cloneContext(context)), + ), + subscribe: (listener) => store.subscribe(listener), + purgeSessionState: (sessionId) => run(() => store.purgeSessionState(sessionId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + return Object.freeze(writer); +} + +function cloneContext( + context: DeepResearchMutationContext | undefined, +): DeepResearchMutationContext | undefined { + return context ? { ...context } : undefined; +} + +function cloneArtifact(artifact: DeepResearchArtifactRef): DeepResearchArtifactRef { + return structuredClone(artifact); +} + +function cloneChecklist( + item: Omit, +): Omit { + return structuredClone(item); +} + +function cloneStep( + step: Omit, +): Omit { + return structuredClone(step); +} + +function cloneCheckpoint( + checkpoint: Omit, +): Omit { + return structuredClone(checkpoint); +} + +function cloneHandoff(handoff: DeepResearchHandoff): DeepResearchHandoff { + return structuredClone(handoff); +} + +export type { + DeepResearchChangedEvent, + DeepResearchEvent, + DeepResearchRun, + DeepResearchScopeLevel, +}; diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2bcfbef89ab65fd00a08bdcd52fef1a78dd42a822e5c01e6f1200ce39c281e81.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2bcfbef89ab65fd00a08bdcd52fef1a78dd42a822e5c01e6f1200ce39c281e81.source new file mode 100644 index 0000000000..9f5353bb5b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2bcfbef89ab65fd00a08bdcd52fef1a78dd42a822e5c01e6f1200ce39c281e81.source @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { fork } from 'node:child_process'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { acquireProcessLifetimeOwner } from '../process-lifetime-owner.js'; + +test('claims an owner only after its process exits', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-')); + const child = fork( + new URL('./fixtures/process-lifetime-owner-holder.js', import.meta.url), + [root], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + const observer = await acquireProcessLifetimeOwner(root); + const competingObserver = await acquireProcessLifetimeOwner(root); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await observer.close().catch(() => undefined); + await competingObserver.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + }); + + const reference = await waitForReference(t, child); + assert.equal(await observer.tryClaimReleased(reference), undefined); + + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', () => resolve())); + + const claims = await Promise.all([ + observer.tryClaimReleased(reference), + competingObserver.tryClaimReleased(reference), + ]); + const acquired = claims.filter((claim) => claim !== undefined); + assert.equal(acquired.length, 1); + await acquired[0]?.retire(); +}); + +test('releases its owner reference on graceful close', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-')); + const owner = await acquireProcessLifetimeOwner(root); + const observer = await acquireProcessLifetimeOwner(root); + t.after(async () => { + await owner.close().catch(() => undefined); + await observer.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + }); + + assert.equal(await observer.tryClaimReleased(owner.reference), undefined); + await owner.close(); + const claim = await observer.tryClaimReleased(owner.reference); + assert.ok(claim); + await claim.retire(); +}); + +test('retires an unreferenced owner file after an unclean process exit', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-')); + const child = fork( + new URL('./fixtures/process-lifetime-owner-holder.js', import.meta.url), + [root], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + }); + + const deadReference = await waitForReference(t, child); + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', () => resolve())); + + const successor = await acquireProcessLifetimeOwner(root); + t.after(() => successor.close()); + await successor.retireUnreferencedReleasedOwners(new Set()); + const files = await readdir(join(root, 'owners')); + assert.deepEqual(files, [`${successor.reference.slice('lock-v1:'.length)}.lease`]); + assert.equal(files.includes(`${deadReference.slice('lock-v1:'.length)}.lease`), false); +}); + +test('rejects owner references outside its versioned namespace', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-')); + const owner = await acquireProcessLifetimeOwner(root); + t.after(async () => { + await owner.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + }); + + await assert.rejects(owner.tryClaimReleased('../other'), /Invalid process lifetime owner/); +}); + +async function waitForReference(t: TestContext, child: ReturnType): Promise { + return new Promise((resolve, reject) => { + const onMessage = (message: unknown) => { + if ( + typeof message === 'object' && + message !== null && + 'reference' in message && + typeof message.reference === 'string' + ) { + resolve(message.reference); + } else { + reject(new Error(`Unexpected child message: ${String(message)}`)); + } + }; + child.once('message', onMessage); + child.once('error', reject); + child.once('exit', (code, signal) => { + reject(new Error(`Owner exited before acquisition (${String(code)}, ${signal})`)); + }); + t.after(() => child.off('message', onMessage)); + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2c739d41de1f9960f4b6ecd9506ad1d99f3577badf2b139568bfebbef86125b0.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2c739d41de1f9960f4b6ecd9506ad1d99f3577badf2b139568bfebbef86125b0.source new file mode 100644 index 0000000000..8497b706b4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/2c739d41de1f9960f4b6ecd9506ad1d99f3577badf2b139568bfebbef86125b0.source @@ -0,0 +1,2273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import type { DatabaseSync } from 'node:sqlite'; +import { decodeAgentRunEvent, decodeRuntimeEvent } from './execution-record-codec.js'; +import { immutableSteeringMessageId } from './runtime-event-invariants.js'; +import { + normalizeSubmittedTurnIntent, + submittedTurnIntentsEqual, + type SubmittedTurnIntent, +} from './submitted-turn-intent.js'; +import { assertNoReservedWorkspaceAuthorityAppend } from './runtime-event-authority.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { + assertEvidenceReadBudget, + measureEvidenceRows, + type BoundedEvidenceReadResult, + type EvidenceReadBudget, +} from './bounded-evidence.js'; +import { + decodeSkillInvocationResult, + type SkillInvocationResult, +} from '@maka/core/skill-invocation'; +import { DurableStoreWriteError, type RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; +import { + aggregateMessageContents, + decodeMessageContent, + isCanonicalAttachmentRef, + messageContentsEqual, + type AttachmentRef, + type MessageContent, +} from '@maka/core/events'; +import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; +import { isTerminalRuntimeEvent, type RuntimeEvent } from '@maka/core/runtime-event'; +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; +import { + LATEST_CONTEXT_PROJECTION_TYPE, + RUN_COMPOSITION_RECORDED_EVENT_TYPE, + supersedesLatestContext, + type LatestContextOrder, + type AgentRunProjectionKey, + type AgentRunAppendOptions, + type LatestContextProjectionInput, + type AgentRunEvent, + type AgentRunEventType, + type AgentRunStore, + type EmittedAgentRunEvent, +} from '@maka/core/agent-run'; +import { + isSessionInlineInvocation, + type RootExecutionDescriptor, +} from '@maka/core/runtime-invocation'; +import { + decodeRuntimeInvocationOpened, + runtimeEventInvocationOpening, +} from '@maka/core/runtime-event'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { + isOrchestrationMode, + isTurnOrchestrationSource, + type TurnOrchestration, +} from '@maka/core/orchestration'; +import { + scanToolLedger, + validateGenericToolLedgerAppend, + validateToolLedgerTransition, +} from '@maka/core/tool-ledger-scanner'; + +const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +export const ROOT_TURN_ADMISSION_SCHEMA_VERSION = 1 as const; +export const ROOT_TURN_ADMISSION_MAX_SOURCE_MESSAGES = 64; +export const ROOT_TURN_ADMISSION_MAX_CONTENT_BYTES = 64 * 1024; +export const ROOT_TURN_ADMISSION_MAX_RECORD_BYTES = 1024 * 1024; +const ROOT_TURN_ADMISSION_MAX_AGGREGATED_ATTACHMENTS = + ROOT_TURN_ADMISSION_MAX_SOURCE_MESSAGES * MAX_ATTACHMENT_COUNT; + +export interface RootTurnSourceMessage { + messageId: string; + content: MessageContent; + submittedContentDigest?: `sha256:${string}`; + /** The original placement before queue promotion; absent legacy records use `placement`. */ + submittedPlacement?: 'current_turn' | 'next_turn'; + /** The admission-time Skill outcome for this exact source Message. */ + skillInvocation?: SkillInvocationResult; + /** + * The exact-Turn intent this Message was submitted with — the Skill ids and + * the orchestration override. Content and placement do not describe it, so + * without this a retry that asks for a different execution mode under the + * same Message identity aliases the earlier success. Absent on a record + * written for a submit that carried no exact intent. + */ + submittedIntent?: SubmittedTurnIntent; + placement: 'current_turn' | 'next_turn'; + disposition: 'steering' | 'followup' | 'turn_started'; +} + +export interface RootTurnAdmission { + schemaVersion: typeof ROOT_TURN_ADMISSION_SCHEMA_VERSION; + sessionId: string; + turnId: string; + runId: string; + userMessageId: string | null; + execution: RootExecutionDescriptor; + previousRootTurnId: string | null; + normalizedInput: MessageContent | null; + turnOrchestration?: TurnOrchestration; + skillInvocation?: SkillInvocationResult; + authorization?: RootTurnAdmissionAuthorization; + sourceMessages: readonly RootTurnSourceMessage[]; + admittedAt: number; +} + +export interface RootTurnAdmissionAuthorization { + readonly kind: 'session_turn_access_request'; + readonly requestId: string; + readonly principalId: string; + readonly grantId: string; + readonly approvedAt: number; + readonly approvedBy: string; +} + +export interface RootTurnStartRejection { + schemaVersion: 1; + sessionId: string; + turnId: string; + execution: RootExecutionDescriptor; + skillInvocation: SkillInvocationResult; + rejectedAt: number; +} + +export interface AdmitRootTurnInput { + sessionId: string; + turnId: string; + proposedRunId: string; + proposedUserMessageId: string | null; + execution: RootExecutionDescriptor; + previousRootTurnId: string | null; + normalizedInput: MessageContent | null; + turnOrchestration?: TurnOrchestration; + skillInvocation?: SkillInvocationResult; + authorization?: RootTurnAdmissionAuthorization; + sourceMessages: readonly RootTurnSourceMessage[]; + admittedAt: number; +} + +export interface CommitRootTurnStartRejectionInput { + sessionId: string; + turnId: string; + execution: RootExecutionDescriptor; + skillInvocation: SkillInvocationResult; + rejectedAt: number; +} + +export type CommitRootTurnStartRejectionResult = + | { kind: 'committed'; rejection: RootTurnStartRejection } + | { kind: 'existing'; rejection: RootTurnStartRejection } + | { kind: 'conflict'; rejection: RootTurnStartRejection }; + +export interface RootTurnSourceMessageReceipt { + admission: RootTurnAdmission; + sourceMessage: RootTurnSourceMessage; +} + +export interface ImmutableSteeringMessageProof { + event: RuntimeEvent; +} + +export type AdmitRootTurnResult = + | { kind: 'admitted'; admission: RootTurnAdmission } + | { kind: 'existing'; admission: RootTurnAdmission } + | { kind: 'conflict'; admission: RootTurnAdmission }; + +export interface RootTurnAdmissionStore { + admitRootTurn(input: AdmitRootTurnInput): Promise; + readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; + readRootTurnSourceMessageReceipt( + sessionId: string, + sourceMessageId: string, + ): Promise; + listRootTurnAdmissionsForRecovery(sessionId: string): Promise; +} + +export interface RootTurnStartRejectionStore { + readRootTurnStartRejection( + sessionId: string, + turnId: string, + ): Promise; + commitRootTurnStartRejection( + input: CommitRootTurnStartRejectionInput, + ): Promise; +} + +export interface DurableAgentRunStore + extends AgentRunStore, + RootTurnAdmissionStore, + RootTurnStartRejectionStore { + readEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise>; + readEventsByTypeBounded( + sessionId: string, + runId: string, + type: AgentRunEventType, + budget: EvidenceReadBudget, + ): Promise>; + readEventsForRecovery(sessionId: string, runId: string): Promise; + readEventsForEvidence(sessionId: string, runId: string): Promise; + readEventProjection( + sessionId: string, + type: AgentRunProjectionKey, + ): Promise; + readEventLedgerRevision(sessionId: string): Promise; + repairEventProjection( + sessionId: string, + type: AgentRunProjectionKey, + event: AgentRunEvent | null, + options: { ifLedgerRevision: string; replaceEventId?: string }, + ): Promise; + ready?(): Promise; + close?(): void; +} + +export type { BoundedEvidenceReadResult, EvidenceReadBudget } from './bounded-evidence.js'; + +export interface ConversationCopyRuntimeEventBatch { + readonly runId: string; + readonly events: readonly RuntimeEvent[]; +} + +export interface RuntimeEventScanBudget { + readonly maxBatchBytes: number; + readonly maxRecordBytes: number; + readonly maxImmutableRecords: number; + readonly maxImmutableBytes: number; + readonly maxPartialRecords: number; + readonly maxPartialBytes: number; +} + +export type RuntimeEventScanResult = { readonly status: 'complete' | 'limit_exceeded' }; + +export interface DurableRuntimeEventStore extends RuntimeEventStore { + listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; + /** Visit one ordered, bounded SQLite snapshot without retaining the immutable ledger. */ + scanRuntimeEvents( + sessionId: string, + runId: string, + budget: RuntimeEventScanBudget, + visit: (events: readonly RuntimeEvent[]) => void, + ): Promise; + readRuntimeEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise>; + importConversationCopyRuntimeEvents( + sessionId: string, + batches: readonly ConversationCopyRuntimeEventBatch[], + ): Promise; + readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; + readImmutableSteeringMessageProof( + sessionId: string, + messageId: string, + ): Promise; + repairImmutableSteeringMessageProofsForRecovery(sessionId: string): Promise; +} + +interface RuntimePartialSnapshot { + version: 1; + event: RuntimeEvent; + afterEventId?: string; +} + +class RuntimeEventPostEffectError extends Error { + readonly name = 'RuntimeEventPostEffectError'; + + constructor( + message: string, + readonly cause: DurableStoreWriteError, + ) { + super(message); + } +} + +export function createSqliteAgentRunStore(workspaceRoot: string): DurableAgentRunStore { + return new SqliteAgentRunStore(workspaceRoot); +} + +class SqliteAgentRunStore implements DurableAgentRunStore { + readonly #lease: OperationalStateDatabaseLease; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + } + + ready(): Promise { + return Promise.resolve(); + } + + async appendEvent( + sessionId: string, + runId: string, + event: EmittedAgentRunEvent, + options: AgentRunAppendOptions = {}, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + this.#lease.transaction('write', () => { + const anchor = readSqliteRunAnchor(this.#lease.database, sessionId, runId); + this.#openLedgerStream(sessionId, runId, anchor.openedAt); + const normalized = decodeAgentRunEvent(JSON.parse(JSON.stringify(event, sanitizeJson)), { + sessionId, + runId, + turnId: anchor.turnId, + }); + const type = normalized.type as AgentRunEventType; + if (type === RUN_COMPOSITION_RECORDED_EVENT_TYPE) { + // Write-once, enforced where the record lives. The composition is what + // the run was dispatched against; a second, different one would claim + // the run ran on a prompt and tool surface it never saw. An identical + // re-append is the writer retrying, so it is absorbed rather than + // refused. + const recorded = readSqliteRunCompositionEvent(this.#lease.database, sessionId, runId); + if (recorded) { + if (!isDeepStrictEqual(recorded.data, normalized.data)) { + throw new Error('AgentRun Run Composition is immutable'); + } + return; + } + } + const projectsCheckpoint = type === 'history_compact_checkpoint_recorded'; + const projection = projectsCheckpoint + ? inspectSqliteAgentRunProjection(this.#lease.database, sessionId, type) + : undefined; + insertAgentRunEvent(this.#lease.database, normalized); + if (projection && projection.state !== 'malformed') { + const current = projectionValue(projection); + const row = shouldPreserveCheckpointProjectionDuringAppend(current, normalized) + ? current! + : normalized; + writeSqliteAgentRunProjection(this.#lease.database, sessionId, type, row); + } + // Derived state, committed with the event that authorises it (#2323). + // Inside THIS transaction, so the projection cannot outlive a metering + // append that failed, nor describe a request the ledger never recorded. + // + // Skipped for a subagent's run: those requests are real, but presenting + // one as the SESSION's latest context attributes another agent's prompt + // to this one. The opening fact is already loaded here, so the check is + // free. + const latestContext = options.latestContext; + if (latestContext && anchor.sessionInline) { + this.#writeLatestContextProjection(sessionId, normalized, latestContext); + } + }); + } + + /** + * Give this run's ledger its stream row, and the Session its first one. + * + * The row carries no semantic state: it is the parent `core_agent_run_events` + * hangs off and the place the model-call high water lives. Creating it on the + * first append is what stops it from being a second record of the run's + * existence — the opening fact already is that. + * + * The Session's first stream also initialises the compaction-checkpoint + * projection to an explicit empty, which is how a reader tells "no checkpoint + * yet" from "projection never built". + */ + #openLedgerStream(sessionId: string, runId: string, createdAt: number): void { + const inserted = this.#lease.database + .prepare( + 'INSERT OR IGNORE INTO core_agent_runs(session_id, run_id, created_at) VALUES (?, ?, ?)', + ) + .run(sessionId, runId, createdAt); + if (inserted.changes !== 1) return; + const count = this.#lease.database + .prepare('SELECT COUNT(*) AS count FROM core_agent_runs WHERE session_id = ?') + .get(sessionId) as { count?: unknown }; + if (count.count !== 1) return; + const projection = this.#lease.database + .prepare(` + SELECT 1 AS present + FROM core_agent_run_projections + WHERE session_id = ? AND event_type = 'history_compact_checkpoint_recorded' + `) + .get(sessionId); + if (projection) return; + this.#lease.database + .prepare(` + INSERT INTO core_agent_run_projections(session_id, event_type, event_json) + VALUES (?, 'history_compact_checkpoint_recorded', NULL) + `) + .run(sessionId); + } + + /** + * Monotonic by the request's own completion, not by arrival. + * + * Overlapping turns append on independent queues, so a request that finished + * at 10 can arrive after one that finished at 20. Taking the newest arrival + * would move the answer backwards and leave a warm read disagreeing with a + * cold rebuild of the same ledger. Ties break on `attemptId` so two requests + * sharing a millisecond still order the same way everywhere. + */ + #writeLatestContextProjection( + sessionId: string, + event: AgentRunEvent, + latest: LatestContextProjectionInput, + ): void { + const inspected = inspectSqliteAgentRunProjection( + this.#lease.database, + sessionId, + LATEST_CONTEXT_PROJECTION_TYPE, + ); + // The canonical append must survive a damaged derived row, but the row's + // ordering is unknowable. Leave it untouched until a ledger rebuild can + // select the real latest attempt and repair it without guessing. + if (inspected.state === 'malformed') return; + const existing = projectionValue(inspected); + // Compared against the stored row's own completion, which the snapshot + // carries — not against an ordering field the row does not have, which is + // how the first version of this guard silently never fired. The rule + // itself is shared with the cold rebuild, so the two cannot disagree about + // which request is the latest one. + const current = existing?.data as { completedAt?: unknown; attemptId?: unknown } | undefined; + if (current && typeof current.completedAt === 'number') { + const incumbent = { + completedAt: current.completedAt, + attemptId: String(current.attemptId ?? ''), + }; + const arriving = { completedAt: latest.orderedAt, attemptId: String(latest.attemptId) }; + if (!supersedesLatestContext(arriving, incumbent)) return; + } + writeSqliteAgentRunProjection(this.#lease.database, sessionId, LATEST_CONTEXT_PROJECTION_TYPE, { + ...event, + type: LATEST_CONTEXT_PROJECTION_TYPE, + id: `latest-context-${latest.attemptId}`, + data: latest.snapshot, + }); + } + + async readEvents(sessionId: string, runId: string): Promise { + return this.readEventsForRecovery(sessionId, runId); + } + + async readEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise> { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + assertEvidenceReadBudget(budget); + return readBoundedSqliteAgentRunEvents(this.#lease.database, sessionId, runId, budget); + } + + async readEventsByTypeBounded( + sessionId: string, + runId: string, + type: AgentRunEventType, + budget: EvidenceReadBudget, + ): Promise> { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + assertEvidenceReadBudget(budget); + return readBoundedSqliteAgentRunEvents(this.#lease.database, sessionId, runId, budget, type); + } + + async readEventsForRecovery(sessionId: string, runId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + return readSqliteAgentRunEvents(this.#lease.database, sessionId, runId); + } + + async readEventsForEvidence(sessionId: string, runId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + return readSqliteAgentRunEventsForEvidence(this.#lease.database, sessionId, runId); + } + + async readEventProjection( + sessionId: string, + type: AgentRunProjectionKey, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + return readSqliteAgentRunProjection(this.#lease.database, sessionId, type); + } + + async readEventLedgerRevision(sessionId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + return readSqliteAgentRunLedgerRevision(this.#lease.database, sessionId); + } + + async repairEventProjection( + sessionId: string, + type: AgentRunProjectionKey, + event: AgentRunEvent | null, + options: { ifLedgerRevision: string; replaceEventId?: string }, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + if (!options || typeof options.ifLedgerRevision !== 'string') { + throw new Error('AgentRun projection repair requires a canonical ledger revision'); + } + if (event !== null && !isProjectedAgentRunEvent(event, sessionId, type)) { + throw new Error(`Invalid AgentRun event projection repair for ${type}`); + } + this.#lease.transaction('write', () => { + if ( + readSqliteAgentRunLedgerRevision(this.#lease.database, sessionId) !== + options.ifLedgerRevision + ) { + return; + } + const inspected = inspectSqliteAgentRunProjection(this.#lease.database, sessionId, type); + const current = projectionValue(inspected); + if ( + inspected.state !== 'malformed' && + current?.id !== options.replaceEventId && + shouldPreserveProjectionDuringRepair(current, event, type) + ) { + return; + } + writeSqliteAgentRunProjection(this.#lease.database, sessionId, type, event); + }); + } + + async admitRootTurn(input: AdmitRootTurnInput): Promise { + const admission = normalizeAdmitRootTurnInput(input); + return this.#lease.transaction('write', () => { + const existing = readSqliteRootTurnAdmission( + this.#lease.database, + admission.sessionId, + admission.turnId, + ); + if (existing) { + return existing.previousRootTurnId === input.previousRootTurnId && + rootTurnAdmissionPayloadsEqual(existing, admission) + ? { kind: 'existing', admission: existing } + : { kind: 'conflict', admission: existing }; + } + if ( + readSqliteRootTurnStartRejection( + this.#lease.database, + admission.sessionId, + admission.turnId, + ) + ) { + throw new Error('Root Turn identity is already rejected'); + } + if (admission.execution.kind === 'safe_boundary_continuation') { + const sourceOwner = this.#lease.database + .prepare(` + SELECT turn_id + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + ORDER BY admitted_at, turn_id + LIMIT 1 + `) + .get( + admission.sessionId, + admission.execution.sourceTurnId, + admission.execution.sourceRunId, + ) as { turn_id?: unknown } | undefined; + if (typeof sourceOwner?.turn_id === 'string') { + const owner = readSqliteRootTurnAdmission( + this.#lease.database, + admission.sessionId, + sourceOwner.turn_id, + ); + if (!owner) throw new Error('Root continuation index has no durable admission'); + return { kind: 'conflict', admission: owner }; + } + } + for (const source of admission.sourceMessages) { + const proof = this.#lease.database + .prepare(` + SELECT turn_id + FROM core_root_source_message_proofs + WHERE session_id = ? AND message_id = ? + `) + .get(admission.sessionId, source.messageId) as { turn_id?: unknown } | undefined; + if (proof && proof.turn_id !== admission.turnId) { + throw new Error( + `Root source message identity belongs to both ${String(proof.turn_id)} and ${admission.turnId}`, + ); + } + } + this.#lease.database + .prepare(` + INSERT INTO core_root_turn_admissions( + session_id, turn_id, admitted_at, record_json + ) VALUES (?, ?, ?, ?) + `) + .run( + admission.sessionId, + admission.turnId, + admission.admittedAt, + JSON.stringify(admission), + ); + for (const source of admission.sourceMessages) { + this.#lease.database + .prepare(` + INSERT INTO core_root_source_message_proofs(session_id, message_id, turn_id) + VALUES (?, ?, ?) + `) + .run(admission.sessionId, source.messageId, admission.turnId); + } + return { kind: 'admitted', admission }; + }); + } + + async readRootTurnAdmission( + sessionId: string, + turnId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(turnId, 'Invalid turn id'); + return readSqliteRootTurnAdmission(this.#lease.database, sessionId, turnId); + } + + async readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(sourceTurnId, 'Invalid source turn id'); + assertSafeId(sourceRunId, 'Invalid source run id'); + const row = this.#lease.database + .prepare(` + SELECT turn_id, record_json + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + ORDER BY admitted_at, turn_id + LIMIT 1 + `) + .get(sessionId, sourceTurnId, sourceRunId) as + | { + turn_id?: unknown; + record_json?: unknown; + } + | undefined; + if (!row) return undefined; + if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite root turn continuation admission row'); + } + const admission = normalizeRootTurnAdmission( + JSON.parse(row.record_json), + sessionId, + row.turn_id, + ); + return admission; + } + + async readRootTurnStartRejection( + sessionId: string, + turnId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(turnId, 'Invalid turn id'); + return readSqliteRootTurnStartRejection(this.#lease.database, sessionId, turnId); + } + + async commitRootTurnStartRejection( + input: CommitRootTurnStartRejectionInput, + ): Promise { + const rejection = normalizeRootTurnStartRejection(input); + return this.#lease.transaction('write', () => { + const admission = readSqliteRootTurnAdmission( + this.#lease.database, + rejection.sessionId, + rejection.turnId, + ); + if (admission) { + throw new Error('Root Turn identity is already admitted'); + } + const existing = readSqliteRootTurnStartRejection( + this.#lease.database, + rejection.sessionId, + rejection.turnId, + ); + if (existing) { + return isDeepStrictEqual(existing.execution, rejection.execution) && + isDeepStrictEqual(existing.skillInvocation, rejection.skillInvocation) + ? { kind: 'existing', rejection: existing } + : { kind: 'conflict', rejection: existing }; + } + this.#lease.database + .prepare(` + INSERT INTO core_root_turn_start_rejections( + session_id, turn_id, rejected_at, record_json + ) VALUES (?, ?, ?, ?) + `) + .run( + rejection.sessionId, + rejection.turnId, + rejection.rejectedAt, + JSON.stringify(rejection), + ); + return { kind: 'committed', rejection }; + }); + } + + async readRootTurnSourceMessageReceipt( + sessionId: string, + sourceMessageId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(sourceMessageId, 'Invalid source message id'); + const row = this.#lease.database + .prepare(` + SELECT turn_id + FROM core_root_source_message_proofs + WHERE session_id = ? AND message_id = ? + `) + .get(sessionId, sourceMessageId) as { turn_id?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.turn_id !== 'string') throw new Error('Invalid root source message proof row'); + const admission = readSqliteRootTurnAdmission(this.#lease.database, sessionId, row.turn_id); + if (!admission) { + throw new Error(`Root source message proof references missing Turn ${row.turn_id}`); + } + const matches = admission.sourceMessages.filter( + (source) => source.messageId === sourceMessageId, + ); + if (matches.length !== 1) { + throw new Error( + `Root source message proof does not identify exactly one source: ${sourceMessageId}`, + ); + } + return Object.freeze({ admission, sourceMessage: matches[0]! }); + } + + async listRootTurnAdmissionsForRecovery(sessionId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + const rows = this.#lease.database + .prepare(` + SELECT turn_id, record_json + FROM core_root_turn_admissions + WHERE session_id = ? + ORDER BY admitted_at, turn_id + `) + .all(sessionId) as Array<{ turn_id?: unknown; record_json?: unknown }>; + const admissions = rows.map((row) => { + if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite root turn admission row'); + } + return normalizeRootTurnAdmission(JSON.parse(row.record_json), sessionId, row.turn_id); + }); + return orderRootTurnAdmissionChain(sessionId, admissions); + } + + close(): void { + this.#lease.close(); + } +} + +function readSqliteAgentRunLedgerRevision(db: DatabaseSync, sessionId: string): string { + const rows = db + .prepare(` + SELECT run.run_id, COUNT(event.sequence) AS event_count, + COALESCE(MAX(event.sequence), -1) AS high_water + FROM core_agent_runs AS run + LEFT JOIN core_agent_run_events AS event + ON event.session_id = run.session_id AND event.run_id = run.run_id + WHERE run.session_id = ? + GROUP BY run.run_id + ORDER BY run.run_id + `) + .all(sessionId) as Array<{ + run_id?: unknown; + event_count?: unknown; + high_water?: unknown; + }>; + return JSON.stringify( + rows.map((row) => { + if ( + typeof row.run_id !== 'string' || + typeof row.event_count !== 'number' || + !Number.isSafeInteger(row.event_count) || + typeof row.high_water !== 'number' || + !Number.isSafeInteger(row.high_water) + ) { + throw new Error('Invalid SQLite AgentRun ledger revision'); + } + return [row.run_id, row.event_count, row.high_water]; + }), + ); +} + +/** + * What the operational ledger needs to know about the run it belongs to. + * + * All of it is read off the event spine rather than kept beside the ledger: the + * turn the records must agree with, when the invocation opened, and whether its + * output is the owning Session's own conversation. Copying any of it into a + * second row is what made the Run header a rival authority. + * + * An invocation whose opening the migration could not project keeps a readable + * ledger: its turn and clock come from the events it does have, and it fails + * closed on the one judgement the opening was needed for. + */ +interface LedgerRunAnchor { + turnId: string; + openedAt: number; + sessionInline: boolean; +} + +function readSqliteRunAnchor(db: DatabaseSync, sessionId: string, runId: string): LedgerRunAnchor { + const opening = db + .prepare(` + SELECT turn_id, committed_at, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? AND event_kind = 'invocation_opened' + LIMIT 1 + `) + .get(sessionId, runId) as + | { turn_id: string; committed_at: number; payload_json: string } + | undefined; + if (opening) { + const content = runtimeEventInvocationOpening( + decodeRuntimeEvent(JSON.parse(opening.payload_json), { + sessionId, + runId, + turnId: opening.turn_id, + }), + ); + if (!content) throw new Error(`RuntimeEvent for run ${runId} is not an opening fact`); + return { + turnId: opening.turn_id, + openedAt: opening.committed_at, + sessionInline: isSessionInlineInvocation(content), + }; + } + const legacy = db + .prepare(` + SELECT turn_id, opened_at, opening_json + FROM runtime_legacy_invocation_openings + WHERE session_id = ? AND run_id = ? + LIMIT 1 + `) + .get(sessionId, runId) as + | { turn_id: string; opened_at: number; opening_json: string } + | undefined; + if (legacy) { + return { + turnId: legacy.turn_id, + openedAt: legacy.opened_at, + sessionInline: isSessionInlineInvocation( + decodeRuntimeInvocationOpened(JSON.parse(legacy.opening_json)), + ), + }; + } + const error = new Error(`Agent run does not exist: ${runId}`) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; +} + +function readSqliteAgentRunEvents( + db: DatabaseSync, + sessionId: string, + runId: string, +): AgentRunEvent[] { + const rows = db + .prepare(` + SELECT record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? + ORDER BY sequence + `) + .all(sessionId, runId) as Array<{ record_json?: unknown }>; + if (rows.length === 0) return []; + const anchor = readSqliteRunAnchor(db, sessionId, runId); + return rows.map((row) => { + if (typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite AgentRun event row'); + } + return decodeAgentRunEvent(JSON.parse(row.record_json), { + sessionId, + runId, + turnId: anchor.turnId, + }); + }); +} + +/** The run's one composition row, or nothing if it has not been dispatched yet. */ +function readSqliteRunCompositionEvent( + db: DatabaseSync, + sessionId: string, + runId: string, +): AgentRunEvent | undefined { + const row = db + .prepare(` + SELECT record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? AND event_type = ? + ORDER BY sequence + LIMIT 1 + `) + .get(sessionId, runId, RUN_COMPOSITION_RECORDED_EVENT_TYPE) as + | { record_json?: unknown } + | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite AgentRun event row'); + } + const anchor = readSqliteRunAnchor(db, sessionId, runId); + return decodeAgentRunEvent(JSON.parse(row.record_json), { + sessionId, + runId, + turnId: anchor.turnId, + }); +} + +function readSqliteAgentRunEventsForEvidence( + db: DatabaseSync, + sessionId: string, + runId: string, + type?: AgentRunEventType, +): AgentRunEvent[] { + const rows = ( + type === undefined + ? db + .prepare(` + SELECT sequence, record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? + ORDER BY sequence + `) + .all(sessionId, runId) + : db + .prepare(` + SELECT sequence, record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? AND event_type = ? + ORDER BY sequence + `) + .all(sessionId, runId, type) + ) as Array<{ sequence?: unknown; record_json?: unknown }>; + if (rows.length === 0) return []; + const anchor = readSqliteRunAnchor(db, sessionId, runId); + return rows.map((row) => { + const lineNumber = + typeof row.sequence === 'number' && Number.isSafeInteger(row.sequence) ? row.sequence + 1 : 0; + try { + if (typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite AgentRun event row'); + } + return decodeAgentRunEvent(JSON.parse(row.record_json), { + sessionId, + runId, + turnId: anchor.turnId, + }); + } catch (error) { + return { + type: 'event_corrupt', + id: `run-event-corrupt-${lineNumber}`, + runId, + sessionId, + turnId: anchor.turnId, + ts: anchor.openedAt, + message: error instanceof Error ? error.message : 'Invalid SQLite AgentRun event row', + data: { lineNumber }, + }; + } + }); +} + +function readBoundedSqliteAgentRunEvents( + db: DatabaseSync, + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + type?: AgentRunEventType, +): BoundedEvidenceReadResult { + const rows = ( + type === undefined + ? db + .prepare(` + SELECT length(CAST(record_json AS BLOB)) AS stored_bytes + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? + ORDER BY sequence + LIMIT ? + `) + .all(sessionId, runId, budget.maxRecords + 1) + : db + .prepare(` + SELECT length(CAST(record_json AS BLOB)) AS stored_bytes + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? AND event_type = ? + ORDER BY sequence + LIMIT ? + `) + .all(sessionId, runId, type, budget.maxRecords + 1) + ) as Array<{ stored_bytes?: unknown }>; + const measurement = measureEvidenceRows( + rows, + budget, + 'Invalid SQLite AgentRun evidence measurement row', + ); + if (!measurement) return { status: 'limit_exceeded' }; + return { + status: 'complete', + records: readSqliteAgentRunEventsForEvidence(db, sessionId, runId, type), + ...measurement, + }; +} + +function insertAgentRunEvent(db: DatabaseSync, event: AgentRunEvent): void { + const row = db + .prepare(` + SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? + `) + .get(event.sessionId, event.runId) as { sequence?: unknown }; + if (typeof row.sequence !== 'number' || !Number.isSafeInteger(row.sequence)) { + throw new Error('Invalid next AgentRun event sequence'); + } + db.prepare(` + INSERT INTO core_agent_run_events( + session_id, run_id, sequence, event_id, event_type, event_ts, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + event.sessionId, + event.runId, + row.sequence, + event.id, + event.type, + event.ts, + JSON.stringify(event, sanitizeJson), + ); + if (event.type === MODEL_CALL_ATTEMPT_EVENT_TYPE) { + const updated = db + .prepare(` + UPDATE core_agent_runs + SET latest_model_call_sequence = ? + WHERE session_id = ? AND run_id = ? + AND (latest_model_call_sequence IS NULL OR latest_model_call_sequence < ?) + `) + .run(row.sequence, event.sessionId, event.runId, row.sequence).changes; + if (updated !== 1) throw new Error('Failed to advance model-call authority high-water'); + } +} + +function readSqliteAgentRunProjection( + db: DatabaseSync, + sessionId: string, + // A projection key, not necessarily an event type: `latest_context` names a + // derived row nothing ever appends under (#2323). + type: string, +): AgentRunEvent | null | undefined { + const inspected = inspectSqliteAgentRunProjection(db, sessionId, type); + if (inspected.state === 'malformed') { + throw new Error(`Invalid AgentRun event projection for ${type}`); + } + return projectionValue(inspected); +} + +type SqliteAgentRunProjectionInspection = + | { state: 'missing' } + | { state: 'empty' } + | { state: 'malformed' } + | { state: 'valid'; event: AgentRunEvent }; + +function inspectSqliteAgentRunProjection( + db: DatabaseSync, + sessionId: string, + type: string, +): SqliteAgentRunProjectionInspection { + const row = db + .prepare(` + SELECT event_json + FROM core_agent_run_projections + WHERE session_id = ? AND event_type = ? + `) + .get(sessionId, type) as { event_json?: unknown } | undefined; + if (!row) return { state: 'missing' }; + if (row.event_json === null) return { state: 'empty' }; + if (typeof row.event_json !== 'string') return { state: 'malformed' }; + let event: unknown; + try { + event = JSON.parse(row.event_json); + } catch { + return { state: 'malformed' }; + } + if (!isProjectedAgentRunEvent(event, sessionId, type)) { + return { state: 'malformed' }; + } + return { state: 'valid', event }; +} + +function projectionValue( + inspected: SqliteAgentRunProjectionInspection, +): AgentRunEvent | null | undefined { + if (inspected.state === 'valid') return inspected.event; + return inspected.state === 'empty' ? null : undefined; +} + +function writeSqliteAgentRunProjection( + db: DatabaseSync, + sessionId: string, + type: string, + event: AgentRunEvent | null, +): void { + db.prepare(` + INSERT INTO core_agent_run_projections(session_id, event_type, event_json) + VALUES (?, ?, ?) + ON CONFLICT(session_id, event_type) DO UPDATE SET event_json = excluded.event_json + `).run(sessionId, type, event === null ? null : JSON.stringify(event, sanitizeJson)); +} + +function readSqliteRootTurnAdmission( + db: DatabaseSync, + sessionId: string, + turnId: string, +): RootTurnAdmission | undefined { + const row = db + .prepare(` + SELECT record_json + FROM core_root_turn_admissions + WHERE session_id = ? AND turn_id = ? + `) + .get(sessionId, turnId) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') throw new Error('Invalid root turn admission row'); + return normalizeRootTurnAdmission(JSON.parse(row.record_json), sessionId, turnId); +} + +function readSqliteRootTurnStartRejection( + db: DatabaseSync, + sessionId: string, + turnId: string, +): RootTurnStartRejection | undefined { + const row = db + .prepare(` + SELECT record_json + FROM core_root_turn_start_rejections + WHERE session_id = ? AND turn_id = ? + `) + .get(sessionId, turnId) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new Error('Invalid root Turn start rejection row'); + } + return normalizeStoredRootTurnStartRejection(JSON.parse(row.record_json), sessionId, turnId); +} + +function normalizeRootTurnStartRejection( + input: CommitRootTurnStartRejectionInput, +): RootTurnStartRejection { + return normalizeStoredRootTurnStartRejection( + { + schemaVersion: 1, + sessionId: input.sessionId, + turnId: input.turnId, + execution: input.execution, + skillInvocation: input.skillInvocation, + rejectedAt: input.rejectedAt, + }, + input.sessionId, + input.turnId, + ); +} + +function normalizeStoredRootTurnStartRejection( + value: unknown, + sessionId: string, + turnId: string, +): RootTurnStartRejection { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(turnId, 'Invalid turn id'); + if ( + !isPlainRecord(value) || + !hasExactKeys(value, [ + 'schemaVersion', + 'sessionId', + 'turnId', + 'execution', + 'skillInvocation', + 'rejectedAt', + ]) || + value.schemaVersion !== 1 || + value.sessionId !== sessionId || + value.turnId !== turnId || + !Number.isSafeInteger(value.rejectedAt) || + (value.rejectedAt as number) < 0 + ) { + throw new Error(`Invalid root Turn start rejection for turn ${turnId}`); + } + const execution = normalizeRootExecutionDescriptor(value.execution); + if (execution.kind !== 'external_message') { + throw new Error('Root Turn start rejection requires external message execution'); + } + const skillInvocation = decodeSkillInvocationResult(value.skillInvocation); + if (skillInvocation.loaded.length !== 0 || skillInvocation.failed.length === 0) { + throw new Error('Root Turn start rejection requires only failed Skill invocations'); + } + const rejection = { + schemaVersion: 1 as const, + sessionId, + turnId, + execution, + skillInvocation, + rejectedAt: value.rejectedAt as number, + }; + assertRootTurnAdmissionSerializedSize(`${JSON.stringify(rejection)}\n`); + Object.freeze(rejection.execution); + return Object.freeze(rejection); +} + +function normalizeAdmitRootTurnInput(input: AdmitRootTurnInput): RootTurnAdmission { + assertSafeId(input.sessionId, 'Invalid session id'); + assertSafeId(input.turnId, 'Invalid turn id'); + assertSafeId(input.proposedRunId, 'Invalid run id'); + if (input.proposedUserMessageId !== null) { + assertSafeId(input.proposedUserMessageId, 'Invalid user message id'); + } + if (input.previousRootTurnId !== null) { + assertSafeId(input.previousRootTurnId, 'Invalid previous root turn id'); + if (input.previousRootTurnId === input.turnId) { + throw new Error('Root turn admission cannot reference itself'); + } + } + if (!Number.isSafeInteger(input.admittedAt) || input.admittedAt < 0) { + throw new Error('Invalid root turn admission timestamp'); + } + const { normalizedInput, sourceMessages } = normalizeRootTurnAdmissionPayload( + input.normalizedInput, + input.sourceMessages, + ); + const turnOrchestration = normalizeTurnOrchestration(input.turnOrchestration); + const skillInvocation = + input.skillInvocation === undefined + ? undefined + : decodeSkillInvocationResult(input.skillInvocation); + const authorization = normalizeRootTurnAdmissionAuthorization(input.authorization); + const execution = normalizeRootExecutionDescriptor(input.execution); + if (execution.kind === 'legacy_automation') { + throw new Error('New root admission cannot use removed Automation authority'); + } + const admission: RootTurnAdmission = { + schemaVersion: ROOT_TURN_ADMISSION_SCHEMA_VERSION, + sessionId: input.sessionId, + turnId: input.turnId, + runId: input.proposedRunId, + userMessageId: input.proposedUserMessageId, + execution, + previousRootTurnId: input.previousRootTurnId, + normalizedInput, + ...(turnOrchestration ? { turnOrchestration } : {}), + ...(skillInvocation ? { skillInvocation } : {}), + ...(authorization ? { authorization } : {}), + sourceMessages, + admittedAt: input.admittedAt, + }; + assertRootTurnAdmissionContract(admission); + assertRootTurnAdmissionRecordSize(admission); + return deepFreezeRootTurnAdmission(admission); +} + +/** Whether a proposed admission satisfies the complete durable record contract and size bound. */ +export function rootTurnAdmissionRecordFits(input: AdmitRootTurnInput): boolean { + try { + normalizeAdmitRootTurnInput(input); + return true; + } catch { + return false; + } +} + +function shouldPreserveCheckpointProjectionDuringAppend( + current: AgentRunEvent | null | undefined, + candidate: AgentRunEvent, +): boolean { + if (!current) return false; + const currentSourceBound = historyCompactProjectionIsSourceBound(current); + const candidateSourceBound = historyCompactProjectionIsSourceBound(candidate); + if (currentSourceBound !== candidateSourceBound) return currentSourceBound; + const currentCoverage = historyCompactProjectionCoverage(current); + const candidateCoverage = historyCompactProjectionCoverage(candidate); + return ( + currentCoverage !== undefined && + (candidateCoverage === undefined || currentCoverage > candidateCoverage) + ); +} + +function shouldPreserveProjectionDuringRepair( + current: AgentRunEvent | null | undefined, + candidate: AgentRunEvent | null, + type: AgentRunProjectionKey, +): boolean { + if (!current) return false; + if (type === LATEST_CONTEXT_PROJECTION_TYPE) { + // Same ordering rule as the append-time guard, so repair and write cannot + // disagree about which request is the latest one. An incumbent whose order + // cannot be read is NOT preserved: the reader already treats an + // undecodable row as unanswered and rebuilds from the ledger, so keeping + // it would make that rebuild unwritable and leave every later refresh + // rescanning the whole session (#2323). + const incumbent = latestContextOrder(current); + if (!incumbent) return false; + const arriving = candidate && latestContextOrder(candidate); + if (!arriving) return true; + return !supersedesLatestContext(arriving, incumbent); + } + if (type !== 'history_compact_checkpoint_recorded') return true; + const currentSourceBound = historyCompactProjectionIsSourceBound(current); + const candidateSourceBound = candidate ? historyCompactProjectionIsSourceBound(candidate) : false; + if (currentSourceBound !== candidateSourceBound) return currentSourceBound; + const currentCoverage = historyCompactProjectionCoverage(current); + const candidateCoverage = candidate && historyCompactProjectionCoverage(candidate); + return ( + currentCoverage !== undefined && + (candidateCoverage === null || + candidateCoverage === undefined || + currentCoverage >= candidateCoverage) + ); +} + +/** + * The ordering facts a stored latest-context row carries, or `undefined` when + * the row cannot state them — a damaged snapshot, or one written by a shape + * this build does not understand. + */ +function latestContextOrder(event: AgentRunEvent): LatestContextOrder | undefined { + const data = event.data as { completedAt?: unknown; attemptId?: unknown } | undefined; + if (!data || typeof data.completedAt !== 'number' || typeof data.attemptId !== 'string') { + return undefined; + } + return { completedAt: data.completedAt, attemptId: data.attemptId }; +} + +function historyCompactProjectionIsSourceBound(event: AgentRunEvent): boolean { + const checkpoint = event.data?.checkpoint; + if (!checkpoint || typeof checkpoint !== 'object') return false; + const source = (checkpoint as { source?: unknown }).source; + if (!source || typeof source !== 'object') return false; + return (source as { kind?: unknown }).kind === 'runtime_event_projection'; +} + +function assertNoReservedToolLedgerFact(event: RuntimeEvent): void { + assertNoReservedWorkspaceAuthorityAppend(event); + if (event.actions?.continuationStart !== undefined) { + throw new Error('Continuation start facts require SQLite continuation authority'); + } + const validation = validateGenericToolLedgerAppend(event); + if (validation.ok) return; + if (validation.code === 'reserved_recovery_fact') { + throw new Error('Tool recovery facts require the atomic recovery bundle writer'); + } + if (validation.code === 'reserved_tool_boundary_fact') { + throw new Error('Durable tool facts require the atomic tool boundary writer'); + } + throw new Error(`RuntimeEvent ${event.id} violates its semantic lane`); +} + +function canonicalizeRuntimeEventForStorage(event: RuntimeEvent): RuntimeEvent { + return encodeCanonicalRuntimeEvent(event).event; +} + +function isToolLedgerBearingEvent(event: RuntimeEvent): boolean { + return ( + event.content?.kind === 'function_call' || + event.content?.kind === 'function_response' || + event.actions?.toolDispatch !== undefined || + event.actions?.toolRecovery !== undefined + ); +} + +function historyCompactProjectionCoverage(event: AgentRunEvent): number | undefined { + const checkpoint = event.data?.checkpoint; + if (!checkpoint || typeof checkpoint !== 'object') return undefined; + const coverage = (checkpoint as { coverage?: unknown }).coverage; + if (!coverage || typeof coverage !== 'object') return undefined; + const eventCount = (coverage as { eventCount?: unknown }).eventCount; + return typeof eventCount === 'number' && Number.isSafeInteger(eventCount) && eventCount >= 0 + ? eventCount + : undefined; +} + +function isProjectedAgentRunEvent( + value: unknown, + sessionId: string, + type: string, +): value is AgentRunEvent { + if (!value || typeof value !== 'object') return false; + const event = value as Partial; + return ( + event.type === type && + event.sessionId === sessionId && + typeof event.id === 'string' && + typeof event.runId === 'string' && + typeof event.turnId === 'string' && + Number.isFinite(event.ts) + ); +} + +function assertSafeId(value: string, message: string): void { + if (!isSafeId(value)) throw new Error(message); +} + +function assertIdentitySearchLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) { + throw new RangeError('AgentRun identity search limit must be an integer between 1 and 256'); + } +} + +function isSafeId(value: string): boolean { + return SAFE_ID_PATTERN.test(value); +} + +function isGraphControlIdentity(value: string): boolean { + return ( + value.length > 0 && + value.length <= 256 && + value.trim() === value && + /^[A-Za-z0-9._:-]+$/.test(value) + ); +} + +function normalizeRootTurnAdmission( + value: unknown, + sessionId: string, + turnId: string, +): RootTurnAdmission { + if (!isPlainRecord(value)) { + throw new Error(`Invalid root turn admission for turn ${turnId}: expected an object`); + } + const record = value; + const valid = + record.schemaVersion === ROOT_TURN_ADMISSION_SCHEMA_VERSION && + record.sessionId === sessionId && + record.turnId === turnId && + typeof record.runId === 'string' && + isSafeId(record.runId) && + (record.userMessageId === null || + (typeof record.userMessageId === 'string' && isSafeId(record.userMessageId))) && + (record.previousRootTurnId === null || + (typeof record.previousRootTurnId === 'string' && + isSafeId(record.previousRootTurnId) && + record.previousRootTurnId !== turnId)) && + Number.isSafeInteger(record.admittedAt) && + (record.admittedAt as number) >= 0 && + hasRootTurnAdmissionKeys(record); + if (!valid) { + throw new Error(`Invalid root turn admission for turn ${turnId}: malformed fields`); + } + const { normalizedInput, sourceMessages } = normalizeRootTurnAdmissionPayload( + record.normalizedInput, + record.sourceMessages, + ); + const turnOrchestration = normalizeTurnOrchestration(record.turnOrchestration); + const skillInvocation = + record.skillInvocation === undefined + ? undefined + : decodeSkillInvocationResult(record.skillInvocation); + const authorization = normalizeRootTurnAdmissionAuthorization(record.authorization); + const admission: RootTurnAdmission = { + schemaVersion: ROOT_TURN_ADMISSION_SCHEMA_VERSION, + sessionId, + turnId, + runId: record.runId as string, + userMessageId: record.userMessageId as string | null, + execution: normalizeRootExecutionDescriptor(record.execution), + previousRootTurnId: record.previousRootTurnId as string | null, + normalizedInput, + ...(turnOrchestration ? { turnOrchestration } : {}), + ...(skillInvocation ? { skillInvocation } : {}), + ...(authorization ? { authorization } : {}), + sourceMessages, + admittedAt: record.admittedAt as number, + }; + assertRootTurnAdmissionContract(admission); + assertRootTurnAdmissionRecordSize(admission); + return deepFreezeRootTurnAdmission(admission); +} + +function decodeRootSourceMessageProofPointer( + value: unknown, + sessionId: string, + messageId: string, +): { readonly turnId: string } { + if ( + !isPlainRecord(value) || + !hasExactKeys(value, ['schemaVersion', 'sessionId', 'messageId', 'turnId']) || + value.schemaVersion !== 1 || + value.sessionId !== sessionId || + value.messageId !== messageId || + typeof value.turnId !== 'string' || + !isSafeId(value.turnId) + ) { + throw new Error(`Invalid root source message proof: ${messageId}`); + } + return Object.freeze({ turnId: value.turnId }); +} + +function orderRootTurnAdmissionChain( + sessionId: string, + admissions: readonly RootTurnAdmission[], +): RootTurnAdmission[] { + if (admissions.length === 0) return []; + const byTurnId = new Map(admissions.map((admission) => [admission.turnId, admission])); + if (byTurnId.size !== admissions.length) { + throw new Error(`Session ${sessionId} has duplicate root turn admissions`); + } + for (const admission of admissions) { + const predecessor = admission.previousRootTurnId; + if (predecessor !== null && !byTurnId.has(predecessor)) { + throw new Error( + `Root turn admission ${admission.turnId} has missing predecessor ${predecessor}`, + ); + } + } + const roots = admissions.filter((admission) => admission.previousRootTurnId === null); + if (roots.length !== 1) { + throw new Error(`Session ${sessionId} must have exactly one root turn admission root`); + } + const childByTurnId = new Map(); + for (const admission of admissions) { + const predecessor = admission.previousRootTurnId; + if (predecessor === null) continue; + const existing = childByTurnId.get(predecessor); + if (existing) { + throw new Error( + `Root turn admission ${predecessor} branches to ${existing.turnId} and ${admission.turnId}`, + ); + } + childByTurnId.set(predecessor, admission); + } + + const ordered: RootTurnAdmission[] = []; + let current: RootTurnAdmission | undefined = roots[0]; + while (current) { + ordered.push(current); + current = childByTurnId.get(current.turnId); + } + if (ordered.length !== admissions.length) { + throw new Error(`Session ${sessionId} root turn admissions do not form one linear chain`); + } + return ordered; +} + +function normalizeRootTurnMessageContent( + value: unknown, + description: string, + maxAttachments: number, +): MessageContent { + let normalized: MessageContent; + try { + normalized = decodeMessageContent(value); + } catch { + if (isPlainRecord(value) && Array.isArray(value.attachments)) { + const invalidAttachmentIndex = value.attachments.findIndex( + (attachment) => !isCanonicalAttachmentRef(attachment), + ); + if (invalidAttachmentIndex >= 0) { + throw new Error(`Invalid ${description} attachment at index ${invalidAttachmentIndex}`); + } + } + throw new Error(`Invalid ${description}`); + } + if (normalized.text.length === 0 || (normalized.attachments?.length ?? 0) > maxAttachments) { + throw new Error(`Invalid ${description}`); + } + for (const [index, attachment] of (normalized.attachments ?? []).entries()) { + if (!isValidRootTurnAttachment(attachment)) { + throw new Error(`Invalid ${description} attachment at index ${index}`); + } + } + if ( + Buffer.byteLength(JSON.stringify(normalized), 'utf8') > ROOT_TURN_ADMISSION_MAX_CONTENT_BYTES + ) { + throw new Error(`Invalid ${description}: content exceeds size limit`); + } + deepFreezeRootTurnMessageContent(normalized); + return normalized; +} + +function isValidRootTurnAttachment(attachment: AttachmentRef): boolean { + return isCanonicalAttachmentRef(attachment) && attachment.bytes <= MAX_ATTACHMENT_BYTES; +} + +export function normalizeRootTurnAdmissionPayload( + normalizedInputValue: MessageContent, + sourceMessagesValue: unknown, +): { + normalizedInput: MessageContent; + sourceMessages: readonly RootTurnSourceMessage[]; +}; +export function normalizeRootTurnAdmissionPayload( + normalizedInputValue: null, + sourceMessagesValue: unknown, +): { + normalizedInput: null; + sourceMessages: readonly RootTurnSourceMessage[]; +}; +export function normalizeRootTurnAdmissionPayload( + normalizedInputValue: unknown, + sourceMessagesValue: unknown, +): { + normalizedInput: MessageContent | null; + sourceMessages: readonly RootTurnSourceMessage[]; +}; +export function normalizeRootTurnAdmissionPayload( + normalizedInputValue: unknown, + sourceMessagesValue: unknown, +): { + normalizedInput: MessageContent | null; + sourceMessages: readonly RootTurnSourceMessage[]; +} { + const sourceMessages = normalizeRootTurnSourceMessages(sourceMessagesValue); + if (normalizedInputValue === null) { + if (sourceMessages.length > 0) { + throw new Error('Root turn admission without input cannot have source messages'); + } + return { normalizedInput: null, sourceMessages }; + } + const normalizedInputMaxAttachments = + sourceMessages.length > 1 + ? ROOT_TURN_ADMISSION_MAX_AGGREGATED_ATTACHMENTS + : MAX_ATTACHMENT_COUNT; + const normalizedInput = normalizeRootTurnMessageContent( + normalizedInputValue, + 'root turn normalized input', + normalizedInputMaxAttachments, + ); + if (sourceMessages.length > 0) { + const expectedInput = normalizeRootTurnMessageContent( + aggregateMessageContents(sourceMessages.map((source) => source.content)), + 'root turn aggregated source content', + normalizedInputMaxAttachments, + ); + if (!messageContentsEqual(normalizedInput, expectedInput)) { + throw new Error('Root turn admission input content does not match source messages'); + } + } + const turnStartedCount = sourceMessages.filter( + (source) => source.disposition === 'turn_started', + ).length; + if (turnStartedCount > 0 && (turnStartedCount !== 1 || sourceMessages.length !== 1)) { + throw new Error('Root turn admission turn_started source must be the only source message'); + } + return { normalizedInput, sourceMessages }; +} + +function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourceMessage[] { + if (!Array.isArray(value) || value.length > ROOT_TURN_ADMISSION_MAX_SOURCE_MESSAGES) { + throw new Error('Invalid root turn source messages: expected a bounded array'); + } + const messageIds = new Set(); + const normalized = value.map((item, index): RootTurnSourceMessage => { + if ( + !isPlainRecord(item) || + !hasExactKeys(item, [ + 'messageId', + 'content', + 'placement', + 'disposition', + ...(Object.hasOwn(item, 'submittedContentDigest') ? ['submittedContentDigest'] : []), + ...(Object.hasOwn(item, 'submittedPlacement') ? ['submittedPlacement'] : []), + ...(Object.hasOwn(item, 'submittedIntent') ? ['submittedIntent'] : []), + ...(Object.hasOwn(item, 'skillInvocation') ? ['skillInvocation'] : []), + ]) + ) { + throw new Error(`Invalid root turn source message at index ${index}`); + } + const { + messageId, + content, + submittedContentDigest, + submittedPlacement, + submittedIntent, + skillInvocation, + placement, + disposition, + } = item; + if ( + typeof messageId !== 'string' || + !isSafeId(messageId) || + (placement !== 'current_turn' && placement !== 'next_turn') || + (disposition !== 'steering' && + disposition !== 'followup' && + disposition !== 'turn_started') || + (disposition === 'steering' && placement !== 'current_turn') || + (disposition === 'followup' && placement !== 'next_turn') || + (submittedPlacement !== undefined && + submittedPlacement !== 'current_turn' && + submittedPlacement !== 'next_turn') || + (submittedContentDigest !== undefined && !isSha256Digest(submittedContentDigest)) + ) { + throw new Error(`Invalid root turn source message at index ${index}`); + } + if (messageIds.has(messageId)) { + throw new Error(`Duplicate root turn source message id: ${messageId}`); + } + messageIds.add(messageId); + return Object.freeze({ + messageId, + content: normalizeRootTurnMessageContent( + content, + `root turn source message content at index ${index}`, + MAX_ATTACHMENT_COUNT, + ), + ...(submittedContentDigest !== undefined ? { submittedContentDigest } : {}), + ...(submittedPlacement !== undefined ? { submittedPlacement } : {}), + ...(submittedIntent !== undefined + ? { submittedIntent: normalizeSubmittedTurnIntent(submittedIntent) } + : {}), + ...(skillInvocation !== undefined + ? { skillInvocation: decodeSkillInvocationResult(skillInvocation) } + : {}), + placement, + disposition, + }); + }); + return Object.freeze(normalized); +} + +function rootTurnAdmissionPayloadsEqual( + left: RootTurnAdmission, + right: RootTurnAdmission, +): boolean { + return ( + isDeepStrictEqual(left.execution, right.execution) && + isDeepStrictEqual(left.turnOrchestration, right.turnOrchestration) && + isDeepStrictEqual(left.skillInvocation, right.skillInvocation) && + isDeepStrictEqual(left.authorization, right.authorization) && + (left.normalizedInput === null || right.normalizedInput === null + ? left.normalizedInput === right.normalizedInput + : messageContentsEqual(left.normalizedInput, right.normalizedInput)) && + left.sourceMessages.length === right.sourceMessages.length && + left.sourceMessages.every((source, index) => { + const other = right.sourceMessages[index]; + return ( + other !== undefined && + source.messageId === other.messageId && + source.placement === other.placement && + source.disposition === other.disposition && + source.submittedContentDigest === other.submittedContentDigest && + (source.submittedPlacement ?? source.placement) === + (other.submittedPlacement ?? other.placement) && + submittedTurnIntentsEqual(source.submittedIntent, other.submittedIntent) && + isDeepStrictEqual(source.skillInvocation, other.skillInvocation) && + messageContentsEqual(source.content, other.content) + ); + }) + ); +} + +function assertRootTurnAdmissionRecordSize(admission: RootTurnAdmission): void { + assertRootTurnAdmissionSerializedSize(`${JSON.stringify(admission)}\n`); +} + +function assertRootTurnAdmissionSerializedSize(serialized: string): void { + if (Buffer.byteLength(serialized, 'utf8') > ROOT_TURN_ADMISSION_MAX_RECORD_BYTES) { + throw new Error('Invalid root turn admission: record exceeds size limit'); + } +} + +function assertRootTurnAdmissionContract(admission: RootTurnAdmission): void { + const execution = admission.execution; + const providerRetry = execution.kind === 'linked_child_provider_retry'; + const inputlessExecution = + execution.kind === 'safe_boundary_continuation' || execution.kind === 'context_compact'; + const sourceBatch = execution.kind === 'external_message' && admission.sourceMessages.length > 1; + const messageLessExecution = inputlessExecution || providerRetry || sourceBatch; + if (execution.kind === 'agent_graph_supervisor_wake') { + if ( + admission.turnOrchestration?.mode !== 'graph' || + admission.turnOrchestration.source !== 'host_api' + ) { + throw new Error( + 'Invalid root turn admission contract: Agent Graph supervisor wake requires Host Graph orchestration', + ); + } + } else if (admission.turnOrchestration && execution.kind !== 'external_message') { + throw new Error( + 'Invalid root turn admission contract: orchestration override is not authorized for this execution', + ); + } + if ((admission.userMessageId === null) !== messageLessExecution) { + throw new Error( + 'Invalid root turn admission contract: execution has an invalid UserMessage requirement', + ); + } + if ((admission.normalizedInput === null) !== inputlessExecution) { + throw new Error( + 'Invalid root turn admission contract: execution has an invalid input requirement', + ); + } + if (execution.kind !== 'external_message' && admission.sourceMessages.length !== 0) { + throw new Error( + 'Invalid root turn admission contract: host-authored execution cannot have source messages', + ); + } + if (admission.skillInvocation && execution.kind !== 'external_message') { + throw new Error( + 'Invalid root turn admission contract: Skill invocation requires external message execution', + ); + } + if ( + admission.authorization && + execution.kind !== 'external_message' && + execution.kind !== 'regenerate' + ) { + throw new Error( + 'Invalid root turn admission contract: authorization proof requires external message or regenerate execution', + ); + } + if (execution.kind === 'claimed_agent_graph_intent') { + if ( + execution.claim.targetSessionId !== admission.sessionId || + execution.claim.targetTurnId !== admission.turnId || + execution.claim.targetRunId !== admission.runId + ) { + throw new Error( + 'Invalid root turn admission contract: agent graph claim target does not match admission identity', + ); + } + if (admission.userMessageId === null) { + throw new Error( + 'Invalid root turn admission contract: agent graph execution requires a UserMessage', + ); + } + } + if ( + (execution.kind === 'linked_child_resume' || + execution.kind === 'linked_child_provider_retry') && + execution.sourceRunId === admission.runId + ) { + throw new Error( + 'Invalid root turn admission contract: linked child source Run cannot be the admitted Run', + ); + } + if ( + execution.kind === 'safe_boundary_continuation' && + (execution.sourceRunId === admission.runId || + execution.sourceTurnId === admission.turnId || + execution.sourceInvocationId === execution.targetInvocationId || + admission.normalizedInput !== null) + ) { + throw new Error( + 'Invalid root turn admission contract: safe-boundary continuation identity is invalid', + ); + } + if (execution.kind === 'regenerate' && execution.sourceTurnId === admission.turnId) { + throw new Error( + 'Invalid root turn admission contract: regenerate source Turn cannot be the admitted Turn', + ); + } + if ( + execution.kind === 'external_message' && + admission.sourceMessages.some( + (source) => + source.disposition === 'turn_started' && source.messageId !== admission.userMessageId, + ) + ) { + throw new Error( + 'Invalid root turn admission contract: turn-started source must own the UserMessage', + ); + } +} + +function deepFreezeRootTurnAdmission(admission: RootTurnAdmission): RootTurnAdmission { + if (admission.execution.kind === 'claimed_agent_graph_intent') { + Object.freeze(admission.execution.claim); + } + Object.freeze(admission.execution); + if (admission.turnOrchestration) Object.freeze(admission.turnOrchestration); + if (admission.skillInvocation) Object.freeze(admission.skillInvocation); + if (admission.authorization) Object.freeze(admission.authorization); + if (admission.normalizedInput) deepFreezeRootTurnMessageContent(admission.normalizedInput); + for (const sourceMessage of admission.sourceMessages) { + deepFreezeRootTurnMessageContent(sourceMessage.content); + Object.freeze(sourceMessage); + } + Object.freeze(admission.sourceMessages); + return Object.freeze(admission); +} + +function normalizeTurnOrchestration(value: unknown): TurnOrchestration | undefined { + if (value === undefined) return undefined; + if ( + !isPlainRecord(value) || + !hasExactKeys(value, ['mode', 'source']) || + !isOrchestrationMode(value.mode) || + !isTurnOrchestrationSource(value.source) + ) { + throw new Error('Invalid root turn orchestration'); + } + return Object.freeze({ mode: value.mode, source: value.source }); +} + +function normalizeRootTurnAdmissionAuthorization( + value: unknown, +): RootTurnAdmissionAuthorization | undefined { + if (value === undefined) return undefined; + if ( + !isPlainRecord(value) || + !hasExactKeys(value, [ + 'kind', + 'requestId', + 'principalId', + 'grantId', + 'approvedAt', + 'approvedBy', + ]) || + value.kind !== 'session_turn_access_request' || + typeof value.requestId !== 'string' || + !isSafeId(value.requestId) || + typeof value.principalId !== 'string' || + !isGraphControlIdentity(value.principalId) || + typeof value.grantId !== 'string' || + !isSafeId(value.grantId) || + !Number.isSafeInteger(value.approvedAt) || + (value.approvedAt as number) < 0 || + typeof value.approvedBy !== 'string' || + !isGraphControlIdentity(value.approvedBy) + ) { + throw new Error('Invalid root turn admission authorization'); + } + return Object.freeze({ + kind: value.kind, + requestId: value.requestId, + principalId: value.principalId, + grantId: value.grantId, + approvedAt: value.approvedAt as number, + approvedBy: value.approvedBy, + }); +} + +function hasRootTurnAdmissionKeys(record: Record): boolean { + const keys = [ + 'schemaVersion', + 'sessionId', + 'turnId', + 'runId', + 'userMessageId', + 'execution', + 'previousRootTurnId', + 'normalizedInput', + 'sourceMessages', + 'admittedAt', + ]; + const optionalKeys = ['turnOrchestration', 'skillInvocation', 'authorization'].filter((key) => + Object.hasOwn(record, key), + ); + return hasExactKeys(record, [...keys, ...optionalKeys]); +} + +function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescriptor { + if (!isPlainRecord(value) || typeof value.kind !== 'string') { + throw new Error('Invalid root execution descriptor'); + } + if (value.kind === 'external_message') { + const allowedKeys = ['kind', 'inputDigest', 'maxSteps']; + if (!Object.keys(value).every((key) => allowedKeys.includes(key))) { + throw new Error('Invalid root execution descriptor'); + } + if (value.inputDigest !== undefined && !isSha256Digest(value.inputDigest)) { + throw new Error('Invalid root execution descriptor'); + } + if ( + value.maxSteps !== undefined && + (typeof value.maxSteps !== 'number' || + !Number.isSafeInteger(value.maxSteps) || + value.maxSteps <= 0) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ + kind: 'external_message', + ...(value.inputDigest !== undefined ? { inputDigest: value.inputDigest } : {}), + ...(value.maxSteps !== undefined ? { maxSteps: value.maxSteps } : {}), + }); + } + if (value.kind === 'workhub_coordination') { + if ( + !hasExactKeys( + value, + value.operation === undefined + ? ['kind', 'inputDigest'] + : [ + 'kind', + 'inputDigest', + 'operation', + ...(value.actionId === undefined ? [] : ['actionId']), + ], + ) || + (value.operation !== undefined && value.operation !== 'action') || + (value.actionId !== undefined && + (typeof value.actionId !== 'string' || !isSafeId(value.actionId))) || + !isSha256Digest(value.inputDigest) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ + kind: 'workhub_coordination', + ...(value.operation === 'action' ? { operation: 'action' as const } : {}), + inputDigest: value.inputDigest, + ...(typeof value.actionId === 'string' ? { actionId: value.actionId } : {}), + }); + } + if (value.kind === 'regenerate') { + if ( + !hasExactKeys(value, ['kind', 'sourceTurnId']) || + typeof value.sourceTurnId !== 'string' || + !isSafeId(value.sourceTurnId) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ kind: 'regenerate', sourceTurnId: value.sourceTurnId }); + } + if (value.kind === 'context_compact') { + if (!hasExactKeys(value, ['kind'])) throw new Error('Invalid root execution descriptor'); + return Object.freeze({ kind: 'context_compact' }); + } + if (value.kind === 'scheduled_task') { + if ( + !hasExactKeys(value, [ + 'kind', + 'scheduledTaskId', + ...(Object.hasOwn(value, 'executionFingerprint') ? ['executionFingerprint'] : []), + ]) || + typeof value.scheduledTaskId !== 'string' || + !isSafeId(value.scheduledTaskId) || + (value.executionFingerprint !== undefined && !isSha256Digest(value.executionFingerprint)) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ + kind: 'scheduled_task', + scheduledTaskId: value.scheduledTaskId, + ...(value.executionFingerprint !== undefined + ? { executionFingerprint: value.executionFingerprint } + : {}), + }); + } + if (value.kind === 'automation' || value.kind === 'legacy_automation') { + if ( + !hasExactKeys(value, ['kind', 'automationId']) || + typeof value.automationId !== 'string' || + !isSafeId(value.automationId) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ kind: 'legacy_automation', automationId: value.automationId }); + } + if (value.kind === 'goal') { + if ( + !hasExactKeys(value, ['kind', 'goalId']) || + typeof value.goalId !== 'string' || + !isSafeId(value.goalId) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ kind: 'goal', goalId: value.goalId }); + } + if (value.kind === 'agent_graph_supervisor_wake') { + if ( + !hasExactKeys(value, ['kind', 'graphId', 'wakeId', 'attemptId']) || + typeof value.graphId !== 'string' || + !isGraphControlIdentity(value.graphId) || + typeof value.wakeId !== 'string' || + !isGraphControlIdentity(value.wakeId) || + !value.wakeId.startsWith(`${value.graphId}:`) || + typeof value.attemptId !== 'string' || + !isGraphControlIdentity(value.attemptId) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ + kind: value.kind, + graphId: value.graphId, + wakeId: value.wakeId, + attemptId: value.attemptId, + }); + } + if (value.kind === 'safe_boundary_continuation') { + const keys = [ + 'kind', + 'sourceInvocationId', + 'sourceRunId', + 'sourceTurnId', + 'sourceRuntimeEventHighWater', + 'claimId', + 'boundaryDigest', + 'providerReplayDigest', + 'safetyDigest', + 'targetInvocationId', + ]; + if ( + !hasExactKeys(value, keys) || + typeof value.sourceInvocationId !== 'string' || + !isSafeId(value.sourceInvocationId) || + typeof value.sourceRunId !== 'string' || + !isSafeId(value.sourceRunId) || + typeof value.sourceTurnId !== 'string' || + !isSafeId(value.sourceTurnId) || + !Number.isSafeInteger(value.sourceRuntimeEventHighWater) || + (value.sourceRuntimeEventHighWater as number) < 1 || + typeof value.claimId !== 'string' || + !isSafeId(value.claimId) || + !isSha256Digest(value.boundaryDigest) || + !isSha256Digest(value.providerReplayDigest) || + !isSha256Digest(value.safetyDigest) || + typeof value.targetInvocationId !== 'string' || + !isSafeId(value.targetInvocationId) + ) { + throw new Error('Invalid root execution descriptor'); + } + return Object.freeze({ + kind: value.kind, + sourceInvocationId: value.sourceInvocationId, + sourceRunId: value.sourceRunId, + sourceTurnId: value.sourceTurnId, + sourceRuntimeEventHighWater: value.sourceRuntimeEventHighWater as number, + claimId: value.claimId, + boundaryDigest: value.boundaryDigest, + providerReplayDigest: value.providerReplayDigest, + safetyDigest: value.safetyDigest, + targetInvocationId: value.targetInvocationId, + }); + } + if (value.kind === 'claimed_agent_graph_intent') { + if ( + !hasExactKeys(value, ['kind', 'claim', 'agentId', 'agentName']) || + typeof value.agentId !== 'string' || + !isSafeId(value.agentId) || + typeof value.agentName !== 'string' || + value.agentName.length === 0 || + Buffer.byteLength(value.agentName, 'utf8') > 256 + ) { + throw new Error('Invalid root execution descriptor'); + } + let claim; + try { + claim = decodeAgentGraphIntentClaim(value.claim); + } catch { + throw new Error('Invalid root execution descriptor'); + } + Object.freeze(claim); + return Object.freeze({ + kind: value.kind, + claim, + agentId: value.agentId, + agentName: value.agentName, + }); + } + if ( + value.kind !== 'linked_child_initial' && + value.kind !== 'linked_child_resume' && + value.kind !== 'linked_child_provider_retry' + ) { + throw new Error('Invalid root execution descriptor'); + } + const hasSource = value.kind !== 'linked_child_initial'; + if ( + !hasExactKeys( + value, + hasSource + ? ['kind', 'agentId', 'agentName', 'sourceRunId'] + : ['kind', 'agentId', 'agentName'], + ) || + typeof value.agentId !== 'string' || + !isSafeId(value.agentId) || + typeof value.agentName !== 'string' || + value.agentName.length === 0 || + Buffer.byteLength(value.agentName, 'utf8') > 256 || + (hasSource && (typeof value.sourceRunId !== 'string' || !isSafeId(value.sourceRunId))) + ) { + throw new Error('Invalid root execution descriptor'); + } + if (value.kind === 'linked_child_initial') { + return Object.freeze({ + kind: value.kind, + agentId: value.agentId, + agentName: value.agentName, + }); + } + return Object.freeze({ + kind: value.kind, + agentId: value.agentId, + agentName: value.agentName, + sourceRunId: value.sourceRunId as string, + }); +} + +function deepFreezeRootTurnMessageContent(content: MessageContent): void { + for (const attachment of content.attachments ?? []) { + Object.freeze(attachment.ref); + Object.freeze(attachment); + } + if (content.attachments) Object.freeze(content.attachments); + for (const reference of content.directoryReferences ?? []) Object.freeze(reference); + if (content.directoryReferences) Object.freeze(content.directoryReferences); + for (const quote of content.quotes ?? []) Object.freeze(quote); + if (content.quotes) Object.freeze(content.quotes); + Object.freeze(content); +} + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); +} + +function hasExactKeys(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record); + return keys.length === expected.length && expected.every((key) => Object.hasOwn(record, key)); +} + +function sanitizeJson(_key: string, value: unknown): unknown { + return value === undefined ? undefined : value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/31ecabb38719595fcb22828d2eb651de6a04c0cb25f90284ae63d8acfd2f0095.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/31ecabb38719595fcb22828d2eb651de6a04c0cb25f90284ae63d8acfd2f0095.source new file mode 100644 index 0000000000..c24042aa06 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/31ecabb38719595fcb22828d2eb651de6a04c0cb25f90284ae63d8acfd2f0095.source @@ -0,0 +1,301 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; +import type { + ScheduledTask, + ScheduledTaskEffect, + ScheduledTaskRun, + ScheduledTaskSchedule, +} from '@maka/core/scheduled-task'; +import type { DatabaseSync } from 'node:sqlite'; +import { canonicalizeLegacyPlanReminderCronExpression } from './legacy-cron-expression.js'; + +const LEGACY_AUTOMATION_TABLES = [ + 'automation_authority_state', + 'automation_definitions', + 'automation_pending_fires', +] as const; +const LAST_RELEASED_PLAN_REMINDER_WORKFLOW_VERSION = 5; +const SCHEDULED_TASK_CATALOG_MAX_ITEMS = 256; + +export function assertLegacySchedulingSchema( + database: DatabaseSync, + versions: ReadonlyMap, +): void { + const automationVersion = versions.get('automation'); + const automationTables = LEGACY_AUTOMATION_TABLES.filter((table) => hasTable(database, table)); + if (automationTables.length > 0 && automationVersion === undefined) { + throw new Error('Legacy Automation schema registry is missing'); + } + if ( + automationVersion !== undefined && + ((automationVersion !== 1 && automationVersion !== 2) || + automationTables.length !== LEGACY_AUTOMATION_TABLES.length) + ) { + throw new Error('Legacy Automation schema is incomplete'); + } + const workflowVersion = versions.get('workflow'); + if ( + workflowVersion !== undefined && + workflowVersion <= LAST_RELEASED_PLAN_REMINDER_WORKFLOW_VERSION && + !hasTable(database, 'workflow_plan_reminders') + ) { + throw new Error('The released Workflow schema is missing workflow_plan_reminders'); + } + if ( + workflowVersion !== undefined && + workflowVersion > LAST_RELEASED_PLAN_REMINDER_WORKFLOW_VERSION && + hasTable(database, 'workflow_plan_reminders') + ) { + throw new Error('The current Workflow schema still contains released Plan Reminder state'); + } +} + +export function planLegacyScheduledTasks( + database: DatabaseSync, + versions: ReadonlyMap, +): ScheduledTask[] { + const tasks = readLegacyPlanReminders(database); + if (versions.get('automation') === 1) assertLegacyAutomationEmpty(database); + if (tasks.length > SCHEDULED_TASK_CATALOG_MAX_ITEMS) { + throw new Error( + `Released scheduling catalog has ${tasks.length} records, exceeding the supported ${SCHEDULED_TASK_CATALOG_MAX_ITEMS}; reopen this workspace with the previous Maka release and reduce the catalog before upgrading`, + ); + } + return tasks; +} + +export function insertMigratedScheduledTasks( + database: DatabaseSync, + tasks: readonly ScheduledTask[], +): void { + const insert = database.prepare(` + INSERT INTO workflow_scheduled_tasks(task_id, created_at, updated_at, record_json) + VALUES (?, ?, ?, ?) + `); + for (const task of tasks) { + insert.run(task.id, task.createdAt, task.updatedAt, JSON.stringify(task)); + } +} + +function assertLegacyAutomationEmpty(database: DatabaseSync): void { + if (!hasColumn(database, 'automation_definitions', 'durable')) { + throw new Error('The released Automation schema is missing durable'); + } + const definition = database + .prepare('SELECT automation_id FROM automation_definitions LIMIT 1') + .get() as { automation_id?: unknown } | undefined; + if (definition) { + throw new Error( + `Released Automation ${String(definition.automation_id)} cannot be migrated without losing its configuration; reopen this workspace with the previous Maka release and remove or export Automation before upgrading`, + ); + } + if (database.prepare('SELECT 1 FROM automation_pending_fires LIMIT 1').get()) { + throw new Error('Released Automation has an in-flight fire'); + } +} + +function readLegacyPlanReminders(database: DatabaseSync): ScheduledTask[] { + if (!hasTable(database, 'workflow_plan_reminders')) { + return []; + } + return database + .prepare(` + SELECT reminder_id, created_at, updated_at, record_json + FROM workflow_plan_reminders + ORDER BY created_at, reminder_id + `) + .all() + .map(decodeLegacyReminder); +} + +function decodeLegacyReminder(value: unknown): ScheduledTask { + const row = record(value, 'legacy Plan Reminder row'); + const source = jsonRecord(row.record_json, 'legacy Plan Reminder'); + const id = text(source.id, 'legacy Plan Reminder id'); + const createdAt = integer(source.createdAt, 'legacy Plan Reminder createdAt'); + const updatedAt = integer(source.updatedAt, 'legacy Plan Reminder updatedAt'); + if (row.reminder_id !== id || row.created_at !== createdAt || row.updated_at !== updatedAt) { + throw new Error(`Legacy Plan Reminder indexes contradict record JSON: ${id}`); + } + const releasedStatus = enumValue(source.status, ['scheduled', 'paused', 'completed'] as const); + const enabled = boolean(source.enabled, 'legacy Plan Reminder enabled'); + const runs = + source.runs === undefined + ? [] + : array(source.runs, 'legacy Plan Reminder runs').map(decodeLegacyRun); + const lastRun = source.lastRun === undefined ? runs[0] : decodeLegacyRun(source.lastRun); + if (runs[0] && lastRun && !sameRun(runs[0], lastRun)) { + throw new Error(`Legacy Plan Reminder lastRun contradicts runs: ${id}`); + } + if (runs.length === 0 && lastRun) runs.push(lastRun); + return { + id, + title: text(source.title, 'legacy Plan Reminder title'), + intent: { kind: 'text', body: text(source.note, 'legacy Plan Reminder note') }, + schedule: legacyReminderSchedule(source.schedule), + effect: legacyReminderEffect(source.delivery), + status: + releasedStatus === 'scheduled' && enabled + ? 'active' + : releasedStatus === 'completed' + ? 'completed' + : 'paused', + nextFireAt: + releasedStatus === 'scheduled' && enabled + ? nullableInteger(source.nextRunAt, 'nextRunAt') + : null, + lastFireAt: lastRun?.at ?? null, + fireCount: integer(source.runCount, 'legacy Plan Reminder runCount'), + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt, + updatedAt, + runs, + lastError: lastRun && lastRun.outcome !== 'ok' ? lastRun.message : null, + }; +} + +function sameRun(left: ScheduledTaskRun, right: ScheduledTaskRun): boolean { + return ( + left.id === right.id && + left.at === right.at && + left.outcome === right.outcome && + left.message === right.message + ); +} + +function legacyReminderSchedule(value: unknown): ScheduledTaskSchedule { + const source = record(value, 'legacy Plan Reminder schedule'); + if (source.kind === 'once') { + return { kind: 'once', runAt: integer(source.runAt, 'legacy Plan Reminder runAt') }; + } + if (source.kind === 'recurring') { + const recurrence = enumValue(source.recurrence, ['daily', 'weekly', 'monthly'] as const); + const startAt = integer(source.startAt, 'legacy Plan Reminder startAt'); + return recurrence === 'monthly' + ? { kind: 'calendar', recurrence, anchorAt: startAt } + : { kind: 'interval', everySeconds: recurrence === 'daily' ? 86_400 : 604_800, startAt }; + } + if (source.kind === 'cron') { + return { + kind: 'cron', + expression: canonicalizeLegacyPlanReminderCronExpression( + text(source.expression, 'legacy Plan Reminder cron expression'), + ), + startAt: integer(source.startAt, 'legacy Plan Reminder startAt'), + }; + } + throw new Error('Invalid legacy Plan Reminder schedule'); +} + +function legacyReminderEffect(value: unknown): ScheduledTaskEffect { + if (value === undefined || value === null) return { kind: 'notify', channel: 'local' }; + const source = record(value, 'legacy Plan Reminder delivery'); + if (source.channel === 'local') return { kind: 'notify', channel: 'local' }; + if (source.channel === 'bot' && isBotDeliveryProvider(source.platform)) { + return { + kind: 'notify', + channel: 'bot', + platform: source.platform, + chatId: text(source.chatId, 'legacy Plan Reminder chat id'), + }; + } + throw new Error('Invalid legacy Plan Reminder delivery'); +} + +function decodeLegacyRun(value: unknown): ScheduledTaskRun { + const source = record(value, 'legacy Plan Reminder run'); + if (source.blockReason !== undefined) { + throw new Error('Legacy Plan Reminder block reason cannot be preserved'); + } + return { + id: text(source.id, 'legacy Plan Reminder run id'), + at: integer(source.at, 'legacy Plan Reminder run at'), + outcome: + source.status === 'triggered' + ? 'ok' + : enumValue(source.status, ['blocked', 'failed'] as const), + message: text(source.message, 'legacy Plan Reminder run message'), + }; +} + +function jsonRecord(value: unknown, label: string): Record { + if (typeof value !== 'string') throw new Error(`Invalid ${label} record JSON`); + try { + return record(JSON.parse(value), label); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`Invalid ${label} record JSON`); + throw error; + } +} + +function record(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid ${label}`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`Invalid ${label}`); + return value; +} + +function text(value: unknown, label: string): string { + if (typeof value !== 'string') throw new Error(`Invalid ${label}`); + return value; +} + +function integer(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid ${label}`); + } + return value; +} + +function nullableInteger(value: unknown, label: string): number | null { + return value === null ? null : integer(value, label); +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw new Error(`Invalid ${label}`); + return value; +} + +function enumValue(value: unknown, values: T): T[number] { + if (typeof value !== 'string' || !values.includes(value)) throw new Error('Invalid enum value'); + return value as T[number]; +} + +function hasTable(database: DatabaseSync, name: string): boolean { + return ( + database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(name) !== + undefined + ); +} + +function hasColumn(database: DatabaseSync, table: string, column: string): boolean { + return ( + database.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column) !== + undefined + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/321caa87cd5198960b3d65b7597639d91a4e7ccd3e9784dec16ff6609234e886.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/321caa87cd5198960b3d65b7597639d91a4e7ccd3e9784dec16ff6609234e886.source new file mode 100644 index 0000000000..2695cc9490 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/321caa87cd5198960b3d65b7597639d91a4e7ccd3e9784dec16ff6609234e886.source @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { chainWrite } from '../write-queue.js'; + +// Flush all pending microtasks (e.g. a .finally cleanup callback queued +// after a chain settles) before asserting on Map state. +const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)); + +describe('chainWrite', () => { + it('evicts queue entries once the chain drains', async () => { + const queues = new Map>(); + for (let i = 0; i < 50; i++) { + await chainWrite(queues, `k${i}`, async () => {}); + } + // Earlier keys' .finally cleanups run during the subsequent awaits; + // setImmediate drains the last key's residual microtask. + await flushMicrotasks(); + assert.equal(queues.size, 0); + }); + + it('does not evict while a newer write is queued behind', async () => { + const queues = new Map>(); + let resolveOp1!: () => void; + let resolveOp2!: () => void; + const op1 = () => + new Promise((resolve) => { + resolveOp1 = resolve; + }); + const op2 = () => + new Promise((resolve) => { + resolveOp2 = resolve; + }); + + const p1 = chainWrite(queues, 'k', op1); + const p2 = chainWrite(queues, 'k', op2); // queues behind op1; overwrites map entry + + // Let op1's body run (it sets resolveOp1) without completing it. + // op2 stays queued behind op1's pending chain. + await flushMicrotasks(); + resolveOp1(); + await p1; // op1 drains; op2 starts running + await flushMicrotasks(); + // op2 is now in flight — the identity guard must keep its entry + // alive (op1's .finally saw get(k) !== its own tracked promise). + assert.equal(queues.has('k'), true); + + resolveOp2(); + await p2; + await flushMicrotasks(); + assert.equal(queues.has('k'), false); + }); + + it('does not evict when a successor is queued behind a failing write', async () => { + // Combines the two axes the tests above split: the in-flight write + // rejects *while* a successor is already queued behind it (rather than + // a successor queued after the rejection settles). Guards the identity + // check under rejection: op1's .finally must see get(k) !== its own + // tracked promise (op2 overwrote it) and leave the entry in place for + // the still-in-flight op2. Note: this does NOT guard the .catch(noop) + // swallow — op2's .then(op, op) already absorbs op1's rejection — that + // invariant is covered by the "keeps the chain alive" test below. + const queues = new Map>(); + let rejectOp1!: (e: Error) => void; + let resolveOp2!: () => void; + const p1 = chainWrite( + queues, + 'k', + () => + new Promise((_, rej) => { + rejectOp1 = rej; + }), + ); + const p2 = chainWrite( + queues, + 'k', + () => + new Promise((res) => { + resolveOp2 = res; + }), + ); + + // op1's body runs and parks on rejectOp1; op2 stays queued behind it. + await flushMicrotasks(); + rejectOp1(new Error('boom')); + await assert.rejects(p1, /boom/); + await flushMicrotasks(); + // op2 is now in flight — the identity guard kept its entry alive. + assert.equal(queues.has('k'), true); + + resolveOp2(); + await p2; + await flushMicrotasks(); + assert.equal(queues.size, 0); + }); + + it('serializes operations under the same key in call order', async () => { + const queues = new Map>(); + const order: number[] = []; + const promises: Promise[] = []; + for (let i = 0; i < 5; i++) { + promises.push( + chainWrite(queues, 'k', async () => { + order.push(i); + }), + ); + } + await Promise.all(promises); + assert.deepEqual(order, [0, 1, 2, 3, 4]); + }); + + it('propagates rejection to the caller and keeps the chain alive', async () => { + const queues = new Map>(); + await assert.rejects( + chainWrite(queues, 'k', async () => { + throw new Error('boom'); + }), + /boom/, + ); + // The Map-held chain swallowed the rejection; a subsequent write + // under the same key must still run. + let ran = false; + await chainWrite(queues, 'k', async () => { + ran = true; + }); + assert.equal(ran, true); + await flushMicrotasks(); + assert.equal(queues.size, 0); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/322ed7ea7f10d02ba1a241250feaec857cdbc868540fc767d0b7820a3dba292f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/322ed7ea7f10d02ba1a241250feaec857cdbc868540fc767d0b7820a3dba292f.source new file mode 100644 index 0000000000..508dedd327 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/322ed7ea7f10d02ba1a241250feaec857cdbc868540fc767d0b7820a3dba292f.source @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants } from 'node:fs'; +import { lstat, open, readdir, rename, rm, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { syncDirectory } from '../stable-storage.js'; +import { + commitOutcomeUnknown, + invalidDocument, + ioFailed, + RuntimePolicyStoreError, +} from './errors.js'; + +export const POLICY_DOCUMENT_MAX_BYTES = 48 * 1024; +export const CATALOG_DOCUMENT_MAX_BYTES = 4 * 1024 * 1024; +export const VAULT_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024; + +const READ_CHUNK_BYTES = 64 * 1024; +const RUNTIME_POLICY_TEMP_PATTERN = + /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding|runtime-policy-oauth-login-receipts|model-facts)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; + +export async function cleanupRuntimePolicyDocumentTemps(root: string): Promise { + let failure: unknown; + try { + const entries = await readdir(root); + for (const entry of entries) { + if (!RUNTIME_POLICY_TEMP_PATTERN.test(entry)) continue; + const path = join(root, entry); + let metadata; + try { + metadata = await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw ioFailed('A runtime policy temporary artifact could not be inspected', error); + } + if (metadata.isDirectory()) { + throw invalidDocument('Runtime policy temporary artifacts must not be directories'); + } + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw ioFailed('A runtime policy temporary artifact could not be removed', error); + } + } + } catch (error) { + failure = + error instanceof RuntimePolicyStoreError + ? error + : ioFailed('Runtime policy temporary artifacts could not be listed', error); + } + + try { + await syncDirectory(root); + } catch (error) { + failure ??= ioFailed( + 'Runtime policy temporary artifact cleanup could not be synchronized', + error, + ); + } + if (failure !== undefined) throw failure; +} + +export async function readBoundedJsonDocument( + root: string, + file: string, + maxBytes: number, +): Promise { + const bytes = await readBoundedDocumentBytes(root, file, maxBytes); + if (bytes === undefined) return undefined; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw invalidDocument(`${file} is not valid UTF-8`, error); + } + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw invalidDocument(`${file} is not valid JSON`, error); + } +} + +export async function readBoundedDocumentBytes( + root: string, + file: string, + maxBytes: number, +): Promise { + const path = join(root, file); + const flags = + process.platform === 'win32' + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + let handle; + try { + handle = await open(path, flags); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + if (process.platform !== 'win32' && (error as NodeJS.ErrnoException).code === 'ELOOP') { + throw invalidDocument(`${file} must not be a symbolic link`, error); + } + throw ioFailed(`${file} could not be opened`, error); + } + + let result: Buffer | undefined; + let failure: unknown; + try { + const metadata = await handle.stat(); + if (!metadata.isFile()) throw invalidDocument(`${file} must be a regular file`); + if (metadata.size > maxBytes) + throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); + + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const remaining = maxBytes + 1 - total; + if (remaining <= 0) throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); + const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, total); + if (bytesRead === 0) break; + total += bytesRead; + chunks.push(buffer.subarray(0, bytesRead)); + } + if (total > maxBytes) throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); + + result = Buffer.concat(chunks, total); + } catch (error) { + failure = error; + } finally { + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + } + + if (failure !== undefined) { + if (failure instanceof RuntimePolicyStoreError) throw failure; + throw ioFailed(`${file} could not be read`, failure); + } + return result; +} + +export async function writeJsonDocument( + root: string, + file: string, + value: unknown, + maxBytes: number, + synchronizeDirectory: (root: string) => Promise = syncDirectory, +): Promise { + const bytes = serializeJsonDocument(value); + if (bytes.length > maxBytes) throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); + + const path = join(root, file); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + let temporaryCreated = false; + let published = false; + let failure: unknown; + try { + handle = await open(temporaryPath, 'wx', 0o600); + temporaryCreated = true; + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporaryPath, path); + published = true; + await synchronizeDirectory(root); + } catch (error) { + failure = error; + } finally { + if (handle !== undefined) { + try { + await handle.close(); + } catch (error) { + failure ??= error; + } + } + if (temporaryCreated && !published) { + try { + await rm(temporaryPath, { force: true }); + } catch (error) { + failure ??= error; + } + } + } + + if (failure === undefined) return; + if (published) { + throw commitOutcomeUnknown( + `${file} commit outcome is unknown; reload before retrying`, + failure, + ); + } + throw ioFailed(`${file} I/O failed before publication`, failure); +} + +export function serializeJsonDocument(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3267b92f41bb892ad301000262662e567194a967abda841d38ae99da4c77aed7.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3267b92f41bb892ad301000262662e567194a967abda841d38ae99da4c77aed7.source new file mode 100644 index 0000000000..cc6f7776b6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3267b92f41bb892ad301000262662e567194a967abda841d38ae99da4c77aed7.source @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionListFilter } from '@maka/core/runtime-inputs'; +import { sqliteOrdinarySessionRolePredicate } from './sqlite-session-role-scope.js'; + +export interface SqliteSessionCatalogCursor { + readonly activityAt: number; + readonly sessionId: string; +} + +export interface SqliteSessionCatalogPageQuery { + readonly sql: string; + readonly parameters: readonly (string | number)[]; +} + +export function buildSqliteSessionCatalogPageQuery( + filter: SessionListFilter, + cursor: SqliteSessionCatalogCursor | undefined, +): SqliteSessionCatalogPageQuery { + const where: string[] = []; + const parameters: Array = []; + const role = sqliteOrdinarySessionRolePredicate(); + where.push( + "COALESCE(json_extract(metadata.payload_json, '$.conversationCopy.state'), '') <> 'preparing'", + ); + where.push(role.sql); + parameters.push(...role.parameters); + where.push("COALESCE(json_extract(metadata.payload_json, '$.transcriptLedgerVersion'), 1) <> 0"); + if (filter.subagentParentSessionId !== undefined) { + where.push('projection.subagent_parent_session_id = ?'); + parameters.push(filter.subagentParentSessionId); + } + if (cursor) { + where.push('projection.activity_at <= ?'); + where.push(` + ( + projection.activity_at < ? + OR ( + projection.activity_at = ? + AND projection.session_id > ? + ) + ) + `); + parameters.push(cursor.activityAt, cursor.activityAt, cursor.activityAt, cursor.sessionId); + } + return { + sql: ` + SELECT + metadata.session_id, + metadata.payload_json, + metadata.metadata_version, + metadata.committed_at, + projection.activity_at, + projection.last_message_preview + FROM session_catalog_projection projection + JOIN session_metadata metadata + ON metadata.session_id = projection.session_id + ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY projection.activity_at DESC, projection.session_id ASC + LIMIT ? + `, + parameters, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3418f2496390c0927201e4fdbcbba72dc04924e469a03b0e9ecf651850e27e75.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3418f2496390c0927201e4fdbcbba72dc04924e469a03b0e9ecf651850e27e75.source new file mode 100644 index 0000000000..f14f7fabb4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3418f2496390c0927201e4fdbcbba72dc04924e469a03b0e9ecf651850e27e75.source @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const SAFE_STORAGE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** Returns whether a value can safely be used as a persisted storage identity. */ +export function isSafeStorageId(value: unknown): value is string { + return typeof value === 'string' && SAFE_STORAGE_ID_PATTERN.test(value); +} + +/** Throws a TypeError unless the value is a safe persisted storage identity. */ +export function assertSafeStorageId( + value: unknown, + message = 'Storage identity is invalid', +): asserts value is string { + if (!isSafeStorageId(value)) throw new TypeError(message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/38b0c5825ab435155899bf5d21c9bede6061db8e8f6f49d222768aef39c1ce83.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/38b0c5825ab435155899bf5d21c9bede6061db8e8f6f49d222768aef39c1ce83.source new file mode 100644 index 0000000000..f3c4a97689 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/38b0c5825ab435155899bf5d21c9bede6061db8e8f6f49d222768aef39c1ce83.source @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ConnectionCatalogMutationResult, + ConnectionCatalogSnapshot, + ConnectionCredentialTarget, + CreateCatalogConnectionInput, + CredentialLocator, + CredentialMutationResult, + CredentialVaultSnapshot, + DeleteCredentialInput, + MutateRuntimePolicyInput, + MutateRuntimePolicyResult, + RemoveCatalogConnectionInput, + RuntimePolicySnapshot, + SetCredentialInput, + MigrateSystemSeedInput, + SetDefaultConnectionTargetInput, + UpdateCatalogConnectionInput, +} from '@maka/core/runtime-policy'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { RuntimePolicyCoordinator } from './runtime-policy/coordinator.js'; +import type { + CredentialStatusQueryResult as CredentialStatusQuery, + RuntimePolicyOperationCoordinator as OperationCoordinator, +} from './runtime-policy/operations.js'; + +export { + RuntimePolicyStoreError, + type RuntimePolicyStoreErrorCode, +} from './runtime-policy/errors.js'; +export type { + BeginConnectionTestResult, + BeginInteractiveOAuthLoginResult, + BeginModelFetchResult, + CompareAndSetOAuthCredentialInput, + CompareAndSetOAuthCredentialResult, + ConnectionEffectChangedDomain, + ConnectionEffectCompletionResult, + BeginConnectionOnboardingInput, + BeginConnectionOnboardingResult, + CommitConnectionOnboardingInput, + CommitConnectionOnboardingResult, + ConnectionEffectPreparationFailure, + ConnectionOnboardingTicket, + ConnectionTestTicket, + InteractiveOAuthLoginCompletionResult, + InteractiveOAuthLoginProvider, + InteractiveOAuthLoginInput, + InteractiveOAuthLoginTarget, + InteractiveOAuthConnectionIdentity, + InteractiveOAuthLoginTicket, + QueryInteractiveOAuthLoginResult, + CredentialStatusQueryResult, + ModelFetchTicket, + ProviderAuthKind, + RuntimePolicyCredentialMaterial, + RuntimePolicyOperationCoordinator, + RuntimePolicyOperationSecretMaterial, + ResolveExecutionConnectionResult, + ReplaceConnectionRequestHeadersResult, + ResolveNetworkProxyExecutionInput, + ResolveNetworkProxyExecutionResult, + ResolveWebSearchExecutionInput, + ResolveWebSearchExecutionResult, + ResolveHostOutboundExecutionResult, +} from './runtime-policy/operations.js'; + +const readerBrand: unique symbol = Symbol('RuntimePolicyStoresReader'); +const writerBrand: unique symbol = Symbol('RuntimePolicyStoresWriter'); +const readers = new WeakSet(); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface RuntimePolicyReader { + getSnapshot(): Promise; +} + +export interface RuntimePolicyWriter extends RuntimePolicyReader { + mutate(input: MutateRuntimePolicyInput): Promise; +} + +export interface ConnectionCatalogReader { + getSnapshot(): Promise; +} + +export interface ConnectionCatalogWriter extends ConnectionCatalogReader { + create(input: CreateCatalogConnectionInput): Promise; + update(input: UpdateCatalogConnectionInput): Promise; + remove(input: RemoveCatalogConnectionInput): Promise; + setDefaultTarget( + input: SetDefaultConnectionTargetInput, + ): Promise; + migrateSystemSeed(input: MigrateSystemSeedInput): Promise; +} + +export interface CredentialVaultReader { + getSnapshot(): Promise; + getStatus(locator: CredentialLocator): Promise; +} + +export interface CredentialVaultWriter extends CredentialVaultReader { + set(input: SetCredentialInput): Promise; + delete(input: DeleteCredentialInput): Promise; +} + +export interface RuntimePolicyStoresReader { + readonly kind: 'interactive'; + readonly access: 'read'; + readonly [readerBrand]: true; + readonly runtimePolicy: Readonly; + readonly connectionCatalog: Readonly; + readonly credentialVault: Readonly; +} + +export interface RuntimePolicyStoresWriter { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + readonly runtimePolicy: Readonly; + readonly connectionCatalog: Readonly; + readonly credentialVault: Readonly; + readonly operations: Readonly; +} + +export function authenticateRuntimePolicyStoresReader( + stores: RuntimePolicyStoresReader, +): RuntimePolicyStoresReader { + if (!readers.has(stores)) throw invalidFacade('read'); + return stores; +} + +export function authenticateRuntimePolicyStoresWriter( + stores: RuntimePolicyStoresWriter, +): RuntimePolicyStoresWriter { + if (!writers.has(stores)) throw invalidFacade('write'); + return stores; +} + +export async function openInteractiveRuntimePolicyStoresForRead( + lease: StorageRootLease<'interactive', 'read'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'read'); + const coordinator = new RuntimePolicyCoordinator((operation: (root: string) => Promise) => + runWithStorageRootLease(lease, 'interactive', 'read', operation), + ); + const stores: RuntimePolicyStoresReader = { + kind: 'interactive', + access: 'read', + [readerBrand]: true, + runtimePolicy: { getSnapshot: () => coordinator.getPolicySnapshot() }, + connectionCatalog: { getSnapshot: () => coordinator.getCatalogSnapshot() }, + credentialVault: { + getSnapshot: () => coordinator.getVaultSnapshot(), + getStatus: (locator) => coordinator.getCredentialStatus(locator), + }, + }; + freezeFacade(stores); + readers.add(stores); + return stores; +} + +export async function openInteractiveRuntimePolicyStoresForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const coordinator = new RuntimePolicyCoordinator((operation: (root: string) => Promise) => + runWithStorageRootLease(lease, 'interactive', 'write', operation), + ); + const pending = Promise.resolve().then(async () => { + await coordinator.recoverForWrite(); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) return recoveredExisting; + const stores = createWriterFacade(coordinator); + writers.add(stores); + writerByLease.set(lease, stores); + return stores; + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolicyStoresWriter { + const stores: RuntimePolicyStoresWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + runtimePolicy: { + getSnapshot: () => coordinator.getPolicySnapshot(), + mutate: (input) => coordinator.mutatePolicy(input), + }, + connectionCatalog: { + getSnapshot: () => coordinator.getCatalogSnapshot(), + create: (input) => coordinator.createConnection(input), + update: (input) => coordinator.updateConnection(input), + remove: (input) => coordinator.removeConnection(input), + setDefaultTarget: (input) => coordinator.setDefaultTarget(input), + migrateSystemSeed: (input) => coordinator.migrateSystemSeed(input), + }, + credentialVault: { + getSnapshot: () => coordinator.getVaultSnapshot(), + getStatus: (locator) => coordinator.getCredentialStatus(locator), + set: (input) => coordinator.setCredential(input), + delete: (input) => coordinator.deleteCredential(input), + }, + operations: { + updateNetworkProxy: (input) => coordinator.updateNetworkProxy(input), + exportCredentialMaterial: (( + locator: CredentialLocator, + expectedConnection?: ConnectionCredentialTarget, + ) => + expectedConnection + ? coordinator.exportCredentialMaterial(locator, expectedConnection) + : coordinator.exportCredentialMaterial( + locator, + )) as OperationCoordinator['exportCredentialMaterial'], + getConnectionRequestHeaders: (connectionId) => + coordinator.getConnectionRequestHeaders(connectionId), + replaceConnectionRequestHeaders: (connectionId, updates) => + coordinator.replaceConnectionRequestHeaders(connectionId, updates), + resolveExecutionConnection: (ref) => coordinator.resolveExecutionConnection(ref), + resolveWebSearchExecution: (input) => coordinator.resolveWebSearchExecution(input), + resolveHostOutboundExecution: () => coordinator.resolveHostOutboundExecution(), + resolveNetworkProxyExecution: (input) => coordinator.resolveNetworkProxyExecution(input), + compareAndSetOAuthCredential: (input) => coordinator.compareAndSetOAuthCredential(input), + importConnectionCredential: (input) => coordinator.importConnectionCredential(input), + beginInteractiveOAuthLogin: (input) => coordinator.beginInteractiveOAuthLogin(input), + queryInteractiveOAuthLogin: (attemptId) => coordinator.queryInteractiveOAuthLogin(attemptId), + completeInteractiveOAuthLogin: (ticket, secret) => + coordinator.completeInteractiveOAuthLogin(ticket, secret), + beginModelFetch: (connectionId) => coordinator.beginModelFetch(connectionId), + completeModelFetch: (ticket, result) => coordinator.completeModelFetch(ticket, result), + beginConnectionOnboarding: (input) => coordinator.beginConnectionOnboarding(input), + completeConnectionOnboarding: (ticket, input) => + coordinator.completeConnectionOnboarding(ticket, input), + beginConnectionTest: (connectionId, modelId) => + coordinator.beginConnectionTest(connectionId, modelId), + completeConnectionTest: (ticket, result) => + coordinator.completeConnectionTest(ticket, result), + }, + }; + freezeFacade(stores); + return stores; +} + +function invalidFacade(access: 'read' | 'write'): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + `Expected authentic interactive ${access} runtime policy stores`, + ); +} + +function freezeFacade(stores: { + readonly runtimePolicy: object; + readonly connectionCatalog: object; + readonly credentialVault: object; + readonly operations?: object; +}): void { + Object.freeze(stores.runtimePolicy); + Object.freeze(stores.connectionCatalog); + Object.freeze(stores.credentialVault); + if (stores.operations) Object.freeze(stores.operations); + Object.freeze(stores); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/390caa98d55b7b3b629602d2596d126c4fe76d6d6ed67954c26ad8b9ff7063f6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/390caa98d55b7b3b629602d2596d126c4fe76d6d6ed67954c26ad8b9ff7063f6.source new file mode 100644 index 0000000000..3dcb76e272 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/390caa98d55b7b3b629602d2596d126c4fe76d6d6ed67954c26ad8b9ff7063f6.source @@ -0,0 +1,327 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { constants as fsConstants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, join, normalize, parse, resolve } from 'node:path'; + +import { execGitText } from './git-exec.js'; +import { hasEnclosingGitEntry } from './git-entry.js'; +import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js'; + +export const WORKSPACE_MARKER_FILE = '.maka-workspace.json'; +export const WORKSPACE_MARKER_SCHEMA_VERSION = 1 as const; +export const WORKSPACE_IDENTITY_PREFIX = 'workspace:v1:' as const; +const MAX_WORKSPACE_MARKER_BYTES = 4_096; +const MAX_GIT_EXCLUDE_BYTES = 1024 * 1024; +interface WorkspaceMarker { + schemaVersion: typeof WORKSPACE_MARKER_SCHEMA_VERSION; + workspaceId: string; +} + +export interface WorkspaceIdentityResolution { + workspaceIdentity: string; + canonicalPath: string; +} + +export interface ResolveWorkspaceIdentityInput { + path: string; +} + +export type WorkspaceIdentityErrorCode = + | 'workspace_not_found' + | 'invalid_workspace' + | 'workspace_unmarked' + | 'invalid_workspace_marker' + | 'workspace_identity_changed' + | 'workspace_io_failed'; + +export class WorkspaceIdentityError extends Error { + constructor( + readonly code: WorkspaceIdentityErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'WorkspaceIdentityError'; + } +} + +/** + * Resolves the intrinsic workspace identity, creating a marker only when the + * workspace has never been marked. Existing markers are authoritative and are + * never rebound based on path or inode. + */ +export async function resolveWorkspaceIdentity( + input: ResolveWorkspaceIdentityInput, +): Promise { + return withWorkspaceFailure(async () => { + const snapshot = await resolveWorkspaceSnapshot(input.path); + const marker = await ensureWorkspaceMarker(snapshot); + return toResolution(snapshot.canonicalPath, marker); + }); +} + +interface WorkspaceSnapshot { + canonicalPath: string; + workspaceStat: BigIntStats; +} + +async function resolveWorkspaceSnapshot(path: string): Promise { + let canonicalPath: string; + try { + canonicalPath = canonicalizePath(await realpath(resolve(path))); + } catch (error) { + if (isMissingPathError(error)) { + throw new WorkspaceIdentityError( + 'workspace_not_found', + `Workspace does not exist: ${resolve(path)}`, + ); + } + throw error; + } + const workspaceStat = await stat(canonicalPath, { bigint: true }); + if (!workspaceStat.isDirectory()) { + throw new WorkspaceIdentityError( + 'invalid_workspace', + `Workspace is not a directory: ${canonicalPath}`, + ); + } + return { canonicalPath, workspaceStat }; +} + +async function ensureWorkspaceMarker(snapshot: WorkspaceSnapshot): Promise { + try { + const marker = await readWorkspaceMarker(snapshot.canonicalPath); + await ensureWorkspaceMarkerIgnored(snapshot.canonicalPath); + return marker; + } catch (error) { + if (!(error instanceof WorkspaceIdentityError) || error.code !== 'workspace_unmarked') { + throw error; + } + } + return createWorkspaceMarker(snapshot, randomUUID()); +} + +async function createWorkspaceMarker( + snapshot: WorkspaceSnapshot, + workspaceId: string, +): Promise { + await ensureWorkspaceMarkerIgnored(snapshot.canonicalPath); + const marker: WorkspaceMarker = { + schemaVersion: WORKSPACE_MARKER_SCHEMA_VERSION, + workspaceId, + }; + await publishMarkerFile({ + root: snapshot.canonicalPath, + markerFile: WORKSPACE_MARKER_FILE, + contents: serializeWorkspaceMarker(marker), + maxBytes: MAX_WORKSPACE_MARKER_BYTES, + publication: 'create', + beforePublish: () => assertWorkspaceSnapshot(snapshot), + invalidFile: () => + new WorkspaceIdentityError( + 'invalid_workspace_marker', + 'Workspace marker candidate exceeds the size limit', + ), + }); + await assertWorkspaceSnapshot(snapshot); + return readWorkspaceMarker(snapshot.canonicalPath); +} + +async function ensureWorkspaceMarkerIgnored(workspacePath: string): Promise { + if (!(await hasEnclosingGitEntry(workspacePath))) return; + + const stdout = await execGitText( + workspacePath, + ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], + { maxBuffer: 64 * 1024, timeoutMs: 3_000 }, + ); + const excludePath = stdout.trim(); + if (!isAbsolute(excludePath)) { + throw new Error(`Git returned a non-absolute exclude path: ${excludePath}`); + } + + const handle = await open( + excludePath, + fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + const [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(excludePath, { bigint: true }), + ]); + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.size > BigInt(MAX_GIT_EXCLUDE_BYTES) || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw new Error(`Git exclude must be one bounded regular file: ${excludePath}`); + } + const contents = await handle.readFile('utf8'); + if (contents.split(/\r?\n/).includes(WORKSPACE_MARKER_FILE)) return; + const separator = contents.length === 0 || contents.endsWith('\n') ? '' : '\n'; + const addition = `${separator}${WORKSPACE_MARKER_FILE}\n`; + if ( + handleStat.size + BigInt(Buffer.byteLength(addition, 'utf8')) > + BigInt(MAX_GIT_EXCLUDE_BYTES) + ) { + throw new Error(`Git exclude must be one bounded regular file: ${excludePath}`); + } + await handle.writeFile(addition, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } +} + +function serializeWorkspaceMarker(marker: WorkspaceMarker): string { + if (!isWorkspaceMarker(marker)) { + throw new WorkspaceIdentityError( + 'invalid_workspace_marker', + 'Workspace marker candidate has invalid fields', + ); + } + return `${JSON.stringify(marker)}\n`; +} + +async function readWorkspaceMarker(root: string): Promise { + const markerPath = join(root, WORKSPACE_MARKER_FILE); + let marker: unknown; + try { + marker = JSON.parse( + await readBoundedMarkerFile({ + path: markerPath, + maxBytes: MAX_WORKSPACE_MARKER_BYTES, + invalidFile: () => + new WorkspaceIdentityError( + 'invalid_workspace_marker', + `Workspace marker must be one bounded regular file: ${markerPath}`, + ), + }), + ); + } catch (error) { + if (error instanceof WorkspaceIdentityError) throw error; + if (isMissingPathError(error)) { + throw new WorkspaceIdentityError('workspace_unmarked', `Workspace is not marked: ${root}`); + } + if (error instanceof SyntaxError || isInvalidMarkerPathError(error)) { + throw new WorkspaceIdentityError( + 'invalid_workspace_marker', + `Invalid workspace marker at ${markerPath}`, + { cause: error }, + ); + } + throw error; + } + if (!isWorkspaceMarker(marker)) { + throw new WorkspaceIdentityError( + 'invalid_workspace_marker', + `Invalid workspace marker at ${markerPath}`, + ); + } + return marker; +} + +function isWorkspaceMarker(value: unknown): value is WorkspaceMarker { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const marker = value as Record; + const keys = Object.keys(marker).sort(); + return ( + keys.length === 2 && + keys[0] === 'schemaVersion' && + keys[1] === 'workspaceId' && + marker.schemaVersion === WORKSPACE_MARKER_SCHEMA_VERSION && + typeof marker.workspaceId === 'string' && + isUuid(marker.workspaceId) + ); +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function toResolution(canonicalPath: string, marker: WorkspaceMarker): WorkspaceIdentityResolution { + return { + workspaceIdentity: `${WORKSPACE_IDENTITY_PREFIX}${marker.workspaceId}`, + canonicalPath, + }; +} + +async function assertWorkspaceSnapshot(snapshot: WorkspaceSnapshot): Promise { + const current = await statWorkspaceIfPresent(snapshot.canonicalPath); + if ( + !current?.isDirectory() || + current.dev !== snapshot.workspaceStat.dev || + current.ino !== snapshot.workspaceStat.ino + ) { + throw new WorkspaceIdentityError( + 'workspace_identity_changed', + `Workspace changed while validating its marker: ${snapshot.canonicalPath}`, + ); + } +} + +function canonicalizePath(path: string): string { + const normalized = normalize(path); + const root = parse(normalized).root; + return normalized === root ? normalized : normalized.replace(/[\\/]+$/, ''); +} + +async function statWorkspaceIfPresent(path: string): Promise { + try { + return await stat(path, { bigint: true }); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} + +function isMissingPathError(error: unknown): boolean { + return isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'); +} + +function isInvalidMarkerPathError(error: unknown): boolean { + return isMissingPathError(error) || isNodeError(error, 'ELOOP') || isNodeError(error, 'ENXIO'); +} + +async function withWorkspaceFailure(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof WorkspaceIdentityError) throw error; + throw new WorkspaceIdentityError( + 'workspace_io_failed', + 'Unable to resolve workspace identity', + { + cause: error, + }, + ); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3969147664af86118d9b2cfe35839e9af672da81ed1b70728a5e50e2bccd9766.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3969147664af86118d9b2cfe35839e9af672da81ed1b70728a5e50e2bccd9766.source new file mode 100644 index 0000000000..ec1181581e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3969147664af86118d9b2cfe35839e9af672da81ed1b70728a5e50e2bccd9766.source @@ -0,0 +1,1972 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { chmod, link, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { Worker } from 'node:worker_threads'; +import { + MemoryItemStoreConflictError, + type MemoryItemSource, + type MemoryItemWrite, +} from '@maka/core/long-term-memory'; +import { + LONG_TERM_MEMORY_DATABASE_NAME, + authenticateInteractiveLongTermMemoryWriter, + openInteractiveLongTermMemoryStoreForWrite, +} from '../long-term-memory-store.js'; +import { + resolveStorageRoot, + STORAGE_ROOT_MARKER_FILE, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION } from '../sqlite-long-term-memory-schema.js'; +import { + SqliteMemoryItemStore, + type SqliteMemoryItemStoreFailpoint, +} from '../sqlite-long-term-memory-store.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +const require = createRequire(import.meta.url); + +describe('SqliteMemoryItemStore', () => { + test('creates a private, versioned WAL database and reopens it idempotently', async () => { + await withStore(async ({ store, databasePath }) => { + assert.equal(store.schemaVersion(), SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + assert.equal(store.journalMode(), 'wal'); + assert.equal(store.foreignKeysEnabled(), true); + if (process.platform !== 'win32') { + assert.equal((await stat(databasePath)).mode & 0o777, 0o600); + for (const sidecar of [`${databasePath}-wal`, `${databasePath}-shm`]) { + assert.equal((await stat(sidecar)).mode & 0o777, 0o600); + } + } + store.close(); + + const reopened = new SqliteMemoryItemStore(databasePath); + try { + assert.equal(reopened.schemaVersion(), SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + assert.equal(await reopened.readItem('missing-item'), undefined); + } finally { + reopened.close(); + } + }); + }); + + test('rolls back a failed migration and rejects a newer unknown schema without changing it', async () => { + await withTempRoot(async (root) => { + const failedPath = join(root, 'failed.sqlite'); + assert.throws( + () => + new SqliteMemoryItemStore(failedPath, { + migrationFailpoint: () => { + throw new Error('migration failure'); + }, + }), + /migration failure/, + ); + const recovered = new SqliteMemoryItemStore(failedPath); + assert.equal(recovered.schemaVersion(), SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + recovered.close(); + + const newerPath = join(root, 'newer.sqlite'); + const Database = loadDatabaseSync(); + const newer = new Database(newerPath); + newer.exec('PRAGMA user_version = 99'); + const originalJournalMode = String( + (newer.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown }).journal_mode, + ); + newer.close(); + assert.throws(() => new SqliteMemoryItemStore(newerPath), /newer than supported/); + const unchanged = new Database(newerPath); + try { + assert.equal( + (unchanged.prepare('PRAGMA user_version').get() as { user_version?: unknown }) + .user_version, + 99, + ); + assert.equal( + String( + (unchanged.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown }) + .journal_mode, + ), + originalJournalMode, + ); + } finally { + unchanged.close(); + } + }); + }); + + test('preserves a pending failure while migrating schema v3 to the current version', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, 'v3-pending.sqlite'); + const initialized = new SqliteMemoryItemStore(databasePath); + initialized.close(); + + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database.exec(` + DROP TABLE memory_compaction_policy_denials; + DROP TABLE memory_extraction_failures; + CREATE TABLE memory_extraction_failures ( + session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0), + from_ordinal INTEGER NOT NULL CHECK (from_ordinal > 0), + through_ordinal INTEGER NOT NULL CHECK (through_ordinal >= from_ordinal), + coverage_hash TEXT NOT NULL CHECK (length(coverage_hash) = 64), + first_operation_id TEXT NOT NULL UNIQUE CHECK (length(first_operation_id) > 0), + first_trigger TEXT NOT NULL CHECK (first_trigger IN ('remember', 'extract')), + first_failure_class TEXT NOT NULL CHECK ( + first_failure_class IN ( + 'provider', 'schema', 'evidence', 'localization', 'requested_admission' + ) + ), + failed_at INTEGER NOT NULL CHECK (failed_at >= 0) + ); + INSERT INTO memory_extraction_failures( + session_id, from_ordinal, through_ordinal, coverage_hash, + first_operation_id, first_trigger, first_failure_class, failed_at + ) VALUES ( + 'session-v3', 2, 7, '${'6'.repeat(64)}', + 'operation-v3', 'extract', 'provider', 900 + ); + PRAGMA user_version = 3; + `); + database.close(); + + const migrated = new SqliteMemoryItemStore(databasePath); + assert.equal(migrated.schemaVersion(), SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + assert.deepEqual(await migrated.readPendingExtractionFailure('session-v3'), { + sessionId: 'session-v3', + fromOrdinal: 2, + throughOrdinal: 7, + coverageHash: '6'.repeat(64), + firstOperationId: 'operation-v3', + firstTrigger: 'extract', + firstFailureClass: 'provider', + failedAt: 900, + }); + migrated.close(); + }); + }); + + test('persists Compaction policy denial independently from Cursor and pending failure', async () => { + await withStore(async ({ store }) => { + const first = await store.recordCompactionPolicyDenial({ + sessionId: 'session-denial', + compactionCheckpointId: 'checkpoint-denial', + deniedAt: 500, + }); + const replay = await store.recordCompactionPolicyDenial({ + sessionId: 'session-denial', + compactionCheckpointId: 'checkpoint-denial', + deniedAt: 900, + }); + + assert.deepEqual(first, { + sessionId: 'session-denial', + compactionCheckpointId: 'checkpoint-denial', + deniedAt: 500, + }); + assert.deepEqual(replay, first); + assert.deepEqual(await store.readCompactionPolicyDenials('session-denial'), [first]); + assert.equal(await store.readExtractionCursor('session-denial'), undefined); + assert.equal(await store.readPendingExtractionFailure('session-denial'), undefined); + }); + }); + + test('rejects current-version databases with missing required tables or columns', async () => { + await withTempRoot(async (root) => { + const Database = loadDatabaseSync(); + const missingTablePath = join(root, 'missing-table.sqlite'); + const missingTable = new Database(missingTablePath); + missingTable.exec(`PRAGMA user_version = ${SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION}`); + missingTable.close(); + assert.throws( + () => new SqliteMemoryItemStore(missingTablePath), + /missing required table memory_items/, + ); + + const missingColumnPath = join(root, 'missing-column.sqlite'); + const missingColumn = new Database(missingColumnPath); + missingColumn.exec(` + CREATE TABLE memory_items (item_id TEXT PRIMARY KEY); + PRAGMA user_version = ${SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION}; + `); + missingColumn.close(); + assert.throws( + () => new SqliteMemoryItemStore(missingColumnPath), + /memory_items is missing required column version/, + ); + }); + }); + + test('rejects a current-version database with a missing query-required index', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, 'missing-index.sqlite'); + const store = new SqliteMemoryItemStore(databasePath); + store.close(); + + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database.exec('DROP INDEX memory_item_keys_by_normalized_key'); + database.close(); + + assert.throws( + () => new SqliteMemoryItemStore(databasePath), + /missing required index memory_item_keys_by_normalized_key/, + ); + }); + }); + + test('serializes truly concurrent first-open migrations', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, 'concurrent.sqlite'); + const gate = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const moduleUrl = new URL('../sqlite-long-term-memory-store.js', import.meta.url).href; + const workers = [ + startConcurrentMigrationWorker(databasePath, moduleUrl, gate), + startConcurrentMigrationWorker(databasePath, moduleUrl, gate), + ]; + await Promise.all(workers.map((worker) => worker.ready)); + const gateView = new Int32Array(gate); + Atomics.store(gateView, 0, 1); + Atomics.notify(gateView, 0, workers.length); + assert.deepEqual((await Promise.all(workers.map((worker) => worker.result))).sort(), [ + SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION, + SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION, + ]); + + const reopened = new SqliteMemoryItemStore(databasePath); + assert.equal(reopened.schemaVersion(), SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + reopened.close(); + }); + }); + + test('rejects database links and sidecar symlinks without changing their targets', async (t) => { + await withTempRoot(async (root) => { + const target = join(root, 'outside.txt'); + await writeFile(target, 'do-not-touch', { mode: 0o644 }); + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + await link(target, databasePath); + assert.throws(() => new SqliteMemoryItemStore(databasePath), /hard-linked/); + assert.equal(await readFile(target, 'utf8'), 'do-not-touch'); + if (process.platform !== 'win32') { + assert.equal((await stat(target)).mode & 0o777, 0o644); + } + + await rm(databasePath); + if (!(await createSymlinkIfSupported(target, databasePath))) { + t.diagnostic('symbolic links are unavailable in this environment'); + return; + } + assert.throws(() => new SqliteMemoryItemStore(databasePath), /symbolic link/); + assert.equal(await readFile(target, 'utf8'), 'do-not-touch'); + + await rm(databasePath); + const initial = new SqliteMemoryItemStore(databasePath); + initial.close(); + await symlink(target, `${databasePath}-wal`); + assert.throws(() => new SqliteMemoryItemStore(databasePath), /symbolic link/); + assert.equal(await readFile(target, 'utf8'), 'do-not-touch'); + }); + }); + + test('normalizes keys and searches global plus the selected workspace', async () => { + await withStore(async ({ store }) => { + const global = await createItem( + store, + 'create-global', + write({ + content: '用户偏好简洁的中文回答。', + keys: [ + { key: ' 偏好 ', keyType: 'concept', keyOrigin: 'llm' }, + { key: '偏好', keyType: 'exact', keyOrigin: 'user' }, + { key: 'Chinese answer', keyType: 'alias', keyOrigin: 'deterministic' }, + ], + }), + ); + const workspace = await createItem( + store, + 'create-workspace', + write({ + content: 'Maka uses RuntimeEvent as evidence.', + kind: 'knowledge', + scopeType: 'workspace', + scopeKey: 'workspace-maka', + keys: [ + { key: 'RuntimeEvent', keyType: 'code', keyOrigin: 'deterministic' }, + { key: 'memory_item', keyType: 'code', keyOrigin: 'deterministic' }, + ], + }), + ); + + assert.deepEqual((await store.readItem(global))?.keys[0], { + key: 'Chinese answer', + normalizedKey: 'chinese answer', + keyType: 'alias', + keyOrigin: 'deterministic', + }); + assert.equal((await store.readItem(global))?.keys[1]?.keyOrigin, 'user'); + assert.deepEqual(await itemIds(store, ['偏好']), [global]); + assert.deepEqual(await itemIds(store, ['runtime'], 'prefix'), []); + assert.deepEqual(await itemIds(store, ['runtime'], 'prefix', 'workspace-maka'), [workspace]); + assert.deepEqual(await itemIds(store, ['memory_'], 'prefix', 'workspace-maka'), [workspace]); + assert.deepEqual(await itemIds(store, ['偏'], 'prefix'), [global]); + await assert.rejects( + store.searchByKeys({ + terms: ['偏好'], + match: 'exact', + includeArchived: 'false' as unknown as boolean, + }), + /includeArchived/, + ); + }); + }); + + test('ranks multi-term matches, deduplicates terms, and enforces result limits', async () => { + await withStore(async ({ store }) => { + const ranked = await store.applyMutations({ + operationId: 'ranked-create', + mutations: [ + { + type: 'create', + item: write({ + content: 'Alpha and beta are both relevant.', + keys: [ + { key: 'alpha', keyType: 'exact', keyOrigin: 'deterministic' }, + { key: 'beta', keyType: 'exact', keyOrigin: 'deterministic' }, + ], + sources: [source({ eventId: 'event-alpha-beta' })], + }), + }, + { + type: 'create', + item: write({ + content: 'Only alpha is relevant.', + keys: [{ key: 'alpha', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-alpha' })], + }), + }, + { + type: 'create', + item: write({ + content: 'Only beta is relevant.', + keys: [{ key: 'beta', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-beta' })], + }), + }, + ], + }); + const rankedIds = ranked.results.map((result) => result.itemId); + assert.deepEqual(await itemIds(store, ['beta', 'alpha', 'ALPHA']), rankedIds); + assert.deepEqual( + (await store.searchByKeys({ terms: ['alpha', 'beta'], match: 'exact', limit: 2 })).map( + (record) => record.item.itemId, + ), + rankedIds.slice(0, 2), + ); + + await store.applyMutations({ + operationId: 'default-limit-create', + mutations: Array.from({ length: 21 }, (_, index) => ({ + type: 'create' as const, + item: write({ + content: `Bulk fact ${index}.`, + keys: [{ key: 'bulk', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: `event-bulk-${index}` })], + }), + })), + }); + assert.equal((await store.searchByKeys({ terms: ['bulk'], match: 'exact' })).length, 20); + await assert.rejects( + store.searchByKeys({ terms: ['bulk'], match: 'exact', limit: 0 }), + /between 1 and 100/, + ); + await assert.rejects( + store.searchByKeys({ terms: ['bulk'], match: 'exact', limit: 101 }), + /between 1 and 100/, + ); + }); + }); + + test('replays operation receipts while allowing independent duplicate assertions', async () => { + await withStore(async ({ store }) => { + const request = { + operationId: 'idempotent-create', + mutations: [{ type: 'create' as const, item: write() }], + }; + const created = await store.applyMutations(request); + const replayed = await store.applyMutations(request); + assert.deepEqual(replayed, { ...created, replayed: true }); + + await assert.rejects( + store.applyMutations({ + operationId: request.operationId, + mutations: [{ type: 'create', item: write({ content: 'Different fact.' }) }], + }), + conflict('operation_reused'), + ); + + const duplicate = await store.applyMutations({ + operationId: 'fact-duplicate', + mutations: [ + { + type: 'create', + item: write({ + origin: 'user_requested', + keys: [{ key: 'other', keyType: 'exact', keyOrigin: 'user' }], + sources: [source({ eventId: 'event-other' })], + }), + }, + ], + }); + assert.equal(duplicate.results[0]?.outcome, 'created'); + assert.deepEqual((await store.readItem(created.results[0]!.itemId))?.sources, [source()]); + assert.ok(await store.readOperation('fact-duplicate')); + }); + }); + + test('uses CAS, replaces current keys and sources, and records no-op writes', async () => { + await withStore(async ({ store }) => { + const itemId = await createItem(store, 'cas-create', write()); + const replacement = write({ + content: 'User prefers no more than three concise points.', + keys: [{ key: 'three-points', keyType: 'exact', keyOrigin: 'user' }], + sources: [source({ eventId: 'event-2', turnId: 'turn-2' })], + }); + const updated = await store.applyMutations({ + operationId: 'cas-update', + mutations: [{ type: 'update', itemId, expectedVersion: 1, item: replacement }], + }); + assert.equal(updated.results[0]?.version, 2); + assert.deepEqual((await store.readItem(itemId))?.sources, [ + source({ eventId: 'event-2', turnId: 'turn-2' }), + ]); + + const noop = await store.applyMutations({ + operationId: 'cas-noop', + mutations: [{ type: 'update', itemId, expectedVersion: 2, item: replacement }], + }); + assert.equal(noop.results[0]?.outcome, 'noop'); + assert.equal(noop.results[0]?.version, 2); + assert.ok(await store.readOperation('cas-noop')); + await assert.rejects( + store.applyMutations({ + operationId: 'cas-stale', + mutations: [{ type: 'update', itemId, expectedVersion: 1, item: replacement }], + }), + conflict('version_conflict'), + ); + }); + }); + + test('allows an Item to be updated to match another active assertion', async () => { + await withStore(async ({ store }) => { + const firstId = await createItem(store, 'active-first', write()); + const secondId = await createItem( + store, + 'active-second', + write({ + content: 'A different current fact.', + keys: [{ key: 'different', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-different' })], + }), + ); + + const updated = await store.applyMutations({ + operationId: 'active-update-duplicate', + mutations: [{ type: 'update', itemId: secondId, expectedVersion: 1, item: write() }], + }); + assert.equal(updated.results[0]?.outcome, 'updated'); + assert.equal((await store.readItem(secondId))?.item.content, 'User prefers concise answers.'); + assert.ok(await store.readItem(firstId)); + }); + }); + + test('rejects a stale CAS from a second SQLite connection', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + const first = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => 'shared-item', + }); + const second = new SqliteMemoryItemStore(databasePath, { now: () => 1_000 }); + try { + const itemId = await createItem(first, 'cross-connection-create', write()); + assert.equal((await second.readItem(itemId))?.item.version, 1); + + await first.applyMutations({ + operationId: 'cross-connection-first-update', + mutations: [ + { + type: 'update', + itemId, + expectedVersion: 1, + item: write({ content: 'First writer wins.' }), + }, + ], + }); + await assert.rejects( + second.applyMutations({ + operationId: 'cross-connection-stale-update', + mutations: [ + { + type: 'update', + itemId, + expectedVersion: 1, + item: write({ content: 'Stale second writer.' }), + }, + ], + }), + conflict('version_conflict'), + ); + assert.equal((await second.readItem(itemId))?.item.content, 'First writer wins.'); + } finally { + first.close(); + second.close(); + } + }); + }); + + test('allows only one truly concurrent CAS update across SQLite connections', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + const setup = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => 'concurrent-item', + }); + await createItem(setup, 'concurrent-create', write()); + setup.close(); + + const gate = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const moduleUrl = new URL('../sqlite-long-term-memory-store.js', import.meta.url).href; + const workers = [ + startConcurrentUpdateWorker( + databasePath, + moduleUrl, + gate, + 'concurrent-update-a', + write({ content: 'Concurrent writer A.' }), + ), + startConcurrentUpdateWorker( + databasePath, + moduleUrl, + gate, + 'concurrent-update-b', + write({ content: 'Concurrent writer B.' }), + ), + ]; + await Promise.all(workers.map((worker) => worker.ready)); + const gateView = new Int32Array(gate); + Atomics.store(gateView, 0, 1); + Atomics.notify(gateView, 0, workers.length); + assert.deepEqual((await Promise.all(workers.map((worker) => worker.result))).sort(), [ + 'updated', + 'version_conflict', + ]); + + const reopened = new SqliteMemoryItemStore(databasePath, { now: () => 1_000 }); + try { + const record = await reopened.readItem('concurrent-item'); + assert.equal(record?.item.version, 2); + assert.ok( + record?.item.content === 'Concurrent writer A.' || + record?.item.content === 'Concurrent writer B.', + ); + } finally { + reopened.close(); + } + }); + }); + + test('allows archived assertions to change and restore even when another assertion matches', async () => { + await withStore(async ({ store }) => { + const archivedId = await createItem(store, 'archived-create', write()); + await store.applyMutations({ + operationId: 'archive', + mutations: [{ type: 'archive', itemId: archivedId, expectedVersion: 1 }], + }); + const activeId = await createItem(store, 'active-replacement', write()); + assert.notEqual(activeId, archivedId); + + const noop = await store.applyMutations({ + operationId: 'archived-noop', + mutations: [{ type: 'update', itemId: archivedId, expectedVersion: 2, item: write() }], + }); + assert.equal(noop.results[0]?.outcome, 'noop'); + assert.equal(noop.results[0]?.version, 2); + assert.equal(noop.results[0]?.lifecycleState, 'archived'); + assert.ok(await store.readOperation('archived-noop')); + + const changed = await store.applyMutations({ + operationId: 'archived-update-collision', + mutations: [ + { + type: 'update', + itemId: archivedId, + expectedVersion: 2, + item: write({ sources: [source({ eventId: 'event-new-evidence' })] }), + }, + ], + }); + assert.equal(changed.results[0]?.version, 3); + assert.equal(changed.results[0]?.lifecycleState, 'archived'); + + const restored = await store.applyMutations({ + operationId: 'restore-collision', + mutations: [{ type: 'restore', itemId: archivedId, expectedVersion: 3 }], + }); + assert.equal(restored.results[0]?.version, 4); + assert.equal(restored.results[0]?.lifecycleState, 'active'); + assert.deepEqual(new Set(await itemIds(store, ['concise'])), new Set([activeId, archivedId])); + }); + }); + + test('excludes archived Items by default and returns them after restore', async () => { + await withStore(async ({ store }) => { + const itemId = await createItem(store, 'lifecycle-create', write()); + const archived = await store.applyMutations({ + operationId: 'lifecycle-archive', + mutations: [{ type: 'archive', itemId, expectedVersion: 1 }], + }); + assert.equal(archived.results[0]?.lifecycleState, 'archived'); + assert.deepEqual(await itemIds(store, ['concise']), []); + assert.deepEqual( + ( + await store.searchByKeys({ + terms: ['concise'], + match: 'exact', + includeArchived: true, + }) + ).map((record) => record.item.itemId), + [itemId], + ); + + const restored = await store.applyMutations({ + operationId: 'lifecycle-restore', + mutations: [{ type: 'restore', itemId, expectedVersion: 2 }], + }); + assert.equal(restored.results[0]?.lifecycleState, 'active'); + assert.deepEqual(await itemIds(store, ['concise']), [itemId]); + }); + }); + + test('reports invalid lifecycle transitions and missing Items', async () => { + await withStore(async ({ store }) => { + const itemId = await createItem(store, 'lifecycle-errors-create', write()); + await assert.rejects( + store.applyMutations({ + operationId: 'restore-active', + mutations: [{ type: 'restore', itemId, expectedVersion: 1 }], + }), + conflict('invalid_lifecycle_transition'), + ); + const stillActive = await store.readItem(itemId); + assert.equal(stillActive?.item.version, 1); + assert.equal(stillActive?.item.lifecycleState, 'active'); + assert.equal(await store.readOperation('restore-active'), undefined); + await store.applyMutations({ + operationId: 'archive-once', + mutations: [{ type: 'archive', itemId, expectedVersion: 1 }], + }); + await assert.rejects( + store.applyMutations({ + operationId: 'archive-twice', + mutations: [{ type: 'archive', itemId, expectedVersion: 2 }], + }), + conflict('invalid_lifecycle_transition'), + ); + const archived = await store.readItem(itemId); + assert.equal(archived?.item.version, 2); + assert.equal(archived?.item.lifecycleState, 'archived'); + assert.equal(await store.readOperation('archive-twice'), undefined); + + for (const [operationId, mutation] of [ + [ + 'missing-update', + { type: 'update', itemId: 'missing-item', expectedVersion: 1, item: write() }, + ], + ['missing-archive', { type: 'archive', itemId: 'missing-item', expectedVersion: 1 }], + ['missing-restore', { type: 'restore', itemId: 'missing-item', expectedVersion: 1 }], + ] as const) { + await assert.rejects( + store.applyMutations({ operationId, mutations: [mutation] }), + conflict('item_not_found'), + ); + assert.equal(await store.readOperation(operationId), undefined); + } + }); + }); + + test('validates temporal writes and commit-time observations', async () => { + await withStore(async ({ store }) => { + const created = await store.applyMutations({ + operationId: 'temporal-create', + mutations: [ + { + type: 'create', + item: write({ + content: 'A point event.', + temporalType: 'point', + eventStartedAt: 900, + keys: [{ key: 'point', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-point' })], + }), + }, + { + type: 'create', + item: write({ + content: 'An interval event.', + temporalType: 'interval', + eventStartedAt: 800, + eventEndedAt: 1_200, + keys: [{ key: 'interval', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-interval' })], + }), + }, + { + type: 'create', + item: write({ + content: 'An open-ended event.', + temporalType: 'open_ended', + eventStartedAt: 700, + keys: [{ key: 'open', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-open' })], + }), + }, + ], + }); + assert.deepEqual( + await Promise.all( + created.results.map( + async (result) => (await store.readItem(result.itemId))?.item.temporalType, + ), + ), + ['point', 'interval', 'open_ended'], + ); + + await assert.rejects( + store.applyMutations({ + operationId: 'invalid-open-ended', + mutations: [ + { + type: 'create', + item: write({ + temporalType: 'open_ended', + eventStartedAt: 800, + eventEndedAt: 900, + }), + }, + ], + }), + /cannot carry eventEndedAt/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'future-observation', + mutations: [{ type: 'create', item: write({ observedAt: 1_001 }) }], + }), + /observedAt cannot be later than commit time/, + ); + }); + }); + + test('rejects malformed mutation input before persistence', async () => { + await withStore(async ({ store }) => { + await assert.rejects( + store.applyMutations({ operationId: 'empty-mutations', mutations: [] }), + /at least one mutation/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'too-many-mutations', + mutations: Array.from({ length: 33 }, () => ({ + type: 'create' as const, + item: write(), + })), + }), + /at most 32 mutations/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'conflicting-provenance', + mutations: [ + { + type: 'create', + item: write({ + sources: [source(), source({ runId: 'run-2' })], + }), + }, + ], + }), + /conflicting provenance/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'control-key', + mutations: [ + { + type: 'create', + item: write({ + keys: [{ key: 'bad\u0000key', keyType: 'exact', keyOrigin: 'deterministic' }], + }), + }, + ], + }), + /control or zero-width/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'lone-surrogate', + mutations: [{ type: 'create', item: write({ content: `lone\uD800surrogate` }) }], + }), + /unpaired surrogate/, + ); + await assert.rejects( + store.applyMutations({ + operationId: 'e\u0301', + mutations: [{ type: 'create', item: write() }], + }), + /NFC-normalized/, + ); + assert.equal(await store.readItem('item-1'), undefined); + }); + }); + + test('rolls back the full batch at every write boundary', async () => { + for (const point of [ + 'after_item_write', + 'after_keys_write', + 'after_sources_write', + 'before_operation_write', + ] as const) { + await withStore(async ({ store, setFailpoint }) => { + setFailpoint(point); + await assert.rejects( + store.applyMutations({ + operationId: `batch-${point}`, + mutations: [ + { type: 'create', item: write({ content: 'First fact.' }) }, + { type: 'create', item: write({ content: 'Second fact.' }) }, + ], + }), + new RegExp(point), + ); + assert.equal(await store.readItem('item-1'), undefined); + assert.equal(await store.readOperation(`batch-${point}`), undefined); + }); + } + }); + + test('rolls back an earlier completed mutation when a later mutation conflicts', async () => { + await withStore(async ({ store }) => { + const existingId = await createItem(store, 'batch-conflict-existing', write()); + await assert.rejects( + store.applyMutations({ + operationId: 'batch-later-conflict', + mutations: [ + { + type: 'create', + item: write({ + content: 'This Item must be rolled back.', + keys: [{ key: 'rolled-back', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'event-rollback' })], + }), + }, + { + type: 'update', + itemId: existingId, + expectedVersion: 99, + item: write({ content: 'Stale update.' }), + }, + ], + }), + conflict('version_conflict'), + ); + assert.equal(await store.readItem('item-2'), undefined); + assert.deepEqual(await itemIds(store, ['rolled-back']), []); + assert.equal(await store.readOperation('batch-later-conflict'), undefined); + assert.equal((await store.readItem(existingId))?.item.version, 1); + }); + }); + + test('stores duplicate assertions independently within one batch', async () => { + await withStore(async ({ store }) => { + const result = await store.applyMutations({ + operationId: 'batch-duplicate-facts', + mutations: [ + { type: 'create', item: write({ content: 'Same batch fact.' }) }, + { type: 'create', item: write({ content: 'Same batch fact.' }) }, + ], + }); + assert.deepEqual( + result.results.map((entry) => entry.outcome), + ['created', 'created'], + ); + assert.ok(await store.readItem('item-1')); + assert.ok(await store.readItem('item-2')); + assert.ok(await store.readOperation('batch-duplicate-facts')); + }); + }); + + test('keeps updated_at monotonic and replays after the injected clock moves backwards', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + let now = 1_000; + const store = new SqliteMemoryItemStore(databasePath, { + now: () => now, + idFactory: () => 'clock-item', + }); + try { + const request = { + operationId: 'clock-create', + mutations: [{ type: 'create' as const, item: write({ observedAt: 900 }) }], + }; + await store.applyMutations(request); + now = 800; + assert.equal((await store.applyMutations(request)).replayed, true); + now = 1_100; + await store.applyMutations({ + operationId: 'clock-update-newer', + mutations: [ + { + type: 'update', + itemId: 'clock-item', + expectedVersion: 1, + item: write({ content: 'New current fact.', observedAt: 1_000 }), + }, + ], + }); + now = 1_050; + await store.applyMutations({ + operationId: 'clock-archive', + mutations: [{ type: 'archive', itemId: 'clock-item', expectedVersion: 2 }], + }); + assert.equal((await store.readItem('clock-item'))?.item.updatedAt, 1_100); + } finally { + store.close(); + } + }); + }); + + test('keeps committed Items and operation receipts after checkpoint and reopen', async () => { + await withTempRoot(async (root) => { + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + const store = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => 'durable-item', + }); + const receipt = await store.applyMutations({ + operationId: 'durable-create', + mutations: [{ type: 'create', item: write() }], + }); + store.close(); + + const Database = loadDatabaseSync(); + const checkpoint = new Database(databasePath); + checkpoint.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + checkpoint.close(); + + const reopened = new SqliteMemoryItemStore(databasePath, { now: () => 1_000 }); + try { + assert.equal((await reopened.readItem('durable-item'))?.item.content, write().content); + assert.deepEqual(await reopened.readOperation('durable-create'), receipt); + } finally { + reopened.close(); + } + }); + }); + + test('fails closed over a structurally corrupt idempotency receipt', async () => { + await withStore(async ({ store, databasePath }) => { + const created = await store.applyMutations({ + operationId: 'corrupt-receipt', + mutations: [{ type: 'create', item: write() }], + }); + store.close(); + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database + .prepare('UPDATE memory_write_operations SET result_json = ? WHERE operation_id = ?') + .run( + JSON.stringify([ + { + mutationIndex: 0, + mutationType: 'update', + itemId: created.results[0]!.itemId, + version: 1, + lifecycleState: 'active', + outcome: 'updated', + }, + ]), + 'corrupt-receipt', + ); + database + .prepare('UPDATE memory_items SET content_hash = ? WHERE item_id = ?') + .run('0'.repeat(64), created.results[0]!.itemId); + database.close(); + + const reopened = new SqliteMemoryItemStore(databasePath); + try { + await assert.rejects(reopened.readOperation('corrupt-receipt'), /Invalid/); + await assert.rejects(reopened.readItem(created.results[0]!.itemId), /content_hash/); + } finally { + reopened.close(); + } + }); + }); + + test('fails closed over corrupt Item child cardinality', async () => { + for (const childTable of ['memory_item_keys', 'memory_item_sources'] as const) { + await withTempRoot(async (root) => { + const databasePath = join(root, `${childTable}.sqlite`); + const store = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => 'corrupt-child-item', + }); + await createItem(store, `create-${childTable}`, write()); + store.close(); + + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database.prepare(`DELETE FROM ${childTable} WHERE item_id = ?`).run('corrupt-child-item'); + database.close(); + + const reopened = new SqliteMemoryItemStore(databasePath, { now: () => 1_000 }); + try { + await assert.rejects(reopened.readItem('corrupt-child-item'), /cardinality/); + } finally { + reopened.close(); + } + }); + } + }); + + test('rejects a corrupt idempotency receipt beyond the batch limit', async () => { + await withStore(async ({ store, databasePath }) => { + await store.applyMutations({ + operationId: 'oversize-receipt', + mutations: [{ type: 'create', item: write() }], + }); + store.close(); + const result = { + mutationIndex: 0, + mutationType: 'create', + itemId: 'item-1', + version: 1, + lifecycleState: 'active', + outcome: 'created', + } as const; + const results = Array.from({ length: 33 }, (_, mutationIndex) => ({ + ...result, + mutationIndex, + })); + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database + .prepare( + `UPDATE memory_write_operations + SET operation_type = 'batch', result_json = ? + WHERE operation_id = 'oversize-receipt'`, + ) + .run(JSON.stringify(results)); + database.close(); + + const reopened = new SqliteMemoryItemStore(databasePath); + try { + await assert.rejects(reopened.readOperation('oversize-receipt'), /at most 32/); + } finally { + reopened.close(); + } + }); + }); + + test('rejects an oversized idempotency receipt before JSON parsing', async () => { + await withStore(async ({ store, databasePath }) => { + await store.applyMutations({ + operationId: 'huge-receipt', + mutations: [{ type: 'create', item: write() }], + }); + store.close(); + const Database = loadDatabaseSync(); + const database = new Database(databasePath); + database + .prepare( + `UPDATE memory_write_operations SET result_json = ? + WHERE operation_id = 'huge-receipt'`, + ) + .run(`"${'x'.repeat(129 * 1_024)}"`); + database.close(); + + const reopened = new SqliteMemoryItemStore(databasePath); + try { + await assert.rejects(reopened.readOperation('huge-receipt'), /too large/); + } finally { + reopened.close(); + } + }); + }); + + test('atomically commits extracted Items and advances the Session Cursor', async () => { + await withStore(async ({ store }) => { + const first = await store.commitExtraction({ + operationId: 'extract-session-1-event-10', + sessionId: 'session-1', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 10, + coverageHash: 'a'.repeat(64), + items: [write({ sources: [source({ eventId: 'event-8' })] })], + requestedItemIndexes: [0], + trigger: 'remember', + }); + assert.equal(first.results[0]?.outcome, 'created'); + assert.equal(first.receipt.status, 'remembered'); + assert.equal(first.receipt.requestedItems[0]?.itemId, first.results[0]?.itemId); + assert.deepEqual(await store.readExtractionCursor('session-1'), { + sessionId: 'session-1', + processedOrdinal: 10, + updatedAt: 1_000, + }); + + const second = await store.commitExtraction({ + operationId: 'extract-session-1-event-20', + sessionId: 'session-1', + expectedCursorOrdinal: 10, + nextCursorOrdinal: 20, + coverageHash: 'b'.repeat(64), + items: [], + requestedItemIndexes: [], + trigger: 'extract', + }); + assert.deepEqual(second.results, []); + assert.equal((await store.readExtractionCursor('session-1'))?.processedOrdinal, 20); + + await assert.rejects( + store.commitExtraction({ + operationId: 'extract-stale-range', + sessionId: 'session-1', + expectedCursorOrdinal: 10, + nextCursorOrdinal: 30, + coverageHash: 'c'.repeat(64), + items: [], + requestedItemIndexes: [], + trigger: 'extract', + }), + conflict('cursor_conflict'), + ); + assert.equal((await store.readExtractionCursor('session-1'))?.processedOrdinal, 20); + }); + }); + + test('replays an extraction receipt without duplicating Items', async () => { + await withStore(async ({ store }) => { + const request = { + operationId: 'extract-replay', + sessionId: 'session-replay', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 5, + coverageHash: 'd'.repeat(64), + items: [write({ sources: [source({ sessionId: 'session-replay' })] })], + requestedItemIndexes: [0], + trigger: 'remember', + } as const; + const first = await store.commitExtraction(request); + const replay = await store.commitExtraction(request); + assert.equal(replay.replayed, true); + assert.deepEqual(replay.results, first.results); + assert.deepEqual(replay.receipt, first.receipt); + assert.deepEqual(await store.readExtractionReceipt('extract-replay'), first.receipt); + assert.equal((await store.searchByKeys({ terms: ['concise'], match: 'exact' })).length, 1); + }); + }); + + test('rolls back Items, Cursor, and receipt when extraction commit fails', async () => { + await withStore(async ({ store, setFailpoint }) => { + setFailpoint('after_cursor_write'); + await assert.rejects( + store.commitExtraction({ + operationId: 'extract-rollback', + sessionId: 'session-rollback', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 9, + coverageHash: 'e'.repeat(64), + items: [write({ sources: [source({ sessionId: 'session-rollback' })] })], + requestedItemIndexes: [0], + trigger: 'remember', + }), + /after_cursor_write/, + ); + assert.equal(await store.readExtractionCursor('session-rollback'), undefined); + assert.equal(await store.readItem('item-1'), undefined); + assert.equal(await store.readOperation('extract-rollback'), undefined); + assert.equal(await store.readExtractionReceipt('extract-rollback'), undefined); + }); + }); + + test('initializes an absent extraction Cursor once without leaping an existing Cursor', async () => { + await withStore(async ({ store }) => { + assert.deepEqual(await store.initializeExtractionCursor('session-bootstrap', 12), { + sessionId: 'session-bootstrap', + processedOrdinal: 12, + updatedAt: 1_000, + }); + assert.equal( + (await store.initializeExtractionCursor('session-bootstrap', 30)).processedOrdinal, + 12, + ); + }); + }); + + test('retries one failed range once, then atomically receipts its discard', async () => { + await withStore(async ({ store }) => { + const coverageHash = 'f'.repeat(64); + const firstRequest = { + operationId: 'failed-trigger-1', + sessionId: 'session-failed', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 8, + coverageHash, + failureClass: 'schema', + trigger: 'remember', + } as const; + const first = await store.settleExtractionFailure(firstRequest); + assert.equal(first.status, 'retry_later'); + assert.equal(await store.readExtractionCursor('session-failed'), undefined); + assert.deepEqual(await store.readPendingExtractionFailure('session-failed'), { + sessionId: 'session-failed', + fromOrdinal: 1, + throughOrdinal: 8, + coverageHash, + firstOperationId: 'failed-trigger-1', + firstTrigger: 'remember', + firstFailureClass: 'schema', + failedAt: 1_000, + }); + + const same = await store.settleExtractionFailure(firstRequest); + assert.equal(same.status, 'retry_later'); + assert.equal(same.replayed, true); + assert.equal(await store.readExtractionCursor('session-failed'), undefined); + + await assert.rejects( + store.settleExtractionFailure({ + ...firstRequest, + operationId: 'failed-trigger-wrong-mode', + trigger: 'extract', + }), + (error: unknown) => + error instanceof MemoryItemStoreConflictError && error.reason === 'cursor_conflict', + ); + + const second = await store.settleExtractionFailure({ + ...firstRequest, + operationId: 'failed-trigger-2', + failureClass: 'provider', + }); + assert.equal(second.status, 'discarded'); + assert.equal(second.replayed, false); + assert.equal(second.receipt.status, 'discarded'); + assert.deepEqual(second.receipt.discardedRange, { + fromOrdinal: 1, + throughOrdinal: 8, + coverageHash, + firstFailureClass: 'schema', + finalFailureClass: 'provider', + }); + assert.equal((await store.readExtractionCursor('session-failed'))?.processedOrdinal, 8); + assert.equal(await store.readPendingExtractionFailure('session-failed'), undefined); + assert.deepEqual(await store.readExtractionReceipt('failed-trigger-2'), second.receipt); + + const replay = await store.settleExtractionFailure({ + ...firstRequest, + operationId: 'failed-trigger-2', + failureClass: 'provider', + }); + assert.equal(replay.status, 'discarded'); + assert.equal(replay.replayed, true); + }); + }); + + test('clears an exact pending failed range in the successful extraction transaction', async () => { + await withStore(async ({ store }) => { + const coverageHash = '1'.repeat(64); + await store.settleExtractionFailure({ + operationId: 'pending-before-success', + sessionId: 'session-recovered', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 4, + coverageHash, + failureClass: 'provider', + trigger: 'extract', + }); + const committed = await store.commitExtraction({ + operationId: 'successful-retry', + sessionId: 'session-recovered', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 4, + coverageHash, + items: [], + requestedItemIndexes: [], + trigger: 'extract', + }); + assert.equal(committed.cursor.processedOrdinal, 4); + assert.equal(await store.readPendingExtractionFailure('session-recovered'), undefined); + }); + }); + + test('keeps a failed Compaction range bound to its checkpoint through retry', async () => { + await withStore(async ({ store }) => { + const coverageHash = '3'.repeat(64); + const first = await store.settleExtractionFailure({ + operationId: 'compaction-failure-first', + sessionId: 'session-compaction', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 5, + coverageHash, + failureClass: 'provider', + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-1', + }); + assert.equal(first.status, 'retry_later'); + assert.equal( + (await store.readPendingExtractionFailure('session-compaction'))?.compactionCheckpointId, + 'checkpoint-1', + ); + + await assert.rejects( + store.commitExtraction({ + operationId: 'compaction-retry-wrong-checkpoint', + sessionId: 'session-compaction', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 5, + coverageHash, + items: [], + requestedItemIndexes: [], + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-2', + }), + conflict('cursor_conflict'), + ); + + const committed = await store.commitExtraction({ + operationId: 'compaction-retry-correct-checkpoint', + sessionId: 'session-compaction', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 5, + coverageHash, + items: [], + requestedItemIndexes: [], + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-1', + }); + assert.equal(committed.receipt.status, 'extracted'); + assert.equal(committed.cursor.processedOrdinal, 5); + assert.equal(await store.readPendingExtractionFailure('session-compaction'), undefined); + + await assert.rejects( + store.settleExtractionFailure({ + operationId: 'compaction-missing-checkpoint', + sessionId: 'session-missing-checkpoint', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 1, + coverageHash, + failureClass: 'provider', + trigger: 'compaction', + }), + /compactionCheckpointId/, + ); + }); + }); + + test('atomically policy-skips through a pending prefix without writing Items', async () => { + await withStore(async ({ store }) => { + await store.settleExtractionFailure({ + operationId: 'policy-skip-pending', + sessionId: 'session-policy-skip', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 5, + coverageHash: '4'.repeat(64), + failureClass: 'provider', + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-pending-compaction', + }); + + const skipped = await store.commitExtraction({ + operationId: 'policy-skip-operation', + sessionId: 'session-policy-skip', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 8, + coverageHash: '5'.repeat(64), + items: [], + requestedItemIndexes: [], + skipReason: 'policy_denied', + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-policy-denied', + }); + + assert.equal(skipped.receipt.status, 'skipped'); + assert.equal(skipped.receipt.skipReason, 'policy_denied'); + assert.equal(skipped.cursor.processedOrdinal, 8); + assert.equal(await store.readPendingExtractionFailure('session-policy-skip'), undefined); + assert.deepEqual(await store.searchByKeys({ terms: ['concise'], match: 'exact' }), []); + + const replay = await store.commitExtraction({ + operationId: 'policy-skip-operation', + sessionId: 'session-policy-skip', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 8, + coverageHash: '5'.repeat(64), + items: [], + requestedItemIndexes: [], + skipReason: 'policy_denied', + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-policy-denied', + }); + assert.equal(replay.replayed, true); + assert.equal(replay.receipt.status, 'skipped'); + }); + }); + + test('policy skip never consumes an explicit remember pending failure', async () => { + await withStore(async ({ store }) => { + await store.settleExtractionFailure({ + operationId: 'remember-pending', + sessionId: 'session-remember-pending', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 5, + coverageHash: '6'.repeat(64), + failureClass: 'provider', + trigger: 'remember', + }); + + await assert.rejects( + store.commitExtraction({ + operationId: 'denied-after-remember', + sessionId: 'session-remember-pending', + expectedCursorOrdinal: 0, + nextCursorOrdinal: 8, + coverageHash: '7'.repeat(64), + items: [], + requestedItemIndexes: [], + skipReason: 'policy_denied', + trigger: 'compaction', + compactionCheckpointId: 'checkpoint-policy-denied', + }), + /does not match the commit/, + ); + + assert.equal( + (await store.readPendingExtractionFailure('session-remember-pending'))?.firstTrigger, + 'remember', + ); + assert.equal(await store.readExtractionCursor('session-remember-pending'), undefined); + }); + }); + + test('rolls back Cursor, pending failure, operation, and receipt when discard fails', async () => { + await withStore(async ({ store, setFailpoint }) => { + const coverageHash = '2'.repeat(64); + await store.settleExtractionFailure({ + operationId: 'discard-first-trigger', + sessionId: 'session-discard-rollback', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 6, + coverageHash, + failureClass: 'schema', + trigger: 'extract', + }); + setFailpoint('after_cursor_write'); + await assert.rejects( + store.settleExtractionFailure({ + operationId: 'discard-second-trigger', + sessionId: 'session-discard-rollback', + expectedCursorOrdinal: 0, + failedThroughOrdinal: 6, + coverageHash, + failureClass: 'provider', + trigger: 'extract', + }), + /after_cursor_write/, + ); + assert.equal(await store.readExtractionCursor('session-discard-rollback'), undefined); + assert.equal( + (await store.readPendingExtractionFailure('session-discard-rollback'))?.firstOperationId, + 'discard-first-trigger', + ); + assert.equal(await store.readOperation('discard-second-trigger'), undefined); + assert.equal(await store.readExtractionReceipt('discard-second-trigger'), undefined); + }); + }); +}); + +describe('long-term memory Storage Root authority', () => { + test('rejects a structurally forged writer facade', () => { + assert.throws( + () => + authenticateInteractiveLongTermMemoryWriter({ + kind: 'interactive', + access: 'write', + } as unknown as Parameters[0]), + /authentic interactive long-term memory writer/, + ); + }); + + test('rejects a forged lease at the Interactive opener without creating a database', async () => { + await withTempRoot(async (root) => { + await assert.rejects( + openInteractiveLongTermMemoryStoreForWrite( + {} as Parameters[0], + ), + /interactive/, + ); + await assert.rejects(stat(join(root, LONG_TERM_MEMORY_DATABASE_NAME)), { code: 'ENOENT' }); + }); + }); + + test('snapshots mutation input before crossing the authority boundary', async () => { + await withTempRoot(async (root) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveLongTermMemoryStoreForWrite(owner.lease); + try { + const item = write({ + keys: [{ key: 'original-key', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source({ eventId: 'original-event' })], + }); + const request = { + operationId: 'snapshot-input', + mutations: [{ type: 'create' as const, item }], + }; + const writing = writer.applyMutations(request); + (item as { content: string }).content = 'Mutated after admission.'; + (item.keys[0] as { key: string }).key = 'mutated-key'; + (item.sources[0] as { eventId: string }).eventId = 'mutated-event'; + + const result = await writing; + const record = await writer.readItem(result.results[0]!.itemId); + assert.equal(record?.item.content, 'User prefers concise answers.'); + assert.deepEqual( + record?.keys.map((key) => key.key), + ['original-key'], + ); + assert.deepEqual( + record?.sources.map((entry) => entry.eventId), + ['original-event'], + ); + } finally { + writer.close(); + await owner.close(); + } + }); + }); + + test('rejects operations after the durable root identity changes', async () => { + await withTempRoot(async (root) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveLongTermMemoryStoreForWrite(owner.lease); + try { + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { rootId: string }; + marker.rootId = `${marker.rootId.startsWith('0') ? '1' : '0'}${marker.rootId.slice(1)}`; + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + await assert.rejects(writer.readItem('after-root-replacement'), { + code: 'root_identity_changed', + }); + } finally { + writer.close(); + await owner.close(); + } + }); + }); + + test('single-flights an Interactive writer and closes it explicitly', async () => { + await withTempRoot(async (root) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const [first, second] = await Promise.all([ + openInteractiveLongTermMemoryStoreForWrite(owner.lease), + openInteractiveLongTermMemoryStoreForWrite(owner.lease), + ]); + assert.equal(first, second); + assert.equal(authenticateInteractiveLongTermMemoryWriter(first), first); + assert.equal((await stat(join(root, LONG_TERM_MEMORY_DATABASE_NAME))).isFile(), true); + first.close(); + await assert.rejects(first.readItem('closed-item'), /closed/); + + const reopened = await openInteractiveLongTermMemoryStoreForWrite(owner.lease); + assert.notEqual(reopened, first); + reopened.close(); + } finally { + await owner.close(); + } + }); + }); +}); + +type Store = SqliteMemoryItemStore; + +async function withStore( + run: (context: { + store: Store; + databasePath: string; + setFailpoint: (point: SqliteMemoryItemStoreFailpoint | undefined) => void; + }) => Promise, +): Promise { + await withTempRoot(async (root) => { + const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); + let failpoint: SqliteMemoryItemStoreFailpoint | undefined; + let nextId = 1; + const store = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => `item-${nextId++}`, + failpoint: (point) => { + if (point === failpoint) throw new Error(`SQLite Memory failpoint: ${point}`); + }, + }); + try { + await run({ + store, + databasePath, + setFailpoint: (point) => { + failpoint = point; + }, + }); + } finally { + store.close(); + } + }); +} + +async function withTempRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-long-term-memory-')); + try { + await chmod(root, 0o700); + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function createItem( + store: Store, + operationId: string, + item: MemoryItemWrite, +): Promise { + const result = await store.applyMutations({ + operationId, + mutations: [{ type: 'create', item }], + }); + return result.results[0]!.itemId; +} + +async function itemIds( + store: Store, + terms: readonly string[], + match: 'exact' | 'prefix' = 'exact', + workspaceKey?: string, +): Promise { + return ( + await store.searchByKeys({ + terms, + match, + ...(workspaceKey ? { workspaceKey } : {}), + }) + ).map((record) => record.item.itemId); +} + +function write(overrides: Partial = {}): MemoryItemWrite { + return { + content: 'User prefers concise answers.', + kind: 'preference', + statementType: 'fact', + temporalType: 'undated', + scopeType: 'global', + observedAt: 900, + origin: 'agent_extracted', + keys: [{ key: 'concise', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [source()], + ...overrides, + }; +} + +function source(overrides: Partial = {}): MemoryItemSource { + return { + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + eventId: 'event-1', + ...overrides, + }; +} + +function conflict(reason: MemoryItemStoreConflictError['reason']) { + return (error: unknown): boolean => + error instanceof MemoryItemStoreConflictError && error.reason === reason; +} + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; +} + +function startConcurrentMigrationWorker( + databasePath: string, + moduleUrl: string, + gate: SharedArrayBuffer, +): { readonly ready: Promise; readonly result: Promise } { + const worker = new Worker( + ` + const { parentPort, workerData } = require('node:worker_threads'); + const gate = new Int32Array(workerData.gate); + parentPort.postMessage({ type: 'ready' }); + Atomics.wait(gate, 0, 0); + import(workerData.moduleUrl) + .then(({ SqliteMemoryItemStore }) => { + const store = new SqliteMemoryItemStore(workerData.databasePath); + const version = store.schemaVersion(); + store.close(); + parentPort.postMessage({ type: 'result', version }); + parentPort.close(); + }) + .catch((error) => { + parentPort.postMessage({ + type: 'error', + message: error && error.stack ? error.stack : String(error), + }); + parentPort.close(); + }); + `, + { + eval: true, + workerData: { databasePath, moduleUrl, gate }, + }, + ); + + let readyResolved = false; + let resultSettled = false; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + let resolveResult!: (version: number) => void; + let rejectResult!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + const fail = (error: Error): void => { + if (!readyResolved) rejectReady(error); + if (!resultSettled) { + resultSettled = true; + rejectResult(error); + } + }; + + worker.on('message', (message: unknown) => { + if (!message || typeof message !== 'object') { + fail(new Error('Concurrent migration worker returned an invalid message')); + return; + } + const payload = message as { type?: unknown; version?: unknown; message?: unknown }; + if (payload.type === 'ready') { + readyResolved = true; + resolveReady(); + return; + } + if (payload.type === 'result' && typeof payload.version === 'number') { + resultSettled = true; + resolveResult(payload.version); + return; + } + if (payload.type === 'error') { + fail(new Error(String(payload.message))); + return; + } + fail(new Error('Concurrent migration worker returned an invalid message')); + }); + worker.on('error', (error) => fail(error instanceof Error ? error : new Error(String(error)))); + worker.on('exit', (code) => { + if (code !== 0) { + fail(new Error(`Concurrent migration worker exited with code ${code}`)); + } else if (!resultSettled) { + fail(new Error('Concurrent migration worker exited before returning a result')); + } + }); + + return { ready, result }; +} + +function startConcurrentUpdateWorker( + databasePath: string, + moduleUrl: string, + gate: SharedArrayBuffer, + operationId: string, + item: MemoryItemWrite, +): { + readonly ready: Promise; + readonly result: Promise<'updated' | 'version_conflict'>; +} { + const worker = new Worker( + ` + const { parentPort, workerData } = require('node:worker_threads'); + import(workerData.moduleUrl) + .then(async ({ SqliteMemoryItemStore }) => { + const store = new SqliteMemoryItemStore(workerData.databasePath, { now: () => 1000 }); + try { + const gate = new Int32Array(workerData.gate); + parentPort.postMessage({ type: 'ready' }); + Atomics.wait(gate, 0, 0); + try { + await store.applyMutations({ + operationId: workerData.operationId, + mutations: [{ + type: 'update', + itemId: 'concurrent-item', + expectedVersion: 1, + item: workerData.item, + }], + }); + parentPort.postMessage({ type: 'result', outcome: 'updated' }); + } catch (error) { + if (error && error.reason === 'version_conflict') { + parentPort.postMessage({ type: 'result', outcome: 'version_conflict' }); + } else { + throw error; + } + } + } finally { + store.close(); + } + parentPort.close(); + }) + .catch((error) => { + parentPort.postMessage({ + type: 'error', + message: error && error.stack ? error.stack : String(error), + }); + parentPort.close(); + }); + `, + { + eval: true, + workerData: { databasePath, moduleUrl, gate, operationId, item }, + }, + ); + + let readyResolved = false; + let resultSettled = false; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + let resolveResult!: (outcome: 'updated' | 'version_conflict') => void; + let rejectResult!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const result = new Promise<'updated' | 'version_conflict'>((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + const fail = (error: Error): void => { + if (!readyResolved) rejectReady(error); + if (!resultSettled) { + resultSettled = true; + rejectResult(error); + } + }; + + worker.on('message', (message: unknown) => { + if (!message || typeof message !== 'object') { + fail(new Error('Concurrent update worker returned an invalid message')); + return; + } + const payload = message as { type?: unknown; outcome?: unknown; message?: unknown }; + if (payload.type === 'ready') { + readyResolved = true; + resolveReady(); + return; + } + if ( + payload.type === 'result' && + (payload.outcome === 'updated' || payload.outcome === 'version_conflict') + ) { + resultSettled = true; + resolveResult(payload.outcome); + return; + } + if (payload.type === 'error') { + fail(new Error(String(payload.message))); + return; + } + fail(new Error('Concurrent update worker returned an invalid message')); + }); + worker.on('error', (error) => fail(error instanceof Error ? error : new Error(String(error)))); + worker.on('exit', (code) => { + if (code !== 0) { + fail(new Error(`Concurrent update worker exited with code ${code}`)); + } else if (!resultSettled) { + fail(new Error('Concurrent update worker exited before returning a result')); + } + }); + + return { ready, result }; +} + +async function createSymlinkIfSupported(target: string, path: string): Promise { + try { + await symlink(target, path); + return true; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + ['EPERM', 'EACCES', 'ENOTSUP'].includes(String(error.code)) + ) { + return false; + } + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3a17b51d4b33284ccf63d0c12c416d46f3833654568ced65072ee6b32af362b1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3a17b51d4b33284ccf63d0c12c416d46f3833654568ced65072ee6b32af362b1.source new file mode 100644 index 0000000000..efc3252f13 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3a17b51d4b33284ccf63d0c12c416d46f3833654568ced65072ee6b32af362b1.source @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 9; +export const MODEL_PROJECTION_TARGET_SQL = + "CASE WHEN json_valid(record_json) THEN CASE WHEN json_type(record_json, '$.data.transition.target.runtimeEventId') = 'text' THEN nullif(json_extract(record_json, '$.data.transition.target.runtimeEventId'), '') WHEN json_type(record_json, '$.data.runtimeEventId') = 'text' THEN nullif(json_extract(record_json, '$.data.runtimeEventId'), '') END END"; + +export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS core_agent_runs ( + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + latest_model_call_sequence INTEGER CHECK (latest_model_call_sequence >= 0), + PRIMARY KEY (session_id, run_id) + ); + + CREATE INDEX IF NOT EXISTS core_agent_runs_session_order + ON core_agent_runs(session_id, created_at, run_id); + + CREATE TABLE IF NOT EXISTS core_agent_run_events ( + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_ts INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, run_id, sequence), + FOREIGN KEY (session_id, run_id) + REFERENCES core_agent_runs(session_id, run_id) + ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS core_agent_run_events_identity + ON core_agent_run_events(session_id, run_id, event_id); + + CREATE INDEX IF NOT EXISTS core_agent_run_events_type_sequence + ON core_agent_run_events(event_type, session_id, run_id, sequence); + + CREATE INDEX IF NOT EXISTS core_model_projection_target + ON core_agent_run_events(session_id, + ${MODEL_PROJECTION_TARGET_SQL} + ) WHERE event_type = 'model_projection_transition_recorded'; + + CREATE TABLE IF NOT EXISTS core_agent_run_projections ( + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_json TEXT, + PRIMARY KEY (session_id, event_type) + ); + + CREATE TABLE IF NOT EXISTS core_root_turn_admissions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id) + ); + + CREATE INDEX IF NOT EXISTS core_root_turn_admissions_order + ON core_root_turn_admissions(session_id, admitted_at, turn_id); + + CREATE TABLE IF NOT EXISTS core_root_turn_start_rejections ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + rejected_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id) + ); + + CREATE TABLE IF NOT EXISTS core_root_source_message_proofs ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + PRIMARY KEY (session_id, message_id), + FOREIGN KEY (session_id, turn_id) + REFERENCES core_root_turn_admissions(session_id, turn_id) + ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS core_interaction_requests ( + request_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + run_id TEXT NOT NULL, + request_kind TEXT NOT NULL, + created_at INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS core_interaction_pending + ON core_interaction_requests(session_id, created_at, request_id); + + CREATE TABLE IF NOT EXISTS core_interaction_outcomes ( + request_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL, + FOREIGN KEY (request_id) + REFERENCES core_interaction_requests(request_id) + ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS core_client_capability_session_grants ( + session_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + server_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + capability TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_value TEXT NOT NULL, + granted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY ( + session_id, provider_id, contract_id, capability, scope_kind, scope_value + ) + ); + + CREATE INDEX IF NOT EXISTS core_client_capability_session_grants_session + ON core_client_capability_session_grants(session_id, granted_at); + + CREATE TABLE IF NOT EXISTS core_shell_runs ( + session_id TEXT NOT NULL, + shell_run_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, shell_run_id) + ); + + CREATE INDEX IF NOT EXISTS core_shell_runs_session_order + ON core_shell_runs(session_id, started_at, shell_run_id); + `); + ensureColumn( + db, + 'core_agent_runs', + 'latest_model_call_sequence', + 'INTEGER CHECK (latest_model_call_sequence >= 0)', + ); + // The runtime migration runs first and has already turned every stored Run header into an + // invocation opening fact, so the row keeps only what the ledger needs to hang its events on. + dropColumn(db, 'core_agent_runs', 'record_json'); + db.exec(` + UPDATE core_agent_runs + SET latest_model_call_sequence = ( + SELECT MAX(sequence) + FROM core_agent_run_events + WHERE core_agent_run_events.session_id = core_agent_runs.session_id + AND core_agent_run_events.run_id = core_agent_runs.run_id + AND event_type = 'model_call_attempt_recorded' + ) + WHERE latest_model_call_sequence IS NULL + AND EXISTS ( + SELECT 1 + FROM core_agent_run_events + WHERE core_agent_run_events.session_id = core_agent_runs.session_id + AND core_agent_run_events.run_id = core_agent_runs.run_id + AND event_type = 'model_call_attempt_recorded' + ); + + CREATE INDEX IF NOT EXISTS core_agent_runs_model_call_high_water + ON core_agent_runs(session_id, latest_model_call_sequence, run_id) + WHERE latest_model_call_sequence IS NOT NULL; + + DROP INDEX IF EXISTS core_root_turn_continuation_source; + + CREATE INDEX IF NOT EXISTS core_root_turn_continuation_source_v2 + ON core_root_turn_admissions( + session_id, + json_extract(record_json, '$.execution.sourceTurnId'), + json_extract(record_json, '$.execution.sourceRunId') + ) + WHERE json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'; + + DROP INDEX IF EXISTS core_agent_runs_identity; + + DROP TABLE IF EXISTS core_message_receipts; + DROP TABLE IF EXISTS core_message_host_epochs; + `); +} + +function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + if (columns.some((candidate) => candidate.name === column)) return; + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +function dropColumn(db: DatabaseSync, table: string, column: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + if (!columns.some((candidate) => candidate.name === column)) return; + db.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3af6058a9f7a897a386a0c4626ae3249d9902c84b6f961624ba40e4d2a4e9f4c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3af6058a9f7a897a386a0c4626ae3249d9902c84b6f961624ba40e4d2a4e9f4c.source new file mode 100644 index 0000000000..343c8cbde7 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3af6058a9f7a897a386a0c4626ae3249d9902c84b6f961624ba40e4d2a4e9f4c.source @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + isNonEmptyUnicodeString, + isSha256Digest, + SESSION_BUNDLE_ARCHIVE_FORMAT, + SESSION_BUNDLE_CANONICALIZATION_VERSION, + SESSION_BUNDLE_CODEC_NAME, + SESSION_BUNDLE_CODEC_VERSION, + SESSION_BUNDLE_COMPRESSION_FORMAT, + SESSION_BUNDLE_COMPRESSION_LEVEL, + SESSION_BUNDLE_SCHEMA_VERSION, + SESSION_BUNDLE_STATE_IDENTITY_PATH, + SESSION_BUNDLE_STATE_PATH, + SESSION_BUNDLE_WORKSPACE_PATH, + SessionBundleFileError, + type SessionBundleManifestV1, +} from './session-bundle-contract.js'; +import { stableJsonStringify } from '@maka/core/canonical-json'; + +const MANIFEST_KEYS = ['codec', 'envelope', 'payload', 'schemaVersion', 'stateIdentity'] as const; +const CODEC_KEYS = [ + 'archive', + 'canonicalizationVersion', + 'compression', + 'compressionLevel', + 'name', + 'version', +] as const; +const ENVELOPE_KEYS = ['lastCommittedActivationId', 'sessionId'] as const; +const STATE_IDENTITY_KEYS = ['mediaType', 'path'] as const; +const PAYLOAD_KEYS = [ + 'entryCount', + 'payloadBytes', + 'statePath', + 'treeDigest', + 'workspacePath', +] as const; + +/** + * Encode Manifest V1 as RFC 8785/JCS UTF-8 with no BOM or trailing newline. + * + * The serializer is intentionally schema-specific: the validated Manifest V1 + * value contains only objects, strings, and non-negative safe integers. This + * keeps the portable codec independent of a general-purpose JSON dependency + * while retaining the exact JCS ordering and ECMAScript primitive encoding + * rules required by the format. + */ +export function encodeSessionBundleManifestV1(manifest: SessionBundleManifestV1): Uint8Array { + const validated = decodeManifestValue(manifest); + return new TextEncoder().encode(canonicalJson(validated)); +} + +/** + * Decode and validate a canonical Manifest V1. + * + * Non-canonical JSON is rejected even when it would parse to the same value. + * That rejects duplicate keys, insignificant whitespace, alternate numeric + * spellings, BOMs, and reordered object members before an archive is trusted. + */ +export function decodeSessionBundleManifestV1(bytes: Uint8Array): SessionBundleManifestV1 { + if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) throw invalidManifest(); + + let text: string; + let parsed: unknown; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + parsed = JSON.parse(text); + } catch { + // JSON.parse diagnostics can contain attacker-controlled input excerpts. + // Preserve the stable public error code without retaining the raw parser + // failure as a cause. + throw invalidManifest(); + } + + const manifest = decodeManifestValue(parsed); + const canonical = encodeSessionBundleManifestV1(manifest); + if (!equalBytes(bytes, canonical)) throw invalidManifest(); + return manifest; +} + +function decodeManifestValue(value: unknown): SessionBundleManifestV1 { + if (!isRecord(value) || !hasExactKeys(value, MANIFEST_KEYS)) throw invalidManifest(); + + if (value.schemaVersion !== SESSION_BUNDLE_SCHEMA_VERSION) { + if (typeof value.schemaVersion === 'number' && Number.isSafeInteger(value.schemaVersion)) { + throw new SessionBundleFileError( + 'unsupported_schema', + 'Session bundle schema version is not supported', + ); + } + throw invalidManifest(); + } + + const codec = decodeCodec(value.codec); + const envelope = decodeEnvelope(value.envelope); + const stateIdentity = decodeStateIdentity(value.stateIdentity); + const payload = decodePayload(value.payload); + + return { + schemaVersion: SESSION_BUNDLE_SCHEMA_VERSION, + codec, + envelope, + stateIdentity, + payload, + }; +} + +function decodeCodec(value: unknown): SessionBundleManifestV1['codec'] { + if (!isRecord(value) || !hasExactKeys(value, CODEC_KEYS)) throw invalidManifest(); + if ( + value.name !== SESSION_BUNDLE_CODEC_NAME || + value.version !== SESSION_BUNDLE_CODEC_VERSION || + value.canonicalizationVersion !== SESSION_BUNDLE_CANONICALIZATION_VERSION || + value.archive !== SESSION_BUNDLE_ARCHIVE_FORMAT || + value.compression !== SESSION_BUNDLE_COMPRESSION_FORMAT || + value.compressionLevel !== SESSION_BUNDLE_COMPRESSION_LEVEL + ) { + throw new SessionBundleFileError( + 'unsupported_codec', + 'Session bundle codec settings are not supported', + ); + } + return { + name: SESSION_BUNDLE_CODEC_NAME, + version: SESSION_BUNDLE_CODEC_VERSION, + canonicalizationVersion: SESSION_BUNDLE_CANONICALIZATION_VERSION, + archive: SESSION_BUNDLE_ARCHIVE_FORMAT, + compression: SESSION_BUNDLE_COMPRESSION_FORMAT, + compressionLevel: SESSION_BUNDLE_COMPRESSION_LEVEL, + }; +} + +function decodeEnvelope(value: unknown): SessionBundleManifestV1['envelope'] { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ENVELOPE_KEYS) || + !Object.hasOwn(value, 'sessionId') || + !isNonEmptyUnicodeString(value.sessionId) + ) { + throw invalidManifest(); + } + let lastCommittedActivationId: string | undefined; + if (Object.hasOwn(value, 'lastCommittedActivationId')) { + if (!isNonEmptyUnicodeString(value.lastCommittedActivationId)) throw invalidManifest(); + lastCommittedActivationId = value.lastCommittedActivationId; + } + return { + sessionId: value.sessionId, + ...(lastCommittedActivationId === undefined ? {} : { lastCommittedActivationId }), + }; +} + +function decodeStateIdentity(value: unknown): SessionBundleManifestV1['stateIdentity'] { + if ( + !isRecord(value) || + !hasExactKeys(value, STATE_IDENTITY_KEYS) || + value.path !== SESSION_BUNDLE_STATE_IDENTITY_PATH || + !isNonEmptyUnicodeString(value.mediaType) + ) { + throw invalidManifest(); + } + return { + path: SESSION_BUNDLE_STATE_IDENTITY_PATH, + mediaType: value.mediaType, + }; +} + +function decodePayload(value: unknown): SessionBundleManifestV1['payload'] { + if ( + !isRecord(value) || + !hasExactKeys(value, PAYLOAD_KEYS) || + value.statePath !== SESSION_BUNDLE_STATE_PATH || + value.workspacePath !== SESSION_BUNDLE_WORKSPACE_PATH || + !isSha256Digest(value.treeDigest) || + !isNonNegativeSafeInteger(value.payloadBytes) || + !isNonNegativeSafeInteger(value.entryCount) || + value.entryCount < 3 + ) { + throw invalidManifest(); + } + return { + statePath: SESSION_BUNDLE_STATE_PATH, + workspacePath: SESSION_BUNDLE_WORKSPACE_PATH, + treeDigest: value.treeDigest, + payloadBytes: value.payloadBytes, + entryCount: value.entryCount, + }; +} + +function canonicalJson(value: unknown): string { + try { + return stableJsonStringify(value); + } catch { + throw invalidManifest(); + } +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === expected.length && actual.every((key) => expected.includes(key)); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + return left.every((byte, index) => byte === right[index]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function invalidManifest(): SessionBundleFileError { + return new SessionBundleFileError( + 'invalid_manifest', + 'Session bundle manifest is invalid or non-canonical', + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c04f9117b470c55eda73c045214f46bf01125eb6b4907b8ca91b8e61c1919d2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c04f9117b470c55eda73c045214f46bf01125eb6b4907b8ca91b8e61c1919d2.source new file mode 100644 index 0000000000..e1ec0c41e5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c04f9117b470c55eda73c045214f46bf01125eb6b4907b8ca91b8e61c1919d2.source @@ -0,0 +1,434 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + decodeProviderType, + decodeCanonicalConnectionCatalogEntry, + decodeCredentialVersionBasis, + decodeConnectionName, + decodeConnectionSlug, + decodeRuntimePolicyEntityId, + normalizeCatalogConnectionBaseUrl, + normalizeConnectionCatalogEntryUpdateForProvider, + normalizeConnectionModelDiscoveryResult, + normalizeCredentialSecret, + type ConnectionModelDiscoveryResult, + type ConnectionCatalogEntry, + type CredentialVersionBasis, +} from '@maka/core/runtime-policy'; +import { + deriveConnectionSlug, + PROVIDER_REGISTRY, + providerAuthSupportsApiKey, + type ProviderType, +} from '@maka/core/llm-connections'; +import { syncDirectory } from '../stable-storage.js'; +import { record } from './codec.js'; +import { + codecError, + commitOutcomeUnknown, + decodeConnectionInput, + decodeCredentialInput, + decodePersistedDomain, + ioFailed, +} from './errors.js'; +import type { InteractiveOAuthLoginTarget, InteractiveOAuthLoginProvider } from './operations.js'; +import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; +const FILE = 'runtime-policy-onboarding.json'; +const SCHEMA_VERSION = 2 as const; +const OAUTH_SCHEMA_VERSION = 3 as const; +const MAX_BYTES = 5 * 1024 * 1024; + +export interface ConnectionOnboardingTransactionInput { + readonly connectionId: unknown; + readonly slug: unknown; + readonly providerType: unknown; + /** Optional caller-chosen display name; absent/null keeps the provider default. */ + readonly name?: unknown; + readonly suppliedSecret: unknown; + readonly baseUrl: unknown; + readonly enabledModelIds: unknown; + readonly discovery: unknown; + readonly invalidateLastTest: unknown; +} + +export interface ConnectionOnboardingIntent { + readonly schemaVersion: 1 | typeof SCHEMA_VERSION; + readonly connectionId: string; + /** Absent only while replaying a schema-v1 identity-first intent. */ + readonly slug: string | null; + readonly providerType: ProviderType; + /** + * Caller-chosen display name pinned into the durable intent; null falls + * back to the provider label at upsert. Absent in intents journaled before + * this field existed — they decode to null and behave exactly as before. + */ + readonly name: string | null; + readonly suppliedSecret: string | null; + readonly baseUrl: string | null; + readonly enabledModelIds: readonly string[]; + readonly discovery: ConnectionModelDiscoveryResult; + readonly invalidateLastTest: boolean; +} + +export interface InteractiveOAuthEnrollmentIntent { + readonly schemaVersion: typeof OAUTH_SCHEMA_VERSION; + readonly kind: 'oauth_enrollment'; + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + readonly credentialBasis: CredentialVersionBasis | null; + readonly secret: string; +} + +export type RuntimePolicyOnboardingIntent = + | ConnectionOnboardingIntent + | InteractiveOAuthEnrollmentIntent; + +export type CurrentConnectionOnboardingIntent = ConnectionOnboardingIntent & { + readonly schemaVersion: 2; + readonly slug: string; +}; + +export function prepareConnectionOnboardingIntent( + input: ConnectionOnboardingTransactionInput, + source: 'input' | 'persisted' = 'input', +): CurrentConnectionOnboardingIntent { + const decode = source === 'persisted' ? decodePersistedDomain : decodeConnectionInput; + const providerType = decode(() => decodeProviderType(input.providerType)); + const definition = PROVIDER_REGISTRY[providerType]; + if (!providerAuthSupportsApiKey(providerType) && definition.authKind !== 'oauth_token') { + throw codecError( + source === 'persisted' ? 'invalid_document' : 'invalid_connection_input', + 'Onboarding requires a provider with a connection credential', + ); + } + const discovery = decode(() => normalizeConnectionModelDiscoveryResult(input.discovery)); + // Non-empty is the requirement; `source` is write provenance, not a + // quality bar. A provider without a model-list endpoint runs discovery by + // replaying the array this build shipped, and that inventory onboards a + // connection exactly as well (#1584). + if (discovery.models.length === 0) { + throw codecError( + source === 'persisted' ? 'invalid_document' : 'invalid_connection_input', + 'Onboarding requires a non-empty model inventory', + ); + } + // Legacy intents predate the field (`undefined` when replayed) and mean + // the same thing as an explicit null: no endpoint override. + const baseUrl = + input.baseUrl === null || input.baseUrl === undefined + ? null + : (decode(() => normalizeCatalogConnectionBaseUrl(input.baseUrl, providerType)) ?? null); + const normalized = decode(() => + normalizeConnectionCatalogEntryUpdateForProvider( + { + name: definition.label, + ...((baseUrl ?? definition.baseUrl) ? { baseUrl: baseUrl ?? definition.baseUrl } : {}), + enabled: true, + enabledModelIds: input.enabledModelIds, + }, + providerType, + ), + ); + const available = new Set(discovery.models.map(({ id }) => id)); + if ( + normalized.enabledModelIds.length === 0 || + normalized.enabledModelIds.some((modelId) => !available.has(modelId)) + ) { + throw codecError( + source === 'persisted' ? 'invalid_document' : 'invalid_connection_input', + 'Onboarding enabled models must come from the fetched inventory', + ); + } + const suppliedSecret = + input.suppliedSecret === null + ? null + : source === 'persisted' + ? decodePersistedDomain(() => normalizeCredentialSecret(input.suppliedSecret)) + : decodeCredentialInput(() => normalizeCredentialSecret(input.suppliedSecret)); + if (typeof input.invalidateLastTest !== 'boolean') { + throw codecError( + source === 'persisted' ? 'invalid_document' : 'invalid_connection_input', + 'Onboarding last-test invalidation must be a boolean', + ); + } + return { + schemaVersion: SCHEMA_VERSION, + connectionId: decode(() => decodeRuntimePolicyEntityId(input.connectionId)), + slug: decode(() => decodeConnectionSlug(input.slug)), + providerType, + name: + input.name === undefined || input.name === null + ? null + : decode(() => decodeConnectionName(input.name)), + suppliedSecret, + baseUrl, + enabledModelIds: normalized.enabledModelIds, + discovery, + invalidateLastTest: input.invalidateLastTest, + }; +} + +export async function readConnectionOnboardingIntent( + root: string, +): Promise { + const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); + if (value === undefined) return undefined; + const envelope = record( + value, + FILE, + 'invalid_document', + [ + 'schemaVersion', + 'kind', + 'attemptId', + 'target', + 'connectionBefore', + 'connectionAfter', + 'credentialBasis', + 'secret', + 'connectionId', + 'slug', + 'providerType', + 'name', + 'suppliedSecret', + 'baseUrl', + 'enabledModelIds', + 'discovery', + 'invalidateLastTest', + ], + ['schemaVersion'], + ); + if (envelope.schemaVersion === OAUTH_SCHEMA_VERSION) { + return decodeInteractiveOAuthEnrollmentIntent(value); + } + // `baseUrl` is allowed but not required for the oldest v1 journal shape. + const raw = record( + value, + FILE, + 'invalid_document', + [ + 'schemaVersion', + 'connectionId', + 'slug', + 'providerType', + 'name', + 'suppliedSecret', + 'baseUrl', + 'enabledModelIds', + 'discovery', + 'invalidateLastTest', + ], + [ + 'schemaVersion', + 'connectionId', + 'providerType', + 'suppliedSecret', + 'enabledModelIds', + 'discovery', + 'invalidateLastTest', + ], + ); + if (raw.schemaVersion !== 1 && raw.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + const prepared = prepareConnectionOnboardingIntent( + { + providerType: raw.providerType, + connectionId: raw.connectionId, + slug: + raw.schemaVersion === 1 ? deriveLegacyIntentPlaceholderSlug(raw.providerType) : raw.slug, + name: raw.name, + suppliedSecret: raw.suppliedSecret, + baseUrl: raw.baseUrl, + enabledModelIds: raw.enabledModelIds, + discovery: raw.discovery, + invalidateLastTest: raw.invalidateLastTest, + }, + 'persisted', + ); + return raw.schemaVersion === 1 ? { ...prepared, schemaVersion: 1, slug: null } : prepared; +} + +export function writeConnectionOnboardingIntent( + root: string, + intent: CurrentConnectionOnboardingIntent | InteractiveOAuthEnrollmentIntent, +): Promise { + return writeJsonDocument(root, FILE, intent, MAX_BYTES); +} + +export function prepareInteractiveOAuthEnrollmentIntent(input: { + readonly attemptId: unknown; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry; + readonly credentialBasis: CredentialVersionBasis | null; + readonly secret: unknown; +}): InteractiveOAuthEnrollmentIntent { + const connectionAfter = decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(input.connectionAfter), + ); + if (!isOAuthProvider(connectionAfter.providerType)) { + throw codecError('invalid_connection_input', 'OAuth enrollment requires an OAuth provider'); + } + return { + schemaVersion: OAUTH_SCHEMA_VERSION, + kind: 'oauth_enrollment', + attemptId: decodeOAuthAttemptId(input.attemptId, 'invalid_connection_input'), + target: structuredClone(input.target), + connectionBefore: + input.connectionBefore === null + ? null + : decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(input.connectionBefore), + ), + connectionAfter: connectionAfter as InteractiveOAuthEnrollmentIntent['connectionAfter'], + credentialBasis: input.credentialBasis ? structuredClone(input.credentialBasis) : null, + secret: decodeCredentialInput(() => normalizeCredentialSecret(input.secret)), + }; +} + +function decodeInteractiveOAuthEnrollmentIntent(value: unknown): InteractiveOAuthEnrollmentIntent { + const raw = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'kind', + 'attemptId', + 'target', + 'connectionBefore', + 'connectionAfter', + 'credentialBasis', + 'secret', + ]); + if (raw.schemaVersion !== OAUTH_SCHEMA_VERSION || raw.kind !== 'oauth_enrollment') { + throw codecError('invalid_document', `${FILE} has an invalid OAuth enrollment intent`); + } + const connectionAfter = decodePersistedDomain(() => + decodeCanonicalConnectionCatalogEntry(raw.connectionAfter), + ); + if (!isOAuthProvider(connectionAfter.providerType)) { + throw codecError('invalid_document', 'OAuth enrollment intent provider is invalid'); + } + const connectionBefore = + raw.connectionBefore === null + ? null + : decodePersistedDomain(() => decodeCanonicalConnectionCatalogEntry(raw.connectionBefore)); + const credentialBasis = + raw.credentialBasis === null + ? null + : decodePersistedDomain(() => decodeCredentialVersionBasis(raw.credentialBasis)); + const target = decodeOAuthTarget(raw.target); + if ( + (target.kind === 'create' && connectionBefore !== null) || + (target.kind === 'create' && target.providerType !== connectionAfter.providerType) || + (target.kind === 'existing' && + (connectionBefore === null || connectionBefore.connectionId !== target.connectionId)) || + connectionAfter.connectionId !== + (target.kind === 'existing' ? target.connectionId : connectionAfter.connectionId) || + (connectionBefore !== null && + (connectionBefore.connectionId !== connectionAfter.connectionId || + connectionBefore.slug !== connectionAfter.slug || + connectionBefore.providerType !== connectionAfter.providerType)) + ) { + throw codecError('invalid_document', 'OAuth enrollment intent identity is inconsistent'); + } + return { + schemaVersion: OAUTH_SCHEMA_VERSION, + kind: 'oauth_enrollment', + attemptId: decodeOAuthAttemptId(raw.attemptId, 'invalid_document'), + target, + connectionBefore, + connectionAfter: connectionAfter as InteractiveOAuthEnrollmentIntent['connectionAfter'], + credentialBasis, + secret: decodePersistedDomain(() => normalizeCredentialSecret(raw.secret)), + }; +} + +function decodeOAuthTarget(value: unknown): InteractiveOAuthLoginTarget { + const base = record( + value, + 'OAuth enrollment target', + 'invalid_document', + ['kind', 'providerType', 'connectionId'], + ['kind'], + ); + if (base.kind === 'create') { + const item = record(value, 'OAuth create target', 'invalid_document', ['kind', 'providerType']); + const providerType = decodePersistedDomain(() => decodeProviderType(item.providerType)); + if (!isOAuthProvider(providerType)) { + throw codecError('invalid_document', 'OAuth create target provider is invalid'); + } + return { kind: 'create', providerType }; + } + if (base.kind === 'existing') { + const item = record(value, 'OAuth existing target', 'invalid_document', [ + 'kind', + 'connectionId', + ]); + return { + kind: 'existing', + connectionId: decodePersistedDomain(() => decodeRuntimePolicyEntityId(item.connectionId)), + }; + } + throw codecError('invalid_document', 'OAuth enrollment target kind is invalid'); +} + +function isOAuthProvider( + providerType: ProviderType, +): providerType is InteractiveOAuthLoginProvider { + return ( + providerType === 'openai-codex' || + providerType === 'xai-oauth' || + providerType === 'github-copilot' + ); +} + +function decodeOAuthAttemptId( + value: unknown, + source: 'invalid_connection_input' | 'invalid_document', +): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError(source, 'OAuth attempt id is invalid'); + } + return value; +} + +function deriveLegacyIntentPlaceholderSlug(rawProviderType: unknown): string { + const providerType = decodePersistedDomain(() => decodeProviderType(rawProviderType)); + return deriveConnectionSlug(providerType); +} + +export async function clearConnectionOnboardingIntent(root: string): Promise { + try { + await unlink(join(root, FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw ioFailed(`${FILE} could not be removed`, error); + } + try { + await syncDirectory(root); + } catch (error) { + throw commitOutcomeUnknown(`${FILE} removal outcome is unknown`, error); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c41843f8eeec84c816ed119e129aeaba9e346d168bd12455a4b0d224e5f3518.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c41843f8eeec84c816ed119e129aeaba9e346d168bd12455a4b0d224e5f3518.source new file mode 100644 index 0000000000..f24d9f6c9d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3c41843f8eeec84c816ed119e129aeaba9e346d168bd12455a4b0d224e5f3518.source @@ -0,0 +1,814 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, test } from 'node:test'; +import { Worker } from 'node:worker_threads'; +import { + createOperationalStateBackup, + restoreOperationalStateBackup, +} from '../operational-state-backup.js'; +import { buildWorkBoardListStatement } from '../work-board-list-query.js'; +import { + createWorkBoardStore, + WorkBoardStoreError, + type WorkBoardMutationOptions, +} from '../work-board-store.js'; +import { + WORK_BOARD_DEFAULT_PAGE_SIZE, + WORK_BOARD_PROJECT_ID_MAX_CHARS, +} from '@maka/core/work-board'; +import { SQLITE_WORKFLOW_SCHEMA_VERSION } from '../sqlite-workflow-schema.js'; + +describe('Work Board store', () => { + test('persists items across reopen', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + const item = await store.create(itemInput(), 100); + store.close(); + + const reopened = createWorkBoardStore(root); + try { + const page = await reopened.list(); + assert.equal(page.items.length, 1); + assert.deepEqual(page.items[0], item); + assert.deepEqual(await reopened.get(item.id), item); + } finally { + reopened.close(); + } + }); + }); + + test('applies semantic patches and enforces optimistic concurrency', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + const renamed = await store.update(item.id, { title: 'Review auth v2' }, {}, 101); + assert.equal(renamed.revision, 2); + assert.equal(renamed.title, 'Review auth v2'); + + await assert.rejects( + store.update(item.id, { state: 'in_progress' }, { expectedRevision: 1 }, 102), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'operation_conflict', + ); + + const moved = await store.update( + item.id, + { scope: { kind: 'project', projectId: 'p1' }, state: 'done' }, + { expectedRevision: 2 }, + 103, + ); + assert.equal(moved.revision, 3); + assert.deepEqual(moved.scope, { kind: 'project', projectId: 'p1' }); + assert.equal(moved.state, 'done'); + } finally { + store.close(); + } + }); + }); + + test('archive, reopen, and permanent delete follow the intended lifecycle', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + const archived = await store.archive(item.id, {}, 101); + assert.equal(archived.archived, true); + assert.equal(archived.archivedAt, 101); + assert.equal(archived.revision, 2); + + const reopened = await store.unarchive(item.id, {}, 102); + assert.equal(reopened.archived, false); + assert.equal('archivedAt' in reopened, false); + assert.equal(reopened.revision, 3); + + await assert.rejects( + store.remove(item.id), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'must_archive_first', + ); + + await store.archive(item.id, {}, 103); + await store.remove(item.id, { expectedRevision: 4 }); + assert.equal(await store.get(item.id), undefined); + } finally { + store.close(); + } + }); + }); + + test('paginates with an opaque cursor and filters by scope and archive state', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const ids: string[] = []; + for (let index = 1; index <= 5; index += 1) { + const item = await store.create(itemInput({ title: `item-${index}` }), index); + ids.push(item.id); + } + const projectItem = await store.create( + itemInput({ + title: 'project item', + scope: { kind: 'project', projectId: 'p1' }, + }), + 6, + ); + + const first = await store.list({ limit: 2, scope: { kind: 'inbox' } }); + assert.deepEqual( + first.items.map((item) => item.id), + [ids[4], ids[3]], + ); + assert.ok(first.nextCursor); + + const second = await store.list({ + limit: 2, + scope: { kind: 'inbox' }, + cursor: first.nextCursor, + }); + assert.deepEqual( + second.items.map((item) => item.id), + [ids[2], ids[1]], + ); + assert.ok(second.nextCursor); + + const third = await store.list({ + limit: 2, + scope: { kind: 'inbox' }, + cursor: second.nextCursor, + }); + assert.deepEqual( + third.items.map((item) => item.id), + [ids[0]], + ); + assert.equal(third.nextCursor, undefined); + + const inbox = await store.list({ scope: { kind: 'inbox' } }); + assert.equal(inbox.items.length, 5); + const project = await store.list({ + scope: { kind: 'project', projectId: 'p1' }, + }); + assert.deepEqual( + project.items.map((item) => item.id), + [projectItem.id], + ); + assert.equal( + (await store.list({ scope: { kind: 'project', projectId: 'missing' } })).items.length, + 0, + ); + + await store.archive(ids[0]!, {}, 7); + assert.equal((await store.list()).items.length, 5); + assert.equal((await store.list({ includeArchived: true })).items.length, 6); + + const absorbedProjectItem = await store.create( + itemInput({ + title: 'absorbed project item', + scope: { kind: 'project', projectId: 'project-stale' }, + }), + 8, + ); + const projectWithAliases = await store.list({ + scope: { kind: 'project', projectId: 'p1' }, + projectIds: ['p1', 'project-stale'], + }); + assert.deepEqual( + projectWithAliases.items.map((item) => item.id), + [absorbedProjectItem.id, projectItem.id], + ); + } finally { + store.close(); + } + }); + }); + + test('bounds default and scoped list work under archive-heavy data with active-row indexes', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const activeIds: string[] = []; + for (let index = 0; index < 5; index += 1) { + const item = await store.create(itemInput({ title: `active-${index}` }), 1_000 + index); + activeIds.push(item.id); + } + for (let index = 0; index < 120; index += 1) { + const item = await store.create(itemInput({ title: `archived-${index}` }), 2_000 + index); + await store.archive(item.id, {}, 3_000 + index); + } + const projectActive = await store.create( + itemInput({ title: 'project-active', scope: { kind: 'project', projectId: 'p1' } }), + 1_000 + 5, + ); + + const page = await store.list(); + assert.deepEqual( + page.items.map((item) => item.id), + [projectActive.id, ...activeIds.slice().reverse()], + ); + assert.equal( + page.items.some((item) => item.archived), + false, + ); + + const projectPage = await store.list({ scope: { kind: 'project', projectId: 'p1' } }); + assert.deepEqual( + projectPage.items.map((item) => item.id), + [projectActive.id], + ); + } finally { + store.close(); + } + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + const unscopedDetail = explainListPlan( + database, + buildWorkBoardListStatement({}, WORK_BOARD_DEFAULT_PAGE_SIZE), + ); + const scopedDetail = explainListPlan( + database, + buildWorkBoardListStatement( + { scope: { kind: 'project', projectId: 'p1' } }, + WORK_BOARD_DEFAULT_PAGE_SIZE, + ), + ); + assert.match(unscopedDetail, /workflow_work_board_items_active_order/); + assert.match(scopedDetail, /workflow_work_board_items_active_scope_order/); + assert.doesNotMatch(unscopedDetail, /USE TEMP B-TREE/); + assert.doesNotMatch(scopedDetail, /USE TEMP B-TREE/); + } finally { + database.close(); + } + }); + }); + + test('rejects a cursor reused with a different filter result set', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + for (let index = 0; index < 3; index += 1) { + await store.create(itemInput({ title: `inbox-${index}` }), 100 + index); + } + const inboxPage = await store.list({ limit: 1, scope: { kind: 'inbox' } }); + assert.ok(inboxPage.nextCursor); + const firstItem = inboxPage.items[0]; + assert.ok(firstItem); + + await assert.rejects( + store.list({ + scope: { kind: 'project', projectId: 'p1' }, + cursor: inboxPage.nextCursor, + }), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'invalid_input', + ); + + const aliasItem = await store.create( + itemInput({ scope: { kind: 'project', projectId: 'absorbed' } }), + 200, + ); + const aliasPage = await store.list({ + limit: 1, + scope: { kind: 'project', projectId: 'canonical' }, + projectIds: ['canonical', 'absorbed'], + }); + assert.deepEqual( + aliasPage.items.map((item) => item.id), + [aliasItem.id], + ); + assert.equal(aliasPage.nextCursor, undefined); + await assert.rejects( + store.list({ includeArchived: true, cursor: inboxPage.nextCursor }), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'invalid_input', + ); + + const secondPage = await store.list({ + limit: 1, + scope: { kind: 'inbox' }, + cursor: inboxPage.nextCursor, + }); + assert.equal(secondPage.items.length, 1); + const secondItem = secondPage.items[0]; + assert.ok(secondItem); + assert.notEqual(secondItem.id, firstItem.id); + } finally { + store.close(); + } + }); + }); + + test('round-trips pagination cursors with a maximum-length project id', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const projectId = 'p'.repeat(WORK_BOARD_PROJECT_ID_MAX_CHARS); + for (let index = 0; index < 2; index += 1) { + await store.create( + itemInput({ + title: `long-project-${index}`, + scope: { kind: 'project', projectId }, + }), + 100 + index, + ); + } + + const first = await store.list({ limit: 1, scope: { kind: 'project', projectId } }); + assert.ok(first.nextCursor); + const second = await store.list({ + limit: 1, + scope: { kind: 'project', projectId }, + cursor: first.nextCursor, + }); + assert.equal(second.items.length, 1); + assert.notEqual(second.items[0]?.id, first.items[0]?.id); + } finally { + store.close(); + } + }); + }); + + test('paginates across an unbounded relinked project identity set', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const projectIds = Array.from( + { length: 15 }, + (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ); + const createdIds: string[] = []; + for (let index = 0; index < 101; index += 1) { + const item = await store.create( + itemInput({ + title: `aliased-project-${index}`, + scope: { kind: 'project', projectId: projectIds[index % projectIds.length]! }, + }), + 1_000 + index, + ); + createdIds.push(item.id); + } + + const query = { + limit: 100, + scope: { kind: 'project' as const, projectId: projectIds[0]! }, + projectIds, + }; + const first = await store.list(query); + assert.equal(first.items.length, 100); + assert.ok(first.nextCursor); + assert.ok(first.nextCursor.length < 512); + + const second = await store.list({ ...query, cursor: first.nextCursor }); + assert.deepEqual( + second.items.map((item) => item.id), + [createdIds[0]], + ); + assert.equal(second.nextCursor, undefined); + } finally { + store.close(); + } + }); + }); + + test('keeps mutation timestamps monotonic when now moves backwards', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + + const renamed = await store.update(item.id, { title: 'earlier clock' }, {}, 50); + assert.equal(renamed.updatedAt, 100); + assert.equal(renamed.revision, 2); + + const archived = await store.archive(item.id, {}, 30); + assert.equal(archived.updatedAt, 100); + assert.equal(archived.archivedAt, 100); + assert.equal(archived.revision, 3); + + const reopened = await store.unarchive(item.id, {}, 40); + assert.equal(reopened.updatedAt, 100); + assert.equal(reopened.revision, 4); + } finally { + store.close(); + } + }); + }); + + test('rejects unknown mutation option keys instead of disabling CAS', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + await store.update(item.id, { title: 'v2' }, {}, 101); + + await assert.rejects( + store.update( + item.id, + { state: 'in_progress' }, + { expectedRevison: 1 } as unknown as WorkBoardMutationOptions, + 102, + ), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'invalid_input', + ); + await assert.rejects( + store.update( + item.id, + { state: 'done' }, + { expectedRevision: 1, extra: true } as unknown as WorkBoardMutationOptions, + 103, + ), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'invalid_input', + ); + + const final = await store.get(item.id); + assert.ok(final); + assert.equal(final.revision, 2); + assert.equal(final.state, 'todo'); + } finally { + store.close(); + } + }); + }); + + // Process-local serialization only; cross-process CAS is protected by the + // BEGIN IMMEDIATE transaction shared by update/archive/unarchive/remove. + test('serializes concurrent mutations through the process-local write queue', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + const results = await Promise.all([ + store.update(item.id, { title: 'renamed' }, {}, 200), + store.update(item.id, { state: 'in_progress' }, {}, 201), + store.archive(item.id, {}, 202), + ]); + assert.deepEqual( + results.map((result) => result.revision), + [2, 3, 4], + ); + const final = await store.get(item.id); + assert.ok(final); + assert.equal(final.title, 'renamed'); + assert.equal(final.state, 'in_progress'); + assert.equal(final.archived, true); + assert.equal(final.revision, 4); + assert.equal(final.updatedAt, 202); + } finally { + store.close(); + } + }); + }); + + test('CAS across separate worker connections produces exactly one winner and one conflict', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + const item = await store.create(itemInput(), 100); + store.close(); + + const workerUrl = new URL('./fixtures/work-board-cas-worker.js', import.meta.url); + const runWorker = (): Promise<{ ok: boolean; revision?: number; code?: unknown }> => + new Promise((resolve, reject) => { + const worker = new Worker(workerUrl, { + workerData: { workspaceRoot: root, itemId: item.id }, + }); + worker.once('message', resolve); + worker.once('error', reject); + worker.once('exit', (code) => { + if (code !== 0) reject(new Error(`Work Board CAS worker exited with ${code}`)); + }); + }); + + const results = await Promise.all([runWorker(), runWorker()]); + const winners = results.filter((result) => result.ok); + const conflicts = results.filter( + (result) => !result.ok && result.code === 'operation_conflict', + ); + assert.equal(winners.length, 1); + assert.equal(conflicts.length, 1); + + const reopened = createWorkBoardStore(root); + try { + const final = await reopened.get(item.id); + assert.ok(final); + assert.equal(final.revision, 2); + assert.match(final.title, /^worker-/); + } finally { + reopened.close(); + } + }); + }); + + test('applies notes patch semantics at the store boundary', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const item = await store.create(itemInput(), 100); + await store.update(item.id, { notes: 'keep me' }, {}, 101); + assert.equal((await store.get(item.id))?.notes, 'keep me'); + + const untouched = await store.update(item.id, { notes: undefined }, {}, 102); + assert.equal(untouched.notes, 'keep me'); + assert.equal(untouched.revision, 2); + + const clearedByNull = await store.update(item.id, { notes: null }, {}, 103); + assert.equal('notes' in clearedByNull, false); + assert.equal(clearedByNull.revision, 3); + + await store.update(item.id, { notes: 'again' }, {}, 104); + const clearedByEmpty = await store.update(item.id, { notes: '' }, {}, 105); + assert.equal('notes' in clearedByEmpty, false); + assert.equal(clearedByEmpty.revision, 5); + + await store.update(item.id, { notes: 'again2' }, {}, 106); + const clearedByWhitespace = await store.update(item.id, { notes: ' ' }, {}, 107); + assert.equal('notes' in clearedByWhitespace, false); + assert.equal(clearedByWhitespace.revision, 7); + + const replaced = await store.update(item.id, { notes: 'new' }, {}, 108); + assert.equal(replaced.notes, 'new'); + assert.equal(replaced.revision, 8); + } finally { + store.close(); + } + }); + }); + + test('keeps revision monotonic when mutations share the same timestamp', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + try { + const created = await store.create(itemInput(), 1000); + assert.equal(created.revision, 1); + assert.equal(created.updatedAt, 1000); + + const renamed = await store.update(created.id, { title: 'same ms' }, {}, 1000); + assert.equal(renamed.revision, 2); + assert.equal(renamed.updatedAt, 1000); + + const moved = await store.update(renamed.id, { state: 'in_progress' }, {}, 1000); + assert.equal(moved.revision, 3); + assert.equal(moved.updatedAt, 1000); + } finally { + store.close(); + } + }); + }); + + test('rejects a row whose record_json is malformed JSON', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + const item = await store.create(itemInput(), 100); + store.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + database + .prepare('UPDATE workflow_work_board_items SET record_json = ? WHERE item_id = ?') + .run('{broken', item.id); + database.close(); + + const reopened = createWorkBoardStore(root); + try { + await assert.rejects( + reopened.get(item.id), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'corrupt_record', + ); + await assert.rejects( + reopened.list(), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'corrupt_record', + ); + } finally { + reopened.close(); + } + }); + }); + + test('SQLite rejects inbox rows with a project id and project rows without one', async () => { + await withTempRoot(async (root) => { + createWorkBoardStore(root).close(); + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + const insert = database.prepare(` + INSERT INTO workflow_work_board_items( + item_id, revision, created_at, updated_at, scope_kind, project_id, archived, record_json + ) + VALUES (?, 1, 1, 1, ?, ?, 0, ?) + `); + assert.throws(() => insert.run('bad-inbox', 'inbox', 'p1', '{}')); + assert.throws(() => insert.run('bad-project', 'project', null, '{}')); + } finally { + database.close(); + } + }); + }); + + test('rejects indexed columns that disagree with record_json', async () => { + await withTempRoot(async (root) => { + const store = createWorkBoardStore(root); + const item = await store.create( + itemInput({ scope: { kind: 'project', projectId: 'p1' } }), + 100, + ); + store.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + database + .prepare( + `UPDATE workflow_work_board_items + SET scope_kind = 'inbox', project_id = NULL + WHERE item_id = ?`, + ) + .run(item.id); + database.close(); + + const reopened = createWorkBoardStore(root); + try { + await assert.rejects( + reopened.get(item.id), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'corrupt_record', + ); + await assert.rejects( + reopened.list(), + (error: unknown) => + error instanceof WorkBoardStoreError && error.code === 'corrupt_record', + ); + } finally { + reopened.close(); + } + }); + }); + + test('migrates a real workflow schema 8 database through event-only version 10', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-work-board-schema8-')); + const stateRoot = join(base, 'state'); + const databasePath = join(stateRoot, 'runtime.sqlite'); + await mkdir(stateRoot, { recursive: true }); + try { + await copyFile( + new URL('../../test-fixtures/v0.1.6-operational-state/runtime.sqlite', import.meta.url), + databasePath, + ); + const v8 = new DatabaseSync(databasePath); + try { + v8.exec( + readFileSync( + new URL('../../test-fixtures/workflow-schema-v8.sql', import.meta.url), + 'utf8', + ), + ); + // The released v0.1.6 fixture ships an Automation definition; clearing + // it keeps the fixture focused on the workflow 8 -> current upgrade while + // leaving every released table and row otherwise intact. + v8.exec('DELETE FROM automation_definitions; DELETE FROM automation_pending_fires;'); + v8.prepare( + "UPDATE operational_schema_migrations SET version = 8 WHERE scope = 'workflow'", + ).run(); + } finally { + v8.close(); + } + + createWorkBoardStore(stateRoot).close(); + const database = new DatabaseSync(databasePath); + try { + const version = database + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'workflow'") + .get() as { version?: unknown } | undefined; + assert.equal(version?.version, SQLITE_WORKFLOW_SCHEMA_VERSION); + assert.ok( + database + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_work_board_items'") + .get(), + ); + assert.equal( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE name IN ('workflow_task_ledger_projections', 'workflow_plan_projections') LIMIT 1", + ) + .get(), + undefined, + ); + assert.ok( + database + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_goal_authority'") + .get(), + ); + assert.ok( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = 'workflow_work_board_items_active_order'", + ) + .get(), + ); + assert.ok( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = 'workflow_work_board_items_active_scope_order'", + ) + .get(), + ); + } finally { + database.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } + }); + + test('backs up and restores board items with the operational state', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-work-board-backup-')); + const stateRoot = join(base, 'state'); + const backupRoot = join(base, 'backup'); + const restoreRoot = join(base, 'restore'); + await mkdir(stateRoot, { recursive: true }); + try { + const store = createWorkBoardStore(stateRoot); + const item = await store.create(itemInput({ title: 'backup me' }), 100); + store.close(); + + await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 }); + await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot }); + + const restored = createWorkBoardStore(restoreRoot); + try { + assert.deepEqual(await restored.get(item.id), item); + } finally { + restored.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } + }); +}); + +function explainListPlan( + database: DatabaseSync, + statement: { sql: string; params: Array }, +): string { + let index = 0; + const literalSql = statement.sql.replace(/\?/g, () => { + const value = statement.params[index++]!; + return typeof value === 'number' ? String(value) : `'${value.replace(/'/g, "''")}'`; + }); + const rows = database.prepare(`EXPLAIN QUERY PLAN ${literalSql}`).all() as Array<{ + detail: string; + }>; + return rows.map((row) => row.detail).join(' '); +} + +function itemInput( + overrides: Partial<{ + scope: { kind: 'inbox' } | { kind: 'project'; projectId: string }; + title: string; + creator: { kind: 'user' } | { kind: 'agent_suggestion'; confirmedAt: number }; + provenance: { kind: 'manual' }; + }> = {}, +): { + scope: { kind: 'inbox' } | { kind: 'project'; projectId: string }; + title: string; + creator: { kind: 'user' } | { kind: 'agent_suggestion'; confirmedAt: number }; + provenance: { kind: 'manual' }; +} { + return { + scope: { kind: 'inbox' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + ...overrides, + }; +} + +async function withTempRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-work-board-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3ddbcf1e0fcb503a91aeb4e6adf89098cce9fddd29dbbf26172871daa1f484d2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3ddbcf1e0fcb503a91aeb4e6adf89098cce9fddd29dbbf26172871daa1f484d2.source new file mode 100644 index 0000000000..afadeb316c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/3ddbcf1e0fcb503a91aeb4e6adf89098cce9fddd29dbbf26172871daa1f484d2.source @@ -0,0 +1,441 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + MCP_CONFIG_VERSION, + createDefaultMcpConfig, + isNonLoopbackCleartextHttp, + type McpConfigFile, + type McpConfigSourceFailureReason, + type McpOAuthConfig, + type McpProtocolPreference, + type McpRemoteServerConfig, + type McpServerConfig, + type McpStdioServerConfig, +} from '@maka/core/mcp'; +import { writeAtomicFile } from './atomic-file-write.js'; +import { withProcessLifetimeFileUpdateLock } from './process-lifetime-file-update-lock.js'; +import { hardenDirectory } from './stable-storage.js'; + +// Consumers reconcile an already-published write through this store's public +// boundary; the shared atomic writer itself remains internal to storage. +export { AtomicFileWriteCommitUnknownError } from './atomic-file-write.js'; + +const MAX_SERVERS = 100; +const MAX_ID_LENGTH = 128; +const MAX_STRING_LENGTH = 8_192; +const MAX_CONFIG_BYTES = 1_048_576; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +export interface McpConfigStore { + get(): Promise; + /** One cross-process read-transform-write transaction. `apply` sees the + * current on-disk config and may finish asynchronous effects that must + * precede the commit, such as retiring credentials. The shared file lock + * remains held until the write settles. A write can fail after publication + * with AtomicFileWriteCommitUnknownError when durability is unconfirmed: + * reload with get() and reconcile consumers before considering a retry. + * Never blindly replay apply, whose effects may already have happened. */ + transform( + apply: (current: McpConfigFile) => McpConfigFile | Promise, + ): Promise; + upsert(serverId: string, config: McpServerConfig): Promise; + remove(serverId: string): Promise; +} + +export class McpConfigSourceError extends Error { + constructor( + readonly reason: McpConfigSourceFailureReason, + readonly version?: string, + message: string = reason, + ) { + super(message); + this.name = 'McpConfigSourceError'; + } +} + +/** Thrown by insert when the id is taken. Same-process callers (the IPC + * layer) match on instanceof and answer the renderer with a typed + * envelope; the message never has to carry a machine-readable code. */ +export class McpServerExistsError extends Error { + constructor(readonly serverId: string) { + super(`MCP server "${serverId}" already exists`); + this.name = 'McpServerExistsError'; + } +} + +export function createMcpConfigStore(workspaceRoot: string): McpConfigStore { + return new FileMcpConfigStore(join(workspaceRoot, 'mcp.json')); +} + +export function normalizeMcpConfig(value: unknown): McpConfigFile { + if (!isRecord(value)) throw new Error('MCP config must be an object'); + const sourceVersion = supportedSourceVersion(value); + if (!isRecord(value.mcpServers)) throw new Error('mcpServers must be an object'); + const entries = Object.entries(value.mcpServers); + if (entries.length > MAX_SERVERS) throw new Error(`mcpServers exceeds ${MAX_SERVERS} entries`); + const mcpServers: Record = Object.create(null); + for (const [serverId, raw] of entries) { + assertSafeKey(serverId, 'server id'); + mcpServers[serverId] = normalizeServer( + raw, + serverId, + sourceVersion, + value.version === undefined, + ); + } + return { version: MCP_CONFIG_VERSION, mcpServers: { ...mcpServers } }; +} + +/** Parse either a wrapped mcp.json document or a direct server map while + * preserving the source wrapper version until schema validation completes. + * Import presentation belongs to the caller; config interpretation lives here + * beside the normalizer so renderer and persistence cannot disagree. */ +export function normalizeMcpImport(source: string): McpConfigFile { + if (Buffer.byteLength(source, 'utf8') > MAX_CONFIG_BYTES) { + throw new Error('MCP config exceeds 1 MiB'); + } + let value: unknown; + try { + value = JSON.parse(source); + } catch { + throw new McpConfigSourceError('invalid-json', undefined, 'MCP config must be valid JSON'); + } + if (!isRecord(value)) { + throw new McpConfigSourceError('not-object', undefined, 'MCP config must be an object'); + } + + const wrapped = + (Object.hasOwn(value, 'version') && !isRecord(value.version)) || + (Object.hasOwn(value, 'mcpServers') && + (!isRecord(value.mcpServers) || !isMcpServerConfigShape(value.mcpServers))); + if (!wrapped) return normalizeMcpConfig({ version: 1, mcpServers: value }); + if (!isRecord(value.mcpServers)) { + throw new McpConfigSourceError('missing-servers', undefined, 'mcpServers must be an object'); + } + return normalizeMcpConfig(value); +} + +class FileMcpConfigStore implements McpConfigStore { + private directoryReady: Promise | undefined; + + constructor(private readonly path: string) {} + + async get(): Promise { + try { + return await this.read(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + return this.withUpdateLock(() => this.readOrCreate()); + } + } + + async transform( + apply: (current: McpConfigFile) => McpConfigFile | Promise, + ): Promise { + return this.withUpdateLock(async () => { + const current = await this.readOrCreate(); + const next = normalizeMcpConfig(await apply(current)); + assertMcpEndpointPolicyOnChanges(current, next); + await this.write(next); + return next; + }); + } + + async upsert(serverId: string, config: McpServerConfig): Promise { + assertSafeKey(serverId, 'server id'); + return this.transform((current) => + normalizeMcpConfig({ + version: MCP_CONFIG_VERSION, + mcpServers: { ...current.mcpServers, [serverId]: config }, + }), + ); + } + + async remove(serverId: string): Promise { + assertSafeKey(serverId, 'server id'); + return this.transform((current) => { + const { [serverId]: _removed, ...mcpServers } = current.mcpServers; + return { version: MCP_CONFIG_VERSION, mcpServers }; + }); + } + + private async read(): Promise { + const text = await readFile(this.path, 'utf8'); + if (Buffer.byteLength(text, 'utf8') > MAX_CONFIG_BYTES) { + throw new Error('MCP config exceeds 1 MiB'); + } + return normalizeMcpConfig(JSON.parse(text)); + } + + private async readOrCreate(): Promise { + try { + return await this.read(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const empty = createDefaultMcpConfig(); + await this.write(empty); + return empty; + } + } + + private async withUpdateLock(operation: () => Promise): Promise { + await this.ensureDirectory(); + return withProcessLifetimeFileUpdateLock(this.path, operation); + } + + private async write(config: McpConfigFile): Promise { + await this.ensureDirectory(); + await writeAtomicFile(this.path, `${JSON.stringify(config, null, 2)}\n`, { + fileMode: 0o600, + }); + } + + private ensureDirectory(): Promise { + if (this.directoryReady) return this.directoryReady; + const ready = hardenDirectory(dirname(this.path), 0o700); + this.directoryReady = ready; + void ready.catch(() => { + if (this.directoryReady === ready) this.directoryReady = undefined; + }); + return ready; + } +} + +/** Endpoint security policy, enforced at the WRITE boundary for new or + * repointed endpoints only. Reads grandfather whatever earlier releases + * accepted: a single legacy `http://` entry must not make the whole file — + * and every other server in it — unreadable and unrepairable from the app. + * The transport layer still refuses to CONNECT such an endpoint, so a + * grandfathered entry surfaces as a per-server error, not a working + * cleartext channel. */ +export function assertMcpEndpointPolicy(server: McpServerConfig, serverId: string): void { + if (!('url' in server)) return; + const parsed = new URL(server.url); + if (isNonLoopbackCleartextHttp(parsed)) { + // A remote MCP endpoint carries bearer tokens and tool payloads. + throw new Error(`${serverId}.url must use https for non-loopback hosts`); + } + if (parsed.username || parsed.password) { + throw new Error(`${serverId}.url must not contain embedded credentials; use headers instead`); + } +} + +export function assertMcpEndpointPolicyOnChanges( + previous: McpConfigFile | undefined, + next: McpConfigFile, +): void { + for (const [serverId, server] of Object.entries(next.mcpServers)) { + if (!('url' in server)) continue; + const before = previous?.mcpServers[serverId]; + const beforeUrl = before && 'url' in before ? before.url : undefined; + // Enabling/disabling or editing headers on a grandfathered entry stays + // possible; introducing or repointing an endpoint takes the policy. + if (server.url !== beforeUrl) assertMcpEndpointPolicy(server, serverId); + } +} + +type McpConfigSourceVersion = 1 | 2 | typeof MCP_CONFIG_VERSION; + +function normalizeServer( + value: unknown, + serverId: string, + sourceVersion: McpConfigSourceVersion, + versionMissing: boolean, +): McpServerConfig { + if (!isRecord(value)) throw new Error(`MCP server "${serverId}" must be an object`); + const hasProtocol = Object.hasOwn(value, 'protocol'); + if (sourceVersion === 1 && hasProtocol) { + const source = versionMissing ? 'without a version' : 'version 1'; + throw new McpConfigSourceError( + 'protocol-version', + undefined, + `MCP config ${source} must not contain "protocol"`, + ); + } + const enabled = value.enabled === undefined ? true : bool(value.enabled, `${serverId}.enabled`); + if (typeof value.command === 'string') { + if (sourceVersion === 2 && hasProtocol) { + throw new McpConfigSourceError( + 'protocol-version', + undefined, + `${serverId}.protocol is not supported for stdio in version 2`, + ); + } + const result: McpStdioServerConfig = { + enabled, + command: nonEmptyString(value.command, `${serverId}.command`), + }; + if (value.args !== undefined) result.args = stringArray(value.args, `${serverId}.args`); + if (value.env !== undefined) result.env = stringMap(value.env, `${serverId}.env`); + if (value.cwd !== undefined) result.cwd = nonEmptyString(value.cwd, `${serverId}.cwd`); + const protocol = protocolPreference(value.protocol, `${serverId}.protocol`); + if (protocol !== undefined) result.protocol = protocol; + return result; + } + const url = nonEmptyString(value.url, `${serverId}.url`); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`${serverId}.url must be a valid URL`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${serverId}.url must use http or https`); + } + const transport = value.transport ?? 'auto'; + if (transport !== 'auto' && transport !== 'streamable-http' && transport !== 'sse') { + throw new Error(`${serverId}.transport is invalid`); + } + const protocol = protocolPreference(value.protocol, `${serverId}.protocol`); + if (transport === 'sse' && protocol !== undefined && protocol !== 'legacy') { + throw new Error(`${serverId}.transport "sse" requires protocol "legacy"`); + } + const result: McpRemoteServerConfig = { + enabled, + url: parsed.toString(), + transport, + }; + if (value.headers !== undefined) result.headers = stringMap(value.headers, `${serverId}.headers`); + if (protocol !== undefined) result.protocol = protocol; + if (value.oauth !== undefined) result.oauth = normalizeOAuth(value.oauth, serverId); + if ( + result.oauth && + Object.keys(result.headers ?? {}).some((key) => key.toLowerCase() === 'authorization') + ) { + // One authority per header: the OAuth bearer owns Authorization. A + // config declaring both is a conflict to reject, not to arbitrate at + // request time. + throw new Error(`${serverId}.headers must not include Authorization when oauth is configured`); + } + return result; +} + +function normalizeOAuth(value: unknown, serverId: string): McpOAuthConfig { + if (!isRecord(value)) throw new Error(`${serverId}.oauth must be an object`); + const result: McpOAuthConfig = {}; + if (value.clientId !== undefined) { + result.clientId = nonEmptyString(value.clientId, `${serverId}.oauth.clientId`); + } + if (value.clientSecret !== undefined) { + result.clientSecret = nonEmptyString(value.clientSecret, `${serverId}.oauth.clientSecret`); + } + if (result.clientSecret !== undefined && result.clientId === undefined) { + // A secret with no client id cannot form static client credentials — + // authentication would fail later, far from the config mistake. + throw new Error(`${serverId}.oauth.clientId is required when clientSecret is configured`); + } + if (value.scopes !== undefined) { + result.scopes = stringArray(value.scopes, `${serverId}.oauth.scopes`).map((scope, index) => { + // RFC 6749 §3.3 scope-token: printable ASCII except space, quote and + // backslash. The list joins space-delimited on the wire, so an entry + // outside the grammar would silently change the requested grant or be + // rejected as invalid_scope far from the config mistake. + if (!/^[\x21\x23-\x5B\x5D-\x7E]+$/u.test(scope)) { + throw new Error(`${serverId}.oauth.scopes[${index}] must be a non-empty scope token`); + } + return scope; + }); + } + if (value.callbackPort !== undefined) { + if ( + typeof value.callbackPort !== 'number' || + !Number.isInteger(value.callbackPort) || + value.callbackPort < 1 || + value.callbackPort > 65_535 + ) { + throw new Error(`${serverId}.oauth.callbackPort must be a port number`); + } + result.callbackPort = value.callbackPort; + } + return result; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function supportedSourceVersion(value: Record): McpConfigSourceVersion { + const sourceVersion = value.version === undefined ? 1 : value.version; + if (sourceVersion !== 1 && sourceVersion !== 2 && sourceVersion !== MCP_CONFIG_VERSION) { + throw new McpConfigSourceError( + 'unsupported-version', + String(value.version), + `Unsupported MCP config version: ${String(value.version)}`, + ); + } + return sourceVersion; +} + +function isMcpServerConfigShape(value: Record): boolean { + return typeof value.command === 'string' || typeof value.url === 'string'; +} + +function assertSafeKey(value: string, label: string): void { + if (!value.trim() || value.length > MAX_ID_LENGTH || FORBIDDEN_KEYS.has(value)) + throw new Error(`Invalid ${label}`); + if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error(`Invalid ${label}`); +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.length > MAX_STRING_LENGTH) { + throw new Error(`${label} must be a non-empty string`); + } + if (value.includes('\0')) throw new Error(`${label} contains a NUL byte`); + return value; +} + +function bool(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`); + return value; +} + +function protocolPreference(value: unknown, label: string): McpProtocolPreference | undefined { + if (value === undefined) return undefined; + if (value !== 'legacy' && value !== 'auto' && value !== '2026-07-28') { + throw new Error(`${label} is invalid`); + } + return value; +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length > 1_000) throw new Error(`${label} must be an array`); + return value.map((item, index) => { + if (typeof item !== 'string' || item.length > MAX_STRING_LENGTH || item.includes('\0')) { + throw new Error(`${label}[${index}] must be a valid string`); + } + return item; + }); +} + +function stringMap(value: unknown, label: string): Record { + if (!isRecord(value) || Object.keys(value).length > 1_000) + throw new Error(`${label} must be an object`); + const result: Record = Object.create(null); + for (const [key, item] of Object.entries(value)) { + assertSafeKey(key, `${label} key`); + if (typeof item !== 'string' || item.length > MAX_STRING_LENGTH || item.includes('\0')) { + throw new Error(`${label}.${key} must be a valid string`); + } + result[key] = item; + } + return { ...result }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/412f858ccc2b1f7414868d27d74cf511ba9b3e61827d3af74fe39f581cadc029.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/412f858ccc2b1f7414868d27d74cf511ba9b3e61827d3af74fe39f581cadc029.source new file mode 100644 index 0000000000..d303658f4b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/412f858ccc2b1f7414868d27d74cf511ba9b3e61827d3af74fe39f581cadc029.source @@ -0,0 +1,2599 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { + CONNECTION_CATALOG_MAX_CONNECTIONS, + decodeConnectionModelId, + connectionCredentialTarget, + decodeConnectionCredentialTarget, + decodeConnectionName, + decodeConnectionSlug, + decodeProviderType, + decodeRuntimePolicyEntityId, + decodeCredentialLocator, + normalizeDeleteCredentialInput, + normalizeRemoveCatalogConnectionInput, + normalizeRequestHeaderUpdates, + normalizeRequestHeaders, + normalizeSetCredentialInput, + parseRequestHeaders, + serializeRequestHeaders, + RequestCustomizationValidationError, + normalizeCredentialSecret, + normalizeCatalogConnectionBaseUrl, + normalizeNetworkProxyUpdate, + networkProxyCredentialTarget, + type ConnectionCatalogEntry, + type ConnectionCatalogSnapshot, + type ConnectionCredentialTarget, + type ConnectionVersionBasis, + type ConnectionModelDiscoveryResult, + type ConnectionTestSummary, + type CreateCatalogConnectionInput, + type CredentialLocator, + type CredentialStatus, + type CredentialVersionBasis, + type DeleteCredentialInput, + type MutateRuntimePolicyInput, + type RemoveCatalogConnectionInput, + type RuntimePolicy, + type RequestHeaderUpdate, + type SavedRequestHeaders, + type SetCredentialInput, + type MigrateSystemSeedInput, + type SetDefaultConnectionTargetInput, + type UpdateCatalogConnectionInput, + type UpdateNetworkProxyInput, + type UpdateNetworkProxyResult, +} from '@maka/core/runtime-policy'; +import { + applyModelFactOverridesToConnection, + applyModelFactOverridesToCatalogSnapshot, + type ModelFactsDocument, +} from '@maka/core/model-facts'; +import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/provider-auth'; +import { isRetiredProvider } from '@maka/core/provider-registry'; +import { + deriveConnectionSlug, + deriveInteractiveOAuthConnectionSlug, + effectiveBaseUrl, + PROVIDER_REGISTRY, + providerFallbackModelIds, + providerAuthRequiresSecret, + providerAuthSupportsApiKey, + type ProviderType, +} from '@maka/core/llm-connections'; +import { deepFreeze, nextRevision } from './codec.js'; +import { + catalogSnapshot, + connectionBasis, + ConnectionCatalogDocumentOwner, + connectionTestModelBasis, + findConnection, + sameConnectionTestModelBasis, + type ConnectionCatalogDocument, + type ConnectionTestModelBasis, +} from './connection-catalog-document.js'; +import { + credentialMaterial, + credentialBasis, + credentialStatus, + CredentialVaultDocumentOwner, + findCredential, + sameCredentialBasis, + vaultSnapshot, +} from './credential-vault-document.js'; +import { cleanupRuntimePolicyDocumentTemps } from './document-io.js'; +import { + codecError, + commitOutcomeUnknown, + decodeConnectionInput, + decodeCredentialInput, + decodePolicyInput, + RuntimePolicyStoreError, +} from './errors.js'; +import { + connectionCredentialLocator, + connectionRequestHeadersLocator, + type CredentialStatusQueryResult, + type BeginConnectionTestResult, + type BoundCredentialMaterialExportResult, + type BeginModelFetchResult, + type BeginInteractiveOAuthLoginResult, + type CompareAndSetOAuthCredentialInput, + type ConnectionEffectChangedDomain, + type ConnectionEffectCompletionResult, + type BeginConnectionOnboardingInput, + type BeginConnectionOnboardingResult, + type CommitConnectionOnboardingInput, + type CommitConnectionOnboardingResult, + type ConnectionOnboardingTicket, + type ConnectionTestTicket, + type InteractiveOAuthLoginCompletionResult, + type InteractiveOAuthLoginInput, + type InteractiveOAuthLoginProvider, + type InteractiveOAuthLoginTarget, + type InteractiveOAuthLoginTicket, + type ModelFetchTicket, + type ExecutionConnectionRef, + type RuntimePolicyCredentialMaterial, + type RuntimePolicyOperationSecretMaterial, + type ResolveExecutionConnectionResult, + type ResolveNetworkProxyExecutionInput, + type ResolveNetworkProxyExecutionResult, + type ResolveHostOutboundExecutionResult, + type ResolveWebSearchExecutionInput, + type ResolveWebSearchExecutionResult, + type ReplaceConnectionRequestHeadersResult, +} from './operations.js'; +import { + clearConnectionOnboardingIntent, + prepareConnectionOnboardingIntent, + prepareInteractiveOAuthEnrollmentIntent, + readConnectionOnboardingIntent, + writeConnectionOnboardingIntent, + type ConnectionOnboardingIntent, + type InteractiveOAuthEnrollmentIntent, +} from './onboarding-transaction.js'; +import { + findInteractiveOAuthLoginReceipt, + readInteractiveOAuthLoginReceipts, + sameInteractiveOAuthLoginTarget, + upsertInteractiveOAuthLoginReceipt, +} from './oauth-login-receipt-document.js'; +import { policySnapshot, RuntimePolicyDocumentOwner } from './policy-document.js'; +import { SerializedOperationLane } from '../serialized-operation-lane.js'; +import { ModelFactsDocumentOwner } from '../model-facts-store.js'; + +type RootExecutor = (operation: (root: string) => Promise) => Promise; + +interface PreparedConnectionMaterial { + readonly kind: 'ready'; + readonly connection: ConnectionCatalogEntry; + readonly connectionCredentialStatus: CredentialStatus | null; + readonly requestHeadersCredentialStatus: CredentialStatus; + readonly proxyCredentialStatus: CredentialStatus | null; + readonly secretMaterial: RuntimePolicyOperationSecretMaterial; + readonly networkProxy: RuntimePolicy['networkProxy']; +} + +type ConnectionTicketKind = 'model_fetch' | 'connection_test'; +type TicketState = 'available' | 'in_flight' | 'consumed'; + +type EffectiveProxyConfigurationBasis = + | { readonly kind: 'direct' } + | { + readonly kind: 'proxy'; + readonly protocol: RuntimePolicy['networkProxy']['protocol']; + readonly host: string; + readonly port: number; + readonly authentication: + | { readonly kind: 'none' } + | { readonly kind: 'credentials'; readonly username: string }; + readonly bypassPatterns: readonly string[]; + }; + +interface CommonSemanticConnectionBasis { + readonly connectionId: string; + readonly providerType: ProviderType; + readonly enabled: true; + readonly effectiveEndpoint: string; + readonly credential: CredentialStatus | null; + readonly requestHeadersCredential: CredentialStatus; + readonly effectiveProxy: EffectiveProxyConfigurationBasis; + readonly proxyCredential: CredentialStatus | null; +} + +type SemanticConnectionBasis = + | (CommonSemanticConnectionBasis & { + readonly kind: 'model_fetch'; + readonly enabledModelIds: readonly string[]; + }) + | (CommonSemanticConnectionBasis & { + readonly kind: 'connection_test'; + readonly requestBodyOverlayJson: string; + readonly model: ConnectionTestModelBasis; + readonly modelFactsFingerprint: string; + }); + +interface ConnectionTicketRecord { + readonly kind: ConnectionTicketKind; + readonly basis: SemanticConnectionBasis; + state: TicketState; +} + +/** + * What onboarding discovery observed. Unlike the model-fetch/test bases, the + * target may not exist yet (first-time creation at the canonical slug), and + * the connection revision stands in for every catalog-visible property of an + * existing target — a swapped endpoint bumps it. + */ +interface ConnectionOnboardingCandidateIdentity { + readonly connectionId: string; + readonly slug: string; + readonly providerType: ProviderType; +} + +interface ConnectionOnboardingBasis { + readonly target: + | { + readonly kind: 'create'; + readonly candidate: ConnectionOnboardingCandidateIdentity; + /** + * True when the caller chose the slug. A collision then reports + * `slug_taken` instead of `superseded`: the fix is the caller's + * (pick another slug), not a silent re-derivation. + */ + readonly slugRequested: boolean; + /** Caller-chosen display name resolved at begin; falls back to the provider label. */ + readonly name: string | null; + } + | { + readonly kind: 'existing'; + readonly candidate: ConnectionOnboardingCandidateIdentity; + readonly revision: number; + }; + readonly baseUrl: string | null; + readonly credential: CredentialStatus | null; + readonly requestHeadersCredential: CredentialStatus | null; + readonly effectiveProxy: EffectiveProxyConfigurationBasis; + readonly proxyCredential: CredentialStatus | null; +} + +interface ConnectionOnboardingTicketRecord { + readonly kind: 'connection_onboarding'; + readonly basis: ConnectionOnboardingBasis; + state: TicketState; +} + +interface InteractiveOAuthLoginTicketRecord { + readonly kind: 'interactive_oauth_login'; + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; + readonly connectionBefore: ConnectionCatalogEntry | null; + readonly connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + readonly credentialBasis: CredentialVersionBasis | null; + state: TicketState; +} + +type OperationTicketRecord = + | ConnectionTicketRecord + | ConnectionOnboardingTicketRecord + | InteractiveOAuthLoginTicketRecord; + +export class RuntimePolicyCoordinator { + private readonly lane: SerializedOperationLane; + private readonly policy = new RuntimePolicyDocumentOwner(); + private readonly catalog = new ConnectionCatalogDocumentOwner(); + private readonly vault = new CredentialVaultDocumentOwner(); + private readonly modelFacts = new ModelFactsDocumentOwner(); + private warnedModelFactsFingerprint: string | undefined; + private readonly tickets = new WeakMap(); + private onboardingRecoveryRequired = false; + + constructor(private readonly execute: RootExecutor) { + this.lane = new SerializedOperationLane(execute); + } + + recoverForWrite(): Promise { + return this.lane.run(async (root) => { + await cleanupRuntimePolicyDocumentTemps(root); + await this.recoverConnectionOnboarding(root); + await readInteractiveOAuthLoginReceipts(root); + const catalog = await this.catalog.read(root); + const vault = await this.vault.read(root); + await this.vault.deleteOrphanedConnectionCredentials( + root, + vault, + new Set(catalog.connections.map((connection) => connection.connectionId)), + ); + }); + } + + getPolicySnapshot() { + return this.inLane(async (root) => policySnapshot(await this.policy.read(root))); + } + + getCatalogSnapshot() { + return this.inLane(async (root) => this.projectCatalogSnapshot(root)); + } + + getVaultSnapshot() { + return this.inLane(async (root) => vaultSnapshot(await this.vault.read(root))); + } + + getCredentialStatus(rawLocator: CredentialLocator): Promise { + return this.inLane(async (root) => { + const locator = decodeCredentialInput(() => decodeCredentialLocator(rawLocator)); + if (locator.scope === 'connection') { + const catalog = await this.catalog.read(root); + if (!this.validateConnectionCredentialLocator(catalog, locator)) { + return deepFreeze({ kind: 'connection_not_found' as const }); + } + } + const status = credentialStatus(await this.vault.read(root), locator); + return deepFreeze({ kind: 'status' as const, status }); + }); + } + + mutatePolicy(input: MutateRuntimePolicyInput) { + return this.inLane(async (root) => { + const current = await this.policy.read(root); + const prepared = this.policy.prepareMutation(current, input); + if (prepared.kind !== 'ready') return prepared; + const proxyChanged = !sameEffectiveProxyConfiguration( + effectiveProxyConfigurationBasis(prepared.current.policy.networkProxy), + effectiveProxyConfigurationBasis(prepared.next.policy.networkProxy), + ); + const cleared = proxyChanged + ? await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)) + : false; + try { + return await this.policy.commitMutation(root, prepared); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before network proxy update completed', + error, + ); + } + throw error; + } + }); + } + + updateNetworkProxy(rawInput: UpdateNetworkProxyInput): Promise { + return this.inLane(async (root) => { + const input = decodePolicyInput(() => normalizeNetworkProxyUpdate(rawInput)); + const policy = await this.policy.read(root); + const preparedPolicy = this.policy.prepareMutation(policy, { + expectedRevision: input.expectedPolicyRevision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + if (preparedPolicy.kind !== 'ready') return preparedPolicy; + + if ( + input.credential.kind === 'replace' && + input.credential.expectedTarget && + !isDeepStrictEqual( + networkProxyCredentialTarget(policy.policy.networkProxy), + input.credential.expectedTarget, + ) + ) { + return deepFreeze({ + kind: 'proxy_target_mismatch' as const, + expected: input.credential.expectedTarget, + actual: networkProxyCredentialTarget(policy.policy.networkProxy), + }); + } + + const vault = await this.vault.read(root); + const existing = findCredential(vault, networkProxyCredentialLocator()); + if (!matchesCredentialExpectation(existing, input.expectedCredential)) { + return deepFreeze({ + kind: 'credential_stale' as const, + expected: input.expectedCredential, + actual: existing ? credentialBasis(existing) : null, + }); + } + // Preflight every document before publishing either side of the compound update. + if (input.credential.kind === 'replace' && existing?.secret !== input.credential.secret) { + const prepared = this.vault.prepareSet(vault, { + locator: networkProxyCredentialLocator(), + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: input.credential.secret, + }); + if (prepared.kind !== 'ready') { + if (prepared.kind === 'credential_stale') return prepared; + throw codecError('invalid_credential_input', 'Network proxy credential is invalid'); + } + } else if (input.credential.kind === 'delete' && existing) { + const prepared = this.vault.prepareDelete(vault, { + expected: credentialBasis(existing), + }); + if (prepared.kind !== 'ready') { + if (prepared.kind === 'credential_stale') return prepared; + throw codecError('invalid_credential_input', 'Network proxy credential is invalid'); + } + } + + return this.applyNetworkProxyUpdate(root, input); + }); + } + + createConnection(input: CreateCatalogConnectionInput) { + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.create(root, input)), + ); + } + + updateConnection(input: UpdateCatalogConnectionInput) { + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.update(root, input)), + ); + } + + removeConnection(rawInput: RemoveCatalogConnectionInput) { + return this.inLane(async (root) => { + const { expected } = decodeConnectionInput(() => + normalizeRemoveCatalogConnectionInput(rawInput), + ); + const catalog = await this.catalog.read(root); + const connection = findConnection(catalog, expected); + if (connection && connection.revision !== expected.revision) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected, + actual: connectionBasis(connection), + }); + } + + const vault = await this.vault.read(root); + if (!connection) { + await this.vault.deleteConnectionCredentials(root, vault, expected.connectionId); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root), + }); + } + const result = await this.catalog.remove(root, { expected }); + if (result.kind === 'committed') { + try { + await this.vault.deleteConnectionCredentials(root, vault, expected.connectionId); + } catch (error) { + throw commitOutcomeUnknown( + 'Connection removal committed before credential cleanup completed', + error, + ); + } + } + return this.projectCatalogMutation(root, result); + }); + } + + setDefaultTarget(input: SetDefaultConnectionTargetInput) { + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.setDefaultTarget(root, input)), + ); + } + + migrateSystemSeed(input: MigrateSystemSeedInput) { + return this.inLane((root) => this.catalog.migrateSystemSeed(root, input)); + } + + setCredential(rawInput: SetCredentialInput) { + return this.setCredentialWithAuthority(rawInput, 'client'); + } + + importConnectionCredential(rawInput: SetCredentialInput) { + return this.setCredentialWithAuthority(rawInput, 'migration'); + } + + private setCredentialWithAuthority( + rawInput: SetCredentialInput, + authority: 'client' | 'migration', + ) { + return this.inLane(async (root) => { + const input = decodeCredentialInput(() => normalizeSetCredentialInput(rawInput)); + const { locator } = input; + if (authority === 'migration' && locator.scope !== 'connection') { + throw codecError( + 'invalid_credential_input', + 'Connection credential import requires a Connection credential locator', + ); + } + let catalog: ConnectionCatalogDocument | null = null; + if (locator.scope === 'connection') { + catalog = await this.catalog.read(root); + const connection = findConnection(catalog, locator); + if (!connection) { + return deepFreeze({ kind: 'connection_not_found' as const }); + } + if ( + input.expectedConnection && + !isDeepStrictEqual(connectionCredentialTarget(connection), input.expectedConnection) + ) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected: { + connectionId: input.expectedConnection.connectionId, + revision: input.expectedConnection.revision, + }, + actual: connectionBasis(connection), + }); + } + assertConnectionIsWritable(connection); + const required = connectionCredentialLocator( + connection.connectionId, + PROVIDER_REGISTRY[connection.providerType].authKind, + ); + if (locator.kind !== 'request_headers' && (!required || required.kind !== locator.kind)) { + throw codecError( + 'invalid_credential_input', + 'Connection credential kind does not match the provider auth contract', + ); + } + if ( + authority === 'client' && + locator.kind === 'oauth_token' && + connection.providerType !== 'github-copilot' + ) { + throw codecError( + 'invalid_credential_input', + 'Client-supplied OAuth credentials are only accepted for GitHub Copilot', + ); + } + } + const prepared = this.vault.prepareSet(await this.vault.read(root), input); + if (prepared.kind !== 'ready') return prepared; + const cleared = await this.clearCredentialDependentLastTests(root, locator, catalog); + try { + await this.vault.commitSet(root, prepared); + return deepFreeze({ + kind: 'committed' as const, + snapshot: vaultSnapshot(prepared.document), + }); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before credential update completed', + error, + ); + } + throw error; + } + }); + } + + compareAndSetOAuthCredential(rawInput: CompareAndSetOAuthCredentialInput) { + return this.inLane(async (root) => { + const input = decodeCredentialInput(() => normalizeSetCredentialInput(rawInput)); + if ( + input.locator.scope !== 'connection' || + input.locator.kind !== 'oauth_token' || + input.expected === null + ) { + throw codecError( + 'invalid_credential_input', + 'OAuth refresh requires an existing connection OAuth credential generation', + ); + } + const catalog = await this.catalog.read(root); + const connection = findConnection(catalog, input.locator); + if (!connection) return deepFreeze({ kind: 'superseded' as const }); + // Refreshing a token is a write like any other, and this path validated + // only the auth kind — so a retired provider whose contract still says + // `oauth_token` could have its credential rotated. No production caller + // reaches it today (execution resolution refuses first), which is + // exactly why it would have stayed open. + assertConnectionIsWritable(connection); + if (PROVIDER_REGISTRY[connection.providerType].authKind !== 'oauth_token') { + throw codecError( + 'invalid_credential_input', + 'OAuth refresh credential does not match the provider auth contract', + ); + } + const prepared = this.vault.prepareSet(await this.vault.read(root), input); + if (prepared.kind !== 'ready') return deepFreeze({ kind: 'superseded' as const }); + await this.vault.commitSet(root, prepared); + return deepFreeze({ + kind: 'committed' as const, + credentialId: prepared.entry.credentialId, + revision: prepared.entry.revision, + }); + }); + } + + beginInteractiveOAuthLogin( + rawInput: InteractiveOAuthLoginInput, + ): Promise { + return this.inLane(async (root) => { + const input = normalizeInteractiveOAuthLoginInput(rawInput); + const receipts = await readInteractiveOAuthLoginReceipts(root); + const receipt = findInteractiveOAuthLoginReceipt(receipts, input.attemptId); + if (receipt) { + return deepFreeze( + sameInteractiveOAuthLoginTarget(receipt.target, input.target) + ? { + kind: 'authenticated' as const, + target: structuredClone(receipt.target), + connection: structuredClone(receipt.connection), + } + : { kind: 'attempt_conflict' as const }, + ); + } + const catalog = await this.catalog.read(root); + let connectionBefore: ConnectionCatalogEntry | null; + let connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + if (input.target.kind === 'create') { + if (catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return deepFreeze({ kind: 'catalog_full' as const }); + } + connectionBefore = null; + connectionAfter = newInteractiveOAuthConnection( + randomUUID(), + deriveInteractiveOAuthConnectionSlug( + input.target.providerType, + catalog.connections.map(({ slug }) => slug), + ), + input.target.providerType, + ); + } else { + const existing = findConnection(catalog, { connectionId: input.target.connectionId }); + if (!existing) return deepFreeze({ kind: 'connection_not_found' as const }); + if (!isInteractiveOAuthLoginProvider(existing.providerType)) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); + } + connectionBefore = structuredClone(existing); + connectionAfter = reenabledInteractiveOAuthConnection( + existing as ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }, + ); + } + const connection = connectionBefore ?? connectionAfter; + if (!isInteractiveOAuthLoginProvider(connection.providerType)) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); + } + const contract = deriveProviderAuthContract({ + providerType: connection.providerType, + hasSecret: false, + }); + if (!contract.actionAvailability.start_oauth) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); + } + const prepared = await this.prepareConnectionMaterial(root, connection, false); + if (prepared.kind !== 'ready') return prepared; + const locator = connectionCredentialLocator(connection.connectionId, 'oauth_token'); + if (!locator || locator.kind !== 'oauth_token') { + throw codecError( + 'invalid_document', + 'OAuth login admission produced no OAuth credential locator', + ); + } + const existing = findCredential(await this.vault.read(root), locator); + const ticket = this.issueInteractiveOAuthLoginTicket( + input.attemptId, + input.target, + connectionBefore, + connectionAfter, + existing ? credentialBasis(existing) : null, + ); + return deepFreeze({ + kind: 'ready' as const, + ticket, + target: structuredClone(input.target), + identity: interactiveOAuthConnectionIdentity(connectionAfter), + connection: structuredClone(connectionAfter), + secretMaterial: prepared.secretMaterial.networkProxy + ? { networkProxy: prepared.secretMaterial.networkProxy } + : {}, + networkProxy: structuredClone(prepared.networkProxy), + }); + }); + } + + queryInteractiveOAuthLogin(rawAttemptId: string) { + return this.inLane(async (root) => { + const attemptId = decodeInteractiveOAuthAttemptId(rawAttemptId, 'invalid_connection_input'); + const receipt = findInteractiveOAuthLoginReceipt( + await readInteractiveOAuthLoginReceipts(root), + attemptId, + ); + return deepFreeze( + receipt + ? { + kind: 'authenticated' as const, + target: structuredClone(receipt.target), + connection: structuredClone(receipt.connection), + } + : { kind: 'not_found' as const }, + ); + }); + } + + async completeInteractiveOAuthLogin( + ticket: InteractiveOAuthLoginTicket, + rawSecret: string, + ): Promise { + const claimed = this.claimInteractiveOAuthLoginTicket(ticket); + return this.completeClaimedTicket(claimed, () => + this.inLane(async (root) => { + const secret = decodeCredentialInput(() => normalizeCredentialSecret(rawSecret)); + const catalog = await this.catalog.read(root); + const changed: Array<'connection' | 'credential'> = []; + const preparedCatalog = this.catalog.prepareOAuthEnrollmentUpsert( + catalog, + claimed.connectionBefore, + claimed.connectionAfter, + ); + if (preparedCatalog.kind !== 'ready') { + changed.push('connection'); + } + const locator = { + scope: 'connection', + connectionId: claimed.connectionAfter.connectionId, + kind: 'oauth_token', + } as const; + const vault = await this.vault.read(root); + const actual = findCredential(vault, locator); + if ( + claimed.credentialBasis + ? !sameCredentialBasis(actual, claimed.credentialBasis) + : actual !== undefined + ) { + changed.push('credential'); + } + if (changed.length > 0) { + return deepFreeze({ kind: 'superseded' as const, changed }); + } + const intent = prepareInteractiveOAuthEnrollmentIntent({ + attemptId: claimed.attemptId, + target: claimed.target, + connectionBefore: claimed.connectionBefore, + connectionAfter: claimed.connectionAfter, + credentialBasis: claimed.credentialBasis, + secret, + }); + try { + await writeConnectionOnboardingIntent(root, intent); + } catch (error) { + if (isCommitOutcomeUnknown(error)) this.onboardingRecoveryRequired = true; + throw error; + } + try { + const result = await this.applyInteractiveOAuthEnrollment(root, intent); + await clearConnectionOnboardingIntent(root); + this.onboardingRecoveryRequired = false; + return deepFreeze({ kind: 'committed' as const, ...result }); + } catch (error) { + this.onboardingRecoveryRequired = true; + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown( + 'OAuth enrollment has a durable intent and must recover before retrying', + error, + ); + } + }), + ); + } + + deleteCredential(rawInput: DeleteCredentialInput) { + return this.inLane(async (root) => { + const { expected } = decodeCredentialInput(() => normalizeDeleteCredentialInput(rawInput)); + const { locator } = expected; + let catalog: ConnectionCatalogDocument | null = null; + if (locator.scope === 'connection') { + catalog = await this.catalog.read(root); + if (!this.validateConnectionCredentialLocator(catalog, locator)) { + return deepFreeze({ kind: 'connection_not_found' as const }); + } + } + const prepared = this.vault.prepareDelete(await this.vault.read(root), { expected }); + if (prepared.kind !== 'ready') return prepared; + const cleared = await this.clearCredentialDependentLastTests(root, locator, catalog); + try { + return await this.vault.commitDelete(root, prepared); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before credential deletion completed', + error, + ); + } + throw error; + } + }); + } + + resolveExecutionConnection( + rawRef: ExecutionConnectionRef, + ): Promise { + return this.inLane(async (root) => { + const ref = decodeConnectionInput(() => { + if (rawRef.kind === 'bound') { + return { + kind: rawRef.kind, + connectionId: decodeRuntimePolicyEntityId(rawRef.connectionId), + connectionSlug: decodeConnectionSlug(rawRef.connectionSlug), + } as const; + } + if (rawRef.kind === 'catalog_slug') { + return { + kind: rawRef.kind, + connectionSlug: decodeConnectionSlug(rawRef.connectionSlug), + } as const; + } + throw new Error('Invalid execution Connection reference kind'); + }); + const catalog = await this.catalog.read(root); + const connection = + ref.kind === 'bound' + ? catalog.connections.find((candidate) => candidate.connectionId === ref.connectionId) + : catalog.connections.find((candidate) => candidate.slug === ref.connectionSlug); + if (!connection) return deepFreeze({ kind: 'not_found' as const }); + if (ref.kind === 'bound' && connection.slug !== ref.connectionSlug) { + return deepFreeze({ kind: 'identity_mismatch' as const }); + } + if (!connection.enabled) return deepFreeze({ kind: 'disabled' as const }); + // Ahead of the credential material: a retired connection keeps its stored + // token, so `requiresSecret` is satisfied and every later check passes. + // Answering `ready` here is what let Bot, CLI and scheduled-task session + // creation persist a session that could only fail once a backend was + // built for it. + if (isRetiredProvider(connection.providerType)) { + return deepFreeze({ kind: 'provider_retired' as const }); + } + + const prepared = await this.prepareConnectionMaterial( + root, + connection, + providerAuthRequiresSecret(connection.providerType), + ); + if (prepared.kind !== 'ready') return prepared; + return deepFreeze({ + kind: 'ready' as const, + connection: applyModelFactOverridesToConnection( + structuredClone(connection), + (await this.readModelFacts(root)).document.overrides, + ), + secretMaterial: prepared.secretMaterial, + networkProxy: structuredClone(prepared.networkProxy), + }); + }); + } + + exportCredentialMaterial( + rawLocator: CredentialLocator, + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection: ConnectionCredentialTarget, + ): Promise; + exportCredentialMaterial( + rawLocator: CredentialLocator, + rawExpectedConnection?: ConnectionCredentialTarget, + ): Promise { + return this.inLane(async (root) => { + const locator = decodeCredentialInput(() => decodeCredentialLocator(rawLocator)); + const expectedConnection = rawExpectedConnection + ? decodeConnectionInput(() => decodeConnectionCredentialTarget(rawExpectedConnection)) + : undefined; + if (expectedConnection && locator.scope !== 'connection') { + throw codecError( + 'invalid_credential_input', + 'Only connection credentials accept a connection target basis', + ); + } + if (locator.scope === 'connection') { + const catalog = await this.catalog.read(root); + const connection = findConnection(catalog, locator); + if ( + expectedConnection && + (!connection || + !isDeepStrictEqual(connectionCredentialTarget(connection), expectedConnection)) + ) { + return deepFreeze({ + kind: 'connection_stale' as const, + expected: { + connectionId: expectedConnection.connectionId, + revision: expectedConnection.revision, + }, + actual: connection ? connectionBasis(connection) : null, + }); + } + if (!this.validateConnectionCredentialLocator(catalog, locator)) { + return expectedConnection + ? deepFreeze({ kind: 'exported' as const, material: null }) + : null; + } + } + const credential = findCredential(await this.vault.read(root), locator); + const material = credential + ? { + ...credentialMaterial(credential), + ...(locator.scope === 'network_proxy' + ? { + proxyTarget: networkProxyCredentialTarget( + (await this.policy.read(root)).policy.networkProxy, + ), + } + : {}), + } + : null; + return expectedConnection ? deepFreeze({ kind: 'exported' as const, material }) : material; + }); + } + + getConnectionRequestHeaders(rawConnectionId: string): Promise { + return this.inLane(async (root) => { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(rawConnectionId), + ); + const catalog = await this.catalog.read(root); + if (!findConnection(catalog, { connectionId })) return null; + const locator = connectionRequestHeadersLocator(connectionId); + const credential = findCredential(await this.vault.read(root), locator); + const headers = credential ? parseRequestHeaders(credential.secret) : {}; + return deepFreeze({ names: Object.keys(headers) }); + }); + } + + replaceConnectionRequestHeaders( + rawConnectionId: string, + rawUpdates: readonly RequestHeaderUpdate[], + ): Promise { + return this.inLane(async (root) => { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(rawConnectionId), + ); + const updates = decodeRequestHeaderUpdates(rawUpdates); + const catalog = await this.catalog.read(root); + const connection = findConnection(catalog, { connectionId }); + if (!connection) { + return deepFreeze({ kind: 'connection_not_found' as const }); + } + assertConnectionIsWritable(connection); + + const locator = connectionRequestHeadersLocator(connectionId); + const vault = await this.vault.read(root); + const existing = findCredential(vault, locator); + const savedHeaders = existing ? parseRequestHeaders(existing.secret) : {}; + const savedByName = new Map( + Object.entries(savedHeaders).map(([name, value]) => [name.toLowerCase(), value]), + ); + const merged = Object.fromEntries( + updates.map(({ name, value }) => { + const retained = value ?? savedByName.get(name.toLowerCase()); + if (retained === undefined) { + throw codecError('invalid_credential_input', `Request header ${name} requires a value`); + } + return [name, retained]; + }), + ); + const headers = decodeRequestHeaders(merged); + const names = Object.keys(headers); + + if (names.length === 0) { + if (!existing) return deepFreeze({ kind: 'unchanged' as const, names }); + const prepared = this.vault.prepareDelete(vault, { expected: credentialBasis(existing) }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Request header credential changed within its lane'); + } + const cleared = await this.clearCredentialDependentLastTests(root, locator, catalog); + try { + await this.vault.commitDelete(root, prepared); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before request headers were deleted', + error, + ); + } + throw error; + } + return deepFreeze({ kind: 'committed' as const, names }); + } + + const secret = serializeRequestHeaders(headers); + if (existing?.secret === secret) { + return deepFreeze({ kind: 'unchanged' as const, names }); + } + const prepared = this.vault.prepareSet(vault, { + locator, + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Request header credential changed within its lane'); + } + const cleared = await this.clearCredentialDependentLastTests(root, locator, catalog); + try { + await this.vault.commitSet(root, prepared); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before request headers were updated', + error, + ); + } + throw error; + } + return deepFreeze({ kind: 'committed' as const, names }); + }); + } + + resolveWebSearchExecution( + input: ResolveWebSearchExecutionInput = {}, + ): Promise { + return this.inLane(async (root) => { + const policy = (await this.policy.read(root)).policy; + if (!input.bypassFeatureGate && policy.privacy.incognitoActive) { + return deepFreeze({ kind: 'privacy_mode' as const }); + } + + const provider = input.provider ?? policy.webSearch.defaultProvider; + if (!input.bypassFeatureGate && !policy.webSearch.enabled) { + return deepFreeze({ kind: 'disabled' as const, provider }); + } + + if (provider === 'model') { + return deepFreeze({ kind: 'model_native_only' as const, provider }); + } + + const vault = await this.vault.read(root); + const locator = { scope: 'web_search', provider, kind: 'api_key' } as const; + const webSearchCredential = findCredential(vault, locator); + const secretOverride = + input.secretOverride === undefined + ? undefined + : decodeCredentialInput(() => normalizeCredentialSecret(input.secretOverride)); + if (!webSearchCredential && secretOverride === undefined) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status: credentialStatus(vault, locator), + }); + } + + const proxyLocator = requiresNetworkProxyCredential(policy.networkProxy) + ? networkProxyCredentialLocator() + : null; + let proxyCredential: RuntimePolicyCredentialMaterial | undefined; + if (proxyLocator) { + const entry = findCredential(vault, proxyLocator); + if (!entry) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status: credentialStatus(vault, proxyLocator), + }); + } + proxyCredential = credentialMaterial(entry); + } + + return deepFreeze({ + kind: 'ready' as const, + provider, + secretMaterial: { + webSearch: + secretOverride === undefined + ? credentialMaterial(webSearchCredential!) + : { + locator, + credentialId: 'ephemeral-web-search-override', + revision: 0, + secret: secretOverride, + }, + ...(proxyCredential ? { networkProxy: proxyCredential } : {}), + }, + networkProxy: structuredClone(policy.networkProxy), + }); + }); + } + + resolveNetworkProxyExecution( + input: ResolveNetworkProxyExecutionInput = {}, + ): Promise { + return this.inLane(async (root) => { + const networkProxy = + input.networkProxy ?? structuredClone((await this.policy.read(root)).policy.networkProxy); + if (!requiresNetworkProxyCredential(networkProxy)) { + return deepFreeze({ + kind: 'ready' as const, + networkProxy: structuredClone(networkProxy), + secretMaterial: {}, + }); + } + const locator = networkProxyCredentialLocator(); + const vault = await this.vault.read(root); + const credential = findCredential(vault, locator); + const secretOverride = + input.secretOverride === undefined + ? undefined + : decodeCredentialInput(() => normalizeCredentialSecret(input.secretOverride)); + if (!credential && secretOverride === undefined) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status: credentialStatus(vault, locator), + }); + } + return deepFreeze({ + kind: 'ready' as const, + networkProxy: structuredClone(networkProxy), + secretMaterial: { + networkProxy: + secretOverride === undefined + ? credentialMaterial(credential!) + : { + locator, + credentialId: 'ephemeral-network-proxy-override', + revision: 0, + secret: secretOverride, + }, + }, + }); + }); + } + + resolveHostOutboundExecution(): Promise { + return this.inLane(async (root) => { + const policy = (await this.policy.read(root)).policy; + if (policy.privacy.incognitoActive) { + return deepFreeze({ kind: 'privacy_mode' as const }); + } + const proxyLocator = requiresNetworkProxyCredential(policy.networkProxy) + ? networkProxyCredentialLocator() + : null; + if (!proxyLocator) { + return deepFreeze({ + kind: 'ready' as const, + networkProxy: structuredClone(policy.networkProxy), + secretMaterial: {}, + }); + } + const vault = await this.vault.read(root); + const credential = findCredential(vault, proxyLocator); + if (!credential) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status: credentialStatus(vault, proxyLocator), + }); + } + return deepFreeze({ + kind: 'ready' as const, + networkProxy: structuredClone(policy.networkProxy), + secretMaterial: { networkProxy: credentialMaterial(credential) }, + }); + }); + } + + beginModelFetch(rawConnectionId: string): Promise { + return this.inLane(async (root) => { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(rawConnectionId), + ); + const prepared = await this.prepareConnectionOperation(root, connectionId, 'fetch_models'); + if (prepared.kind !== 'ready') return prepared; + const ticket = this.issueTicket('model_fetch', modelFetchSemanticBasis(prepared)); + return deepFreeze({ + kind: 'ready' as const, + ticket: ticket as ModelFetchTicket, + connection: structuredClone(prepared.connection), + secretMaterial: prepared.secretMaterial, + networkProxy: structuredClone(prepared.networkProxy), + }); + }); + } + + async completeModelFetch( + ticket: ModelFetchTicket, + result: ConnectionModelDiscoveryResult, + ): Promise { + const claimed = this.claimTicket(ticket, 'model_fetch'); + return this.completeClaimedTicket(claimed, () => + this.inLane(async (root) => { + const catalog = await this.catalog.read(root); + const checked = await this.checkSemanticConnectionBasis(root, catalog, claimed.basis); + if (checked.changed.length > 0 || !checked.connection) { + return deepFreeze({ kind: 'superseded' as const, changed: checked.changed }); + } + const snapshot = await this.catalog.writeModelFetchResult( + root, + catalog, + connectionBasis(checked.connection), + result, + ); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root), + }); + }), + ); + } + + beginConnectionOnboarding( + input: BeginConnectionOnboardingInput, + ): Promise { + return this.inLane(async (root) => { + const catalog = await this.catalog.read(root); + let existing: ConnectionCatalogEntry | undefined; + let target: ConnectionOnboardingBasis['target']; + const requestedTarget = input.target; + if (requestedTarget.kind === 'create') { + const providerType = decodeConnectionInput(() => + decodeProviderType(requestedTarget.providerType), + ); + const requestedSlug = + requestedTarget.slug === undefined + ? null + : decodeConnectionInput(() => decodeConnectionSlug(requestedTarget.slug)); + target = { + kind: 'create', + candidate: { + connectionId: randomUUID(), + slug: + requestedSlug ?? + deriveConnectionSlug( + providerType, + catalog.connections.map((connection) => connection.slug), + ), + providerType, + }, + slugRequested: requestedSlug !== null, + name: + requestedTarget.name === undefined + ? null + : decodeConnectionInput(() => decodeConnectionName(requestedTarget.name)), + }; + } else if (requestedTarget.kind === 'existing') { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(requestedTarget.connectionId), + ); + existing = findConnection(catalog, { connectionId }); + if (!existing) return deepFreeze({ kind: 'target_missing' as const }); + target = { + kind: 'existing', + candidate: { + connectionId: existing.connectionId, + slug: existing.slug, + providerType: existing.providerType, + }, + revision: existing.revision, + }; + } else { + throw codecError('invalid_connection_input', 'Unknown connection onboarding target'); + } + const providerType = target.candidate.providerType; + // Onboarding may adopt either an API key or canonical serialized OAuth + // material. Providers without a connection credential slot have no + // business here (the Host applies the same gate before discovery). + if ( + !providerAuthSupportsApiKey(providerType) && + PROVIDER_REGISTRY[providerType].authKind !== 'oauth_token' + ) { + return deepFreeze({ kind: 'provider_unsupported' as const }); + } + if ( + target.kind === 'create' && + catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS + ) { + return deepFreeze({ kind: 'catalog_full' as const }); + } + if ( + target.kind === 'create' && + target.slugRequested && + catalog.connections.some((connection) => connection.slug === target.candidate.slug) + ) { + return deepFreeze({ kind: 'slug_taken' as const }); + } + const baseUrl = + input.baseUrl === null + ? null + : (decodeConnectionInput(() => + normalizeCatalogConnectionBaseUrl(input.baseUrl, providerType), + ) ?? null); + const policy = await this.policy.read(root); + const networkProxy = structuredClone(policy.policy.networkProxy); + const vault = await this.vault.read(root); + let credential: CredentialStatus | null = null; + let storedSecret: string | null = null; + let requestHeadersCredential: CredentialStatus | null = null; + let requestHeadersSecret: string | null = null; + const locator = connectionCredentialLocator( + target.candidate.connectionId, + PROVIDER_REGISTRY[providerType].authKind, + ); + if (locator) { + credential = credentialStatus(vault, locator); + if (existing) { + storedSecret = findCredential(vault, locator)?.secret ?? null; + } + } + // Discovery must probe with the same header customization the models + // path applies, so even absence is pinned for a new candidate. + const headersLocator = connectionRequestHeadersLocator(target.candidate.connectionId); + requestHeadersCredential = credentialStatus(vault, headersLocator); + if (existing) { + requestHeadersSecret = findCredential(vault, headersLocator)?.secret ?? null; + } + // The proxy discovery will run through is pinned HERE, like + // beginModelFetch pins it — re-resolving it later would let an A→B→A + // proxy flip commit an inventory fetched through egress this basis + // never saw. + const proxyLocator = requiresNetworkProxyCredential(networkProxy) + ? networkProxyCredentialLocator() + : null; + const proxyCredential = proxyLocator ? credentialStatus(vault, proxyLocator) : null; + const proxySecret = proxyLocator + ? (findCredential(vault, proxyLocator)?.secret ?? null) + : null; + const ticket = Object.freeze(Object.create(null)) as object; + this.tickets.set(ticket, { + kind: 'connection_onboarding', + basis: { + target, + baseUrl, + credential, + requestHeadersCredential, + effectiveProxy: effectiveProxyConfigurationBasis(networkProxy), + proxyCredential, + }, + state: 'available', + }); + return deepFreeze({ + kind: 'ready' as const, + ticket: ticket as ConnectionOnboardingTicket, + candidate: structuredClone(target.candidate), + existingConnection: existing ? structuredClone(existing) : null, + baseUrl, + storedSecret, + requestHeadersSecret, + networkProxy, + proxySecret, + proxyCredentialMissing: proxyLocator !== null && proxySecret === null, + }); + }); + } + + async completeConnectionOnboarding( + ticket: ConnectionOnboardingTicket, + input: CommitConnectionOnboardingInput, + ): Promise { + const record = ticket && typeof ticket === 'object' ? this.tickets.get(ticket) : undefined; + if (!record || record.kind !== 'connection_onboarding' || record.state !== 'available') { + throw codecError( + 'invalid_connection_input', + 'Expected an authentic available connection onboarding ticket', + ); + } + record.state = 'in_flight'; + return this.completeClaimedTicket(record, () => + this.inLane(async (root) => { + const catalog = await this.catalog.read(root); + // Revalidate the discovery basis under the write lane: the committed + // inventory must describe the connection state it was discovered + // from, not whatever a concurrent policy update left behind. + const checked = await this.checkOnboardingBasis(root, catalog, record.basis); + if (checked.kind !== 'unchanged') { + if (checked.kind === 'catalog_full') { + return deepFreeze({ kind: 'catalog_full' as const }); + } + if (checked.kind === 'slug_taken') { + return deepFreeze({ kind: 'slug_taken' as const }); + } + return deepFreeze( + checked.kind === 'target_missing' + ? { kind: 'target_missing' as const } + : { kind: 'superseded' as const, changed: checked.changed }, + ); + } + return this.commitConnectionOnboardingInLane(root, catalog, record.basis, input); + }), + ); + } + + private async checkOnboardingBasis( + root: string, + catalog: Awaited>, + basis: ConnectionOnboardingBasis, + ): Promise< + | { readonly kind: 'unchanged' } + | { readonly kind: 'target_missing' } + | { readonly kind: 'catalog_full' } + | { readonly kind: 'slug_taken' } + | { readonly kind: 'superseded'; readonly changed: ConnectionEffectChangedDomain[] } + > { + const changed: ConnectionEffectChangedDomain[] = []; + // One vault read serves every credential-status compare below. + const vault = await this.vault.read(root); + if (basis.target.kind === 'existing') { + const connection = findConnection(catalog, { + connectionId: basis.target.candidate.connectionId, + }); + // A vanished target is its own answer — "the connection is gone" beats + // "the connection changed" — while a survived one is compared by + // revision, which covers every catalog-visible property, endpoint + // included. + if (!connection) return { kind: 'target_missing' }; + if ( + connection.revision !== basis.target.revision || + connection.slug !== basis.target.candidate.slug || + connection.providerType !== basis.target.candidate.providerType + ) { + changed.push('connection'); + } else if ( + basis.credential && + !sameCredentialStatus(credentialStatus(vault, basis.credential.locator), basis.credential) + ) { + changed.push('credential'); + } + } else if ( + catalog.connections.some( + (connection) => + connection.connectionId === basis.target.candidate.connectionId || + connection.slug === basis.target.candidate.slug, + ) + ) { + // A caller-chosen slug losing the race is the caller's to fix, so it + // reports distinctly instead of as a generic basis change. A derived + // slug colliding still resolves by re-running the wizard, which simply + // derives again. + if ( + basis.target.slugRequested && + catalog.connections.some( + (connection) => + connection.slug === basis.target.candidate.slug && + connection.connectionId !== basis.target.candidate.connectionId, + ) + ) { + return { kind: 'slug_taken' }; + } + changed.push('connection'); + } else if (catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return { kind: 'catalog_full' }; + } + if ( + basis.requestHeadersCredential && + // The probe went out with these custom headers; a rotation since means + // the inventory no longer describes what the connection would fetch. + !sameCredentialStatus( + credentialStatus(vault, basis.requestHeadersCredential.locator), + basis.requestHeadersCredential, + ) && + !changed.includes('credential') + ) { + changed.push('credential'); + } + const policy = await this.policy.read(root); + if ( + !sameEffectiveProxyConfiguration( + effectiveProxyConfigurationBasis(policy.policy.networkProxy), + basis.effectiveProxy, + ) + ) { + changed.push('network_proxy'); + } + if ( + basis.proxyCredential && + !sameCredentialStatus( + credentialStatus(vault, basis.proxyCredential.locator), + basis.proxyCredential, + ) && + !changed.includes('credential') + ) { + changed.push('credential'); + } + return changed.length > 0 ? { kind: 'superseded', changed } : { kind: 'unchanged' }; + } + + private async commitConnectionOnboardingInLane( + root: string, + catalog: Awaited>, + basis: ConnectionOnboardingBasis, + input: CommitConnectionOnboardingInput, + ): Promise { + const candidate = basis.target.candidate; + const existing = + basis.target.kind === 'existing' + ? findConnection(catalog, { connectionId: candidate.connectionId }) + : undefined; + const connectionId = candidate.connectionId; + let invalidateLastTest = false; + if (input.suppliedSecret !== null) { + const locator = connectionCredentialLocator( + connectionId, + PROVIDER_REGISTRY[candidate.providerType].authKind, + ); + if (!locator) { + throw codecError('invalid_document', 'Onboarding provider has no credential locator'); + } + const vault = await this.vault.read(root); + const credential = findCredential(vault, locator); + if (credential?.secret !== input.suppliedSecret) { + invalidateLastTest = true; + const prepared = this.vault.prepareSet(vault, { + locator, + expected: credential + ? { credentialId: credential.credentialId, revision: credential.revision } + : null, + secret: input.suppliedSecret, + }); + if (prepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `Onboarding credential preflight returned ${prepared.kind}`, + ); + } + } + } + const intent = prepareConnectionOnboardingIntent({ + ...input, + connectionId, + slug: candidate.slug, + providerType: candidate.providerType, + name: basis.target.kind === 'create' ? basis.target.name : null, + baseUrl: basis.baseUrl, + invalidateLastTest, + }); + const catalogPreflight = this.catalog.prepareOnboardingUpsert( + catalog, + intent.connectionId, + intent.slug, + intent.providerType, + intent.name, + intent.baseUrl, + intent.enabledModelIds, + intent.discovery, + intent.invalidateLastTest, + ); + if (catalogPreflight.kind === 'slug_conflict') { + if (basis.target.kind === 'create' && basis.target.slugRequested) { + return deepFreeze({ kind: 'slug_taken' as const }); + } + return deepFreeze({ kind: 'superseded' as const, changed: ['connection'] as const }); + } + if (catalogPreflight.kind === 'catalog_full') { + return deepFreeze({ kind: 'catalog_full' as const }); + } + try { + await writeConnectionOnboardingIntent(root, intent); + } catch (error) { + if (isCommitOutcomeUnknown(error)) this.onboardingRecoveryRequired = true; + throw error; + } + try { + const result = await this.applyConnectionOnboarding(root, intent); + await clearConnectionOnboardingIntent(root); + this.onboardingRecoveryRequired = false; + return deepFreeze({ kind: 'committed' as const, ...result }); + } catch (error) { + this.onboardingRecoveryRequired = true; + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown( + 'Connection onboarding has a durable intent and must recover before retrying', + error, + ); + } + } + + beginConnectionTest( + rawConnectionId: string, + rawModelId: string | null, + ): Promise { + return this.inLane(async (root) => { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(rawConnectionId), + ); + const prepared = await this.prepareConnectionOperation( + root, + connectionId, + 'test_credentials', + ); + if (prepared.kind !== 'ready') return prepared; + const facts = await this.readModelFacts(root); + const projectedConnection = applyModelFactOverridesToConnection( + structuredClone(prepared.connection), + facts.document.overrides, + ); + const modelId = + rawModelId === null + ? null + : decodeConnectionInput(() => decodeConnectionModelId(rawModelId)); + if (modelId !== null && !isCanonicalConnectionTestModel(projectedConnection, modelId)) { + throw codecError( + 'invalid_connection_input', + 'Connection test model is not in the canonical model set', + ); + } + const ticket = this.issueTicket( + 'connection_test', + connectionTestSemanticBasis( + prepared, + this.modelFacts.fingerprintForConnection(facts.document, prepared.connection), + ), + ); + return deepFreeze({ + kind: 'ready' as const, + ticket: ticket as ConnectionTestTicket, + connection: projectedConnection, + modelId, + secretMaterial: prepared.secretMaterial, + networkProxy: structuredClone(prepared.networkProxy), + }); + }); + } + + async completeConnectionTest( + ticket: ConnectionTestTicket, + result: ConnectionTestSummary, + ): Promise { + const claimed = this.claimTicket(ticket, 'connection_test'); + return this.completeClaimedTicket(claimed, () => + this.inLane(async (root) => { + if (claimed.basis.kind !== 'connection_test') { + throw new Error('Coordinator admitted a non-connection-test ticket'); + } + const catalog = await this.catalog.read(root); + const checked = await this.checkSemanticConnectionBasis(root, catalog, claimed.basis); + if (checked.changed.length > 0 || !checked.connection) { + return deepFreeze({ kind: 'superseded' as const, changed: checked.changed }); + } + const snapshot = await this.catalog.writeConnectionTestResult( + root, + catalog, + connectionBasis(checked.connection), + result, + claimed.basis.modelFactsFingerprint, + ); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root), + }); + }), + ); + } + + private async prepareConnectionOperation( + root: string, + connectionId: string, + action: ProviderAuthAction, + ): Promise< + PreparedConnectionMaterial | Exclude + > { + const catalog = await this.catalog.read(root); + const connection = findConnection(catalog, { connectionId }); + if (!connection) return deepFreeze({ kind: 'connection_not_found' as const }); + if (!connection.enabled) return deepFreeze({ kind: 'connection_disabled' as const }); + + const contract = deriveProviderAuthContract({ + providerType: connection.providerType, + hasSecret: true, + }); + if (!contract.actionAvailability[action]) { + return deepFreeze({ kind: 'provider_action_unavailable' as const }); + } + return this.prepareConnectionMaterial(root, connection, contract.requiresSecret); + } + + private async prepareConnectionMaterial( + root: string, + connection: ConnectionCatalogEntry, + requiresConnectionSecret: boolean, + ): Promise< + | PreparedConnectionMaterial + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + > { + const authKind = PROVIDER_REGISTRY[connection.providerType].authKind; + const locator = connectionCredentialLocator(connection.connectionId, authKind); + const policy = await this.policy.read(root); + const networkProxy = structuredClone(policy.policy.networkProxy); + const proxyLocator = requiresNetworkProxyCredential(networkProxy) + ? networkProxyCredentialLocator() + : null; + const requestHeadersLocator = connectionRequestHeadersLocator(connection.connectionId); + let connectionCredentialStatus: CredentialStatus | null = null; + let proxyCredentialStatus: CredentialStatus | null = null; + const vault = await this.vault.read(root); + const requestHeadersCredentialStatus = credentialStatus(vault, requestHeadersLocator); + const secretMaterial: { + connection?: RuntimePolicyCredentialMaterial; + requestHeaders?: RuntimePolicyCredentialMaterial; + networkProxy?: RuntimePolicyCredentialMaterial; + } = {}; + + const requestHeaders = findCredential(vault, requestHeadersLocator); + if (requestHeaders) secretMaterial.requestHeaders = credentialMaterial(requestHeaders); + if (locator || proxyLocator) { + if (locator) { + const status = credentialStatus(vault, locator); + connectionCredentialStatus = status; + const entry = findCredential(vault, locator); + if (!entry) { + if (requiresConnectionSecret) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status, + }); + } + } else { + secretMaterial.connection = credentialMaterial(entry); + } + } + if (proxyLocator) { + const status = credentialStatus(vault, proxyLocator); + proxyCredentialStatus = status; + const entry = findCredential(vault, proxyLocator); + if (!entry) { + return deepFreeze({ + kind: 'credential_not_configured' as const, + status, + }); + } + secretMaterial.networkProxy = credentialMaterial(entry); + } + } + + return { + kind: 'ready', + connection, + connectionCredentialStatus, + requestHeadersCredentialStatus, + proxyCredentialStatus, + secretMaterial, + networkProxy, + }; + } + + private validateConnectionCredentialLocator( + catalog: ConnectionCatalogDocument, + locator: CredentialLocator, + ): boolean { + if (locator.scope !== 'connection') return true; + const connection = findConnection(catalog, locator); + if (!connection) return false; + if (locator.kind === 'request_headers') return true; + const required = connectionCredentialLocator( + connection.connectionId, + PROVIDER_REGISTRY[connection.providerType].authKind, + ); + if (!required || required.kind !== locator.kind) { + throw codecError( + 'invalid_credential_input', + 'Connection credential kind does not match the provider auth contract', + ); + } + return true; + } + + private async clearCredentialDependentLastTests( + root: string, + locator: CredentialLocator, + connectionCatalog: ConnectionCatalogDocument | null, + ): Promise { + if (locator.scope === 'connection') { + return this.catalog.clearConnectionLastTest(root, connectionCatalog!, locator.connectionId); + } + if (locator.scope !== 'network_proxy') return false; + const policy = await this.policy.read(root); + if (!requiresNetworkProxyCredential(policy.policy.networkProxy)) return false; + return this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); + } + + private async checkSemanticConnectionBasis( + root: string, + catalog: Awaited>, + basis: SemanticConnectionBasis, + ): Promise<{ + readonly connection: ConnectionCatalogEntry | undefined; + readonly changed: ConnectionEffectChangedDomain[]; + }> { + const connection = findConnection(catalog, { connectionId: basis.connectionId }); + const changed: ConnectionEffectChangedDomain[] = []; + const facts = basis.kind === 'connection_test' ? await this.readModelFacts(root) : undefined; + if ( + basis.kind === 'connection_test' && + (!connection || + this.modelFacts.fingerprintForConnection(facts!.document, connection) !== + basis.modelFactsFingerprint) + ) { + changed.push('connection'); + } + const effectiveConnection = + connection && basis.kind === 'connection_test' + ? applyModelFactOverridesToConnection(connection, facts!.document.overrides) + : connection; + if ( + !effectiveConnection || + effectiveConnection.providerType !== basis.providerType || + !effectiveConnection.enabled || + canonicalEffectiveEndpoint(effectiveConnection) !== basis.effectiveEndpoint || + (basis.kind === 'model_fetch' && + !sameStringArray(effectiveConnection.enabledModelIds, basis.enabledModelIds)) || + (basis.kind === 'connection_test' && + JSON.stringify(effectiveConnection.requestBodyOverlay ?? {}) !== + basis.requestBodyOverlayJson) || + (basis.kind === 'connection_test' && + !sameConnectionTestModelBasis(connectionTestModelBasis(connection!), basis.model)) + ) { + changed.push('connection'); + } + + const policy = await this.policy.read(root); + if ( + !sameEffectiveProxyConfiguration( + effectiveProxyConfigurationBasis(policy.policy.networkProxy), + basis.effectiveProxy, + ) + ) { + changed.push('network_proxy'); + } + + if (basis.credential || basis.requestHeadersCredential || basis.proxyCredential) { + const vault = await this.vault.read(root); + const connectionCredentialChanged = Boolean( + basis.credential && + !sameCredentialStatus( + credentialStatus(vault, basis.credential.locator), + basis.credential, + ), + ); + const proxyCredentialChanged = Boolean( + basis.proxyCredential && + !sameCredentialStatus( + credentialStatus(vault, basis.proxyCredential.locator), + basis.proxyCredential, + ), + ); + const requestHeadersCredentialChanged = !sameCredentialStatus( + credentialStatus(vault, basis.requestHeadersCredential.locator), + basis.requestHeadersCredential, + ); + if ( + connectionCredentialChanged || + requestHeadersCredentialChanged || + proxyCredentialChanged + ) { + changed.push('credential'); + } + } + return { connection, changed }; + } + + private issueTicket(kind: ConnectionTicketKind, basis: SemanticConnectionBasis): object { + const ticket = Object.freeze(Object.create(null)) as object; + this.tickets.set(ticket, { kind, basis, state: 'available' }); + return ticket; + } + + private issueInteractiveOAuthLoginTicket( + attemptId: string, + target: InteractiveOAuthLoginTarget, + connectionBefore: ConnectionCatalogEntry | null, + connectionAfter: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }, + credentialBasisValue: CredentialVersionBasis | null, + ): InteractiveOAuthLoginTicket { + const ticket = Object.freeze(Object.create(null)) as object; + this.tickets.set(ticket, { + kind: 'interactive_oauth_login', + attemptId, + target: structuredClone(target), + connectionBefore: connectionBefore ? structuredClone(connectionBefore) : null, + connectionAfter: structuredClone(connectionAfter), + credentialBasis: credentialBasisValue, + state: 'available', + }); + return ticket as InteractiveOAuthLoginTicket; + } + + private claimTicket(ticket: object, expectedKind: ConnectionTicketKind): ConnectionTicketRecord { + const record = ticket && typeof ticket === 'object' ? this.tickets.get(ticket) : undefined; + if (!record || record.kind !== expectedKind || record.state !== 'available') { + throw codecError( + 'invalid_connection_input', + `Expected an authentic available ${ticketLabel(expectedKind)} ticket`, + ); + } + record.state = 'in_flight'; + return record; + } + + private claimInteractiveOAuthLoginTicket( + ticket: InteractiveOAuthLoginTicket, + ): InteractiveOAuthLoginTicketRecord { + const record = ticket && typeof ticket === 'object' ? this.tickets.get(ticket) : undefined; + if (!record || record.kind !== 'interactive_oauth_login' || record.state !== 'available') { + throw codecError( + 'invalid_credential_input', + 'Expected an authentic available interactive OAuth login ticket', + ); + } + record.state = 'in_flight'; + return record; + } + + private async completeClaimedTicket( + ticket: OperationTicketRecord, + operation: () => Promise, + ): Promise { + try { + return await operation(); + } finally { + ticket.state = 'consumed'; + } + } + + private async recoverConnectionOnboarding(root: string): Promise { + const intent = await readConnectionOnboardingIntent(root); + if (!intent) { + this.onboardingRecoveryRequired = false; + return; + } + this.onboardingRecoveryRequired = true; + try { + if (intent.schemaVersion === 3) { + await this.applyInteractiveOAuthEnrollment(root, intent); + } else { + await this.applyConnectionOnboarding(root, intent); + } + await clearConnectionOnboardingIntent(root); + this.onboardingRecoveryRequired = false; + } catch (error) { + if (isObsoleteConnectionOnboardingIntent(error)) { + await clearConnectionOnboardingIntent(root); + this.onboardingRecoveryRequired = false; + return; + } + if (isCommitOutcomeUnknown(error)) throw error; + throw commitOutcomeUnknown('Connection onboarding recovery did not converge', error); + } + } + + private async applyNetworkProxyUpdate( + root: string, + input: UpdateNetworkProxyInput, + ): Promise> { + const policy = await this.policy.read(root); + const vault = await this.vault.read(root); + const locator = networkProxyCredentialLocator(); + const existing = findCredential(vault, locator); + const credentialChanged = + input.credential.kind === 'replace' + ? existing?.secret !== input.credential.secret + : input.credential.kind === 'delete' && existing !== undefined; + const proxyChanged = !isDeepStrictEqual(policy.policy.networkProxy, input.networkProxy); + const effectiveProxyChanged = !sameEffectiveProxyConfiguration( + effectiveProxyConfigurationBasis(policy.policy.networkProxy), + effectiveProxyConfigurationBasis(input.networkProxy), + ); + const cleared = + credentialChanged || effectiveProxyChanged + ? await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)) + : false; + let durableChange = cleared; + let snapshot = policySnapshot(policy); + try { + const commitCredential = async (): Promise => { + if (input.credential.kind === 'replace' && credentialChanged) { + const prepared = this.vault.prepareSet(vault, { + locator, + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: input.credential.secret, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential update became stale'); + } + await this.vault.commitSet(root, prepared); + durableChange = true; + } else if (input.credential.kind === 'delete' && existing) { + const prepared = this.vault.prepareDelete(vault, { + expected: credentialBasis(existing), + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy credential deletion became stale'); + } + await this.vault.commitDelete(root, prepared); + durableChange = true; + } + }; + + const commitPolicy = async (): Promise => { + if (!proxyChanged) return; + const prepared = this.policy.prepareMutation(policy, { + expectedRevision: policy.revision, + operation: { kind: 'set_network_proxy', value: input.networkProxy }, + }); + if (prepared.kind !== 'ready') { + throw codecError('invalid_document', 'Network proxy policy update became stale'); + } + snapshot = (await this.policy.commitMutation(root, prepared)).snapshot; + durableChange = true; + }; + + // Never leave an enabled policy pointing at an absent credential: publish a + // replacement before enabling its use, and retire credential use before deletion. + if ( + input.credential.kind === 'delete' && + !requiresNetworkProxyCredential(input.networkProxy) + ) { + await commitPolicy(); + await commitCredential(); + } else { + await commitCredential(); + await commitPolicy(); + } + } catch (error) { + if (durableChange && !isCommitOutcomeUnknown(error)) { + throw commitOutcomeUnknown('Network proxy update committed only some effects', error); + } + throw error; + } + const finalVault = await this.vault.read(root); + return deepFreeze({ + kind: 'committed' as const, + snapshot, + credentialStatus: credentialStatus(finalVault, locator), + }); + } + + private async applyInteractiveOAuthEnrollment( + root: string, + intent: InteractiveOAuthEnrollmentIntent, + ): Promise<{ + readonly credentialId: string; + readonly revision: number; + readonly connection: ReturnType; + }> { + const existingReceipt = findInteractiveOAuthLoginReceipt( + await readInteractiveOAuthLoginReceipts(root), + intent.attemptId, + ); + const intendedIdentity = interactiveOAuthConnectionIdentity(intent.connectionAfter); + if ( + existingReceipt && + (!sameInteractiveOAuthLoginTarget(existingReceipt.target, intent.target) || + existingReceipt.connection.connectionId !== intendedIdentity.connectionId || + existingReceipt.connection.slug !== intendedIdentity.slug || + existingReceipt.connection.providerType !== intendedIdentity.providerType) + ) { + throw codecError( + 'invalid_document', + 'OAuth login receipt conflicts with the enrollment intent', + ); + } + const catalog = await this.catalog.read(root); + // Validate the complete catalog transition before the vault-first write. + // A damaged intent must never rotate a real account and discover its + // identity collision only afterwards. + const catalogPrepared = this.catalog.prepareOAuthEnrollmentUpsert( + catalog, + intent.connectionBefore, + intent.connectionAfter, + ); + if (catalogPrepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `OAuth enrollment catalog preflight returned ${catalogPrepared.kind}`, + ); + } + const locator = { + scope: 'connection', + connectionId: intent.connectionAfter.connectionId, + kind: 'oauth_token', + } as const; + const vault = await this.vault.read(root); + let credential = findCredential(vault, locator); + if (credential?.secret !== intent.secret) { + if ( + intent.credentialBasis + ? !sameCredentialBasis(credential, intent.credentialBasis) + : credential !== undefined + ) { + throw codecError('invalid_document', 'OAuth enrollment credential basis changed'); + } + const prepared = this.vault.prepareSet(vault, { + locator, + expected: intent.credentialBasis + ? { + credentialId: intent.credentialBasis.credentialId, + revision: intent.credentialBasis.revision, + } + : null, + secret: intent.secret, + }); + if (prepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `OAuth enrollment credential write returned ${prepared.kind}`, + ); + } + await this.vault.commitSet(root, prepared); + credential = prepared.entry; + } + if (!credential) { + throw codecError('invalid_document', 'OAuth enrollment did not produce a credential'); + } + await this.catalog.commitPreparedOnboarding(root, catalogPrepared); + const connection = intendedIdentity; + await upsertInteractiveOAuthLoginReceipt(root, { + attemptId: intent.attemptId, + target: intent.target, + connection, + }); + return { + credentialId: credential.credentialId, + revision: credential.revision, + connection, + }; + } + + private async applyConnectionOnboarding( + root: string, + intent: ConnectionOnboardingIntent, + ): Promise<{ + readonly snapshot: ConnectionCatalogSnapshot; + readonly changed: boolean; + readonly connection: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' | 'revision' + >; + }> { + let changed = false; + const catalog = await this.catalog.read(root); + const existingConnection = findConnection(catalog, { connectionId: intent.connectionId }); + const slug = + intent.slug ?? existingConnection?.slug ?? deriveConnectionSlug(intent.providerType); + // Validate the durable identity and final catalog shape before touching + // the vault. A damaged v2 intent must not rotate a real connection's + // credential before discovering that its ID/slug pair cannot commit. + const prepared = this.catalog.prepareOnboardingUpsert( + catalog, + intent.connectionId, + slug, + intent.providerType, + intent.name, + intent.baseUrl, + intent.enabledModelIds, + intent.discovery, + intent.invalidateLastTest, + ); + if (prepared.kind === 'slug_conflict') { + throw codecError( + 'invalid_document', + intent.schemaVersion === 1 + ? 'Legacy onboarding intent conflicts with the connection id' + : 'Onboarding intent conflicts with the connection slug', + ); + } + if (prepared.kind === 'catalog_full') { + throw codecError('invalid_document', 'Onboarding intent exceeds the connection catalog'); + } + if (intent.suppliedSecret !== null) { + const locator = connectionCredentialLocator( + intent.connectionId, + PROVIDER_REGISTRY[intent.providerType].authKind, + ); + if (!locator) { + throw codecError('invalid_document', 'Onboarding provider has no credential locator'); + } + const vault = await this.vault.read(root); + const existing = findCredential(vault, locator); + if (existing?.secret !== intent.suppliedSecret) { + const prepared = this.vault.prepareSet(vault, { + locator, + expected: existing + ? { credentialId: existing.credentialId, revision: existing.revision } + : null, + secret: intent.suppliedSecret, + }); + if (prepared.kind !== 'ready') { + throw codecError( + 'invalid_document', + `Onboarding credential write returned ${prepared.kind}`, + ); + } + await this.vault.commitSet(root, prepared); + changed = true; + } + } + + const snapshot = await this.catalog.commitPreparedOnboarding(root, prepared); + const connection = snapshot.connections.find( + (candidate) => candidate.connectionId === intent.connectionId, + ); + if (!connection) + throw codecError('invalid_document', 'Onboarding commit omitted its connection'); + return { + snapshot, + changed: changed || prepared.changed, + connection: { + connectionId: connection.connectionId, + slug: connection.slug, + providerType: connection.providerType, + revision: connection.revision, + }, + }; + } + + private inLane(operation: (root: string) => Promise): Promise { + return this.lane.run(async (root) => { + if (this.onboardingRecoveryRequired) await this.recoverConnectionOnboarding(root); + return operation(root); + }); + } + + private async projectCatalogSnapshot(root: string): Promise { + const facts = await this.readModelFacts(root); + const snapshot = catalogSnapshot(await this.catalog.read(root)); + return deepFreeze( + hideStaleModelFactsVerification( + applyModelFactOverridesToCatalogSnapshot(snapshot, facts.document.overrides), + snapshot, + facts.document, + this.modelFacts, + ), + ); + } + + private async readModelFacts(root: string) { + const facts = await this.modelFacts.readWithDiagnostics(root); + if (facts.diagnostic !== undefined && this.warnedModelFactsFingerprint !== facts.fingerprint) { + process.emitWarning(`model-facts.json is ${facts.diagnostic}; ignoring its overrides`, { + type: 'RuntimePolicyWarning', + }); + this.warnedModelFactsFingerprint = facts.fingerprint; + } + return facts; + } + + private async projectCatalogMutation( + root: string, + result: T, + ): Promise { + if (result.kind !== 'committed' || !('snapshot' in result)) return result; + return deepFreeze({ + ...result, + snapshot: await this.projectCatalogSnapshot(root), + }) as T; + } +} + +function hideStaleModelFactsVerification( + projected: ConnectionCatalogSnapshot, + persisted: ConnectionCatalogSnapshot, + document: ModelFactsDocument, + owner: ModelFactsDocumentOwner, +): ConnectionCatalogSnapshot { + const persistedById = new Map( + persisted.connections.map((connection) => [connection.connectionId, connection] as const), + ); + return { + ...projected, + connections: projected.connections.map((connection) => { + if (connection.lastTest === undefined) return connection; + const raw = persistedById.get(connection.connectionId); + if (!raw) return connection; + const current = owner.fingerprintForConnection(document, raw); + const emptyFactsFingerprint = owner.fingerprintForConnection( + { ...document, overrides: {} }, + raw, + ); + // Catalogs written before model facts existed have no marker. They remain + // valid until facts for this connection actually exist; every test + // recorded by this feature carries a connection-scoped marker and is + // checked exactly. + if ( + raw.lastTestModelFactsFingerprint === current || + (raw.lastTestModelFactsFingerprint === undefined && current === emptyFactsFingerprint) + ) { + return connection; + } + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = connection; + return withoutLastTest; + }), + }; +} + +function matchesCredentialExpectation( + actual: ReturnType, + expected: CredentialVersionBasis | null, +): boolean { + return expected === null ? actual === undefined : sameCredentialBasis(actual, expected); +} + +function isCommitOutcomeUnknown(error: unknown): error is RuntimePolicyStoreError { + return error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown'; +} + +function isObsoleteConnectionOnboardingIntent(error: unknown): boolean { + return ( + error instanceof RuntimePolicyStoreError && + error.code === 'invalid_document' && + error.message === 'Legacy onboarding intent conflicts with the connection id' + ); +} + +function commonSemanticConnectionBasis( + prepared: PreparedConnectionMaterial, +): CommonSemanticConnectionBasis { + return { + connectionId: prepared.connection.connectionId, + providerType: prepared.connection.providerType, + enabled: true, + effectiveEndpoint: canonicalEffectiveEndpoint(prepared.connection), + credential: prepared.connectionCredentialStatus, + requestHeadersCredential: prepared.requestHeadersCredentialStatus, + effectiveProxy: effectiveProxyConfigurationBasis(prepared.networkProxy), + proxyCredential: prepared.proxyCredentialStatus, + }; +} + +function modelFetchSemanticBasis( + prepared: PreparedConnectionMaterial, +): Extract { + return { + kind: 'model_fetch', + ...commonSemanticConnectionBasis(prepared), + enabledModelIds: [...prepared.connection.enabledModelIds], + }; +} + +function connectionTestSemanticBasis( + prepared: PreparedConnectionMaterial, + modelFactsFingerprint: string, +): Extract { + return { + kind: 'connection_test', + ...commonSemanticConnectionBasis(prepared), + requestBodyOverlayJson: JSON.stringify(prepared.connection.requestBodyOverlay ?? {}), + model: connectionTestModelBasis(prepared.connection), + modelFactsFingerprint, + }; +} + +function isCanonicalConnectionTestModel( + connection: ConnectionCatalogEntry, + modelId: string, +): boolean { + const basis = connectionTestModelBasis(connection); + // Either source admits: testing a discovered model before enabling it is the + // point of the button, and the user's own selection is authorization no + // catalog overrules (#1584). + return ( + basis.models.some((model) => model.id === modelId) || basis.enabledModelIds.includes(modelId) + ); +} + +function canonicalEffectiveEndpoint(connection: ConnectionCatalogEntry): string { + const endpoint = effectiveBaseUrl(connection); + try { + return new URL(endpoint).toString(); + } catch { + throw codecError('invalid_document', 'Connection has an invalid effective endpoint'); + } +} + +function effectiveProxyConfigurationBasis( + networkProxy: RuntimePolicy['networkProxy'], +): EffectiveProxyConfigurationBasis { + if (!networkProxy.enabled) return { kind: 'direct' }; + return { + kind: 'proxy', + protocol: networkProxy.protocol, + host: networkProxy.host.trim().toLowerCase(), + port: networkProxy.port, + authentication: networkProxy.authEnabled + ? { kind: 'credentials', username: networkProxy.username } + : { kind: 'none' }, + bypassPatterns: normalizeProxyPatterns([ + ...networkProxy.bypassList, + ...networkProxy.autoBypassDomains, + ]), + }; +} + +function sameEffectiveProxyConfiguration( + actual: EffectiveProxyConfigurationBasis, + expected: EffectiveProxyConfigurationBasis, +): boolean { + if (actual.kind !== expected.kind) return false; + if (actual.kind === 'direct' || expected.kind === 'direct') return true; + return ( + actual.protocol === expected.protocol && + actual.host === expected.host && + actual.port === expected.port && + sameProxyAuthentication(actual.authentication, expected.authentication) && + sameStringArray(actual.bypassPatterns, expected.bypassPatterns) + ); +} + +function sameProxyAuthentication( + actual: Extract['authentication'], + expected: Extract['authentication'], +): boolean { + if (actual.kind !== expected.kind) return false; + return ( + actual.kind === 'none' || + (expected.kind === 'credentials' && actual.username === expected.username) + ); +} + +function normalizeProxyPatterns(patterns: readonly string[]): readonly string[] { + return [ + ...new Set( + patterns + .map((pattern) => pattern.trim().toLowerCase()) + .filter((pattern) => pattern.length > 0), + ), + ].sort(); +} + +function sameStringArray(actual: readonly string[], expected: readonly string[]): boolean { + return ( + actual.length === expected.length && actual.every((value, index) => value === expected[index]) + ); +} + +function decodeRequestHeaderUpdates(value: unknown): readonly RequestHeaderUpdate[] { + try { + return normalizeRequestHeaderUpdates(value); + } catch (error) { + if (error instanceof RequestCustomizationValidationError) { + throw codecError('invalid_credential_input', error.message); + } + throw error; + } +} + +function decodeRequestHeaders(value: unknown): Readonly> { + try { + return normalizeRequestHeaders(value); + } catch (error) { + if (error instanceof RequestCustomizationValidationError) { + throw codecError('invalid_credential_input', error.message); + } + throw error; + } +} + +function sameCredentialStatus(actual: CredentialStatus, expected: CredentialStatus): boolean { + return ( + sameCredentialLocator(actual.locator, expected.locator) && + actual.configured === expected.configured && + actual.credentialId === expected.credentialId && + actual.revision === expected.revision + ); +} + +function sameCredentialLocator(actual: CredentialLocator, expected: CredentialLocator): boolean { + return ( + actual.scope === expected.scope && + actual.kind === expected.kind && + (actual.scope !== 'connection' || + (expected.scope === 'connection' && actual.connectionId === expected.connectionId)) + ); +} + +/** + * A retired provider's connection is a tombstone: it may be decoded, queried + * and deleted, and nothing else. Every write a connection owns funnels through + * here rather than growing its own guard — the catalog update, the credential + * vault, and the request-header replacement are siblings, and guarding them one + * at a time is what left the last two open. + */ +function assertConnectionIsWritable(connection: { readonly providerType: ProviderType }): void { + if (isRetiredProvider(connection.providerType)) { + throw codecError( + 'invalid_connection_input', + `"${connection.providerType}" is retired; its connections can only be read or deleted`, + ); + } +} + +function ticketLabel(kind: ConnectionTicketKind): string { + return kind === 'model_fetch' ? 'model fetch' : 'connection test'; +} + +function networkProxyCredentialLocator(): Extract { + return { scope: 'network_proxy', kind: 'password' }; +} + +function requiresNetworkProxyCredential(networkProxy: RuntimePolicy['networkProxy']): boolean { + return networkProxy.enabled && networkProxy.authEnabled; +} + +function isInteractiveOAuthLoginProvider( + providerType: ProviderType, +): providerType is InteractiveOAuthLoginProvider { + return ( + providerType === 'openai-codex' || + providerType === 'xai-oauth' || + providerType === 'github-copilot' + ); +} + +function normalizeInteractiveOAuthLoginInput( + input: InteractiveOAuthLoginInput, +): InteractiveOAuthLoginInput { + const attemptId = decodeInteractiveOAuthAttemptId(input?.attemptId, 'invalid_connection_input'); + const target = input?.target; + if (target?.kind === 'create') { + const providerType = decodeConnectionInput(() => decodeProviderType(target.providerType)); + if (!isInteractiveOAuthLoginProvider(providerType)) { + throw codecError('invalid_connection_input', 'OAuth create target provider is unsupported'); + } + return { attemptId, target: { kind: 'create', providerType } }; + } + if (target?.kind === 'existing') { + return { + attemptId, + target: { + kind: 'existing', + connectionId: decodeConnectionInput(() => decodeRuntimePolicyEntityId(target.connectionId)), + }, + }; + } + throw codecError('invalid_connection_input', 'Unknown interactive OAuth login target'); +} + +function newInteractiveOAuthConnection( + connectionId: string, + slug: string, + providerType: InteractiveOAuthLoginProvider, +): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { + const defaults = PROVIDER_REGISTRY[providerType]; + return { + connectionId, + revision: 1, + slug, + name: defaults.label, + providerType, + enabled: true, + enabledModelIds: providerFallbackModelIds(defaults), + models: [], + }; +} + +function reenabledInteractiveOAuthConnection( + connection: ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider }, +): ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider } { + if (connection.enabled && connection.lastTest === undefined) return structuredClone(connection); + const { lastTest: _lastTest, ...withoutLastTest } = connection; + return { + ...withoutLastTest, + revision: nextRevision(connection.revision), + enabled: true, + }; +} + +function interactiveOAuthConnectionIdentity( + connection: ConnectionCatalogEntry & { readonly providerType: InteractiveOAuthLoginProvider }, +) { + return { + connectionId: connection.connectionId, + slug: connection.slug, + providerType: connection.providerType, + } as const; +} + +function decodeInteractiveOAuthAttemptId( + value: unknown, + source: 'invalid_connection_input' | 'invalid_document', +): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw codecError(source, 'OAuth attempt id is invalid'); + } + return value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/41809820ad9b8e6cddd547b201d44515889957f25c1144dc25033dfaac0d932b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/41809820ad9b8e6cddd547b201d44515889957f25c1144dc25033dfaac0d932b.source new file mode 100644 index 0000000000..2e74ce9a1a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/41809820ad9b8e6cddd547b201d44515889957f25c1144dc25033dfaac0d932b.source @@ -0,0 +1,972 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; +import { + decodePersistedLegacyRunHeader, + invocationOpeningFromLegacyRunHeader, + type LegacyRunHeader, +} from './legacy-run-header.js'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js'; +import { + buildInvocationOpenedEvent, + buildSyntheticTerminalRuntimeEvent, +} from '@maka/core/runtime-invocation'; + +export const SQLITE_RUNTIME_SCHEMA_VERSION = 18; +export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; +export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION = 1; +export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY = 'runtime_continuation_authority'; +export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY_VERSION = 1; +export const RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY = 'runtime_workspace_version_authority'; +export const RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION = 1; +const SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS = 5_000; +const SQLITE_INITIALIZATION_RETRY_DELAY_MS = 10; +const initializationRetryGate = new Int32Array(new SharedArrayBuffer(4)); + +const MIGRATIONS: ReadonlyMap = new Map([ + [ + 1, + ` + CREATE TABLE runtime_events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + event_seq INTEGER NOT NULL CHECK (event_seq > 0), + event_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + committed_at INTEGER NOT NULL, + UNIQUE (invocation_id, event_seq) + ); + + CREATE INDEX runtime_events_by_run + ON runtime_events(session_id, run_id, event_seq); + + CREATE INDEX runtime_events_by_session + ON runtime_events(session_id, committed_at, event_id); + + CREATE TABLE tool_journal_events ( + journal_seq INTEGER PRIMARY KEY AUTOINCREMENT, + journal_event_id TEXT NOT NULL UNIQUE, + operation_id TEXT NOT NULL, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + state TEXT NOT NULL, + runtime_event_id TEXT, + canonical_args_hash TEXT, + recovery_mode TEXT, + external_handle TEXT, + metadata_json TEXT, + committed_at INTEGER NOT NULL, + FOREIGN KEY(runtime_event_id) REFERENCES runtime_events(event_id) + ); + + CREATE INDEX tool_journal_events_by_operation + ON tool_journal_events(operation_id, journal_seq); + + CREATE TABLE tool_operations ( + operation_id TEXT PRIMARY KEY, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + provider_tool_call_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + canonical_args_hash TEXT NOT NULL, + recovery_mode TEXT NOT NULL, + current_state TEXT NOT NULL, + call_event_id TEXT NOT NULL, + result_event_id TEXT, + version INTEGER NOT NULL CHECK (version > 0), + FOREIGN KEY(call_event_id) REFERENCES runtime_events(event_id), + FOREIGN KEY(result_event_id) REFERENCES runtime_events(event_id), + UNIQUE(invocation_id, provider_tool_call_id) + ); + `, + ], + [ + 2, + ` + CREATE TABLE runtime_partial_snapshots ( + stream_key TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + after_event_id TEXT, + payload_json TEXT NOT NULL, + text_content TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX runtime_partial_snapshots_by_run + ON runtime_partial_snapshots(session_id, run_id, updated_at, stream_key); + `, + ], + [ + 3, + ` + SELECT 1; + `, + ], + [ + 4, + ` + ALTER TABLE tool_operations ADD COLUMN dispatch_event_id TEXT + REFERENCES runtime_events(event_id); + `, + ], + [ + 5, + ` + CREATE TABLE runtime_capabilities ( + capability TEXT PRIMARY KEY, + version INTEGER NOT NULL CHECK (version > 0) + ); + + INSERT INTO runtime_capabilities(capability, version) + VALUES ('runtime_recovery_authority', 1); + `, + ], + [ + 6, + ` + CREATE TABLE runtime_continuation_claims ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version = 1), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_run_header_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), + start_kind TEXT CHECK ( + start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair') + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE ( + source_session_id, + source_run_id, + source_event_high_water, + source_prefix_digest + ), + UNIQUE (target_session_id, target_turn_id) + ); + + INSERT INTO runtime_capabilities(capability, version) + VALUES ('runtime_continuation_authority', 1); + `, + ], + [ + 7, + ` + CREATE TABLE runtime_workspace_epochs ( + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL UNIQUE, + repository_id TEXT NOT NULL, + workspace_instance_id TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL CHECK (mode = 'managed_worktree'), + object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), + source_commit_oid TEXT NOT NULL, + source_tree_oid TEXT NOT NULL, + initial_workspace_version_id TEXT NOT NULL UNIQUE, + materialization_profile_digest TEXT NOT NULL, + materialization_semantics TEXT NOT NULL + CHECK (materialization_semantics = 'git_tree_materialized_with_fixed_config_v1'), + policy_hash TEXT NOT NULL, + authority_session_id TEXT NOT NULL CHECK (authority_session_id = 'maka_workspace_authority'), + authority_invocation_id TEXT NOT NULL UNIQUE, + authority_run_id TEXT NOT NULL UNIQUE, + authority_turn_id TEXT NOT NULL UNIQUE, + epoch_opened_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + committed_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, workspace_epoch_id) + ); + + CREATE TABLE runtime_workspace_versions ( + workspace_version_id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), + origin_kind TEXT NOT NULL CHECK (origin_kind = 'baseline'), + origin_event_id TEXT NOT NULL, + parents_json TEXT NOT NULL CHECK (parents_json = '[]'), + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + policy_hash TEXT NOT NULL, + tree_delta_digest TEXT NOT NULL, + changed_file_count INTEGER NOT NULL CHECK (changed_file_count >= 0), + deleted_file_count INTEGER NOT NULL CHECK (deleted_file_count = 0), + accepted_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + committed_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + UNIQUE ( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ) + ); + + CREATE TABLE runtime_workspace_heads ( + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + workspace_version_id TEXT NOT NULL, + accepted_event_id TEXT NOT NULL, + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + PRIMARY KEY (workspace_id, workspace_epoch_id), + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + FOREIGN KEY ( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ) REFERENCES runtime_workspace_versions( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ) + ); + + INSERT INTO runtime_capabilities(capability, version) + VALUES ('runtime_workspace_version_authority', 1); + `, + ], + [ + 8, + ` + SELECT 1; + `, + ], + [ + 9, + ` + CREATE TABLE runtime_storage_root_binding ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + root_id TEXT NOT NULL CHECK ( + length(root_id) = 64 AND root_id NOT GLOB '*[^0-9a-f]*' + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1) + ); + `, + ], + [ + 10, + ` + CREATE TABLE runtime_partial_segments ( + stream_key TEXT NOT NULL, + segment_seq INTEGER NOT NULL CHECK (segment_seq > 0), + text_content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (stream_key, segment_seq), + FOREIGN KEY (stream_key) + REFERENCES runtime_partial_snapshots(stream_key) + ON DELETE CASCADE + ); + `, + ], + [ + 11, + ` + CREATE TABLE runtime_session_event_ordinals ( + session_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal > 0), + event_id TEXT NOT NULL UNIQUE, + PRIMARY KEY (session_id, ordinal), + FOREIGN KEY (event_id) REFERENCES runtime_events(event_id) ON DELETE CASCADE + ) WITHOUT ROWID; + + INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) + SELECT + session_id, + ROW_NUMBER() OVER ( + PARTITION BY session_id + ORDER BY rowid ASC + ), + event_id + FROM runtime_events; + `, + ], + [ + 12, + ` + DROP TABLE IF EXISTS headless_task_run_events; + `, + ], + [ + 13, + ` + ALTER TABLE runtime_workspace_heads RENAME TO runtime_workspace_heads_v12; + ALTER TABLE runtime_workspace_versions RENAME TO runtime_workspace_versions_v12; + + CREATE TABLE runtime_workspace_versions ( + workspace_version_id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), + origin_kind TEXT NOT NULL CHECK (origin_kind IN ('baseline', 'tool_mutation')), + origin_event_id TEXT NOT NULL, + parents_json TEXT NOT NULL, + operation_id TEXT, + dispatch_event_id TEXT REFERENCES runtime_events(event_id), + outcome_event_id TEXT REFERENCES runtime_events(event_id), + base_head_revision INTEGER CHECK (base_head_revision IS NULL OR base_head_revision > 0), + execution_profile_digest TEXT, + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + policy_hash TEXT NOT NULL, + tree_delta_digest TEXT NOT NULL, + changed_file_count INTEGER NOT NULL CHECK (changed_file_count >= 0), + deleted_file_count INTEGER NOT NULL CHECK (deleted_file_count >= 0), + accepted_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + committed_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + UNIQUE ( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ), + CHECK ( + (origin_kind = 'baseline' AND parents_json = '[]' AND operation_id IS NULL + AND dispatch_event_id IS NULL AND outcome_event_id IS NULL + AND base_head_revision IS NULL AND execution_profile_digest IS NULL) + OR + (origin_kind = 'tool_mutation' AND parents_json <> '[]' AND operation_id IS NOT NULL + AND dispatch_event_id IS NOT NULL AND outcome_event_id IS NOT NULL + AND base_head_revision IS NOT NULL AND execution_profile_digest IS NOT NULL) + ) + ); + + INSERT INTO runtime_workspace_versions ( + workspace_version_id, repository_id, workspace_id, workspace_epoch_id, + object_format, origin_kind, origin_event_id, parents_json, + operation_id, dispatch_event_id, outcome_event_id, base_head_revision, + execution_profile_digest, commit_oid, tree_oid, policy_hash, + tree_delta_digest, changed_file_count, deleted_file_count, + accepted_event_id, protocol_version, committed_at + ) + SELECT + workspace_version_id, repository_id, workspace_id, workspace_epoch_id, + object_format, origin_kind, origin_event_id, parents_json, + NULL, NULL, NULL, NULL, NULL, commit_oid, tree_oid, policy_hash, + tree_delta_digest, changed_file_count, deleted_file_count, + accepted_event_id, protocol_version, committed_at + FROM runtime_workspace_versions_v12; + + CREATE TABLE runtime_workspace_heads ( + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + workspace_version_id TEXT NOT NULL, + accepted_event_id TEXT NOT NULL, + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + PRIMARY KEY (workspace_id, workspace_epoch_id), + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + FOREIGN KEY ( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ) REFERENCES runtime_workspace_versions( + workspace_id, + workspace_epoch_id, + workspace_version_id, + accepted_event_id + ) + ); + + INSERT INTO runtime_workspace_heads + SELECT * FROM runtime_workspace_heads_v12; + + DROP TABLE runtime_workspace_heads_v12; + DROP TABLE runtime_workspace_versions_v12; + `, + ], + [ + 14, + ` + ALTER TABLE runtime_workspace_versions + ADD COLUMN changed_paths_json TEXT NOT NULL DEFAULT '[]'; + + CREATE TABLE runtime_managed_mutation_reservations ( + workspace_instance_id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + operation_id TEXT NOT NULL UNIQUE REFERENCES tool_operations(operation_id), + dispatch_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id), + base_workspace_version_id TEXT NOT NULL, + base_accepted_event_id TEXT NOT NULL, + base_head_revision INTEGER NOT NULL CHECK (base_head_revision > 0), + base_commit_oid TEXT NOT NULL, + base_tree_oid TEXT NOT NULL, + expected_paths_json TEXT NOT NULL, + execution_profile_digest TEXT NOT NULL, + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + reserved_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id) + ); + `, + ], + [ + 15, + ` + CREATE TABLE runtime_continuation_claims_v15 ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version IN (1, 2)), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_run_header_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), + start_kind TEXT CHECK ( + start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair') + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE ( + source_session_id, + source_run_id, + source_event_high_water, + source_prefix_digest + ), + UNIQUE (target_session_id, target_turn_id) + ); + + INSERT INTO runtime_continuation_claims_v15 + SELECT * FROM runtime_continuation_claims; + DROP TABLE runtime_continuation_claims; + ALTER TABLE runtime_continuation_claims_v15 RENAME TO runtime_continuation_claims; + `, + ], + [ + 16, + ` + CREATE INDEX runtime_events_by_session_kind + ON runtime_events(session_id, event_kind, invocation_id); + + CREATE UNIQUE INDEX runtime_events_one_opening_per_invocation + ON runtime_events(invocation_id) + WHERE event_kind = 'invocation_opened'; + + CREATE TABLE runtime_legacy_invocation_openings ( + invocation_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + opened_at INTEGER NOT NULL, + opening_json TEXT NOT NULL, + -- UNIQUE is what indexes this side of the foreign key. SQLite indexes only + -- the parent, so without it every deleted RuntimeEvent scans this whole + -- table looking for rows to cascade. + anchor_event_id TEXT NOT NULL UNIQUE + REFERENCES runtime_events(event_id) ON DELETE CASCADE + ) WITHOUT ROWID; + + CREATE INDEX runtime_legacy_invocation_openings_by_session + ON runtime_legacy_invocation_openings(session_id, opened_at, invocation_id); + + -- Rebuilt rather than renamed in place, because the column rename is not + -- the only thing this claim needs. Its start event belongs to the target + -- Session, and the foreign key had no ON DELETE clause, so purging that + -- Session was refused outright by the constraint and the whole purge rolled + -- back. A continuation whose target has been deleted no longer names + -- anything, so the claim goes with it and the source boundary it held is + -- free again. + CREATE TABLE runtime_continuation_claims_v16 ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version IN (1, 2)), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_opening_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id) ON DELETE CASCADE, + start_kind TEXT CHECK ( + start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair') + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE ( + source_session_id, + source_run_id, + source_event_high_water, + source_prefix_digest + ), + UNIQUE (target_session_id, target_turn_id) + ); + + INSERT INTO runtime_continuation_claims_v16 ( + claim_id, source_session_id, source_invocation_id, source_run_id, source_turn_id, + source_event_high_water, source_prefix_digest, boundary_digest, boundary_json, + provider_projection_version, provider_replay_digest, target_session_id, + target_invocation_id, target_run_id, target_turn_id, target_opening_json, + claimed_at, start_event_id, start_kind, protocol_version + ) + SELECT + claim_id, source_session_id, source_invocation_id, source_run_id, source_turn_id, + source_event_high_water, source_prefix_digest, boundary_digest, boundary_json, + provider_projection_version, provider_replay_digest, target_session_id, + target_invocation_id, target_run_id, target_turn_id, target_run_header_json, + claimed_at, start_event_id, start_kind, protocol_version + FROM runtime_continuation_claims; + DROP TABLE runtime_continuation_claims; + ALTER TABLE runtime_continuation_claims_v16 RENAME TO runtime_continuation_claims; + `, + ], + [ + 17, + ` + -- Handoff replaces a physical attempt, not the admitted logical Turn. + -- Keep physical identities and each source boundary exclusive. + CREATE TABLE runtime_continuation_claims_v17 ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version IN (1, 2)), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_opening_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id) ON DELETE CASCADE, + start_kind TEXT CHECK (start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair')), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE (source_session_id, source_run_id, source_event_high_water, source_prefix_digest) + ); + INSERT INTO runtime_continuation_claims_v17 SELECT * FROM runtime_continuation_claims; + DROP TABLE runtime_continuation_claims; + ALTER TABLE runtime_continuation_claims_v17 RENAME TO runtime_continuation_claims; + CREATE UNIQUE INDEX runtime_continuation_fresh_turn + ON runtime_continuation_claims(target_session_id, target_turn_id) + WHERE json_extract(target_opening_json, '$.source.kind') <> 'handoff'; + `, + ], + [ + 18, + ` + CREATE INDEX IF NOT EXISTS runtime_events_terminal ON runtime_events(invocation_id, event_seq) WHERE ${TERMINAL_RUNTIME_EVENT_SQL}; + `, + ], +]); + +/** + * Data migrations that a SQL statement cannot express, applied inside the same + * transaction as their schema step. They project persisted records through the + * one TypeScript mapping that owns that projection, so a migration and the live + * writer can never classify a field two different ways. + */ +const DATA_MIGRATIONS: ReadonlyMap void> = new Map([ + [ + 16, + (db) => { + backfillInvocationOpeningFacts(db); + projectContinuationClaimOpenings(db); + }, + ], +]); + +/** + * Replace each open claim's embedded target Run header with the opening fact it + * always implied. + * + * The header was only ever there so the start event could be checked against + * it, and the check went through the projection anyway. Projecting once, here, + * leaves one representation instead of a copy plus a derivation. + * + * A row this cannot project is dropped rather than left half-migrated: an + * undecodable claim could not have admitted a start event before this migration + * either, and keeping it would only block the boundary it holds. + */ +function projectContinuationClaimOpenings(db: DatabaseSync): void { + const rows = db + .prepare('SELECT claim_id, target_opening_json FROM runtime_continuation_claims') + .all() as Array<{ claim_id: string; target_opening_json: string }>; + const update = db.prepare( + 'UPDATE runtime_continuation_claims SET target_opening_json = ? WHERE claim_id = ?', + ); + const remove = db.prepare('DELETE FROM runtime_continuation_claims WHERE claim_id = ?'); + for (const row of rows) { + try { + const header = decodePersistedLegacyRunHeader(JSON.parse(row.target_opening_json)); + update.run(JSON.stringify(invocationOpeningFromLegacyRunHeader(header)), row.claim_id); + } catch { + remove.run(row.claim_id); + } + } +} + +/** + * Give every Run header its opening fact, so that after this migration the + * opening lives in the runtime database rather than on the header. + * + * A run that never wrote a RuntimeEvent gets the real thing: the opening fact + * as event one of its own invocation. A run that already has events cannot, + * because it owns an immutable sequence whose position 1, digests and coverage + * other facts already point at; inserting into it would rewrite signed history. + * Its opening is recorded in `runtime_legacy_invocation_openings` instead, + * which only this migration ever writes. Readers merge the two, so nothing + * downstream has to know which shelf a given opening came off. + * + * A shelved opening describes a ledger that already exists, so it is anchored to + * that ledger's first event and dies with it. Without the anchor, deleting a + * Session's events would leave the opening behind, and the inventory would + * report the run again with no ending — a completed run coming back as an + * active one. + * + * A header this cannot project fails closed: it is skipped, and its transcript + * and tool evidence stay exactly as readable as before. + */ +function backfillInvocationOpeningFacts(db: DatabaseSync): void { + if (!hasTable(db, 'core_agent_runs')) return; + // The header column is dropped by the core-execution migration that follows + // this one, so its absence means every header it held is already an opening + // fact. Nothing left to project, and the two scopes stay independently + // replayable. + if (!hasColumn(db, 'core_agent_runs', 'record_json')) return; + const rows = db + .prepare(` + SELECT + r.session_id, + r.run_id, + r.record_json, + first_event.invocation_id AS existing_invocation_id, + first_event.event_id AS anchor_event_id + FROM core_agent_runs r + LEFT JOIN runtime_events first_event ON first_event.event_id = ( + SELECT e.event_id FROM runtime_events e + WHERE e.session_id = r.session_id AND e.run_id = r.run_id + ORDER BY e.event_seq ASC LIMIT 1 + ) + ORDER BY r.created_at ASC, r.run_id ASC + `) + .all() as Array<{ + session_id: string; + run_id: string; + record_json: string; + existing_invocation_id: string | null; + anchor_event_id: string | null; + }>; + const insertEvent = db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertOrdinal = db.prepare(` + INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) + SELECT ?, COALESCE(MAX(ordinal), 0) + 1, ? + FROM runtime_session_event_ordinals WHERE session_id = ? + `); + const insertLegacyOpening = db.prepare(` + INSERT OR IGNORE INTO runtime_legacy_invocation_openings ( + invocation_id, session_id, run_id, turn_id, opened_at, opening_json, + anchor_event_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + // The header column is dropped right after this, so a header this cannot read + // is a run that would silently cease to exist. Refusing the whole migration + // keeps the database as it was, and the failure names the row instead of + // hiding it. + const unreadable = (row: { session_id: string; run_id: string }, cause: unknown): Error => + new Error( + `Cannot migrate the AgentRun header of ${row.session_id}/${row.run_id}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + { cause }, + ); + for (const row of rows) { + let header: LegacyRunHeader; + let opening: string; + try { + header = decodePersistedLegacyRunHeader(JSON.parse(row.record_json)); + opening = JSON.stringify(invocationOpeningFromLegacyRunHeader(header)); + } catch (error) { + throw unreadable(row, error); + } + if (row.existing_invocation_id !== null && row.anchor_event_id !== null) { + // The invocation id its own events already carry is the one every reader + // joins on, so the legacy row is keyed by that rather than by the header's + // copy, which older builds minted independently. + insertLegacyOpening.run( + row.existing_invocation_id, + header.sessionId, + header.runId, + header.turnId, + header.createdAt, + opening, + row.anchor_event_id, + ); + continue; + } + // A run with no events of its own gets the facts its header held, where + // facts live now: the opening, and the ending if the header recorded one. + // A header still marked in flight stays open; recovery settles it the way it + // settles any run the process died holding. + const run = { + sessionId: header.sessionId, + invocationId: header.invocationId ?? header.runId, + runId: header.runId, + turnId: header.turnId, + }; + const events = [ + buildInvocationOpenedEvent({ + id: `invocation_opened:${header.runId}`, + run, + openedAt: header.createdAt, + opening: invocationOpeningFromLegacyRunHeader(header), + }), + ...(header.status === 'completed' || + header.status === 'failed' || + header.status === 'cancelled' + ? [ + buildSyntheticTerminalRuntimeEvent({ + id: `invocation_terminal:${header.runId}`, + invocationId: run.invocationId, + run, + status: header.status, + ts: header.completedAt ?? header.updatedAt, + ...(header.failureClass !== undefined ? { failureClass: header.failureClass } : {}), + ...(header.failureMessage !== undefined ? { message: header.failureMessage } : {}), + ...(header.abortSource !== undefined ? { abortSource: header.abortSource } : {}), + }), + ] + : []), + ]; + events.forEach((event, index) => { + let encoded: { event: RuntimeEvent; json: string }; + try { + encoded = encodeCanonicalRuntimeEvent(event); + } catch (error) { + throw unreadable(row, error); + } + insertEvent.run( + event.id, + event.sessionId, + event.invocationId, + event.runId, + event.turnId, + index + 1, + runtimeEventKind(event), + encoded.json, + event.ts, + ); + insertOrdinal.run(event.sessionId, event.id, event.sessionId); + }); + } +} + +/** The `event_kind` column: the one coarse label every reader indexes events by. */ +export function runtimeEventKind(event: RuntimeEvent): string { + return ( + event.content?.kind ?? + event.status ?? + (event.actions?.workspaceFact ? 'workspace_fact' : undefined) ?? + (event.actions?.toolDispatch ? 'tool_dispatch' : undefined) ?? + (event.actions?.endInvocation ? 'invocation_end' : 'runtime_fact') + ); +} + +function hasColumn(db: DatabaseSync, table: string, column: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return columns.some((candidate) => candidate.name === column); +} + +function hasTable(db: DatabaseSync, name: string): boolean { + const row = db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) as { present?: unknown } | undefined; + return row?.present === 1; +} + +export function configureSqliteRuntimeDatabase(db: DatabaseSync): void { + // Bound lock acquisition before touching persistent journal state. WAL mode is + // database-persistent, so established workspaces only need to verify it rather + // than making every concurrent opener execute the setting form of the pragma. + configureSqliteRuntimeLockWait(db); + ensureWalJournalMode(db); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); +} + +/** Configure connection-local lock waiting without changing persistent database state. */ +export function configureSqliteRuntimeLockWait(db: DatabaseSync): void { + db.exec(`PRAGMA busy_timeout = ${SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS}`); +} + +export function migrateSqliteRuntimeDatabase( + db: DatabaseSync, + options: { transaction?: 'self' | 'caller' } = {}, +): void { + const observedVersion = readUserVersion(db); + if (observedVersion > SQLITE_RUNTIME_SCHEMA_VERSION) { + throw new Error( + `SQLite runtime schema ${observedVersion} is newer than supported version ${SQLITE_RUNTIME_SCHEMA_VERSION}`, + ); + } + if (observedVersion === SQLITE_RUNTIME_SCHEMA_VERSION) return; + + // The optimistic read keeps established databases on a read-only open path. + // Any pending upgrade is serialized by one write transaction, then re-reads + // user_version under that lock so a concurrent opener cannot apply a + // migration another process just committed. + const ownsTransaction = options.transaction !== 'caller'; + if (ownsTransaction) db.exec('BEGIN IMMEDIATE'); + try { + const current = readUserVersion(db); + if (current > SQLITE_RUNTIME_SCHEMA_VERSION) { + throw new Error( + `SQLite runtime schema ${current} is newer than supported version ${SQLITE_RUNTIME_SCHEMA_VERSION}`, + ); + } + for (let version = current + 1; version <= SQLITE_RUNTIME_SCHEMA_VERSION; version += 1) { + const sql = MIGRATIONS.get(version); + if (!sql) throw new Error(`Missing SQLite runtime migration ${version}`); + db.exec(sql); + DATA_MIGRATIONS.get(version)?.(db); + db.exec(`PRAGMA user_version = ${version}`); + } + if (ownsTransaction) db.exec('COMMIT'); + } catch (error) { + if (ownsTransaction) rollback(db); + throw error; + } +} + +export function readUserVersion(db: DatabaseSync): number { + const row = db.prepare('PRAGMA user_version').get() as { user_version?: unknown } | undefined; + const value = row?.user_version; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid SQLite runtime schema version'); + } + return value; +} + +function readJournalMode(db: DatabaseSync): string { + const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined; + if (typeof row?.journal_mode !== 'string') { + throw new Error('Invalid SQLite runtime journal mode'); + } + return row.journal_mode.toLowerCase(); +} + +function ensureWalJournalMode(db: DatabaseSync): void { + const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS; + while (true) { + try { + const journalMode = readJournalMode(db); + if (journalMode === 'wal' || journalMode === 'memory') return; + db.exec('PRAGMA journal_mode = WAL'); + const configuredMode = readJournalMode(db); + if (configuredMode !== 'wal') { + throw new Error(`SQLite runtime requires WAL journal mode, received ${configuredMode}`); + } + return; + } catch (error) { + if (!isSqliteBusy(error) || Date.now() >= deadline) throw error; + Atomics.wait( + initializationRetryGate, + 0, + 0, + Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())), + ); + } + } +} + +function isSqliteBusy(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const sqliteError = error as Error & { + code?: unknown; + errcode?: unknown; + errstr?: unknown; + }; + return ( + sqliteError.errcode === 5 || + sqliteError.code === 'SQLITE_BUSY' || + sqliteError.errstr === 'database is locked' || + /database (?:is )?(?:locked|busy)/i.test(sqliteError.message) + ); +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the migration failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/42d57adf09dae74418030a000b661eaa3d5d28db14441db9a322ad0d6c9ec7ae.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/42d57adf09dae74418030a000b661eaa3d5d28db14441db9a322ad0d6c9ec7ae.source new file mode 100644 index 0000000000..d685970734 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/42d57adf09dae74418030a000b661eaa3d5d28db14441db9a322ad0d6c9ec7ae.source @@ -0,0 +1,856 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, mkdir, open, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { syncDirectory } from './stable-storage.js'; +import { + decodeManagedSecretReference, + MANAGED_SECRET_MAX_VALUE_BYTES, + managedSecretIdentifier, + managedSecretReference, + managedSecretRevision, + managedSecretTimestamp, + managedSecretValue, + ManagedSecretError, + publicManagedSecretMetadata, + type AuthorizeManagedSecretSessionInput, + type CreateManagedSecretInput, + type GetManagedSecretMetadataInput, + type ManagedSecretActivationContext, + type ManagedSecretMaterial, + type ManagedSecretMetadata, + type ManagedSecretMutationResult, + type ManagedSecretReference, + type ManagedSecretStatus, + type ManagedSecretStore, + type MutateManagedSecretInput, + type ResolveManagedSecretsForActivationInput, + type RotateManagedSecretInput, +} from './managed-secret-store.js'; + +export const MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION = 1 as const; +export const MANAGED_SECRET_DOCUMENT_FILE = 'managed-secrets.json'; + +const DOCUMENT_MAX_BYTES = 16 * 1024 * 1024; +const MAX_SECRET_RECORDS = 2_048; +const MAX_GRANTS = 8_192; +const LOCK_POLL_MS = 25; +const LOCK_TIMEOUT_MS = 10_000; + +/** + * Raw AES material used only by the encrypted-file reference backend. This is + * deliberately not a general KMS/HSM interface; production providers may keep + * master keys non-exportable behind opaque envelope operations. + */ +export interface EncryptedFileManagedSecretKey { + readonly keyId: string; + /** Exactly 32 bytes. The provider, not the persisted store, owns this key. */ + readonly key: Uint8Array; +} + +export interface EncryptedFileManagedSecretKeyProvider { + activeKey(): Promise; + keyById(keyId: string): Promise; +} + +export interface EncryptedFileManagedSecretStoreOptions { + /** A control-plane/config root that is outside Session-owned portable state. */ + readonly controlPlaneRoot: string; + readonly keyProvider: EncryptedFileManagedSecretKeyProvider; + readonly now?: () => number; + readonly newSecretId?: () => string; +} + +interface EncryptedSecretEnvelope { + readonly algorithm: 'A256GCM'; + readonly keyId: string; + readonly iv: string; + readonly ciphertext: string; + readonly tag: string; +} + +interface StoredSecretRecord { + readonly secretId: string; + readonly ownerPrincipalId: string; + readonly revision: number; + readonly status: Exclude; + readonly createdAt: number; + readonly updatedAt: number; + readonly envelope?: EncryptedSecretEnvelope; +} + +interface StoredSecretGrant { + readonly secretId: string; + readonly ownerPrincipalId: string; + readonly cloudSessionId: string; + readonly createdAt: number; +} + +interface ManagedSecretDocument { + readonly schemaVersion: typeof MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION; + readonly revision: number; + readonly secrets: readonly StoredSecretRecord[]; + readonly grants: readonly StoredSecretGrant[]; +} + +interface NormalizedAuthorization { + readonly principalId: string; + readonly reference: ManagedSecretReference; + readonly cloudSessionId: string; +} + +export function createEncryptedFileManagedSecretStore( + options: EncryptedFileManagedSecretStoreOptions, +): ManagedSecretStore { + return new EncryptedFileManagedSecretStore(options); +} + +/** + * Local/control-plane reference backend. The value store contains only + * AES-256-GCM envelopes; key material is supplied out-of-band and never + * serialized beside those envelopes. + */ +class EncryptedFileManagedSecretStore implements ManagedSecretStore { + readonly #path: string; + readonly #keyProvider: EncryptedFileManagedSecretKeyProvider; + readonly #now: () => number; + readonly #newSecretId: () => string; + + constructor(options: EncryptedFileManagedSecretStoreOptions) { + this.#path = join(options.controlPlaneRoot, MANAGED_SECRET_DOCUMENT_FILE); + this.#keyProvider = options.keyProvider; + this.#now = options.now ?? Date.now; + this.#newSecretId = options.newSecretId ?? randomUUID; + } + + createSecret(input: CreateManagedSecretInput): Promise { + const principalId = managedSecretIdentifier(input.principalId, 'principalId'); + const value = managedSecretValue(input.value); + return this.#locked(async (document) => { + if (document.secrets.length >= MAX_SECRET_RECORDS) { + throw new ManagedSecretError('storage_failure', 'Managed Secret record limit reached'); + } + const reference = decodeManagedSecretReference(managedSecretReference(this.#newSecretId())); + if (document.secrets.some((record) => record.secretId === reference.secretId)) { + throw new ManagedSecretError('storage_failure', 'Managed Secret identity collision'); + } + const now = managedSecretTimestamp(this.#now()); + const envelope = await sealSecret(this.#keyProvider, { + reference, + ownerPrincipalId: principalId, + revision: 1, + value, + }); + const record: StoredSecretRecord = { + secretId: reference.secretId, + ownerPrincipalId: principalId, + revision: 1, + status: 'active', + createdAt: now, + updatedAt: now, + envelope, + }; + await this.#write({ + ...document, + revision: document.revision + 1, + secrets: [...document.secrets, record], + }); + return metadata(record); + }); + } + + getSecretMetadata(input: GetManagedSecretMetadataInput): Promise { + const principal = managedSecretIdentifier(input.principalId, 'principalId'); + const normalized = decodeManagedSecretReference(input.reference); + return this.#locked(async (document) => { + const record = document.secrets.find((item) => item.secretId === normalized.secretId); + if (!record) return null; + assertOwner(record, principal); + return metadata(record); + }); + } + + rotateSecret(input: RotateManagedSecretInput): Promise { + const normalized = normalizeMutation(input); + const value = managedSecretValue(input.value); + return this.#locked(async (document) => { + const prepared = prepareMutation(document, normalized); + if (prepared.kind === 'revision_conflict') return prepared; + assertActive(prepared.record); + const revision = prepared.record.revision + 1; + const next: StoredSecretRecord = { + ...prepared.record, + revision, + updatedAt: mutationTimestamp(prepared.record, this.#now()), + envelope: await sealSecret(this.#keyProvider, { + reference: normalized.reference, + ownerPrincipalId: normalized.principalId, + revision, + value, + }), + }; + await this.#replaceSecret(document, prepared.index, next); + return { kind: 'committed', secret: metadata(next) }; + }); + } + + revokeSecret(input: MutateManagedSecretInput): Promise { + const normalized = normalizeMutation(input); + return this.#locked(async (document) => { + const prepared = prepareMutation(document, normalized); + if (prepared.kind === 'revision_conflict') return prepared; + assertActive(prepared.record); + const next: StoredSecretRecord = { + secretId: prepared.record.secretId, + ownerPrincipalId: prepared.record.ownerPrincipalId, + revision: prepared.record.revision + 1, + status: 'revoked', + createdAt: prepared.record.createdAt, + updatedAt: mutationTimestamp(prepared.record, this.#now()), + }; + await this.#replaceSecret(document, prepared.index, next); + return { kind: 'committed', secret: metadata(next) }; + }); + } + + deleteSecret(input: MutateManagedSecretInput): Promise { + const normalized = normalizeMutation(input); + return this.#locked(async (document) => { + const prepared = prepareMutation(document, normalized); + if (prepared.kind === 'revision_conflict') return prepared; + const deleted = publicManagedSecretMetadata({ + reference: managedSecretReference(prepared.record.secretId), + ownerPrincipalId: prepared.record.ownerPrincipalId, + revision: prepared.record.revision + 1, + status: 'deleted', + createdAt: prepared.record.createdAt, + updatedAt: mutationTimestamp(prepared.record, this.#now()), + }); + await this.#write({ + ...document, + revision: document.revision + 1, + secrets: document.secrets.filter((_, index) => index !== prepared.index), + grants: document.grants.filter((grant) => grant.secretId !== deleted.reference.secretId), + }); + return { kind: 'committed', secret: deleted }; + }); + } + + authorizeSession(input: AuthorizeManagedSecretSessionInput): Promise { + const normalized = normalizeAuthorization(input); + return this.#locked(async (document) => { + const record = requiredRecord(document, normalized.reference); + assertOwner(record, normalized.principalId); + assertActive(record); + if (findGrant(document, normalized)) return; + if (document.grants.length >= MAX_GRANTS) { + throw new ManagedSecretError('storage_failure', 'Managed Secret grant limit reached'); + } + await this.#write({ + ...document, + revision: document.revision + 1, + grants: [ + ...document.grants, + { + secretId: normalized.reference.secretId, + ownerPrincipalId: normalized.principalId, + cloudSessionId: normalized.cloudSessionId, + createdAt: managedSecretTimestamp(this.#now()), + }, + ], + }); + }); + } + + revokeSessionAuthorization(input: AuthorizeManagedSecretSessionInput): Promise { + const normalized = normalizeAuthorization(input); + return this.#locked(async (document) => { + const record = requiredRecord(document, normalized.reference); + assertOwner(record, normalized.principalId); + const next = document.grants.filter((grant) => !sameGrant(grant, normalized)); + if (next.length === document.grants.length) return; + await this.#write({ ...document, revision: document.revision + 1, grants: next }); + }); + } + + resolveForActivation( + input: ResolveManagedSecretsForActivationInput, + ): Promise { + const context = normalizeActivationContext(input.context); + if (!Array.isArray(input.references) || input.references.length > 128) { + throw new ManagedSecretError( + 'invalid_input', + 'Managed Secret references must be a bounded array', + ); + } + const references = input.references.map(decodeManagedSecretReference); + return this.#locked(async (document) => { + const selected = references.map((reference) => { + const record = requiredRecord(document, reference); + assertOwner(record, context.principalId); + assertActive(record); + if (!findGrant(document, { ...context, reference })) { + throw new ManagedSecretError( + 'unauthorized', + 'Managed Secret is not authorized for this Cloud Session', + ); + } + if (!record.envelope) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret envelope is unavailable', + ); + } + return { reference, record }; + }); + + const material: ManagedSecretMaterial[] = []; + for (const item of selected) { + material.push({ + reference: { ...item.reference }, + revision: item.record.revision, + value: await openSecret(this.#keyProvider, item.record), + }); + } + return material; + }); + } + + #replaceSecret( + document: ManagedSecretDocument, + index: number, + record: StoredSecretRecord, + ): Promise { + const secrets = [...document.secrets]; + secrets[index] = record; + return this.#write({ ...document, revision: document.revision + 1, secrets }); + } + + #locked(operation: (document: ManagedSecretDocument) => Promise): Promise { + return withManagedSecretFileLock(this.#path, async () => + operation(await readDocument(this.#path)), + ); + } + + #write(document: ManagedSecretDocument): Promise { + return writeDocument(this.#path, decodeDocument(document)); + } +} + +async function sealSecret( + provider: EncryptedFileManagedSecretKeyProvider, + input: { + reference: ManagedSecretReference; + ownerPrincipalId: string; + revision: number; + value: string; + }, +): Promise { + let material: EncryptedFileManagedSecretKey; + try { + material = await provider.activeKey(); + } catch { + throw new ManagedSecretError('key_unavailable', 'Managed Secret encryption key is unavailable'); + } + const keyId = managedSecretIdentifier(material.keyId, 'keyId'); + const key = encryptionKey(material); + const iv = randomBytes(12); + try { + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + cipher.setAAD(aad(input.reference, input.ownerPrincipalId, input.revision)); + const ciphertext = Buffer.concat([cipher.update(input.value, 'utf8'), cipher.final()]); + return { + algorithm: 'A256GCM', + keyId, + iv: iv.toString('base64url'), + ciphertext: ciphertext.toString('base64url'), + tag: cipher.getAuthTag().toString('base64url'), + }; + } catch (error) { + if (error instanceof ManagedSecretError) throw error; + throw new ManagedSecretError('integrity_failure', 'Managed Secret encryption failed', { + cause: error, + }); + } finally { + key.fill(0); + } +} + +async function openSecret( + provider: EncryptedFileManagedSecretKeyProvider, + record: StoredSecretRecord, +): Promise { + const envelope = record.envelope; + if (!envelope) { + throw new ManagedSecretError('integrity_failure', 'Managed Secret envelope is unavailable'); + } + let material: EncryptedFileManagedSecretKey | null; + try { + material = await provider.keyById(envelope.keyId); + } catch { + throw new ManagedSecretError('key_unavailable', 'Managed Secret encryption key is unavailable'); + } + if (!material || material.keyId !== envelope.keyId) { + throw new ManagedSecretError('key_unavailable', 'Managed Secret encryption key is unavailable'); + } + const key = encryptionKey(material); + try { + const iv = decodeBase64Url(envelope.iv, 12, 'initialization vector'); + const tag = decodeBase64Url(envelope.tag, 16, 'authentication tag'); + const ciphertext = decodeBase64Url(envelope.ciphertext, undefined, 'ciphertext'); + if (ciphertext.length === 0 || ciphertext.length > MANAGED_SECRET_MAX_VALUE_BYTES) { + throw new ManagedSecretError('integrity_failure', 'Managed Secret ciphertext is invalid'); + } + const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + decipher.setAAD( + aad(managedSecretReference(record.secretId), record.ownerPrincipalId, record.revision), + ); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(plaintext); + } finally { + plaintext.fill(0); + } + } catch (error) { + if (error instanceof ManagedSecretError) throw error; + throw new ManagedSecretError('integrity_failure', 'Managed Secret authentication failed', { + cause: error, + }); + } finally { + key.fill(0); + } +} + +function encryptionKey(material: EncryptedFileManagedSecretKey): Buffer { + managedSecretIdentifier(material.keyId, 'keyId'); + if (!(material.key instanceof Uint8Array) || material.key.byteLength !== 32) { + throw new ManagedSecretError( + 'key_unavailable', + 'Managed Secret encryption key must contain 32 bytes', + ); + } + return Buffer.from(material.key); +} + +function aad(reference: ManagedSecretReference, principalId: string, revision: number): Buffer { + return Buffer.from( + `maka-managed-secret/v1\0${reference.secretId}\0${principalId}\0${revision}`, + 'utf8', + ); +} + +function decodeBase64Url(value: string, expectedBytes: number | undefined, field: string): Buffer { + if (!/^[A-Za-z0-9_-]*$/u.test(value)) { + throw new ManagedSecretError('integrity_failure', `Managed Secret ${field} is invalid`); + } + const bytes = Buffer.from(value, 'base64url'); + if ( + bytes.toString('base64url') !== value || + (expectedBytes !== undefined && bytes.length !== expectedBytes) + ) { + throw new ManagedSecretError('integrity_failure', `Managed Secret ${field} is invalid`); + } + return bytes; +} + +function normalizeMutation(input: MutateManagedSecretInput) { + return { + principalId: managedSecretIdentifier(input.principalId, 'principalId'), + reference: decodeManagedSecretReference(input.reference), + expectedRevision: managedSecretRevision(input.expectedRevision), + }; +} + +function normalizeAuthorization( + input: AuthorizeManagedSecretSessionInput, +): NormalizedAuthorization { + return { + principalId: managedSecretIdentifier(input.principalId, 'principalId'), + reference: decodeManagedSecretReference(input.reference), + cloudSessionId: managedSecretIdentifier(input.cloudSessionId, 'cloudSessionId'), + }; +} + +function normalizeActivationContext( + context: ManagedSecretActivationContext, +): ManagedSecretActivationContext { + return { + principalId: managedSecretIdentifier(context.principalId, 'principalId'), + cloudSessionId: managedSecretIdentifier(context.cloudSessionId, 'cloudSessionId'), + activationId: managedSecretIdentifier(context.activationId, 'activationId'), + }; +} + +function prepareMutation( + document: ManagedSecretDocument, + input: ReturnType, +): + | { readonly kind: 'ready'; readonly index: number; readonly record: StoredSecretRecord } + | Extract { + const index = document.secrets.findIndex( + (record) => record.secretId === input.reference.secretId, + ); + const record = index < 0 ? undefined : document.secrets[index]; + if (!record) { + throw new ManagedSecretError('secret_not_found', 'Managed Secret was not found'); + } + assertOwner(record, input.principalId); + if (record.revision !== input.expectedRevision) { + return { kind: 'revision_conflict', actualRevision: record.revision }; + } + return { kind: 'ready', index, record }; +} + +function requiredRecord( + document: ManagedSecretDocument, + reference: ManagedSecretReference, +): StoredSecretRecord { + const record = document.secrets.find((item) => item.secretId === reference.secretId); + if (!record) { + throw new ManagedSecretError('secret_not_found', 'Managed Secret was not found'); + } + return record; +} + +function assertOwner(record: StoredSecretRecord, principalId: string): void { + if (record.ownerPrincipalId !== principalId) { + throw new ManagedSecretError('unauthorized', 'Managed Secret access is not authorized'); + } +} + +function assertActive(record: StoredSecretRecord): void { + if (record.status !== 'active') { + throw new ManagedSecretError('secret_revoked', 'Managed Secret is revoked'); + } +} + +function metadata(record: StoredSecretRecord): ManagedSecretMetadata { + return publicManagedSecretMetadata({ + reference: managedSecretReference(record.secretId), + ownerPrincipalId: record.ownerPrincipalId, + revision: record.revision, + status: record.status, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); +} + +function findGrant( + document: ManagedSecretDocument, + input: NormalizedAuthorization, +): StoredSecretGrant | undefined { + return document.grants.find((grant) => sameGrant(grant, input)); +} + +function sameGrant(grant: StoredSecretGrant, input: NormalizedAuthorization): boolean { + return ( + grant.secretId === input.reference.secretId && + grant.ownerPrincipalId === input.principalId && + grant.cloudSessionId === input.cloudSessionId + ); +} + +function mutationTimestamp(record: Pick, now: number): number { + return Math.max(record.updatedAt, managedSecretTimestamp(now)); +} + +async function readDocument(path: string): Promise { + let handle: Awaited> | undefined; + try { + const flags = + process.platform === 'win32' + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + try { + handle = await open(path, flags); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyDocument(); + throw error; + } + const info = await handle.stat(); + if (!info.isFile() || info.size > DOCUMENT_MAX_BYTES) { + throw new ManagedSecretError('integrity_failure', 'Managed Secret document is invalid'); + } + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) await handle.chmod(0o600); + const buffer = Buffer.alloc(info.size + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + if (bytesRead !== info.size) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret document changed while read', + ); + } + let value: unknown; + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytesRead)), + ); + } catch (error) { + throw new ManagedSecretError('integrity_failure', 'Managed Secret document is invalid', { + cause: error, + }); + } + return decodeDocument(value); + } catch (error) { + if (error instanceof ManagedSecretError) throw error; + throw new ManagedSecretError('storage_failure', 'Managed Secret document could not be read', { + cause: error, + }); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function writeDocument(path: string, document: ManagedSecretDocument): Promise { + const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`, 'utf8'); + if (bytes.length > DOCUMENT_MAX_BYTES) { + throw new ManagedSecretError('storage_failure', 'Managed Secret document limit reached'); + } + const temporaryPath = `${path}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + let published = false; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporaryPath, path); + published = true; + await syncDirectory(dirname(path)); + } catch (error) { + throw new ManagedSecretError( + 'storage_failure', + published + ? 'Managed Secret commit outcome is unknown; reload before retrying' + : 'Managed Secret document could not be written', + { cause: error }, + ); + } finally { + await handle?.close().catch(() => undefined); + if (!published) await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +async function withManagedSecretFileLock( + targetPath: string, + operation: () => Promise, +): Promise { + const root = dirname(targetPath); + try { + await mkdir(root, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await chmod(root, 0o700); + } catch (error) { + throw new ManagedSecretError('storage_failure', 'Managed Secret root is unavailable', { + cause: error, + }); + } + const lockPath = `${targetPath}.lock`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + for (;;) { + try { + await mkdir(lockPath, { mode: 0o700 }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw new ManagedSecretError('storage_failure', 'Managed Secret lock failed', { + cause: error, + }); + } + if (Date.now() >= deadline) { + throw new ManagedSecretError( + 'storage_failure', + 'Managed Secret store is locked; remove the abandoned lock only after confirming no owner is active', + ); + } + await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS)); + } + } + let result: T | undefined; + let operationFailed = false; + let operationError: unknown; + try { + result = await operation(); + } catch (error) { + operationFailed = true; + operationError = error; + } + let cleanupError: unknown; + try { + await rm(lockPath, { recursive: true, force: true }); + } catch (error) { + cleanupError = error; + } + if (operationFailed && cleanupError !== undefined) { + throw new ManagedSecretError( + 'storage_failure', + 'Managed Secret operation failed and lock cleanup was incomplete', + ); + } + if (operationFailed) throw operationError; + if (cleanupError !== undefined) { + throw new ManagedSecretError('storage_failure', 'Managed Secret lock cleanup failed', { + cause: cleanupError, + }); + } + return result as T; +} + +function decodeDocument(value: unknown): ManagedSecretDocument { + const document = exactRecord(value, ['schemaVersion', 'revision', 'secrets', 'grants']); + if (document.schemaVersion !== MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION) { + throw new ManagedSecretError( + 'integrity_failure', + 'Managed Secret document schema is unsupported', + ); + } + if (!Number.isSafeInteger(document.revision) || (document.revision as number) < 0) { + throw invalidDocument(); + } + if (!Array.isArray(document.secrets) || document.secrets.length > MAX_SECRET_RECORDS) { + throw invalidDocument(); + } + if (!Array.isArray(document.grants) || document.grants.length > MAX_GRANTS) { + throw invalidDocument(); + } + const secrets = document.secrets.map(decodeStoredSecret); + const grants = document.grants.map(decodeStoredGrant); + if (new Set(secrets.map((record) => record.secretId)).size !== secrets.length) { + throw invalidDocument(); + } + const secretById = new Map(secrets.map((record) => [record.secretId, record])); + const grantKeys = new Set(); + for (const grant of grants) { + const record = secretById.get(grant.secretId); + if (!record || record.ownerPrincipalId !== grant.ownerPrincipalId) { + throw invalidDocument(); + } + const key = `${grant.secretId}\0${grant.ownerPrincipalId}\0${grant.cloudSessionId}`; + if (grantKeys.has(key)) throw invalidDocument(); + grantKeys.add(key); + } + return { + schemaVersion: MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION, + revision: document.revision as number, + secrets, + grants, + }; +} + +function decodeStoredSecret(value: unknown): StoredSecretRecord { + const record = exactRecord( + value, + ['secretId', 'ownerPrincipalId', 'revision', 'status', 'createdAt', 'updatedAt'], + ['envelope'], + ); + const reference = decodeManagedSecretReference(managedSecretReference(asString(record.secretId))); + const ownerPrincipalId = managedSecretIdentifier(record.ownerPrincipalId, 'ownerPrincipalId'); + const revision = managedSecretRevision(record.revision); + const createdAt = managedSecretTimestamp(record.createdAt); + const updatedAt = managedSecretTimestamp(record.updatedAt); + if (updatedAt < createdAt) throw invalidDocument(); + if (record.status !== 'active' && record.status !== 'revoked') { + throw invalidDocument(); + } + const envelope = record.envelope === undefined ? undefined : decodeEnvelope(record.envelope); + if ((record.status === 'active') !== (envelope !== undefined)) throw invalidDocument(); + return { + secretId: reference.secretId, + ownerPrincipalId, + revision, + status: record.status, + createdAt, + updatedAt, + ...(envelope ? { envelope } : {}), + }; +} + +function decodeStoredGrant(value: unknown): StoredSecretGrant { + const grant = exactRecord(value, ['secretId', 'ownerPrincipalId', 'cloudSessionId', 'createdAt']); + const reference = decodeManagedSecretReference(managedSecretReference(asString(grant.secretId))); + return { + secretId: reference.secretId, + ownerPrincipalId: managedSecretIdentifier(grant.ownerPrincipalId, 'ownerPrincipalId'), + cloudSessionId: managedSecretIdentifier(grant.cloudSessionId, 'cloudSessionId'), + createdAt: managedSecretTimestamp(grant.createdAt), + }; +} + +function decodeEnvelope(value: unknown): EncryptedSecretEnvelope { + const envelope = exactRecord(value, ['algorithm', 'keyId', 'iv', 'ciphertext', 'tag']); + if (envelope.algorithm !== 'A256GCM') throw invalidDocument(); + const keyId = managedSecretIdentifier(envelope.keyId, 'keyId'); + const iv = boundedString(envelope.iv, 64); + const ciphertext = boundedString(envelope.ciphertext, 96 * 1024); + const tag = boundedString(envelope.tag, 64); + decodeBase64Url(iv, 12, 'initialization vector'); + decodeBase64Url(tag, 16, 'authentication tag'); + if (ciphertext.length === 0) throw invalidDocument(); + decodeBase64Url(ciphertext, undefined, 'ciphertext'); + return { algorithm: 'A256GCM', keyId, iv, ciphertext, tag }; +} + +function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidDocument(); + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !(key in record)) || + Object.keys(record).some((key) => !allowed.has(key)) + ) { + throw invalidDocument(); + } + return record; +} + +function asString(value: unknown): string { + if (typeof value !== 'string') throw invalidDocument(); + return value; +} + +function boundedString(value: unknown, maxLength: number): string { + if (typeof value !== 'string' || value.length > maxLength) throw invalidDocument(); + return value; +} + +function invalidDocument(): ManagedSecretError { + return new ManagedSecretError('integrity_failure', 'Managed Secret document is invalid'); +} + +function emptyDocument(): ManagedSecretDocument { + return { + schemaVersion: MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION, + revision: 0, + secrets: [], + grants: [], + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/43a8689657a5aa1eec81e0e31324721f39759d98f1b05490b797a27f0886d454.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/43a8689657a5aa1eec81e0e31324721f39759d98f1b05490b797a27f0886d454.source new file mode 100644 index 0000000000..7ffd3fac70 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/43a8689657a5aa1eec81e0e31324721f39759d98f1b05490b797a27f0886d454.source @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ArtifactRecord, ArtifactSource } from '@maka/core/artifacts'; +import { + createSqliteArtifactStoreWriteAuthority, + type ArtifactAuthorityStore, + type ArtifactStoreWriteAuthority, + type ConversationArtifactCopyInput, + type ConversationArtifactCopyResult, + type CreateArtifactInput, + type DurableArtifactAttachmentReader, +} from './artifact-store.js'; + +export { sanitizeArtifactName } from './artifact-store.js'; +import { + assertStorageRootLease, + createStorageRootLeaseIdentityGuard, + prepareArtifactWriterLockAuthorityForLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; + +export { + createArtifactAttachmentResourceReader, + createAttachmentByteReader, + createReadImageSnapshotPlanner, + createReadImageSnapshotter, + type ArtifactAttachmentResourceReader, + type ReadImageSnapshotPlan, +} from './artifact-attachments.js'; + +const writerBrand: unique symbol = Symbol('InteractiveArtifactStoreWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmentReader { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + create(input: CreateArtifactInput): Promise; + /** + * Narrow system delete for one Session-owned artifact of a declared source. + * + * The caller must name the source it believes it owns, and a mismatch throws, + * so a caller that is wrong about what it is reclaiming reclaims nothing. + */ + deleteOwnedArtifactInSession( + sessionId: string, + artifactId: string, + source: ArtifactSource, + ): Promise; + copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise; + purgeSessionArtifacts(sessionId: string): Promise; + reclaimUpgradeResidue: ArtifactAuthorityStore['reclaimUpgradeResidue']; + listPage: ArtifactAuthorityStore['listPage']; + listTurnArtifacts: ArtifactAuthorityStore['listTurnArtifacts']; + getInSession: ArtifactAuthorityStore['getInSession']; + readTextInSession: ArtifactAuthorityStore['readTextInSession']; + readBinaryInSession: ArtifactAuthorityStore['readBinaryInSession']; + readChunkInSession: ArtifactAuthorityStore['readChunkInSession']; + deleteUserArtifactInSession: ArtifactAuthorityStore['deleteUserArtifactInSession']; + close(): void; +} + +export function authenticateInteractiveArtifactStoreWriter( + store: InteractiveArtifactStoreWriter, +): InteractiveArtifactStoreWriter { + if (!writers.has(store)) throw invalidFacade(); + return store; +} + +export async function openInteractiveArtifactStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + const leaseBoundWriterLockAuthority = await prepareArtifactWriterLockAuthorityForLease( + lease, + 'interactive', + ); + const assertAuthority = createStorageRootLeaseIdentityGuard(lease, 'interactive', 'write'); + const authority = createSqliteArtifactStoreWriteAuthority(lease.canonicalPath, { + assertAuthority, + leaseBoundWriterLockAuthority, + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) return recoveredExisting; + const facade = createWriterFacade(lease, authority); + writers.add(facade); + writerByLease.set(lease, facade); + return facade; + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + authority: ArtifactStoreWriteAuthority, +): InteractiveArtifactStoreWriter { + const { store } = authority; + const run = (operation: () => Promise) => + runWithStorageRootLease(lease, 'interactive', 'write', operation); + const facade: InteractiveArtifactStoreWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + listPage: (sessionId, options) => run(() => store.listPage(sessionId, options)), + listTurnArtifacts: (sessionId, turnId) => run(() => store.listTurnArtifacts(sessionId, turnId)), + getInSession: (sessionId, artifactId) => run(() => store.getInSession(sessionId, artifactId)), + readTextInSession: (sessionId, artifactId, options) => + run(() => store.readTextInSession(sessionId, artifactId, options)), + readBinaryInSession: (sessionId, artifactId, options) => + run(() => store.readBinaryInSession(sessionId, artifactId, options)), + readChunkInSession: (sessionId, artifactId, options) => + run(() => store.readChunkInSession(sessionId, artifactId, options)), + readDurableAttachmentBinary: (input) => run(() => store.readDurableAttachmentBinary(input)), + create: (input) => { + const acceptedInput = snapshotCreateInput(input); + return run(() => store.create(acceptedInput)); + }, + deleteOwnedArtifactInSession: (sessionId, artifactId, source) => + run(() => store.deleteOwnedArtifactInSession(sessionId, artifactId, source)), + copyConversationArtifacts: (input) => { + const acceptedInput: ConversationArtifactCopyInput = Object.freeze({ + ...input, + turnIds: Object.freeze([...input.turnIds]), + ...(input.excludeArtifactIds + ? { excludeArtifactIds: Object.freeze([...input.excludeArtifactIds]) } + : {}), + ...(input.includeArtifactIds + ? { includeArtifactIds: Object.freeze([...input.includeArtifactIds]) } + : {}), + ...(input.linkedArtifacts + ? { + linkedArtifacts: Object.freeze( + input.linkedArtifacts.map((linked) => + Object.freeze({ + sessionId: linked.sessionId, + artifactIds: Object.freeze([...linked.artifactIds]), + }), + ), + ), + } + : {}), + }); + return run(() => store.copyConversationArtifacts(acceptedInput)); + }, + purgeSessionArtifacts: (sessionId) => run(() => store.purgeSessionArtifacts(sessionId)), + reclaimUpgradeResidue: (input) => run(() => store.reclaimUpgradeResidue(input)), + deleteUserArtifactInSession: (sessionId, artifactId) => + run(() => store.deleteUserArtifactInSession(sessionId, artifactId)), + close: () => { + if (writerByLease.get(lease) === facade) writerByLease.delete(lease); + authority.close(); + }, + }; + return Object.freeze(facade); +} + +function snapshotCreateInput(input: CreateArtifactInput): CreateArtifactInput { + return Object.freeze({ + ...input, + content: typeof input.content === 'string' ? input.content : new Uint8Array(input.content), + }); +} + +function invalidFacade(): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + 'Expected authentic interactive write artifact store', + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/46f2eea4b719d5f6193e26ce4cd4c4e8c5b059801ad0dc9c36ab094915f9b0e7.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/46f2eea4b719d5f6193e26ce4cd4c4e8c5b059801ad0dc9c36ab094915f9b0e7.source new file mode 100644 index 0000000000..e35d48cce9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/46f2eea4b719d5f6193e26ce4cd4c4e8c5b059801ad0dc9c36ab094915f9b0e7.source @@ -0,0 +1,1472 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { createRequire } from 'node:module'; +import { + chmod, + cp, + lstat, + mkdir, + open, + readFile, + readdir, + readlink, + realpath, + rename, + rm, + stat, + utimes, +} from 'node:fs/promises'; +import { + dirname, + isAbsolute, + join, + normalize, + posix, + relative, + resolve, + toNamespacedPath, +} from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { tryLock, unlock } from 'fs-native-extensions'; + +const MANAGED_DEPENDENCY_IDENTITY_DOMAIN = 'maka.managed_dependency_environment.v1\0'; +const MANAGED_DEPENDENCY_TREE_DOMAIN = 'maka.managed_dependency_environment.tree.v1\0'; +const AUTHORITY_DATABASE_NAME = 'dependency-environment-authority-v1.sqlite'; +const DEPENDENCY_ROOT_NAME = 'node_modules'; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const MANAGED_DEPENDENCY_PRODUCER_POLICY_DOMAIN = + 'maka.managed_dependency_environment.producer_policy.v1\0'; +const MANAGED_DEPENDENCY_PRODUCER_POLICY_V1 = Object.freeze({ + protocolVersion: 1 as const, + kind: 'hermetic_dependency_builder_v1' as const, + network: 'registry_https_only' as const, + filesystem: 'maka_owned_staging_only' as const, + secrets: 'none' as const, + childProcess: 'verified_runtime_only' as const, + lifecycleScripts: 'disabled' as const, +}); +const activeAuthorityOwners = new Map(); +// Each query spawns a fresh powershell.exe that JIT-compiles the C# helper +// below via `Add-Type`. On a cold GitHub-hosted Windows runner that first +// compile (cold csc.exe launch + .NET Framework warmup) has been observed at +// ~31s, tripping a 30s budget while the following warm queries finish in 2-4s. +// The bounded, non-recursive walk cannot hang, so the timeout only exists to +// bound a wedged interpreter; give the cold compile generous headroom rather +// than misreport it as a wedge. The 45-minute job budget still bounds a true +// hang. +const WINDOWS_STREAM_QUERY_TIMEOUT_MS = 120_000; +const WINDOWS_STREAM_QUERY_MAX_OUTPUT_BYTES = 1024 * 1024; +const WINDOWS_STREAM_QUERY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class MakaWindowsStreamQuery +{ + private const int ErrorHandleEof = 38; + private static readonly IntPtr InvalidHandleValue = new IntPtr(-1); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct FindStreamData + { + public long StreamSize; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 296)] + public string StreamName; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr FindFirstStreamW( + string fileName, + int infoLevel, + out FindStreamData findStreamData, + int flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool FindNextStreamW( + IntPtr findStream, + out FindStreamData findStreamData); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FindClose(IntPtr findHandle); + + public static bool HasAlternateDataStream(string path) + { + FindStreamData data; + IntPtr handle = FindFirstStreamW(path, 0, out data, 0); + if (handle == InvalidHandleValue) + { + int error = Marshal.GetLastWin32Error(); + if (error == ErrorHandleEof) + { + return false; + } + throw new Win32Exception(error); + } + + try + { + do + { + if (!String.Equals(data.StreamName, "::$DATA", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + while (FindNextStreamW(handle, out data)); + + int error = Marshal.GetLastWin32Error(); + if (error != ErrorHandleEof) + { + throw new Win32Exception(error); + } + return false; + } + finally + { + if (!FindClose(handle)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + } +} +'@ +$reader = [IO.StreamReader]::new( + [Console]::OpenStandardInput(), + [Text.UTF8Encoding]::new($false, $true), + $true +) +try { + $raw = $reader.ReadToEnd() +} finally { + $reader.Dispose() +} +$paths = if ([string]::IsNullOrWhiteSpace($raw)) { @() } else { @($raw | ConvertFrom-Json) } +$alternate = [System.Collections.Generic.List[string]]::new() +foreach ($path in $paths) { + if ([MakaWindowsStreamQuery]::HasAlternateDataStream([string]$path)) { + $alternate.Add([string]$path) + } +} +[Console]::Out.Write((ConvertTo-Json -InputObject @($alternate.ToArray()) -Compress)) +`; +const RECEIPT_KEYS = [ + 'protocolVersion', + 'environmentId', + 'manifestPath', + 'manifestSha256', + 'lockfilePath', + 'lockfileSha256', + 'packageManagerName', + 'packageManagerVersion', + 'nodeVersion', + 'nodeAbi', + 'platform', + 'arch', + 'producerRuntimeIdentitySha256', + 'producerPolicyIdentitySha256', + 'policyVersion', + 'dependencyRootName', + 'contentTreeSha256', + 'contentBytes', + 'contentEntries', +] as const; +const require = createRequire(import.meta.url); + +export type ManagedDependencyPackageManager = 'npm' | 'pnpm' | 'yarn'; + +export interface ComputeManagedDependencyEnvironmentIdentityInput { + readonly manifestPath: string; + readonly manifestBytes: Uint8Array; + readonly lockfilePath: string; + readonly lockfileBytes: Uint8Array; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly producerRuntimeIdentitySha256: `sha256:${string}`; + readonly producerPolicyIdentitySha256: `sha256:${string}`; + readonly policyVersion: 'managed_dependency_environment_v1'; +} + +export interface ManagedDependencyEnvironmentIdentityV1 { + readonly protocolVersion: 1; + readonly environmentId: `sha256:${string}`; + readonly manifestPath: string; + readonly manifestSha256: `sha256:${string}`; + readonly lockfilePath: string; + readonly lockfileSha256: `sha256:${string}`; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeVersion: string; + readonly nodeAbi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly producerRuntimeIdentitySha256: `sha256:${string}`; + readonly producerPolicyIdentitySha256: `sha256:${string}`; + readonly policyVersion: 'managed_dependency_environment_v1'; +} + +export interface ManagedDependencyEnvironmentProducerCapabilityV1 { + readonly protocolVersion: 1; + readonly kind: 'hermetic_dependency_builder_v1'; + readonly runtimeIdentitySha256: `sha256:${string}`; + readonly policyIdentitySha256: `sha256:${string}`; + readonly network: 'registry_https_only'; + readonly filesystem: 'maka_owned_staging_only'; + readonly secrets: 'none'; + readonly childProcess: 'verified_runtime_only'; + readonly lifecycleScripts: 'disabled'; +} + +export interface ManagedDependencyEnvironmentProducerInput { + readonly identity: ManagedDependencyEnvironmentIdentityV1; + readonly outputRoot: string; + readonly scratchRoot: string; + readonly manifestBytes: Uint8Array; + readonly lockfileBytes: Uint8Array; + readonly abortSignal?: AbortSignal; +} + +export interface ManagedDependencyEnvironmentProducer { + readonly capability: ManagedDependencyEnvironmentProducerCapabilityV1; + readonly packageManagerName: ManagedDependencyPackageManager; + readonly packageManagerVersion: string; + readonly nodeRuntime: { + readonly version: string; + readonly abi: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + }; + provision(input: ManagedDependencyEnvironmentProducerInput): Promise; +} + +export function createManagedDependencyEnvironmentProducerCapability( + runtimeIdentitySha256: `sha256:${string}`, +): ManagedDependencyEnvironmentProducerCapabilityV1 { + if (!SHA256_PATTERN.test(runtimeIdentitySha256)) { + throw new TypeError('Managed dependency producer runtime identity must be a SHA-256 digest'); + } + return Object.freeze({ + ...MANAGED_DEPENDENCY_PRODUCER_POLICY_V1, + runtimeIdentitySha256, + policyIdentitySha256: managedDependencyProducerPolicyIdentity(), + }); +} + +export interface CreateManagedDependencyEnvironmentAuthorityInput { + readonly storageRoot: string; + readonly producer: ManagedDependencyEnvironmentProducer; + readonly maxCacheBytes?: number; + readonly failpoint?: (point: ManagedDependencyEnvironmentFailpoint) => void | Promise; +} + +export type ManagedDependencyEnvironmentFailpoint = + | 'before_environment_lease' + | 'after_environment_tree_durable' + | 'after_environment_receipt_durable' + | 'after_environment_publish'; + +export interface AcquireManagedDependencyEnvironmentInput { + readonly manifestBytes: Uint8Array; + readonly lockfileBytes: Uint8Array; + readonly abortSignal?: AbortSignal; +} + +export interface ManagedDependencyEnvironmentLease { + readonly environmentId: `sha256:${string}`; + readonly dependencyRoot: string; + release(): Promise; +} + +export interface ManagedDependencyEnvironmentAuthority { + acquire( + identity: ManagedDependencyEnvironmentIdentityV1, + input: AcquireManagedDependencyEnvironmentInput, + ): Promise; + close(): Promise; +} + +interface ManagedDependencyEnvironmentReceiptV1 extends ManagedDependencyEnvironmentIdentityV1 { + readonly dependencyRootName: typeof DEPENDENCY_ROOT_NAME; + readonly contentTreeSha256: `sha256:${string}`; + readonly contentBytes: number; + readonly contentEntries: number; +} + +interface PublishedManagedDependencyEnvironment { + readonly receipt: ManagedDependencyEnvironmentReceiptV1; + readonly dependencyRoot: string; +} + +interface DependencyReceiptAuthority { + read(digest: string): ManagedDependencyEnvironmentReceiptV1 | undefined; + list(): readonly ManagedDependencyEnvironmentReceiptV1[]; + write(receipt: ManagedDependencyEnvironmentReceiptV1): void; + delete(digest: string): void; + close(): void; +} + +interface DependencyAuthorityOwnerLock { + release(): Promise; +} + +function openDependencyReceiptAuthority(path: string): DependencyReceiptAuthority { + const Database = (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; + const database: DatabaseSync = new Database(path); + database.exec('PRAGMA synchronous = FULL'); + const version = Number( + ( + database.prepare('PRAGMA user_version').get() as { + user_version?: unknown; + } + ).user_version, + ); + if (version !== 0 && version !== 1) { + database.close(); + throw new Error(`Unsupported managed dependency receipt authority version ${version}`); + } + database.exec(` + BEGIN IMMEDIATE; + CREATE TABLE IF NOT EXISTS managed_dependency_environment_receipts ( + environment_digest TEXT PRIMARY KEY NOT NULL CHECK ( + length(environment_digest) = 64 AND + environment_digest NOT GLOB '*[^0-9a-f]*' + ), + receipt_json TEXT NOT NULL + ) STRICT; + PRAGMA user_version = 1; + COMMIT; + `); + let closed = false; + const assertOpen = () => { + if (closed) throw new Error('Managed dependency receipt authority is closed'); + }; + return Object.freeze({ + read(digest: string) { + assertOpen(); + requireDigest(digest); + const row = database + .prepare( + 'SELECT receipt_json FROM managed_dependency_environment_receipts WHERE environment_digest = ?', + ) + .get(digest) as { receipt_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.receipt_json !== 'string') { + throw new Error('Managed dependency receipt authority contains an invalid row'); + } + return decodeReceipt(JSON.parse(row.receipt_json)); + }, + list() { + assertOpen(); + return Object.freeze( + ( + database + .prepare( + 'SELECT environment_digest, receipt_json FROM managed_dependency_environment_receipts ORDER BY environment_digest', + ) + .all() as Array<{ + environment_digest?: unknown; + receipt_json?: unknown; + }> + ).map((row) => { + if (typeof row.environment_digest !== 'string' || typeof row.receipt_json !== 'string') { + throw new Error('Managed dependency receipt authority contains an invalid row'); + } + const receipt = decodeReceipt(JSON.parse(row.receipt_json)); + if (receipt.environmentId !== `sha256:${row.environment_digest}`) { + throw new Error('Managed dependency receipt row does not match its payload identity'); + } + return receipt; + }), + ); + }, + write(receipt: ManagedDependencyEnvironmentReceiptV1) { + assertOpen(); + const digest = receipt.environmentId.slice('sha256:'.length); + requireDigest(digest); + database.exec('BEGIN IMMEDIATE'); + try { + database + .prepare( + 'INSERT INTO managed_dependency_environment_receipts (environment_digest, receipt_json) VALUES (?, ?)', + ) + .run(digest, JSON.stringify(receipt)); + database.exec('COMMIT'); + } catch (error) { + database.exec('ROLLBACK'); + throw error; + } + }, + delete(digest: string) { + assertOpen(); + requireDigest(digest); + database + .prepare('DELETE FROM managed_dependency_environment_receipts WHERE environment_digest = ?') + .run(digest); + }, + close() { + if (closed) return; + closed = true; + database.close(); + }, + }); +} + +function requireDigest(value: string): void { + if (!/^[0-9a-f]{64}$/u.test(value)) { + throw new Error('Managed dependency receipt digest is invalid'); + } +} + +export function computeManagedDependencyEnvironmentIdentity( + input: ComputeManagedDependencyEnvironmentIdentityInput, +): ManagedDependencyEnvironmentIdentityV1 { + const manifestPath = normalizeTrackedPath(input.manifestPath, 'manifestPath'); + const lockfilePath = normalizeTrackedPath(input.lockfilePath, 'lockfilePath'); + const manifestSha256 = sha256(input.manifestBytes); + const lockfileSha256 = sha256(input.lockfileBytes); + assertIdentityText(input.packageManagerVersion, 'packageManagerVersion'); + assertIdentityText(input.nodeVersion, 'nodeVersion'); + assertIdentityText(input.nodeAbi, 'nodeAbi'); + assertIdentityText(input.platform, 'platform'); + assertIdentityText(input.arch, 'arch'); + assertSha256(input.producerRuntimeIdentitySha256, 'producerRuntimeIdentitySha256'); + assertSha256(input.producerPolicyIdentitySha256, 'producerPolicyIdentitySha256'); + + const canonicalIdentity = JSON.stringify({ + manifestPath, + manifestSha256, + lockfilePath, + lockfileSha256, + packageManagerName: input.packageManagerName, + packageManagerVersion: input.packageManagerVersion, + nodeVersion: input.nodeVersion, + nodeAbi: input.nodeAbi, + platform: input.platform, + arch: input.arch, + producerRuntimeIdentitySha256: input.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: input.producerPolicyIdentitySha256, + policyVersion: input.policyVersion, + }); + const environmentId = sha256( + Buffer.concat([ + Buffer.from(MANAGED_DEPENDENCY_IDENTITY_DOMAIN, 'utf8'), + Buffer.from(canonicalIdentity, 'utf8'), + ]), + ); + + return Object.freeze({ + protocolVersion: 1, + environmentId, + manifestPath, + manifestSha256, + lockfilePath, + lockfileSha256, + packageManagerName: input.packageManagerName, + packageManagerVersion: input.packageManagerVersion, + nodeVersion: input.nodeVersion, + nodeAbi: input.nodeAbi, + platform: input.platform, + arch: input.arch, + producerRuntimeIdentitySha256: input.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: input.producerPolicyIdentitySha256, + policyVersion: input.policyVersion, + }); +} + +export async function createManagedDependencyEnvironmentAuthority( + input: CreateManagedDependencyEnvironmentAuthorityInput, +): Promise { + assertProducerCapability(input.producer.capability); + const canonicalStorageRoot = await realpath(input.storageRoot).catch(async () => { + await mkdir(input.storageRoot, { recursive: true }); + return await realpath(input.storageRoot); + }); + const ownerKey = canonicalAuthorityOwnerKey(canonicalStorageRoot); + if (activeAuthorityOwners.has(ownerKey)) { + throw new Error('Managed dependency storage root already has an active owner'); + } + const ownerClaim = {}; + activeAuthorityOwners.set(ownerKey, ownerClaim); + let ownerLock: DependencyAuthorityOwnerLock | undefined; + try { + const managedWorkspacesRoot = await ensureOwnedDirectory( + join(canonicalStorageRoot, 'managed-workspaces'), + canonicalStorageRoot, + ); + ownerLock = await acquireDependencyAuthorityOwnerLock(managedWorkspacesRoot); + return await createManagedDependencyEnvironmentAuthorityForOwner( + input, + canonicalStorageRoot, + ownerKey, + ownerClaim, + ownerLock, + ); + } catch (error) { + try { + await ownerLock?.release(); + } finally { + if (activeAuthorityOwners.get(ownerKey) === ownerClaim) { + activeAuthorityOwners.delete(ownerKey); + } + } + throw error; + } +} + +async function createManagedDependencyEnvironmentAuthorityForOwner( + input: CreateManagedDependencyEnvironmentAuthorityInput, + canonicalStorageRoot: string, + ownerKey: string, + ownerClaim: object, + ownerLock: DependencyAuthorityOwnerLock, +): Promise { + const environmentsRoot = join( + canonicalStorageRoot, + 'managed-workspaces', + 'dependency-environments', + ); + const authorityDatabasePath = join( + canonicalStorageRoot, + 'managed-workspaces', + AUTHORITY_DATABASE_NAME, + ); + const stagingRoot = join(environmentsRoot, '.staging'); + const maxCacheBytes = input.maxCacheBytes ?? 2 * 1024 * 1024 * 1024; + if (!Number.isSafeInteger(maxCacheBytes) || maxCacheBytes < 0) { + throw new TypeError('Managed dependency cache quota must be a non-negative safe integer'); + } + await Promise.all([ensureOwnedDirectory(environmentsRoot, canonicalStorageRoot)]); + await ensureOwnedDirectory(stagingRoot, environmentsRoot); + await cleanupOrphanStaging(stagingRoot); + const receiptAuthority = openDependencyReceiptAuthority(authorityDatabasePath); + try { + await cleanupIncompletePublications(environmentsRoot, receiptAuthority); + } catch (error) { + receiptAuthority.close(); + throw error; + } + const inflight = new Map>(); + const leaseCounts = new Map(); + const pendingCounts = new Map(); + let state: 'open' | 'draining' | 'closed' = 'open'; + let activeAcquisitions = 0; + const acquisitionDrainWaiters = new Set<() => void>(); + let closeTask: Promise | undefined; + let gcTask = Promise.resolve(); + + const beginAcquisition = () => { + if (state !== 'open') { + throw new Error(`Managed dependency environment authority is ${state}`); + } + activeAcquisitions += 1; + let finished = false; + return () => { + if (finished) return; + finished = true; + activeAcquisitions -= 1; + if (activeAcquisitions !== 0) return; + for (const resolveWaiter of acquisitionDrainWaiters) resolveWaiter(); + acquisitionDrainWaiters.clear(); + }; + }; + const waitForAcquisitions = () => + activeAcquisitions === 0 + ? Promise.resolve() + : new Promise((resolveWaiter) => acquisitionDrainWaiters.add(resolveWaiter)); + + const authority: ManagedDependencyEnvironmentAuthority = { + async acquire(identity, source) { + const finishAcquisition = beginAcquisition(); + try { + assertCanonicalIdentity(identity, source); + if ( + identity.packageManagerName !== input.producer.packageManagerName || + identity.packageManagerVersion !== input.producer.packageManagerVersion || + identity.nodeVersion !== input.producer.nodeRuntime.version || + identity.nodeAbi !== input.producer.nodeRuntime.abi || + identity.platform !== input.producer.nodeRuntime.platform || + identity.arch !== input.producer.nodeRuntime.arch || + identity.producerRuntimeIdentitySha256 !== + input.producer.capability.runtimeIdentitySha256 || + identity.producerPolicyIdentitySha256 !== input.producer.capability.policyIdentitySha256 + ) { + throw new Error('Managed dependency producer does not match the requested identity'); + } + assertSourceMatchesIdentity(identity, source); + const digest = identity.environmentId.slice('sha256:'.length); + pendingCounts.set(digest, (pendingCounts.get(digest) ?? 0) + 1); + let artifact: PublishedManagedDependencyEnvironment; + try { + let task = inflight.get(digest); + if (!task) { + task = openOrPublishEnvironment({ + environmentsRoot, + receiptAuthority, + stagingRoot, + identity, + source, + producer: input.producer, + failpoint: input.failpoint, + }).finally(() => inflight.delete(digest)); + inflight.set(digest, task); + } + artifact = await task; + const now = new Date(); + await utimes(dirname(artifact.dependencyRoot), now, now); + await input.failpoint?.('before_environment_lease'); + leaseCounts.set(digest, (leaseCounts.get(digest) ?? 0) + 1); + } finally { + decrementCount(pendingCounts, digest); + } + let released = false; + return Object.freeze({ + environmentId: identity.environmentId, + dependencyRoot: artifact.dependencyRoot, + async release() { + if (released) return; + released = true; + const remaining = (leaseCounts.get(digest) ?? 1) - 1; + if (remaining > 0) leaseCounts.set(digest, remaining); + else leaseCounts.delete(digest); + gcTask = gcTask + .catch(() => undefined) + .then(() => + collectEnvironmentGarbage({ + environmentsRoot, + receiptAuthority, + maxCacheBytes, + leaseCounts, + pendingCounts, + protectedDigest: digest, + }), + ); + await gcTask; + }, + }); + } finally { + finishAcquisition(); + } + }, + close() { + if (state === 'closed') return Promise.resolve(); + if (closeTask) return closeTask; + state = 'draining'; + closeTask = (async () => { + await waitForAcquisitions(); + let gcError: unknown; + try { + await gcTask; + } catch (error) { + gcError = error; + } + if (leaseCounts.size > 0) { + throw new Error('Managed dependency environment authority still has active leases'); + } + receiptAuthority.close(); + state = 'closed'; + try { + await ownerLock.release(); + } finally { + if (activeAuthorityOwners.get(ownerKey) === ownerClaim) { + activeAuthorityOwners.delete(ownerKey); + } + } + if (gcError) throw gcError; + })().catch((error: unknown) => { + if (state === 'draining') state = 'open'; + closeTask = undefined; + throw error; + }); + return closeTask; + }, + }; + return Object.freeze(authority); +} + +function canonicalAuthorityOwnerKey(canonicalStorageRoot: string): string { + const normalized = normalize(canonicalStorageRoot); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +async function acquireDependencyAuthorityOwnerLock( + managedWorkspacesRoot: string, +): Promise { + const lockPath = join(managedWorkspacesRoot, 'dependency-environment-authority-v1.lock'); + const handle = await open(lockPath, 'a+', 0o600); + let locked = false; + try { + const [opened, current] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(lockPath, { bigint: true }), + ]); + if ( + !opened.isFile() || + !current.isFile() || + current.isSymbolicLink() || + opened.dev !== current.dev || + opened.ino !== current.ino + ) { + throw new Error('Managed dependency authority owner lock is not a stable file'); + } + await handle.chmod(0o600); + locked = tryLock(handle.fd); + if (!locked) { + throw new Error('Managed dependency storage root already has an active owner'); + } + } catch (error) { + if (locked) unlock(handle.fd); + await handle.close(); + throw error; + } + + let released = false; + return Object.freeze({ + async release() { + if (released) return; + released = true; + try { + unlock(handle.fd); + } finally { + await handle.close(); + } + }, + }); +} + +async function openOrPublishEnvironment(input: { + readonly environmentsRoot: string; + readonly receiptAuthority: DependencyReceiptAuthority; + readonly stagingRoot: string; + readonly identity: ManagedDependencyEnvironmentIdentityV1; + readonly source: AcquireManagedDependencyEnvironmentInput; + readonly producer: ManagedDependencyEnvironmentProducer; + readonly failpoint?: (point: ManagedDependencyEnvironmentFailpoint) => void | Promise; +}): Promise { + const digest = input.identity.environmentId.slice('sha256:'.length); + const artifactRoot = publicationPath(input.environmentsRoot, digest); + const existing = await openPublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + if (existing) return existing; + + const transactionRoot = join(input.stagingRoot, `${digest}-${randomUUID()}`); + const producerRoot = join(transactionRoot, 'producer'); + const projectRoot = join(producerRoot, 'project'); + const producerOutputRoot = join(projectRoot, DEPENDENCY_ROOT_NAME); + const scratchRoot = join(projectRoot, '.maka-runtime'); + const artifactStagingRoot = join(transactionRoot, 'artifact'); + const dependencyRoot = join(artifactStagingRoot, DEPENDENCY_ROOT_NAME); + await Promise.all([ + mkdir(producerOutputRoot, { recursive: true }), + mkdir(scratchRoot, { recursive: true }), + mkdir(artifactStagingRoot, { recursive: true }), + ]); + try { + await input.producer.provision({ + identity: input.identity, + outputRoot: producerOutputRoot, + scratchRoot, + manifestBytes: input.source.manifestBytes, + lockfileBytes: input.source.lockfileBytes, + ...(input.source.abortSignal ? { abortSignal: input.source.abortSignal } : {}), + }); + const canonicalOutput = await realpath(producerOutputRoot); + if (!isPathWithin(canonicalOutput, producerRoot)) { + throw new Error('Managed dependency producer output escapes its staging authority'); + } + await cp(producerOutputRoot, dependencyRoot, { + recursive: true, + dereference: false, + errorOnExist: true, + force: false, + verbatimSymlinks: true, + }); + await rm(producerRoot, { recursive: true, force: true }); + const content = await hashDependencyTree(dependencyRoot, { durable: true }); + const receipt: ManagedDependencyEnvironmentReceiptV1 = Object.freeze({ + ...input.identity, + dependencyRootName: DEPENDENCY_ROOT_NAME, + contentTreeSha256: content.sha256, + contentBytes: content.bytes, + contentEntries: content.entries, + }); + await syncDirectory(artifactStagingRoot); + await input.failpoint?.('after_environment_tree_durable'); + await rename(artifactStagingRoot, artifactRoot); + await syncDirectory(input.environmentsRoot); + await input.failpoint?.('after_environment_publish'); + input.receiptAuthority.write(receipt); + await input.failpoint?.('after_environment_receipt_durable'); + await rm(transactionRoot, { recursive: true, force: true }); + return await requirePublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + } catch (error) { + await rm(transactionRoot, { recursive: true, force: true }).catch(() => undefined); + const raced = await openPublishedEnvironment( + artifactRoot, + input.environmentsRoot, + input.receiptAuthority, + input.identity, + ); + if (raced) return raced; + const receiptExists = input.receiptAuthority.read(digest) !== undefined; + if (!receiptExists) { + await rm(artifactRoot, { recursive: true, force: true }).catch(() => undefined); + } + throw error; + } +} + +async function cleanupOrphanStaging(stagingRoot: string): Promise { + const entries = await readdir(stagingRoot, { withFileTypes: true }); + for (const entry of entries) { + const path = join(stagingRoot, entry.name); + const info = await lstat(path); + if (!entry.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed dependency staging contains an unowned entry'); + } + await rm(path, { recursive: true, force: true }); + } +} + +async function ensureOwnedDirectory(path: string, parentRoot: string): Promise { + await mkdir(path, { recursive: true }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Managed dependency authority path is not an owned directory'); + } + const canonical = normalize(await realpath(path)); + const canonicalParent = normalize(await realpath(parentRoot)); + if (!isPathWithin(canonical, canonicalParent)) { + throw new Error('Managed dependency authority path escapes its storage root'); + } + return canonical; +} + +async function cleanupIncompletePublications( + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, +): Promise { + const receipts = new Set(); + for (const receipt of receiptAuthority.list()) { + const digest = receipt.environmentId.slice('sha256:'.length); + if (receipt.environmentId !== `sha256:${digest}`) { + throw new Error('Managed dependency authority receipt has the wrong identity'); + } + receipts.add(digest); + } + const artifacts = new Set(); + for (const entry of await readdir(environmentsRoot, { + withFileTypes: true, + })) { + if (entry.name === '.staging') continue; + if (!entry.isDirectory() || !/^[0-9a-f]{64}$/u.test(entry.name)) { + throw new Error('Managed dependency cache contains an unowned entry'); + } + const info = await lstat(join(environmentsRoot, entry.name)); + if (info.isSymbolicLink()) { + throw new Error('Managed dependency cache contains a reparse point'); + } + artifacts.add(entry.name); + } + for (const digest of artifacts) { + if (!receipts.has(digest)) { + await rm(join(environmentsRoot, digest), { + recursive: true, + force: true, + }); + } + } + for (const digest of receipts) { + if (!artifacts.has(digest)) { + receiptAuthority.delete(digest); + } + } +} + +function publicationPath(environmentsRoot: string, digest: string) { + if (!/^[0-9a-f]{64}$/u.test(digest)) { + throw new Error('Managed dependency environment identity is not a canonical SHA-256 digest'); + } + const artifactRoot = join(environmentsRoot, digest); + if (!isPathWithin(artifactRoot, environmentsRoot)) { + throw new Error('Managed dependency publication path escapes its authority root'); + } + return artifactRoot; +} + +async function openPublishedEnvironment( + artifactRoot: string, + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, + identity: ManagedDependencyEnvironmentIdentityV1, +): Promise { + try { + return await requirePublishedEnvironment( + artifactRoot, + environmentsRoot, + receiptAuthority, + identity, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +async function requirePublishedEnvironment( + artifactRoot: string, + environmentsRoot: string, + receiptAuthority: DependencyReceiptAuthority, + identity: ManagedDependencyEnvironmentIdentityV1, +): Promise { + const artifactInfo = await lstat(artifactRoot); + if (!artifactInfo.isDirectory() || artifactInfo.isSymbolicLink()) { + throw new Error('Managed dependency environment artifact root is not an owned directory'); + } + const canonicalArtifactRoot = normalize(await realpath(artifactRoot)); + if (!isPathWithin(canonicalArtifactRoot, environmentsRoot)) { + throw new Error('Managed dependency environment artifact escapes its authority root'); + } + const artifactEntries = await readdir(artifactRoot); + if (artifactEntries.length !== 1 || artifactEntries[0] !== DEPENDENCY_ROOT_NAME) { + throw new Error('Managed dependency environment artifact contains an unowned entry'); + } + const receipt = receiptAuthority.read(identity.environmentId.slice('sha256:'.length)); + if (!receipt) + throw Object.assign(new Error('Managed dependency receipt is unavailable'), { code: 'ENOENT' }); + if (!sameIdentity(receipt, identity)) { + throw new Error('Managed dependency environment receipt identity does not match the request'); + } + const dependencyRoot = join(artifactRoot, receipt.dependencyRootName); + const dependencyInfo = await lstat(dependencyRoot); + if (!dependencyInfo.isDirectory() || dependencyInfo.isSymbolicLink()) { + throw new Error('Managed dependency environment content root is unavailable'); + } + const content = await hashDependencyTree(dependencyRoot); + if ( + content.sha256 !== receipt.contentTreeSha256 || + content.bytes !== receipt.contentBytes || + content.entries !== receipt.contentEntries + ) { + throw new Error('Managed dependency environment content does not match its receipt'); + } + return Object.freeze({ + receipt, + dependencyRoot: await realpath(dependencyRoot), + }); +} + +function assertSourceMatchesIdentity( + identity: ManagedDependencyEnvironmentIdentityV1, + input: AcquireManagedDependencyEnvironmentInput, +): void { + if ( + sha256(input.manifestBytes) !== identity.manifestSha256 || + sha256(input.lockfileBytes) !== identity.lockfileSha256 + ) { + throw new Error('Managed dependency source bytes do not match the requested identity'); + } +} + +function assertCanonicalIdentity( + identity: ManagedDependencyEnvironmentIdentityV1, + source: AcquireManagedDependencyEnvironmentInput, +): void { + const expected = computeManagedDependencyEnvironmentIdentity({ + manifestPath: identity.manifestPath, + manifestBytes: source.manifestBytes, + lockfilePath: identity.lockfilePath, + lockfileBytes: source.lockfileBytes, + packageManagerName: identity.packageManagerName, + packageManagerVersion: identity.packageManagerVersion, + nodeVersion: identity.nodeVersion, + nodeAbi: identity.nodeAbi, + platform: identity.platform, + arch: identity.arch, + producerRuntimeIdentitySha256: identity.producerRuntimeIdentitySha256, + producerPolicyIdentitySha256: identity.producerPolicyIdentitySha256, + policyVersion: identity.policyVersion, + }); + if (!sameEnvironmentIdentity(expected, identity)) { + throw new Error('Managed dependency environment identity is not canonical'); + } +} + +async function hashDependencyTree( + root: string, + options: { readonly durable?: boolean } = {}, +): Promise<{ + readonly sha256: `sha256:${string}`; + readonly bytes: number; + readonly entries: number; +}> { + const hash = createHash('sha256'); + const counter = { bytes: 0, entries: 0 }; + hash.update(MANAGED_DEPENDENCY_TREE_DOMAIN); + await hashDirectory(root, '', hash, counter, options.durable === true); + if (process.platform === 'win32') await assertNoWindowsAlternateStreams(root); + return Object.freeze({ + sha256: `sha256:${hash.digest('hex')}`, + bytes: counter.bytes, + entries: counter.entries, + }); +} + +async function hashDirectory( + root: string, + relativeRoot: string, + hash: ReturnType, + counter: { bytes: number; entries: number }, + durable: boolean, +) { + const directory = relativeRoot ? join(root, relativeRoot) : root; + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => Buffer.from(left.name).compare(Buffer.from(right.name))); + for (const entry of entries) { + if (entry.name.includes(':') || entry.name.includes('\0')) { + throw new Error('Managed dependency environment contains a non-portable path'); + } + counter.entries += 1; + const relativePath = relativeRoot ? join(relativeRoot, entry.name) : entry.name; + const portablePath = relativePath.replaceAll('\\', '/'); + const absolutePath = join(root, relativePath); + const info = await lstat(absolutePath); + const mode = process.platform === 'win32' ? 0 : info.mode & 0o777; + if (entry.isDirectory()) { + hash.update(`d\0${portablePath}\0${mode}\0`); + await hashDirectory(root, relativePath, hash, counter, durable); + continue; + } + if (entry.isFile()) { + hash.update(`f\0${portablePath}\0${mode}\0${info.size}\0`); + counter.bytes += info.size; + for await (const chunk of createReadStream(absolutePath)) hash.update(chunk as Buffer); + hash.update('\0'); + if (durable) await syncRegularFile(absolutePath, info.mode); + continue; + } + if (entry.isSymbolicLink()) { + if (process.platform === 'win32') { + throw new Error('Managed dependency environment contains a Windows reparse point'); + } + const target = await readlink(absolutePath); + if (isAbsolute(target) || !isPathWithin(resolve(dirname(absolutePath), target), root)) { + throw new Error('Managed dependency environment contains an escaping symbolic link'); + } + hash.update(`l\0${portablePath}\0${target.replaceAll('\\', '/')}\0`); + continue; + } + throw new Error('Managed dependency environment contains an unsupported filesystem entry'); + } + if (durable) await syncDirectory(directory); +} + +/** + * Reject NTFS alternate data streams under a dependency tree. + * + * Previously this shelled out to a recursive PowerShell `Get-ChildItem -Recurse` + * + `Get-Item -Stream *` walk. On GitHub-hosted Windows runners that path + * routinely hung until the 30s `execFile` timeout, which misreported every + * timeout as "contains an alternate data stream" and burned ~5 minutes across + * managed-dependency crash recovery tests. Walk the tree in Node (no reparse + * follow), then send the bounded object list through stdin to one non-recursive + * Windows PowerShell stream query. `fsutil file queryStreams` is not a supported + * command on the Windows builds used by developers or hosted runners. + */ +async function assertNoWindowsAlternateStreams(root: string): Promise { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (!systemRoot) { + throw new Error('Cannot verify Windows alternate streams without SystemRoot'); + } + const powershell = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + const paths = [toNamespacedPath(root)]; + const stack = [root]; + while (stack.length > 0) { + const directory = stack.pop()!; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = join(directory, entry.name); + // Dirent for a reparse point reports as directory/file without following. + // Do not recurse into reparse points; hashDirectory already rejects them + // on win32, and following junctions can hang or leave the tree. + if (entry.isSymbolicLink()) { + throw new Error('Managed dependency environment contains a Windows reparse point'); + } + if (entry.isDirectory()) { + paths.push(toNamespacedPath(absolutePath)); + stack.push(absolutePath); + continue; + } + if (!entry.isFile()) continue; + // Windows PowerShell 5.1 cannot open long provider paths unless the + // caller supplies the Win32 namespaced spelling. + paths.push(toNamespacedPath(absolutePath)); + } + } + const alternate = await queryWindowsAlternateStreams(powershell, paths); + if (alternate.length > 0) { + throw new Error('Managed dependency environment contains an alternate data stream'); + } +} + +function queryWindowsAlternateStreams( + powershell: string, + paths: readonly string[], +): Promise { + if (paths.length === 0) return Promise.resolve([]); + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn( + powershell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_STREAM_QUERY_SCRIPT], + { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let stderr = ''; + let timedOut = false; + let overflow = false; + let settled = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, WINDOWS_STREAM_QUERY_TIMEOUT_MS); + const append = (current: string, chunk: Buffer): string => { + const next = current + chunk.toString('utf8'); + if (Buffer.byteLength(next, 'utf8') <= WINDOWS_STREAM_QUERY_MAX_OUTPUT_BYTES) return next; + overflow = true; + child.kill(); + return current; + }; + child.stdout.on('data', (chunk: Buffer) => { + stdout = append(stdout, chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + child.stdin.on('error', () => {}); + child.once('error', (error) => { + clearTimeout(timer); + if (settled) return; + settled = true; + rejectPromise( + new Error('Unable to start the Windows alternate-stream query', { cause: error }), + ); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (settled) return; + settled = true; + if (timedOut) { + rejectPromise(new Error('Timed out querying Windows alternate data streams')); + return; + } + if (overflow) { + rejectPromise(new Error('Windows alternate-stream query output exceeded its limit')); + return; + } + if (code !== 0) { + rejectPromise( + new Error( + `Unable to query Windows alternate data streams: ${stderr.trim() || `exit ${code}`}`, + ), + ); + return; + } + try { + const value: unknown = JSON.parse(stdout || '[]'); + if (!Array.isArray(value) || !value.every((path) => typeof path === 'string')) { + throw new Error('query returned an invalid result'); + } + resolvePromise(value); + } catch (error) { + rejectPromise( + new Error('Windows alternate-stream query returned invalid JSON', { cause: error }), + ); + } + }); + child.stdin.end(JSON.stringify(paths)); + }); +} + +function isPathWithin(candidate: string, root: string): boolean { + const path = relative(normalize(root), normalize(candidate)); + return path === '' || (!path.startsWith('..') && !isAbsolute(path)); +} + +function decodeReceipt(value: unknown): ManagedDependencyEnvironmentReceiptV1 { + if (!value || typeof value !== 'object') throw new Error('Invalid dependency receipt'); + const receipt = value as Partial; + const keys = Object.keys(value).sort(); + const expectedKeys = [...RECEIPT_KEYS].sort(); + if ( + keys.length !== expectedKeys.length || + keys.some((key, index) => key !== expectedKeys[index]) || + receipt.protocolVersion !== 1 || + receipt.dependencyRootName !== DEPENDENCY_ROOT_NAME || + typeof receipt.environmentId !== 'string' || + !SHA256_PATTERN.test(receipt.environmentId) || + typeof receipt.contentTreeSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.contentTreeSha256) || + typeof receipt.contentBytes !== 'number' || + !Number.isSafeInteger(receipt.contentBytes) || + receipt.contentBytes < 0 || + typeof receipt.contentEntries !== 'number' || + !Number.isSafeInteger(receipt.contentEntries) || + receipt.contentEntries < 0 || + typeof receipt.manifestSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.manifestSha256) || + typeof receipt.lockfileSha256 !== 'string' || + !SHA256_PATTERN.test(receipt.lockfileSha256) || + typeof receipt.manifestPath !== 'string' || + typeof receipt.lockfilePath !== 'string' || + (receipt.packageManagerName !== 'npm' && + receipt.packageManagerName !== 'pnpm' && + receipt.packageManagerName !== 'yarn') || + typeof receipt.packageManagerVersion !== 'string' || + typeof receipt.nodeVersion !== 'string' || + typeof receipt.nodeAbi !== 'string' || + typeof receipt.platform !== 'string' || + typeof receipt.arch !== 'string' || + typeof receipt.producerRuntimeIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(receipt.producerRuntimeIdentitySha256) || + typeof receipt.producerPolicyIdentitySha256 !== 'string' || + !SHA256_PATTERN.test(receipt.producerPolicyIdentitySha256) || + receipt.policyVersion !== 'managed_dependency_environment_v1' + ) { + throw new Error('Invalid dependency receipt'); + } + return Object.freeze(receipt as ManagedDependencyEnvironmentReceiptV1); +} + +async function collectEnvironmentGarbage(input: { + readonly environmentsRoot: string; + readonly receiptAuthority: DependencyReceiptAuthority; + readonly maxCacheBytes: number; + readonly leaseCounts: ReadonlyMap; + readonly pendingCounts: ReadonlyMap; + readonly protectedDigest: string; +}): Promise { + const artifacts: Array<{ + readonly digest: string; + readonly root: string; + readonly bytes: number; + readonly lastUsedMs: number; + }> = []; + for (const entry of await readdir(input.environmentsRoot, { + withFileTypes: true, + })) { + if (entry.name === '.staging') continue; + if (!entry.isDirectory() || !/^[0-9a-f]{64}$/u.test(entry.name)) { + throw new Error('Managed dependency cache contains an unowned entry'); + } + const root = join(input.environmentsRoot, entry.name); + const receipt = input.receiptAuthority.read(entry.name); + if (!receipt) throw new Error('Managed dependency cache is missing its authority receipt'); + if (receipt.environmentId !== `sha256:${entry.name}`) { + throw new Error('Managed dependency cache directory does not match its receipt'); + } + artifacts.push({ + digest: entry.name, + root, + // Empty files and directories consume filesystem metadata even when + // contentBytes is zero. Charge one conservative 4 KiB unit per entry so + // inode-only trees cannot bypass the cache quota. + bytes: receipt.contentBytes + receipt.contentEntries * 4_096, + lastUsedMs: (await stat(root)).mtimeMs, + }); + } + let totalBytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0); + artifacts.sort( + (left, right) => left.lastUsedMs - right.lastUsedMs || left.digest.localeCompare(right.digest), + ); + for (const artifact of artifacts) { + if (totalBytes <= input.maxCacheBytes) break; + if ( + artifact.digest === input.protectedDigest || + input.leaseCounts.has(artifact.digest) || + input.pendingCounts.has(artifact.digest) + ) + continue; + await rm(artifact.root, { recursive: true, force: true }); + input.receiptAuthority.delete(artifact.digest); + totalBytes -= artifact.bytes; + } +} + +function sameIdentity( + receipt: ManagedDependencyEnvironmentReceiptV1, + identity: ManagedDependencyEnvironmentIdentityV1, +): boolean { + return ( + receipt.environmentId === identity.environmentId && + receipt.manifestPath === identity.manifestPath && + receipt.manifestSha256 === identity.manifestSha256 && + receipt.lockfilePath === identity.lockfilePath && + receipt.lockfileSha256 === identity.lockfileSha256 && + receipt.packageManagerName === identity.packageManagerName && + receipt.packageManagerVersion === identity.packageManagerVersion && + receipt.nodeVersion === identity.nodeVersion && + receipt.nodeAbi === identity.nodeAbi && + receipt.platform === identity.platform && + receipt.arch === identity.arch && + receipt.producerRuntimeIdentitySha256 === identity.producerRuntimeIdentitySha256 && + receipt.producerPolicyIdentitySha256 === identity.producerPolicyIdentitySha256 && + receipt.policyVersion === identity.policyVersion + ); +} + +function sameEnvironmentIdentity( + left: ManagedDependencyEnvironmentIdentityV1, + right: ManagedDependencyEnvironmentIdentityV1, +): boolean { + return ( + left.protocolVersion === right.protocolVersion && + left.environmentId === right.environmentId && + left.manifestPath === right.manifestPath && + left.manifestSha256 === right.manifestSha256 && + left.lockfilePath === right.lockfilePath && + left.lockfileSha256 === right.lockfileSha256 && + left.packageManagerName === right.packageManagerName && + left.packageManagerVersion === right.packageManagerVersion && + left.nodeVersion === right.nodeVersion && + left.nodeAbi === right.nodeAbi && + left.platform === right.platform && + left.arch === right.arch && + left.producerRuntimeIdentitySha256 === right.producerRuntimeIdentitySha256 && + left.producerPolicyIdentitySha256 === right.producerPolicyIdentitySha256 && + left.policyVersion === right.policyVersion + ); +} + +function decrementCount(counts: Map, key: string): void { + const remaining = (counts.get(key) ?? 1) - 1; + if (remaining > 0) counts.set(key, remaining); + else counts.delete(key); +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } catch (error) { + if (process.platform !== 'win32') throw error; + } finally { + await handle.close(); + } +} + +async function syncRegularFile(path: string, mode: number): Promise { + if (process.platform !== 'win32') { + await syncOpenedFile(path, 'r'); + return; + } + + const originalMode = mode & 0o777; + const writableMode = originalMode | 0o200; + if (writableMode !== originalMode) await chmod(path, writableMode); + try { + await syncOpenedFile(path, 'r+'); + } finally { + if (writableMode !== originalMode) await chmod(path, originalMode); + } +} + +async function syncOpenedFile(path: string, flags: 'r' | 'r+'): Promise { + const handle = await open(path, flags); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function sha256(value: Uint8Array): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function normalizeTrackedPath(value: string, field: string): string { + assertIdentityText(value, field); + const normalized = posix.normalize(value.replaceAll('\\', '/')); + if ( + normalized === '.' || + normalized.startsWith('/') || + normalized === '..' || + normalized.startsWith('../') + ) { + throw new TypeError(`${field} must be a workspace-relative tracked path`); + } + return normalized; +} + +function assertIdentityText(value: string, field: string): void { + if (!value || value.includes('\0')) { + throw new TypeError(`${field} must be non-empty text without NUL bytes`); + } +} + +function assertSha256(value: string, field: string): void { + if (!SHA256_PATTERN.test(value)) { + throw new TypeError(`${field} must be a SHA-256 digest`); + } +} + +function managedDependencyProducerPolicyIdentity(): `sha256:${string}` { + return sha256( + Buffer.concat([ + Buffer.from(MANAGED_DEPENDENCY_PRODUCER_POLICY_DOMAIN, 'utf8'), + Buffer.from(JSON.stringify(MANAGED_DEPENDENCY_PRODUCER_POLICY_V1), 'utf8'), + ]), + ); +} + +function assertProducerCapability( + capability: ManagedDependencyEnvironmentProducerCapabilityV1, +): void { + const expected = createManagedDependencyEnvironmentProducerCapability( + capability.runtimeIdentitySha256, + ); + if ( + Object.keys(capability).sort().join('\0') !== Object.keys(expected).sort().join('\0') || + Object.entries(expected).some( + ([key, value]) => + capability[key as keyof ManagedDependencyEnvironmentProducerCapabilityV1] !== value, + ) + ) { + throw new Error('Managed dependency producer capability is invalid'); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/480a50e0c1277fa3b68b95b691e3b4f6f6d634869c2690f50040efbcda3f5b0f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/480a50e0c1277fa3b68b95b691e3b4f6f6d634869c2690f50040efbcda3f5b0f.source new file mode 100644 index 0000000000..8ea22afe9e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/480a50e0c1277fa3b68b95b691e3b4f6f6d634869c2690f50040efbcda3f5b0f.source @@ -0,0 +1,462 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Buffer } from 'node:buffer'; +import { createHash, type Hash } from 'node:crypto'; +import { + isSha256Digest, + isValidUnicodeString, + SESSION_BUNDLE_CANONICALIZATION_VERSION, + SESSION_BUNDLE_STATE_IDENTITY_PATH, + SESSION_BUNDLE_STATE_PATH, + SESSION_BUNDLE_WORKSPACE_PATH, + SessionBundleFileError, + type Sha256Digest, +} from './session-bundle-contract.js'; + +const TREE_MAGIC = Buffer.from('MAKA_SESSION_BUNDLE_TREE\0', 'ascii'); +const TREE_HEADER_BYTES = TREE_MAGIC.byteLength + 4 + 8; +const TREE_ENTRY_FIXED_BYTES = 1 + 4 + 2 + 8 + 32; +const MAX_UINT32 = 0xffff_ffff; +const DIRECTORY_ENTRY_TYPE = 0x01; +const REGULAR_FILE_ENTRY_TYPE = 0x02; +const DIRECTORY_MODE = 0o755; +const REGULAR_FILE_MODES = new Set([0o644, 0o755]); +const DIRECTORY_DIGEST = Buffer.alloc(32); + +export interface SessionBundleCanonicalDirectoryEntry { + kind: 'directory'; + path: string; +} + +export interface SessionBundleCanonicalFileEntry { + kind: 'file'; + path: string; + mode: 0o644 | 0o755; + size: number; + contentDigest: Sha256Digest; +} + +export type SessionBundleCanonicalTreeEntry = + | SessionBundleCanonicalDirectoryEntry + | SessionBundleCanonicalFileEntry; + +export type SessionBundleCanonicalLayoutEntry = + | SessionBundleCanonicalDirectoryEntry + | Pick; + +export interface SessionBundleCanonicalTreeDigest { + treeDigest: Sha256Digest; + payloadBytes: number; + entryCount: number; +} + +interface ValidatedCanonicalTreeEntry { + entry: SessionBundleCanonicalTreeEntry; + pathBytes: Buffer; + logicalPath: string; + parentDirectory?: string; +} + +/** + * Encode the exact canonical tree-record stream defined by codec V1. + * + * Input order is irrelevant. Entries are validated and sorted by raw UTF-8 + * path bytes before encoding. + */ +export function encodeSessionBundleCanonicalTree( + entries: readonly SessionBundleCanonicalTreeEntry[], +): Uint8Array { + const sorted = validateAndSortEntries(entries); + const builder = new SessionBundleCanonicalTreeDigestBuilder(sorted.length); + const chunks: Buffer[] = [encodeTreeHeader(sorted.length)]; + for (const validated of sorted) { + builder.add(validated.entry); + chunks.push(encodeTreeEntry(validated)); + } + builder.finish(); + return Buffer.concat(chunks); +} + +export function computeSessionBundleCanonicalTreeDigest( + entries: readonly SessionBundleCanonicalTreeEntry[], +): SessionBundleCanonicalTreeDigest { + const sorted = validateAndSortEntries(entries); + const builder = new SessionBundleCanonicalTreeDigestBuilder(sorted.length); + for (const validated of sorted) builder.add(validated.entry); + return builder.finish(); +} + +/** + * Incremental digest builder for PR 2's streaming USTAR reader. + * + * The caller supplies the entry count from the validated manifest, then adds + * payload entries in canonical raw-UTF-8 order after each regular file's + * content digest is known. The builder enforces ordering, parent directories, + * layout, normalized modes, and the final count without retaining file bytes. + */ +export class SessionBundleCanonicalTreeDigestBuilder { + readonly #hash: Hash; + readonly #layout: SessionBundleCanonicalLayoutValidator; + #entryCount = 0; + #payloadBytes = 0; + #failed = false; + #result?: SessionBundleCanonicalTreeDigest; + + constructor(expectedEntryCount: number) { + if (!Number.isSafeInteger(expectedEntryCount) || expectedEntryCount < 0) { + throw new RangeError('Canonical tree entry count must be a non-negative safe integer'); + } + this.#layout = new SessionBundleCanonicalLayoutValidator(expectedEntryCount); + this.#hash = createHash('sha256'); + this.#hash.update(encodeTreeHeader(expectedEntryCount)); + } + + add(value: SessionBundleCanonicalTreeEntry): void { + if (this.#result !== undefined) { + throw integrityError('Canonical tree digest is already finalized'); + } + if (this.#failed) { + throw integrityError('Canonical tree digest builder previously rejected an entry'); + } + + try { + const validated = validateEntry(value); + this.#layout.add(validated.entry); + const nextPayloadBytes = + validated.entry.kind === 'file' + ? this.#payloadBytes + validated.entry.size + : this.#payloadBytes; + if (!Number.isSafeInteger(nextPayloadBytes)) { + throw unsupportedEntry('Canonical tree payload byte count exceeds safe integer range'); + } + const record = encodeTreeEntry(validated); + + // Commit only after every validation and encoding step has succeeded. + this.#hash.update(record); + this.#entryCount += 1; + this.#payloadBytes = nextPayloadBytes; + } catch (error) { + this.#failed = true; + throw error; + } + } + + finish(): SessionBundleCanonicalTreeDigest { + if (this.#result !== undefined) return { ...this.#result }; + if (this.#failed) { + throw integrityError('Canonical tree digest builder previously rejected an entry'); + } + try { + this.#layout.finish(); + } catch (error) { + this.#failed = true; + throw error; + } + this.#result = Object.freeze({ + treeDigest: `sha256:${this.#hash.digest('hex')}` as Sha256Digest, + payloadBytes: this.#payloadBytes, + entryCount: this.#entryCount, + }); + return { ...this.#result }; + } +} + +/** + * Shared state machine for canonical path order, explicit parents, conflicts, + * required roots, and entry count. The streaming reader uses it before any + * hydration write, while the digest builder applies the same rules after file + * content hashes are available. + */ +export class SessionBundleCanonicalLayoutValidator { + readonly #expectedEntryCount: number; + readonly #logicalPaths = new Set(); + readonly #directories = new Set(); + #previousPathBytes?: Buffer; + #entryCount = 0; + #sawStateIdentity = false; + #sawStateRoot = false; + #sawWorkspaceRoot = false; + #failed = false; + #finished = false; + + constructor(expectedEntryCount: number) { + if (!Number.isSafeInteger(expectedEntryCount) || expectedEntryCount < 0) { + throw new RangeError('Canonical layout entry count must be a non-negative safe integer'); + } + this.#expectedEntryCount = expectedEntryCount; + } + + add(value: SessionBundleCanonicalLayoutEntry): void { + if (this.#finished) throw integrityError('Canonical layout is already finalized'); + if (this.#failed) + throw integrityError('Canonical layout validator previously rejected an entry'); + + try { + const entry = validateLayoutEntry(value); + if (this.#entryCount >= this.#expectedEntryCount) { + throw integrityError('Canonical tree contains more entries than declared'); + } + const validated = validateCanonicalPath(entry.path, entry.kind); + if ( + this.#previousPathBytes !== undefined && + Buffer.compare(this.#previousPathBytes, validated.pathBytes) >= 0 + ) { + throw unsafePath('Canonical tree paths are duplicated or out of order'); + } + if (this.#logicalPaths.has(validated.logicalPath)) { + throw unsafePath('Canonical tree contains a file and directory path conflict'); + } + if ( + validated.parentDirectory !== undefined && + !this.#directories.has(validated.parentDirectory) + ) { + throw unsafePath('Canonical tree entry is missing its explicit parent directory'); + } + + const layout = classifyPayloadPath(entry); + this.#logicalPaths.add(validated.logicalPath); + if (entry.kind === 'directory') this.#directories.add(entry.path); + this.#previousPathBytes = validated.pathBytes; + this.#entryCount += 1; + if (layout === 'state_identity') this.#sawStateIdentity = true; + if (layout === 'state_root') this.#sawStateRoot = true; + if (layout === 'workspace_root') this.#sawWorkspaceRoot = true; + } catch (error) { + this.#failed = true; + throw error; + } + } + + finish(): void { + if (this.#finished) return; + if (this.#failed) + throw integrityError('Canonical layout validator previously rejected an entry'); + try { + if (this.#entryCount !== this.#expectedEntryCount) { + throw integrityError('Canonical tree entry count does not match the declared count'); + } + if (!this.#sawStateIdentity || !this.#sawStateRoot || !this.#sawWorkspaceRoot) { + throw integrityError('Canonical tree is missing a required V1 payload root'); + } + this.#finished = true; + } catch (error) { + this.#failed = true; + throw error; + } + } +} + +export function compareSessionBundleCanonicalPaths(left: string, right: string): number { + if (!isValidUnicodeString(left) || !isValidUnicodeString(right)) { + throw unsafePath('Canonical tree path is not valid Unicode'); + } + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} + +function validateAndSortEntries( + entries: readonly SessionBundleCanonicalTreeEntry[], +): ValidatedCanonicalTreeEntry[] { + if (!Array.isArray(entries)) { + throw new TypeError('Canonical tree entries must be an array'); + } + return entries + .map((entry) => validateEntry(entry)) + .sort((left, right) => Buffer.compare(left.pathBytes, right.pathBytes)); +} + +function validateEntry(value: unknown): ValidatedCanonicalTreeEntry { + if (!isRecord(value) || typeof value.kind !== 'string' || typeof value.path !== 'string') { + throw unsupportedEntry('Canonical tree entry has an invalid shape'); + } + + let entry: SessionBundleCanonicalTreeEntry; + if (value.kind === 'directory') { + if (!hasExactKeys(value, ['kind', 'path'])) { + throw unsupportedEntry('Canonical directory entry has unsupported metadata'); + } + entry = { kind: 'directory', path: value.path }; + } else if (value.kind === 'file') { + if ( + !hasExactKeys(value, ['contentDigest', 'kind', 'mode', 'path', 'size']) || + typeof value.mode !== 'number' || + !REGULAR_FILE_MODES.has(value.mode) || + typeof value.size !== 'number' || + !Number.isSafeInteger(value.size) || + value.size < 0 || + !isSha256Digest(value.contentDigest) + ) { + throw unsupportedEntry('Canonical regular file entry has unsupported metadata'); + } + entry = { + kind: 'file', + path: value.path, + mode: value.mode as 0o644 | 0o755, + size: value.size, + contentDigest: value.contentDigest, + }; + } else { + throw unsupportedEntry('Canonical tree entry type is not supported'); + } + + const path = validateCanonicalPath(entry.path, entry.kind); + return { + entry, + pathBytes: path.pathBytes, + logicalPath: path.logicalPath, + ...(path.parentDirectory === undefined ? {} : { parentDirectory: path.parentDirectory }), + }; +} + +function validateLayoutEntry(value: unknown): SessionBundleCanonicalLayoutEntry { + if (!isRecord(value) || typeof value.path !== 'string') { + throw unsupportedEntry('Canonical layout entry has an invalid shape'); + } + if (value.kind === 'directory') return { kind: 'directory', path: value.path }; + if ( + value.kind === 'file' && + typeof value.mode === 'number' && + REGULAR_FILE_MODES.has(value.mode) + ) { + return { kind: 'file', path: value.path, mode: value.mode as 0o644 | 0o755 }; + } + throw unsupportedEntry('Canonical layout entry has unsupported metadata'); +} + +function validateCanonicalPath( + path: string, + kind: SessionBundleCanonicalTreeEntry['kind'], +): { + pathBytes: Buffer; + logicalPath: string; + parentDirectory?: string; +} { + if (!path || !isValidUnicodeString(path) || path.includes('\0') || path.includes('\\')) { + throw unsafePath('Canonical tree path contains unsupported characters'); + } + if (path.startsWith('/') || /^[A-Za-z]:/.test(path)) { + throw unsafePath('Canonical tree path must be relative'); + } + + const isDirectory = kind === 'directory'; + if (isDirectory !== path.endsWith('/')) { + throw unsafePath('Canonical tree directory marker does not match its entry type'); + } + const logicalPath = isDirectory ? path.slice(0, -1) : path; + const segments = logicalPath.split('/'); + if ( + logicalPath.length === 0 || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw unsafePath('Canonical tree path contains an unsafe segment'); + } + + const pathBytes = Buffer.from(path, 'utf8'); + if (pathBytes.byteLength > MAX_UINT32) { + throw unsafePath('Canonical tree path exceeds the record encoding limit'); + } + + const lastSlash = logicalPath.lastIndexOf('/'); + const parentDirectory = lastSlash < 0 ? undefined : `${logicalPath.slice(0, lastSlash)}/`; + return { + pathBytes, + logicalPath, + ...(parentDirectory === undefined ? {} : { parentDirectory }), + }; +} + +function classifyPayloadPath( + entry: SessionBundleCanonicalLayoutEntry, +): 'state_identity' | 'state_root' | 'workspace_root' | 'payload' { + if (entry.path === SESSION_BUNDLE_STATE_IDENTITY_PATH) { + if (entry.kind !== 'file' || entry.mode !== 0o644) { + throw unsupportedEntry('State identity must be a non-executable regular file'); + } + return 'state_identity'; + } + if (entry.path === SESSION_BUNDLE_STATE_PATH) { + if (entry.kind !== 'directory') throw unsupportedEntry('State root must be a directory'); + return 'state_root'; + } + if (entry.path === SESSION_BUNDLE_WORKSPACE_PATH) { + if (entry.kind !== 'directory') throw unsupportedEntry('Workspace root must be a directory'); + return 'workspace_root'; + } + if ( + entry.path.startsWith(SESSION_BUNDLE_STATE_PATH) || + entry.path.startsWith(SESSION_BUNDLE_WORKSPACE_PATH) + ) { + return 'payload'; + } + throw unsafePath('Canonical tree path is outside the V1 payload roots'); +} + +function encodeTreeHeader(entryCount: number): Buffer { + const header = Buffer.alloc(TREE_HEADER_BYTES); + TREE_MAGIC.copy(header, 0); + header.writeUInt32BE(SESSION_BUNDLE_CANONICALIZATION_VERSION, TREE_MAGIC.byteLength); + header.writeBigUInt64BE(BigInt(entryCount), TREE_MAGIC.byteLength + 4); + return header; +} + +function encodeTreeEntry(validated: ValidatedCanonicalTreeEntry): Buffer { + const { entry, pathBytes } = validated; + const record = Buffer.alloc(TREE_ENTRY_FIXED_BYTES + pathBytes.byteLength); + let offset = 0; + record.writeUInt8( + entry.kind === 'directory' ? DIRECTORY_ENTRY_TYPE : REGULAR_FILE_ENTRY_TYPE, + offset, + ); + offset += 1; + record.writeUInt32BE(pathBytes.byteLength, offset); + offset += 4; + pathBytes.copy(record, offset); + offset += pathBytes.byteLength; + record.writeUInt16BE(entry.kind === 'directory' ? DIRECTORY_MODE : entry.mode, offset); + offset += 2; + record.writeBigUInt64BE(BigInt(entry.kind === 'directory' ? 0 : entry.size), offset); + offset += 8; + if (entry.kind === 'directory') { + DIRECTORY_DIGEST.copy(record, offset); + } else { + Buffer.from(entry.contentDigest.slice('sha256:'.length), 'hex').copy(record, offset); + } + return record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function unsafePath(message: string): SessionBundleFileError { + return new SessionBundleFileError('unsafe_path', message); +} + +function unsupportedEntry(message: string): SessionBundleFileError { + return new SessionBundleFileError('unsupported_entry', message); +} + +function integrityError(message: string): SessionBundleFileError { + return new SessionBundleFileError('integrity_mismatch', message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/487ec79757a9a17fb4320b4deb9c0864c01fef0bfa657ca3e86c2b8ec34ae914.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/487ec79757a9a17fb4320b4deb9c0864c01fef0bfa657ca3e86c2b8ec34ae914.source new file mode 100644 index 0000000000..3c34f50bd6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/487ec79757a9a17fb4320b4deb9c0864c01fef0bfa657ca3e86c2b8ec34ae914.source @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createMcpConfigStore } from '../../mcp-config-store.js'; + +const root = process.argv[2]; +if (!root) throw new Error('Missing MCP config workspace root'); + +await createMcpConfigStore(root).transform(async (current) => { + process.send?.('locked'); + await new Promise(() => setInterval(() => undefined, 1_000)); + return current; +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/49b61c12d523af1ba804b848425a8c91bee90c031d78e4abc0296bac5d0cdb98.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/49b61c12d523af1ba804b848425a8c91bee90c031d78e4abc0296bac5d0cdb98.source new file mode 100644 index 0000000000..743f1ca726 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/49b61c12d523af1ba804b848425a8c91bee90c031d78e4abc0296bac5d0cdb98.source @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PricingConfig } from '@maka/core/usage-stats/types'; + +export interface PricingSnapshot { + readonly revision: number; + readonly overrides: readonly Readonly[]; +} + +export interface PricingMutationResult { + readonly committed: boolean; + readonly changed: boolean; + readonly snapshot: PricingSnapshot; +} + +export interface PricingStore { + snapshot(): PricingSnapshot; + upsert(expectedRevision: number, pricing: PricingConfig): Promise; + delete(expectedRevision: number, modelKey: string): Promise; + load(): Promise; + flush(): Promise; + beginDrain(): Promise; + close(): Promise; +} + +export interface CreatePricingStoreOptions { + readonly createIfMissing?: boolean; +} + +export class PricingStoreClosedError extends Error { + constructor() { + super('Pricing store is draining or closed'); + this.name = 'PricingStoreClosedError'; + } +} + +export class PricingStoreNotLoadedError extends Error { + constructor() { + super('Pricing store has not been loaded'); + this.name = 'PricingStoreNotLoadedError'; + } +} + +export class PricingRevisionConflictError extends Error { + constructor( + readonly expectedRevision: number, + readonly actualRevision: number, + ) { + super(`Pricing revision conflict: expected ${expectedRevision}, actual ${actualRevision}`); + this.name = 'PricingRevisionConflictError'; + } +} + +export class PricingValidationError extends Error { + constructor(message: string) { + super(`Invalid pricing authority: ${message}`); + this.name = 'PricingValidationError'; + } +} + +export class PricingStorePublicationError extends Error { + readonly domain = 'pricing_authority'; + + constructor(options: { cause: unknown }) { + super('Unable to publish pricing authority', options); + this.name = 'PricingStorePublicationError'; + } +} + +export class PricingCommitUnknownError extends Error { + readonly domain = 'pricing_authority'; + + constructor(options: { cause: unknown }) { + super('Pricing commit outcome is unknown; reload before retrying', options); + this.name = 'PricingCommitUnknownError'; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4d7e41e72fcdae82668405e604a70451ad2e524a80ae21778a208b3d5f34972d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4d7e41e72fcdae82668405e604a70451ad2e524a80ae21778a208b3d5f34972d.source new file mode 100644 index 0000000000..10a228f0a4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4d7e41e72fcdae82668405e604a70451ad2e524a80ae21778a208b3d5f34972d.source @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; +import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; + +test('regenerate admission durably binds the immutable source Turn', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-regenerate-admission-')); + try { + const store = createSqliteAgentRunStore(root); + const input = admissionInput(); + const admitted = await store.admitRootTurn(input); + assert.equal(admitted.kind, 'admitted'); + assert.deepEqual(admitted.admission.execution, { + kind: 'regenerate', + sourceTurnId: 'source-turn', + }); + assert.deepEqual(admitted.admission.authorization, input.authorization); + store.close?.(); + + const reopened = createSqliteAgentRunStore(root); + assert.deepEqual( + await reopened.readRootTurnAdmission(input.sessionId, input.turnId), + admitted.admission, + ); + await assert.rejects( + () => + reopened.admitRootTurn( + admissionInput({ + execution: { + kind: 'regenerate', + sourceTurnId: ' source-turn ', + } as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + await assert.rejects( + () => + reopened.admitRootTurn( + admissionInput({ + execution: { kind: 'regenerate', sourceTurnId: 'regenerated-turn' }, + }), + ), + /regenerate source Turn cannot be the admitted Turn/, + ); + + const compact = await reopened.admitRootTurn({ + sessionId: input.sessionId, + turnId: 'compact-turn', + proposedRunId: 'compact-run', + proposedUserMessageId: null, + execution: { kind: 'context_compact' }, + previousRootTurnId: input.turnId, + normalizedInput: null, + sourceMessages: [], + admittedAt: 60, + }); + assert.equal(compact.kind, 'admitted'); + assert.deepEqual(compact.admission.execution, { kind: 'context_compact' }); + reopened.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('new root admissions reject removed Automation authority', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-legacy-automation-admission-')); + try { + const store = createSqliteAgentRunStore(root); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + kind: 'legacy_automation', + automationId: 'automation-1', + } as RootExecutionDescriptor, + }), + ), + /removed Automation authority/, + ); + store.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function admissionInput(overrides: Partial = {}): AdmitRootTurnInput { + return { + sessionId: 'root-session', + turnId: 'regenerated-turn', + proposedRunId: 'regenerated-run', + proposedUserMessageId: 'regenerated-message', + execution: { kind: 'regenerate', sourceTurnId: 'source-turn' }, + previousRootTurnId: null, + normalizedInput: { text: 'Original request' }, + sourceMessages: [], + authorization: { + kind: 'session_turn_access_request', + requestId: 'request-regenerate', + principalId: 'session_guest:guest-1', + grantId: 'grant-1', + approvedAt: 45, + approvedBy: 'local_owner', + }, + admittedAt: 50, + ...overrides, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4dd3091e80cefbd4b2a4d166524df2feddc8b945ba700ed2e514ff4d6179086f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4dd3091e80cefbd4b2a4d166524df2feddc8b945ba700ed2e514ff4d6179086f.source new file mode 100644 index 0000000000..8460bf5285 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4dd3091e80cefbd4b2a4d166524df2feddc8b945ba700ed2e514ff4d6179086f.source @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type WorkBoardStoreErrorCode = + | 'invalid_input' + | 'not_found' + | 'operation_conflict' + | 'corrupt_record' + | 'must_archive_first'; + +export class WorkBoardStoreError extends Error { + readonly code: WorkBoardStoreErrorCode; + + constructor(code: WorkBoardStoreErrorCode, message: string) { + super(message); + this.name = 'WorkBoardStoreError'; + this.code = code; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e478ab9cb693bb5d1bbd7a1753197197915b9d74408e1d2c9456522ede65f27.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e478ab9cb693bb5d1bbd7a1753197197915b9d74408e1d2c9456522ede65f27.source new file mode 100644 index 0000000000..e1f92393a4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e478ab9cb693bb5d1bbd7a1753197197915b9d74408e1d2c9456522ede65f27.source @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { homedir } from 'node:os'; +import { posix, win32 } from 'node:path'; + +const DEFAULT_MAKA_PROFILE_NAME = 'Maka'; + +export interface ResolveMakaClientDataRootInput { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + profileName?: string; +} + +export interface ResolveMakaWorkspaceRootInput extends ResolveMakaClientDataRootInput { + workspaceName?: string; +} + +export interface DeriveMakaDataRootsInput { + platform?: NodeJS.Platform; + workspaceName?: string; +} + +export interface MakaDataRoots { + readonly clientDataRoot: string; + readonly workspaceRoot: string; +} + +export function resolveMakaClientDataRoot(input: ResolveMakaClientDataRootInput = {}): string { + const platform = input.platform ?? process.platform; + const env = input.env ?? process.env; + const home = input.homeDir ?? homedir(); + const profileName = input.profileName ?? DEFAULT_MAKA_PROFILE_NAME; + assertMakaProfileName(profileName); + return resolveElectronUserDataRoot(platform, env, home, profileName); +} + +export function deriveMakaDataRoots( + clientDataRoot: string, + input: DeriveMakaDataRootsInput = {}, +): MakaDataRoots { + const platform = input.platform ?? process.platform; + const workspaceName = input.workspaceName ?? 'default'; + const pathApi = platform === 'win32' ? win32 : posix; + return { + clientDataRoot, + workspaceRoot: pathApi.join(clientDataRoot, 'workspaces', workspaceName), + }; +} + +export function resolveMakaWorkspaceRoot(input: ResolveMakaWorkspaceRootInput = {}): string { + return resolveMakaDataRoots(input).workspaceRoot; +} + +export function resolveMakaDataRoots(input: ResolveMakaWorkspaceRootInput = {}): MakaDataRoots { + return deriveMakaDataRoots(resolveMakaClientDataRoot(input), input); +} + +function resolveElectronUserDataRoot( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + home: string, + profileName: string, +): string { + if (platform === 'darwin') { + return posix.join(home, 'Library', 'Application Support', profileName); + } + if (platform === 'win32') { + return win32.join(env.APPDATA || win32.join(home, 'AppData', 'Roaming'), profileName); + } + return posix.join(resolveXdgConfigHome(env, home), profileName); +} + +export function resolveXdgConfigHome( + env: NodeJS.ProcessEnv = process.env, + homeDir: string = homedir(), +): string { + const configured = env.XDG_CONFIG_HOME; + return configured && posix.isAbsolute(configured) ? configured : posix.join(homeDir, '.config'); +} + +function assertMakaProfileName(profileName: string): void { + if ( + profileName.length === 0 || + profileName === '.' || + profileName === '..' || + /[\\/\0]/u.test(profileName) + ) { + throw new Error('Maka profile name must be a non-empty path segment'); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e73c8eddbd73054ae3fae5569ebaab6b155a18e1535c52c52665f10b8e4b4a5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e73c8eddbd73054ae3fae5569ebaab6b155a18e1535c52c52665f10b8e4b4a5.source new file mode 100644 index 0000000000..80af74d9b0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/4e73c8eddbd73054ae3fae5569ebaab6b155a18e1535c52c52665f10b8e4b4a5.source @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { + decodeModelFactsDocument, + MODEL_FACTS_SCHEMA_VERSION, + UnsupportedModelFactsSchemaError, + type ModelFactsDocument, +} from '@maka/core/model-facts'; +import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; +import { readBoundedDocumentBytes } from './runtime-policy/document-io.js'; +import { RuntimePolicyStoreError } from './runtime-policy/errors.js'; + +export const MODEL_FACTS_DOCUMENT_MAX_BYTES = 256 * 1024; +const FILE = 'model-facts.json'; + +export interface ModelFactsReadResult { + readonly document: ModelFactsDocument; + readonly diagnostic?: 'malformed' | 'oversized' | 'unsupported_schema'; + readonly fingerprint: string; +} + +export class ModelFactsDocumentOwner { + async readWithDiagnostics(root: string): Promise { + let bytes: Buffer | undefined; + try { + bytes = await readBoundedDocumentBytes(root, FILE, MODEL_FACTS_DOCUMENT_MAX_BYTES); + } catch (error) { + if (error instanceof RuntimePolicyStoreError && error.code === 'invalid_document') { + return { + document: emptyDocument(), + diagnostic: error.message.includes('exceeds') ? 'oversized' : 'malformed', + fingerprint: `invalid:${error.message}`, + }; + } + throw error; + } + if (bytes === undefined) return { document: emptyDocument(), fingerprint: 'missing' }; + const fingerprint = fingerprintBytes(bytes); + let value: unknown; + try { + value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; + return { document: decodeModelFactsDocument(value), fingerprint }; + } catch (error) { + if (error instanceof UnsupportedModelFactsSchemaError) { + return { document: emptyDocument(), diagnostic: 'unsupported_schema', fingerprint }; + } + return { document: emptyDocument(), diagnostic: 'malformed', fingerprint }; + } + } + + fingerprintForConnection( + document: ModelFactsDocument, + connection: Pick, + ): string { + const modelIds = new Set([ + ...(connection.models ?? []).map((model) => model.id), + ...connection.enabledModelIds, + ]); + const entries = Object.entries(document.overrides) + .filter(([key]) => { + const separator = key.indexOf(':'); + return ( + separator > 0 && + key.slice(0, separator) === connection.providerType && + modelIds.has(key.slice(separator + 1)) && + Object.prototype.hasOwnProperty.call(document.overrides[key]!, 'apiProtocol') + ); + }) + .map(([key, override]) => [key, { apiProtocol: override.apiProtocol }] as const) + .sort(([left], [right]) => left.localeCompare(right)); + return fingerprintBytes(Buffer.from(JSON.stringify(entries), 'utf8')); + } +} + +function emptyDocument(): ModelFactsDocument { + return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides: {} }; +} + +function fingerprintBytes(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50773baa95f8c0d216929a949baa423fad2627c50b043513a5d8700bdc5bd9c6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50773baa95f8c0d216929a949baa423fad2627c50b043513a5d8700bdc5bd9c6.source new file mode 100644 index 0000000000..2c46a1ca3b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50773baa95f8c0d216929a949baa423fad2627c50b043513a5d8700bdc5bd9c6.source @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { chmod, copyFile, lstat, mkdir, realpath } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { backup, DatabaseSync } from 'node:sqlite'; +import { isCanonicalStorageRef } from '@maka/core/events'; +import { + discoverMarkedStorageRoot, + runWithStorageRootLease, + tryAcquireInteractiveRootOwner, +} from './root-authority.js'; +import { + migrateSqliteContextOffloadDatabase, + SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION, +} from './sqlite-context-offload-schema.js'; +import { + CONTEXT_OFFLOAD_DATABASE_NAME, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, +} from './sqlite-context-offload-store.js'; +import { syncDirectoryChain, syncFile } from './stable-storage.js'; +import { SQLITE_SESSION_MESSAGE_CHUNK_MARKER } from './sqlite-session-metadata-schema.js'; + +/** + * Existing artifact-only snapshots keep their old admission contract. Context + * snapshots require the root owner: a Session gate does not fence global GC. + * The callback must recheck absence before publishing an artifact-only snapshot. + */ +export async function withOfflineContextSnapshot( + root: string, + operation: (contextLocked: boolean) => Promise, +): Promise { + if (!(await exists(join(root, CONTEXT_OFFLOAD_DATABASE_NAME)))) return operation(false); + const capability = await discoverMarkedStorageRoot({ path: root }); + const owner = await tryAcquireInteractiveRootOwner(capability); + if (!owner) + throw new Error( + 'Context snapshot requires an offline Storage Root; stop the Runtime Host first', + ); + try { + return await runWithStorageRootLease(owner.lease, 'interactive', 'write', () => + operation(true), + ); + } finally { + await owner.close(); + } +} + +/** Copies under the offline owner and the caller's Artifact lock; never mutates the source DB. */ +export async function copyContextSnapshot( + sourceRoot: string, + targetRoot: string, + contextLocked: boolean, + sessionId?: string, +): Promise { + const sourcePath = join(sourceRoot, CONTEXT_OFFLOAD_DATABASE_NAME); + if (!(await exists(sourcePath))) return false; + if (!contextLocked) + throw new Error( + 'Context storage appeared during snapshot; retry with the Runtime Host stopped', + ); + await assertRegularPath(sourceRoot, CONTEXT_OFFLOAD_DATABASE_NAME); + const targetPath = join(targetRoot, CONTEXT_OFFLOAD_DATABASE_NAME); + const source = new DatabaseSync(sourcePath, { readOnly: true }); + try { + await backup(source, targetPath); + } finally { + source.close(); + } + await chmod(targetPath, 0o600); + const target = new DatabaseSync(targetPath); + try { + target.exec('PRAGMA foreign_keys = ON'); + migrateSqliteContextOffloadDatabase(target); + target.exec('BEGIN IMMEDIATE'); + if (sessionId !== undefined) + target.prepare('DELETE FROM context_refs WHERE session_id <> ?').run(sessionId); + target.exec(` + DELETE FROM context_gc_candidates; + DELETE FROM context_file_deletions; + DELETE FROM context_blobs WHERE NOT EXISTS ( + SELECT 1 FROM context_refs WHERE context_refs.blob_id = context_blobs.blob_id + ); + DELETE FROM context_session_usage; + INSERT INTO context_session_usage + SELECT r.session_id, count(*), sum(b.size_bytes) + FROM context_refs r JOIN context_blobs b USING(blob_id) GROUP BY r.session_id; + UPDATE context_store_usage SET + blob_count = (SELECT count(*) FROM context_blobs), + physical_bytes = (SELECT coalesce(sum(size_bytes), 0) FROM context_blobs) + WHERE singleton = 1; + COMMIT; + PRAGMA journal_mode = DELETE; + VACUUM; + `); + // VACUUM on the private destination removes other Sessions' deleted bytes, + // including SQLite free pages. It is never run on the live source. + for (const row of target + .prepare( + "SELECT blob_id, payload, size_bytes FROM context_blobs WHERE storage_kind = 'managed_file'", + ) + .iterate()) { + const path = managedPath(row.blob_id, row.payload); + await assertRegularPath(sourceRoot, path); + const destination = join(targetRoot, path); + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + await copyFile(join(sourceRoot, path), destination); + await chmod(destination, 0o600); + await syncFile(destination); + await syncDirectoryChain(dirname(destination), targetRoot); + } + } finally { + target.close(); + } + await syncFile(targetPath); + await syncDirectoryChain(targetRoot, targetRoot); + return true; +} + +export async function planContextSnapshotFiles(root: string, sessionId: string): Promise { + const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + if (!(await exists(path))) return []; + await assertRegularPath(root, CONTEXT_OFFLOAD_DATABASE_NAME); + const database = new DatabaseSync(path, { readOnly: true }); + try { + const version = Number(database.prepare('PRAGMA user_version').get()?.user_version); + if (![1, 2, SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION].includes(version)) + throw new Error('Unsupported context snapshot schema'); + const files = [CONTEXT_OFFLOAD_DATABASE_NAME]; + if (version < 3) return files; + for (const row of database + .prepare(`SELECT DISTINCT b.blob_id, b.payload FROM context_refs r + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.session_id = ? AND b.storage_kind = 'managed_file' ORDER BY b.blob_id`) + .iterate(sessionId)) { + const file = managedPath(row.blob_id, row.payload); + await assertRegularPath(root, file); + files.push(file); + } + return files; + } finally { + database.close(); + } +} + +/** Verifies both payload integrity and typed ledger/message references before publication. */ +export async function validateContextSnapshot(root: string): Promise { + let context: DatabaseSync | undefined; + const contextPath = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); + try { + if (await exists(contextPath)) { + await assertRegularPath(root, CONTEXT_OFFLOAD_DATABASE_NAME); + context = new DatabaseSync(contextPath, { readOnly: true }); + if ( + context.prepare('PRAGMA user_version').get()?.user_version !== + SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION + ) + throw new Error('Unsupported context snapshot schema'); + if ( + context.prepare('PRAGMA integrity_check').get()?.integrity_check !== 'ok' || + context.prepare('PRAGMA foreign_key_check').get() + ) + throw new Error('Invalid context snapshot database'); + for (const row of context + .prepare('SELECT blob_id, storage_kind, payload, size_bytes FROM context_blobs') + .iterate()) { + const digest = blobHash(row.blob_id); + if (!Number.isSafeInteger(row.size_bytes) || Number(row.size_bytes) < 0) + throw new Error('Invalid context snapshot size'); + if (row.storage_kind === 'managed_file') { + const path = managedPath(row.blob_id, row.payload); + await assertRegularPath(root, path); + const info = await lstat(join(root, path)); + if (info.size !== row.size_bytes || (await hashFile(join(root, path))) !== digest) { + throw new Error('Context snapshot payload size/hash mismatch'); + } + } else if ( + row.storage_kind !== 'inline' || + !(row.payload instanceof Uint8Array) || + row.payload.byteLength !== row.size_bytes || + createHash('sha256').update(row.payload).digest('hex') !== digest + ) { + throw new Error('Context snapshot inline payload size/hash mismatch'); + } + } + const actual = context + .prepare('SELECT count(*) AS n, coalesce(sum(size_bytes), 0) AS bytes FROM context_blobs') + .get()!; + const usage = context + .prepare('SELECT blob_count, physical_bytes FROM context_store_usage WHERE singleton = 1') + .get(); + if (usage?.blob_count !== actual.n || usage.physical_bytes !== actual.bytes) + throw new Error('Context snapshot usage mismatch'); + if ( + context + .prepare(`SELECT 1 FROM ( + SELECT r.session_id, count(*) AS reference_count, sum(b.size_bytes) AS logical_bytes + FROM context_refs r JOIN context_blobs b USING(blob_id) GROUP BY r.session_id + EXCEPT SELECT session_id, reference_count, logical_bytes FROM context_session_usage + ) LIMIT 1`) + .get() + ) + throw new Error('Context snapshot Session usage mismatch'); + } + validateLedgerContextRefs(root, context); + } finally { + context?.close(); + } +} + +function validateLedgerContextRefs(root: string, context: DatabaseSync | undefined): void { + const runtime = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + const find = context?.prepare('SELECT 1 FROM context_refs WHERE session_id = ? AND ref_id = ?'); + const check = (value: unknown, sessionId: string): void => { + const ref = record(value); + if (ref.kind !== 'session_context') return; + if ( + !isCanonicalStorageRef(value) || + ref.sessionId !== sessionId || + !find?.get(sessionId, String(ref.refId)) + ) + throw new Error('Snapshot has a missing or cross-Session context reference'); + }; + const content = (value: unknown, sessionId: string): void => { + const item = record(value); + if (item.kind === 'image') check(item.ref, sessionId); + }; + const attachments = (value: unknown, sessionId: string): void => { + const item = record(value); + if (Array.isArray(item.attachments)) { + for (const attachment of item.attachments) check(record(attachment).ref, sessionId); + } + }; + const projection = (value: unknown, sessionId: string): void => { + const item = record(value); + if (item.kind === 'content' && Array.isArray(item.parts)) { + for (const part of item.parts) { + const entry = record(part); + if (entry.kind === 'artifact') check(entry.ref, sessionId); + } + } + }; + try { + for (const row of runtime + .prepare('SELECT session_id, payload_json FROM runtime_events') + .iterate()) { + const event = record(JSON.parse(String(row.payload_json))); + const item = record(event.content); + const sessionId = String(row.session_id); + if (item.kind === 'text') attachments(item, sessionId); + if (item.kind === 'function_response') { + content(item.result, sessionId); + projection(item.modelProjection, sessionId); + } + } + for (const row of runtime + .prepare( + "SELECT session_id, record_json FROM core_agent_run_events WHERE event_type = 'model_projection_transition_recorded'", + ) + .iterate()) { + const event = record(JSON.parse(String(row.record_json))); + projection(record(record(event.data).transition).replacement, String(row.session_id)); + } + const chunks = runtime.prepare( + 'SELECT data FROM session_message_chunks WHERE session_id = ? AND sequence = ? ORDER BY chunk_index', + ); + for (const row of runtime + .prepare('SELECT session_id, sequence, record_json FROM session_messages') + .iterate()) { + const encoded = + row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER + ? Buffer.concat( + chunks.all(row.session_id!, row.sequence!).map((chunk) => { + if (!(chunk.data instanceof Uint8Array)) + throw new Error('Invalid snapshot message chunk'); + return Buffer.from(chunk.data); + }), + ).toString('utf8') + : String(row.record_json); + const message = record(JSON.parse(encoded)); + const sessionId = String(row.session_id); + if (message.type === 'user') attachments(message, sessionId); + if (message.type === 'tool_result') content(message.content, sessionId); + } + } finally { + runtime.close(); + } +} + +function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function blobHash(value: unknown): string { + if (!(value instanceof Uint8Array) || value.byteLength !== 32) + throw new Error('Invalid context snapshot blob identity'); + return Buffer.from(value).toString('hex'); +} + +function managedPath(blobId: unknown, payload: unknown): string { + const digest = blobHash(blobId); + const expected = `sha256/${digest.slice(0, 2)}/${digest}`; + if (!(payload instanceof Uint8Array) || !Buffer.from(payload).equals(Buffer.from(expected))) { + throw new Error('Invalid context snapshot managed locator'); + } + return `${CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME}/${expected}`; +} + +async function assertRegularPath(root: string, relativePath: string): Promise { + const canonical = await realpath(root); + const parts = relativePath.split('/'); + let path = canonical; + for (const [index, part] of parts.entries()) { + path = join(path, part); + const info = await lstat(path); + if ( + info.isSymbolicLink() || + (index === parts.length - 1 ? !info.isFile() : !info.isDirectory()) + ) { + throw new Error('Context snapshot path must not contain symlinks or special files'); + } + } + if ((await realpath(path)) !== resolve(canonical, relativePath)) + throw new Error('Context snapshot path escaped its root'); +} + +async function hashFile(path: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false; + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50a0e9dca404dbc503d6c086c10e1f373a37be65da667af7bf722af397052219.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50a0e9dca404dbc503d6c086c10e1f373a37be65da667af7bf722af397052219.source new file mode 100644 index 0000000000..c0e44789df --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50a0e9dca404dbc503d6c086c10e1f373a37be65da667af7bf722af397052219.source @@ -0,0 +1,362 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + ActivationEnvironmentSecretSink, + ActivationSecretInjector, + type ActivationSecretInjectionLease, + type ActivationSecretSink, +} from '../activation-secret-injector.js'; +import { + InMemoryManagedSecretStore, + ManagedSecretError, + type ManagedSecretMaterial, + type ManagedSecretMetadata, + type ResolveManagedSecretsForActivationInput, +} from '../managed-secret-store.js'; + +const PRINCIPAL = 'user-1'; +const SESSION = 'cloud-session-1'; +const CONTEXT = { + principalId: PRINCIPAL, + cloudSessionId: SESSION, + activationId: 'activation-1', +}; + +describe('ActivationSecretInjector', () => { + test('injects into an isolated launch environment and restores its prior state', async () => { + const { store, first, second } = await preparedStore(); + const environment: NodeJS.ProcessEnv = { FIRST_TOKEN: 'previous', UNRELATED: 'kept' }; + const handle = await new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink: new ActivationEnvironmentSecretSink(environment), + }); + assert.deepEqual(environment, { + FIRST_TOKEN: 'first-value', + SECOND_TOKEN: 'second-value', + UNRELATED: 'kept', + }); + + await handle.release(); + assert.deepEqual(environment, { FIRST_TOKEN: 'previous', UNRELATED: 'kept' }); + }); + + test('literal redaction cannot rewrite its own replacement marker', async () => { + const ids = ['00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000012']; + const store = new InMemoryManagedSecretStore({ newSecretId: () => ids.shift()! }); + const longer = await store.createSecret({ principalId: PRINCIPAL, value: 'long-value' }); + const markerSubstring = await store.createSecret({ principalId: PRINCIPAL, value: 'red' }); + for (const secret of [longer, markerSubstring]) { + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SESSION, + }); + } + const handle = await new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(longer, 'LONGER'), binding(markerSubstring, 'SHORTER')], + sink: recordingSink([]), + }); + assert.equal(handle.redact('long-value then red'), '[redacted] then [redacted]'); + await handle.release(); + }); + + test('resolves before injection and gives the sink one complete batch effect', async () => { + const { store, first, second } = await preparedStore(); + const events: string[] = []; + const sink = recordingSink(events); + const handle = await new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink, + }); + + assert.deepEqual(events, [ + 'inject:FIRST_TOKEN:first-value', + 'inject:SECOND_TOKEN:second-value', + ]); + assert.equal(JSON.stringify(handle).includes('first-value'), false); + assert.deepEqual(handle.references, [first.reference, second.reference]); + assert.equal( + handle.redact('tool printed first-value and second-value'), + 'tool printed [redacted] and [redacted]', + ); + assert.equal(handle.redact('the red value is first-value'), 'the red value is [redacted]'); + + await handle.release(); + await handle.release(); + assert.equal(handle.redact('first-value'), 'first-value'); + assert.deepEqual(events, [ + 'inject:FIRST_TOKEN:first-value', + 'inject:SECOND_TOKEN:second-value', + 'release:batch', + ]); + }); + + test('the environment sink restores the complete batch if applying an entry fails', async () => { + const target = { FIRST_TOKEN: 'previous', UNRELATED: 'kept' } as NodeJS.ProcessEnv; + const environment = new Proxy(target, { + set(object, property, value) { + if (property === 'SECOND_TOKEN') throw new Error('setter failed'); + return Reflect.set(object, property, value); + }, + }); + + await assert.rejects( + new ActivationEnvironmentSecretSink(environment).injectEnvironmentVariables({ + entries: [ + { name: 'FIRST_TOKEN', value: 'first-value' }, + { name: 'SECOND_TOKEN', value: 'second-value' }, + ], + }), + /setter failed/u, + ); + assert.deepEqual(target, { FIRST_TOKEN: 'previous', UNRELATED: 'kept' }); + }); + + test('injects nothing when any reference is missing authorization', async () => { + const { store, first, second } = await preparedStore(); + await store.revokeSessionAuthorization({ + principalId: PRINCIPAL, + reference: second.reference, + cloudSessionId: SESSION, + }); + const events: string[] = []; + await assert.rejects( + new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink: recordingSink(events), + }), + (error: unknown) => error instanceof ManagedSecretError && error.code === 'unauthorized', + ); + assert.deepEqual(events, []); + }); + + test('rejects reordered material references before the first sink effect', async () => { + const ids = ['00000000-0000-4000-8000-000000000021', '00000000-0000-4000-8000-000000000022']; + const store = new ReorderingManagedSecretStore({ newSecretId: () => ids.shift()! }); + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'first-value' }); + const second = await store.createSecret({ principalId: PRINCIPAL, value: 'second-value' }); + for (const secret of [first, second]) { + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SESSION, + }); + } + const events: string[] = []; + + await assert.rejects( + new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink: recordingSink(events), + }), + (error: unknown) => error instanceof ManagedSecretError && error.code === 'integrity_failure', + ); + assert.deepEqual(events, []); + }); + + test('uses a private material snapshot across asynchronous sink effects', async () => { + const ids = ['00000000-0000-4000-8000-000000000031', '00000000-0000-4000-8000-000000000032']; + const store = new MutableMaterialManagedSecretStore({ newSecretId: () => ids.shift()! }); + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'first-value' }); + const second = await store.createSecret({ principalId: PRINCIPAL, value: 'second-value' }); + for (const secret of [first, second]) { + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SESSION, + }); + } + const events: string[] = []; + + const handle = await new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink: { + async injectEnvironmentVariables({ entries }) { + store.replaceSecondMaterialWithFirst(); + events.push(...entries.map(({ name, value }) => `inject:${name}:${value}`)); + return oneShotLease(() => events.push('release:batch')); + }, + }, + }); + + assert.deepEqual(events, [ + 'inject:FIRST_TOKEN:first-value', + 'inject:SECOND_TOKEN:second-value', + ]); + await handle.release(); + }); + + test('wraps a batch sink failure without exposing resolved material', async () => { + const { store, first, second } = await preparedStore(); + const events: string[] = []; + const leaked = 'second-value'; + const sink: ActivationSecretSink = { + async injectEnvironmentVariables({ entries }) { + events.push(...entries.map(({ name, value }) => `inject:${name}:${value}`)); + throw new Error(`sink exposed ${leaked}`); + }, + }; + + await assert.rejects( + new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN'), binding(second, 'SECOND_TOKEN')], + sink, + }), + (error: unknown) => { + assert.ok(error instanceof ManagedSecretError); + assert.equal(error.code, 'injection_failed'); + assert.equal(error.message.includes(leaked), false); + assert.equal(JSON.stringify(error).includes(leaked), false); + return true; + }, + ); + assert.deepEqual(events, [ + 'inject:FIRST_TOKEN:first-value', + 'inject:SECOND_TOKEN:second-value', + ]); + }); + + test('keeps successful-handle cleanup failures retryable', async () => { + const { store, first } = await preparedStore(); + let attempts = 0; + const handle = await new ActivationSecretInjector(store).prepare({ + context: CONTEXT, + bindings: [binding(first, 'FIRST_TOKEN')], + sink: { + async injectEnvironmentVariables() { + return { + async release() { + attempts += 1; + if (attempts === 1) throw new Error('transient'); + }, + }; + }, + }, + }); + await assert.rejects( + handle.release(), + (error: unknown) => error instanceof ManagedSecretError && error.code === 'cleanup_failed', + ); + await handle.release(); + assert.equal(attempts, 2); + }); + + test('rejects duplicate and malformed environment targets before resolution', async () => { + const { store, first, second } = await preparedStore(); + const injector = new ActivationSecretInjector(store); + await assert.rejects( + injector.prepare({ + context: CONTEXT, + bindings: [binding(first, 'TOKEN'), binding(second, 'token')], + sink: recordingSink([]), + }), + (error: unknown) => error instanceof ManagedSecretError && error.code === 'invalid_input', + ); + await assert.rejects( + injector.prepare({ + context: CONTEXT, + bindings: [binding(first, 'NOT-AN-ENV-NAME')], + sink: recordingSink([]), + }), + (error: unknown) => error instanceof ManagedSecretError && error.code === 'invalid_input', + ); + }); +}); + +class ReorderingManagedSecretStore extends InMemoryManagedSecretStore { + override async resolveForActivation( + input: ResolveManagedSecretsForActivationInput, + ): Promise { + return [...(await super.resolveForActivation(input))].reverse(); + } +} + +class MutableMaterialManagedSecretStore extends InMemoryManagedSecretStore { + #material: ManagedSecretMaterial[] = []; + + override async resolveForActivation( + input: ResolveManagedSecretsForActivationInput, + ): Promise { + this.#material = (await super.resolveForActivation(input)).map((item) => ({ + ...item, + reference: { ...item.reference }, + })); + return this.#material; + } + + replaceSecondMaterialWithFirst(): void { + const first = this.#material[0]; + if (first) this.#material[1] = first; + } +} + +async function preparedStore() { + const ids = ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002']; + const store = new InMemoryManagedSecretStore({ + newSecretId: () => { + const id = ids.shift(); + if (!id) throw new Error('ids exhausted'); + return id; + }, + }); + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'first-value' }); + const second = await store.createSecret({ principalId: PRINCIPAL, value: 'second-value' }); + for (const secret of [first, second]) { + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SESSION, + }); + } + return { store, first, second }; +} + +function binding(secret: ManagedSecretMetadata, name: string) { + return { reference: secret.reference, target: { kind: 'environment' as const, name } }; +} + +function recordingSink(events: string[]): ActivationSecretSink { + return { + async injectEnvironmentVariables({ entries }) { + events.push(...entries.map(({ name, value }) => `inject:${name}:${value}`)); + return oneShotLease(() => events.push('release:batch')); + }, + }; +} + +function oneShotLease(release: () => void): ActivationSecretInjectionLease { + let released = false; + return { + async release() { + if (released) return; + release(); + released = true; + }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50bbb968805fa5596629c15cda92c01eb7287bff35cd9a0f3462beb5e62f7653.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50bbb968805fa5596629c15cda92c01eb7287bff35cd9a0f3462beb5e62f7653.source new file mode 100644 index 0000000000..b0576f05fa --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/50bbb968805fa5596629c15cda92c01eb7287bff35cd9a0f3462beb5e62f7653.source @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentFailpoint, +} from '../managed-dependency-environment.js'; + +const execFileAsync = promisify(execFile); +const producerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); +const childEntrypoint = fileURLToPath( + new URL('./fixtures/managed-dependency-environment-crash-child.js', import.meta.url), +); +const ownerChildEntrypoint = fileURLToPath( + new URL('./fixtures/managed-dependency-environment-owner-child.js', import.meta.url), +); + +test('rejects a second authority for the same storage root in another process', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-owner-process-')); + const child = spawn(process.execPath, [ownerChildEntrypoint], { + env: { ...process.env, MAKA_DEPENDENCY_OWNER_ROOT: storageRoot }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + t.after(async () => { + if (child.exitCode === null) child.kill(); + if (child.exitCode === null) await onceChildExit(child); + await rm(storageRoot, { recursive: true, force: true }); + }); + await waitForChildReady(child); + + const second = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision() {}, + }, + }).then( + (authority) => ({ authority }), + (error: unknown) => ({ error }), + ); + if ('authority' in second) { + await second.authority.close(); + assert.fail('a second process acquired the same dependency authority'); + } + assert.match(String(second.error), /already has an active owner/u); +}); + +for (const failpoint of [ + 'during_environment_provision', + 'after_environment_tree_durable', + 'after_environment_receipt_durable', + 'after_environment_publish', +] as const satisfies readonly ( + | ManagedDependencyEnvironmentFailpoint + | 'during_environment_provision' +)[]) { + test(`converges after process exit at ${failpoint}`, async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-dependency-crash-')); + t.after(() => rm(storageRoot, { recursive: true, force: true })); + await assert.rejects( + execFileAsync(process.execPath, [childEntrypoint], { + env: { + ...process.env, + MAKA_DEPENDENCY_CRASH_ROOT: storageRoot, + MAKA_DEPENDENCY_CRASH_POINT: failpoint, + }, + windowsHide: true, + }), + (error: unknown) => error instanceof Error && 'code' in error && Number(error.code) === 73, + ); + + let provisionCalls = 0; + const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + provisionCalls += 1; + await mkdir(join(input.outputRoot, 'fixture-package'), { + recursive: true, + }); + await writeFile(join(input.outputRoot, 'fixture-package', 'index.js'), 'safe\n'); + }, + }, + }); + const source = dependencySource(); + const lease = await authority.acquire( + computeManagedDependencyEnvironmentIdentity(source), + source, + ); + assert.equal( + await readFile(join(lease.dependencyRoot, 'fixture-package', 'index.js'), 'utf8'), + 'safe\n', + ); + assert.equal(provisionCalls, failpoint === 'after_environment_receipt_durable' ? 0 : 1); + assert.deepEqual( + await readdir(join(storageRoot, 'managed-workspaces', 'dependency-environments', '.staging')), + [], + ); + await lease.release(); + await authority.close(); + }); +} + +function dependencySource() { + return { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, + }; +} + +async function waitForChildReady(child: ChildProcess): Promise { + await new Promise((resolve, reject) => { + let output = ''; + const timeout = setTimeout(() => finish(new Error('owner child did not become ready')), 15_000); + const finish = (error?: Error) => { + clearTimeout(timeout); + child.stdout?.off('data', onData); + child.stderr?.off('data', onErrorData); + child.off('error', onError); + child.off('exit', onExit); + if (error) reject(error); + else resolve(); + }; + const onData = (chunk: Buffer) => { + output += chunk.toString('utf8'); + if (output.includes('READY\n')) finish(); + }; + const onErrorData = (chunk: Buffer) => finish(new Error(chunk.toString('utf8'))); + const onError = (error: Error) => finish(error); + const onExit = (code: number | null) => finish(new Error(`owner child exited early: ${code}`)); + child.stdout?.on('data', onData); + child.stderr?.on('data', onErrorData); + child.on('error', onError); + child.on('exit', onExit); + }); +} + +async function onceChildExit(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + await new Promise((resolve) => { + const onExit = () => resolve(); + child.once('exit', onExit); + if (child.exitCode !== null) { + child.off('exit', onExit); + resolve(); + } + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/515fca9e5b2cc35f15c4308f67e386c75affe87e381fbad43e8a99e6cdbe0229.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/515fca9e5b2cc35f15c4308f67e386c75affe87e381fbad43e8a99e6cdbe0229.source new file mode 100644 index 0000000000..217833f97b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/515fca9e5b2cc35f15c4308f67e386c75affe87e381fbad43e8a99e6cdbe0229.source @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isAbsolute } from 'node:path'; +import { + ARTIFACT_KINDS, + ARTIFACT_SOURCES, + type ArtifactKind, + type ArtifactRecord, + type ArtifactSource, + isArtifactTurnKey, + isCanonicalArtifactEntityId, +} from '@maka/core/artifacts'; +import { isDeepResearchArtifactRole } from '@maka/core/deep-research-run'; + +const ARTIFACT_KIND_SET = new Set(ARTIFACT_KINDS); +const ARTIFACT_SOURCE_SET = new Set(ARTIFACT_SOURCES); +const ARTIFACT_RECORD_KEYS = new Set([ + 'id', + 'sessionId', + 'turnId', + 'createdAt', + 'name', + 'kind', + 'relativePath', + 'sizeBytes', + 'mimeType', + 'source', + 'summary', + 'deepResearchRole', +]); + +export function decodeArtifactRecordJsons(values: readonly unknown[]): ArtifactRecord[] { + const records: ArtifactRecord[] = []; + const ids = new Set(); + for (const [index, value] of values.entries()) { + if (typeof value !== 'string') continue; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + continue; + } + if (!hasSupportedArtifactSource(parsed)) continue; + try { + const record = decodeArtifactRecord(parsed, index + 1); + if (ids.has(record.id)) continue; + ids.add(record.id); + records.push(record); + } catch {} + } + return records; +} + +function hasSupportedArtifactSource(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.source === 'string' && + ARTIFACT_SOURCE_SET.has(value.source as ArtifactSource) + ); +} + +export function isSafeRelativeArtifactPath(relativePath: string): boolean { + if (!relativePath || isAbsolute(relativePath)) return false; + if (relativePath.includes('\0')) return false; + if (relativePath.includes('//') || relativePath.includes('\\\\')) return false; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(relativePath)) return false; + const parts = relativePath.split(/[\\/]+/); + return parts.every((part) => part !== '' && part !== '.' && part !== '..'); +} + +export function validateRelativeArtifactPath(relativePath: string): void { + if (!isSafeRelativeArtifactPath(relativePath)) { + throw new Error('Artifact relativePath must be artifact-root-relative'); + } +} + +function decodeArtifactRecord(value: unknown, index: number): ArtifactRecord { + if (!isRecord(value)) throw invalidMetadataRecord(index); + if (Object.keys(value).some((key) => !ARTIFACT_RECORD_KEYS.has(key))) { + throw invalidMetadataRecord(index); + } + if ( + !isCanonicalArtifactEntityId(value.id) || + !isCanonicalArtifactEntityId(value.sessionId) || + !isArtifactTurnKey(value.turnId) || + !isNonEmptyString(value.name) || + typeof value.kind !== 'string' || + !ARTIFACT_KIND_SET.has(value.kind as ArtifactKind) || + !isNonEmptyString(value.relativePath) || + typeof value.createdAt !== 'number' || + !Number.isSafeInteger(value.createdAt) || + value.createdAt < 0 || + typeof value.sizeBytes !== 'number' || + !Number.isSafeInteger(value.sizeBytes) || + value.sizeBytes < 0 || + !isOptionalNonEmptyString(value.mimeType) || + !isOptionalNonEmptyString(value.summary) || + (value.deepResearchRole !== undefined && !isDeepResearchArtifactRole(value.deepResearchRole)) || + typeof value.source !== 'string' + ) { + throw invalidMetadataRecord(index); + } + validateRelativeArtifactPath(value.relativePath); + if (!isCompatibleArtifactName(value.name)) throw invalidMetadataRecord(index); + if (value.relativePath !== `${value.sessionId}/${value.id}-${value.name}`) { + throw invalidMetadataRecord(index); + } + return value as unknown as ArtifactRecord; +} + +function isCompatibleArtifactName(name: string): boolean { + if (name.length === 0 || name.length > 120) return false; + if (/[\\/:*?"<>|\0]/.test(name)) return false; + if (/^\s|\s{2,}|[^\S ]/u.test(name)) return false; + if (name.endsWith(' ') && name.length < 120) return false; + return true; +} + +function invalidMetadataRecord(index: number): Error { + return new Error(`Invalid artifact metadata record ${index}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isOptionalNonEmptyString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/51ad1282a62435d08a3b2cc81fe361d57cb042577af736ec5ecdc1c8ca8fbf05.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/51ad1282a62435d08a3b2cc81fe361d57cb042577af736ec5ecdc1c8ca8fbf05.source new file mode 100644 index 0000000000..03307ed9f0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/51ad1282a62435d08a3b2cc81fe361d57cb042577af736ec5ecdc1c8ca8fbf05.source @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { resolve } from 'node:path'; +import { syncBuiltinESMExports } from 'node:module'; + +const [archivePath, limitsJson] = process.argv.slice(2); +if (archivePath === undefined || limitsJson === undefined) process.exit(2); + +const target = resolve(archivePath); +const originalOpen = fs.promises.open.bind(fs.promises); +let mutated = false; +fs.promises.open = async (...args) => { + const handle = await originalOpen(...args); + if (resolve(args[0].toString()) !== target) return handle; + const originalCreateReadStream = handle.createReadStream.bind(handle); + handle.createReadStream = (options) => { + if (!mutated) { + mutated = true; + const changed = new Date(Date.now() + 60_000); + fs.utimesSync(target, changed, changed); + } + return originalCreateReadStream(options); + }; + return handle; +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); +try { + await createSessionBundleFileService().inspect({ + source: { path: target }, + limits: JSON.parse(limitsJson), + }); + process.exit(3); +} catch (error) { + process.stdout.write( + JSON.stringify({ + code: error instanceof SessionBundleFileError ? error.code : 'unexpected', + operation: error instanceof SessionBundleFileError ? error.details?.operation : undefined, + }), + ); + process.exit(error instanceof SessionBundleFileError && error.code === 'source_changed' ? 0 : 4); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5369dc7815b4f1c0116805a72b8814f7d307a2285382e962a3fc26b9e0baf0d6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5369dc7815b4f1c0116805a72b8814f7d307a2285382e962a3fc26b9e0baf0d6.source new file mode 100644 index 0000000000..72fef3c27b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5369dc7815b4f1c0116805a72b8814f7d307a2285382e962a3fc26b9e0baf0d6.source @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + tryAcquireInteractiveRootReader, +} from '../../root-authority.js'; + +const [root, access] = process.argv.slice(2); +if (!root || (access !== 'read' && access !== 'write')) { + throw new Error('usage: root-lock-holder '); +} + +const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); +const lock = + access === 'write' + ? await tryAcquireInteractiveRootOwner(capability) + : await tryAcquireInteractiveRootReader(capability); + +if (!lock) { + process.send?.({ type: 'denied' }); + process.exit(2); +} + +process.send?.({ type: 'locked' }); +process.on('message', (message) => { + if (message === 'close') { + void lock.close().finally(() => process.exit(0)); + return; + } + if (message === 'throw') { + throw new Error('intentional uncaught holder failure'); + } + if (message === 'abort') { + process.abort(); + } + if (message === 'spawn-descendant') { + const descendant = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 1000)'], { + detached: true, + stdio: 'ignore', + }); + const pid = descendant.pid; + if (pid === undefined) throw new Error('descendant did not receive a process id'); + descendant.unref(); + process.send?.({ type: 'descendant', pid }, () => process.exit(0)); + } +}); + +setInterval(() => undefined, 1_000).unref(); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/53cb7d41ec896d9d7ed475ad1a87b68fd2349114335336d3e08a9100d1f577c9.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/53cb7d41ec896d9d7ed475ad1a87b68fd2349114335336d3e08a9100d1f577c9.source new file mode 100644 index 0000000000..56f3811cae --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/53cb7d41ec896d9d7ed475ad1a87b68fd2349114335336d3e08a9100d1f577c9.source @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { isSafeStorageId } from './storage-id.js'; + +export function isRuntimeStorageSafeId(value: string): boolean { + return isSafeStorageId(value); +} + +export function immutableSteeringMessageId(event: RuntimeEvent): string | undefined { + const messageId = event.refs?.providerEventId; + return event.partial === false && + typeof messageId === 'string' && + isRuntimeStorageSafeId(messageId) && + event.content?.kind === 'text' && + event.content.steering === true + ? messageId + : undefined; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/54c305c6fbfd63a70c405ed49b73a58075e12294e8d5411066a7f7648a20a5fc.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/54c305c6fbfd63a70c405ed49b73a58075e12294e8d5411066a7f7648a20a5fc.source new file mode 100644 index 0000000000..bbd5b73322 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/54c305c6fbfd63a70c405ed49b73a58075e12294e8d5411066a7f7648a20a5fc.source @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Source resolution for a Claude Code transcript, ahead of any conversion. + * + * A `.jsonl` transcript is an append-only log of a `uuid` / `parentUuid` + * graph, and the graph is not a path. Reading it as one is the mistake this + * module exists to avoid — in both directions: + * + * **It branches for ordinary reasons.** Of 358 forked parents across 1130 + * local transcripts, 281 are one shape: an assistant record carrying a + * `tool_use` has two children, the next fragment of that same response and + * the `tool_result` of the call it just made. Nothing was abandoned; a + * response and a completion simply share a parent. Selecting "the active + * lineage" by walking parents back from the newest record treats all of that + * as dead — measured on this corpus, such a walk strands 6535 tool results + * and 18815 other records. + * + * **It branches for one real reason.** 72 forked parents have two or more + * *user prompt* children. Those are rewinds — the user edited a prompt and + * resubmitted — and the transcript keeps both. Importing both presents a + * question the user withdrew, and its answer, as conversation: + * + * ``` + * StreamVByte 是什么类型?我忘了 ← withdrawn + * StreamVByte 是什么方式?我忘了 ← asked + * ``` + * + * So resolution is exactly that narrow: among sibling prompts the last one + * written wins, and a withdrawn prompt takes its subtree with it. Every other + * branch is kept, because nothing in the records says it was abandoned. + * + * **Compaction is not a branch.** The boundary record carries + * `parentUuid: null` and starts a new root, because after a compaction the + * model's context no longer holds what came before. Both sides are + * conversation that happened and both are kept — keeping only the newest root + * would discard 24,695 records here. Its `logicalParentUuid` is *not* the + * backward link it resembles: it equals + * `compactMetadata.preservedSegment.tailUuid`, a record written after the + * boundary, so following it as a parent points forward and closes a cycle. + * + * Every rule above is read off fields the transcript states outright. Where + * the records are silent the record is kept: dropping history is the failure + * that cannot be undone once it is persisted as canonical. + */ + +export type TranscriptRecord = Record; + +export interface LineageResolution { + /** The selected records, in the order the file wrote them. */ + readonly records: readonly TranscriptRecord[]; + /** Records dropped as descending from a withdrawn prompt. */ + readonly abandoned: number; + /** Prompts withdrawn by a later sibling. */ + readonly withdrawnPrompts: number; + /** Records dropped as a repeat of a `uuid` already seen. */ + readonly duplicates: number; + /** `compact_boundary` records the file carries. */ + readonly compactBoundaries: number; +} + +function stringField(record: TranscriptRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * The parent a record hangs from: `parentUuid`, and nothing else. + * + * `logicalParentUuid` is deliberately not consulted. On a compaction boundary + * it holds `preservedSegment.tailUuid` — a record written after the boundary, + * not before it — so reading it as a parent points forward and closes a cycle + * back through the summary. + */ +export function transcriptParentUuid(record: TranscriptRecord): string | undefined { + return stringField(record, 'parentUuid'); +} + +/** + * A record that is a human prompt rather than the harness speaking. + * + * Tool results are written as `user` records — the harness answering the + * model — so a response's completions are not prompts, and two of them + * sharing a parent is not a rewind. That distinction is the whole of the + * difference between the 281 ordinary forks and the 72 real ones. + */ +export function isPromptRecord(record: TranscriptRecord): boolean { + if (record.type !== 'user') return false; + const message = record.message; + if (typeof message !== 'object' || message === null) return true; + const content = (message as Record).content; + if (!Array.isArray(content)) return true; + return !content.some( + (block) => + typeof block === 'object' && + block !== null && + (block as Record).type === 'tool_result', + ); +} + +/** + * Drop repeats of a `uuid` already seen, keeping the first. + * + * A record written twice replays whatever identity it carries — a prompt, a + * turn boundary, a tool call. Measured: 3 repeats across 1130 local + * transcripts. Rare enough to be invisible in testing, permanent once it is + * persisted as canonical history. + */ +function deduplicate(records: readonly TranscriptRecord[]): { + readonly kept: readonly TranscriptRecord[]; + readonly duplicates: number; +} { + const seen = new Set(); + const kept: TranscriptRecord[] = []; + let duplicates = 0; + for (const record of records) { + const uuid = stringField(record, 'uuid'); + if (uuid !== undefined) { + if (seen.has(uuid)) { + duplicates += 1; + continue; + } + seen.add(uuid); + } + kept.push(record); + } + return { kept, duplicates }; +} + +const ROOT_KEY = ' root'; + +export function resolveTranscriptLineage( + rawRecords: readonly TranscriptRecord[], +): LineageResolution { + const { kept: records, duplicates } = deduplicate(rawRecords); + const compactBoundaries = records.filter((r) => r.subtype === 'compact_boundary').length; + + const main = records.filter( + (r) => r.isSidechain !== true && stringField(r, 'uuid') !== undefined, + ); + if (main.length === 0) { + return { records, abandoned: 0, withdrawnPrompts: 0, duplicates, compactBoundaries }; + } + + const present = new Set(main.map((r) => stringField(r, 'uuid') as string)); + const childrenOf = new Map(); + for (const record of main) { + const parent = transcriptParentUuid(record); + // A parent outside this file is no parent: the record roots its own + // segment rather than being orphaned into nothing. + const key = parent !== undefined && present.has(parent) ? parent : ROOT_KEY; + const siblings = childrenOf.get(key); + if (siblings) siblings.push(record); + else childrenOf.set(key, [record]); + } + + // Among sibling prompts the last written is the one that was asked; the + // earlier ones were withdrawn by the edit that replaced them. + const withdrawn: TranscriptRecord[] = []; + for (const [, siblings] of childrenOf) { + const prompts = siblings.filter(isPromptRecord); + if (prompts.length < 2) continue; + withdrawn.push(...prompts.slice(0, -1)); + } + if (withdrawn.length === 0) { + return { records, abandoned: 0, withdrawnPrompts: 0, duplicates, compactBoundaries }; + } + + // A withdrawn prompt takes its subtree: the answer to a question that was + // never asked is not conversation either. + const dropped = new Set(); + const stack = [...withdrawn]; + while (stack.length > 0) { + const record = stack.pop() as TranscriptRecord; + const uuid = stringField(record, 'uuid') as string; + if (dropped.has(uuid)) continue; + dropped.add(uuid); + for (const child of childrenOf.get(uuid) ?? []) stack.push(child); + } + + const resolved = records.filter((record) => { + const uuid = stringField(record, 'uuid'); + return uuid === undefined || !dropped.has(uuid); + }); + + return { + records: resolved, + abandoned: dropped.size, + withdrawnPrompts: withdrawn.length, + duplicates, + compactBoundaries, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5518a8dd2844daabdac12512cb7a1edd86ceca5e5f6afa4561b24f343bee07e9.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5518a8dd2844daabdac12512cb7a1edd86ceca5e5f6afa4561b24f343bee07e9.source new file mode 100644 index 0000000000..62dee5035c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5518a8dd2844daabdac12512cb7a1edd86ceca5e5f6afa4561b24f343bee07e9.source @@ -0,0 +1,519 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { lstat, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + openFileSessionRepository, + type FileSessionRepository, +} from '../file-session-repository.js'; +import { + materializeSessionCheckpointV1, + publishSessionCheckpointV1, + SessionRepositoryError, +} from '../session-repository.js'; +import type { SessionBundleArtifact, Sha256Digest } from '../session-bundle-contract.js'; + +test('persists a current Manifest head and first immutable object prefix across reopened adapters', async () => { + await withTemporaryDirectory(async (root) => { + const first = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + first, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await first.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + first, + await writeArtifact(root, 'next.tar.zst', 'next bytes'), + ); + const committed = await first.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + commitId: 'commit-a', + }); + + const reopened = await openFileSessionRepository({ storageRoot: root }); + assert.deepEqual(await reopened.checkoutCurrent('session-a'), committed); + await assert.rejects( + reopened.checkoutExact(created.ref), + hasRepositoryCode('revision_not_available'), + ); + await reopened.objectStore.assertReadable(committed.checkpoint.manifest); + await reopened.objectStore.assertReadable(committed.checkpoint.value.compatibilityBundle); + }); +}); + +test('serializes concurrent local CAS writers across adapter instances', async () => { + await withTemporaryDirectory(async (root) => { + const left = await openFileSessionRepository({ storageRoot: root }); + const right = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + left, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await left.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const leftCheckpoint = await publishCheckpoint( + left, + await writeArtifact(root, 'left.tar.zst', 'left bytes'), + ); + const rightCheckpoint = await publishCheckpoint( + right, + await writeArtifact(root, 'right.tar.zst', 'right bytes'), + ); + + const results = await Promise.allSettled([ + left.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: leftCheckpoint, + }), + right.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: rightCheckpoint, + }), + ]); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + const rejected = results.find((result) => result.status === 'rejected'); + assert.ok(rejected); + if (!rejected || rejected.status !== 'rejected') return; + assert.ok(rejected.reason instanceof SessionRepositoryError); + assert.equal(rejected.reason.code, 'revision_conflict'); + }); +}); + +for (const crashPoint of ['before-rename', 'after-rename'] as const) { + test(`recovers a repository writer killed ${crashPoint} without removing its lock`, async () => { + await withTemporaryDirectory(async (root) => { + const repository = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + repository, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + repository, + await writeArtifact(root, 'next.tar.zst', 'next bytes'), + ); + const input = { + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + commitId: 'interrupted-commit', + }; + const inputPath = join(root, 'commit-input.json'); + await writeFile(inputPath, JSON.stringify(input), 'utf8'); + const writer = fork( + new URL('./fixtures/file-session-repository-crash-writer.js', import.meta.url), + [root, inputPath, crashPoint], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + const closed = new Promise((resolve) => writer.once('close', () => resolve())); + try { + const ready = new AbortController(); + const timer = setTimeout(() => ready.abort(), 10_000); + try { + const [message] = await Promise.race([ + once(writer, 'message', { signal: ready.signal }), + closed.then(() => { + throw new Error('Repository writer exited before its crash point'); + }), + ]); + assert.equal(message, crashPoint); + } finally { + clearTimeout(timer); + ready.abort(); + } + assert.equal(writer.kill('SIGKILL'), true); + await closed; + // No finally ran in the child. Recovery must tolerate the marker that + // is still on disk; this test never removes repository-internal state. + await lstat(join(root, 'session-repository-v1.json.lock')); + + const reopened = await openFileSessionRepository({ storageRoot: root }); + assert.equal( + (await reopened.checkoutCurrent('session-a')).ref.revision, + crashPoint === 'before-rename' ? 'r1' : 'r2', + ); + const recovered = await reopened.commit(input); + assert.equal(recovered.ref.revision, 'r2'); + assert.deepEqual(recovered.checkpoint, next); + assert.deepEqual(await reopened.commit(input), recovered); + // An after-rename retry can return its existing receipt without taking + // a write lock. A new commit proves the repository is actually writable. + const advanced = await reopened.commit({ + sessionId: 'session-a', + expectedRevision: recovered.ref.revision, + checkpoint: initial, + commitId: 'after-recovery', + }); + assert.equal(advanced.ref.revision, 'r3'); + const final = await openFileSessionRepository({ storageRoot: root }); + assert.deepEqual(await final.checkoutCurrent('session-a'), advanced); + assert.deepEqual(await final.commit(input), recovered); + } finally { + if (writer.exitCode === null && writer.signalCode === null) writer.kill('SIGKILL'); + await closed; + } + }); + }); +} + +test('persists Fork source binding and crash recovery across reopened adapters', async () => { + await withTemporaryDirectory(async (root) => { + const first = await openFileSessionRepository({ storageRoot: root }); + const sourceCheckpoint = await publishCheckpoint( + first, + await writeArtifact(root, 'source.tar.zst', 'source bytes'), + ); + const source = await first.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: sourceCheckpoint, + }); + const pending = await first.claimFork({ + forkId: 'fork-a', + source: source.ref, + targetSessionId: 'session-b', + }); + assert.equal(pending.state, 'pending'); + assert.equal(pending.sourceAgentId, 'agent-a'); + assert.deepEqual(pending.sourceCheckpoint, sourceCheckpoint); + + const advancedCheckpoint = await publishCheckpoint( + first, + await writeArtifact(root, 'source-advanced.tar.zst', 'source advanced bytes'), + ); + await first.commit({ + sessionId: source.ref.sessionId, + expectedRevision: source.ref.revision, + checkpoint: advancedCheckpoint, + }); + + const afterCrash = await openFileSessionRepository({ storageRoot: root }); + const recovered = await afterCrash.claimFork({ + forkId: 'fork-a', + source: source.ref, + targetSessionId: 'session-b', + }); + assert.deepEqual(recovered.sourceCheckpoint, sourceCheckpoint); + await afterCrash.objectStore.assertReadable(recovered.sourceCheckpoint.manifest); + await afterCrash.objectStore.assertReadable( + recovered.sourceCheckpoint.value.compatibilityBundle, + ); + const recoveredBundle = join(root, 'recovered-source.tar.zst'); + const bundleSource = await materializeSessionCheckpointV1({ + objectStore: afterCrash.objectStore, + checkpoint: recovered.sourceCheckpoint, + destination: recoveredBundle, + maxBytes: 1024, + }); + assert.equal(bundleSource.path, recoveredBundle); + assert.equal( + bundleSource.expectedArchiveDigest, + sourceCheckpoint.value.compatibilityBundle.digest, + ); + assert.equal((await readFile(recoveredBundle)).toString(), 'source bytes'); + const targetCheckpoint = await publishCheckpoint( + afterCrash, + await writeArtifact(root, 'target.tar.zst', 'target bytes'), + ); + await assert.rejects( + afterCrash.createSession({ + sessionId: 'session-b', + agentId: 'agent-a', + checkpoint: targetCheckpoint, + lastCommittedActivationId: 'source-activation', + forkedFrom: source.ref, + createdByForkId: 'fork-a', + }), + /Fork-created Session must not carry an Activation identity/, + ); + const target = await afterCrash.createSession({ + sessionId: 'session-b', + agentId: 'agent-a', + checkpoint: targetCheckpoint, + forkedFrom: source.ref, + createdByForkId: 'fork-a', + }); + + const afterTargetCrash = await openFileSessionRepository({ storageRoot: root }); + const completed = await afterTargetCrash.completeFork({ forkId: 'fork-a' }); + assert.deepEqual(completed.target, target.ref); + assert.deepEqual(await afterTargetCrash.completeFork({ forkId: 'fork-a' }), completed); + }); +}); + +test('fails closed when durable local control-plane state is corrupt', async () => { + await withTemporaryDirectory(async (root) => { + const repository = await openFileSessionRepository({ storageRoot: root }); + const checkpoint = await publishCheckpoint( + repository, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint, + }); + await writeFile(join(root, 'session-repository-v1.json'), '{broken', 'utf8'); + const reopened = await openFileSessionRepository({ storageRoot: root }); + await assert.rejects( + reopened.checkoutCurrent('session-a'), + hasRepositoryCode('integrity_mismatch'), + ); + }); +}); + +test('fails closed when persisted revision allocation contradicts its current head', async () => { + await withTemporaryDirectory(async (root) => { + const repository = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + repository, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + repository, + await writeArtifact(root, 'next.tar.zst', 'next bytes'), + ); + await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + commitId: 'commit-a', + }); + + const statePath = join(root, 'session-repository-v1.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')) as { + sessions: Array<{ nextRevisionNumber: number }>; + }; + state.sessions[0].nextRevisionNumber = 2; + await writeFile(statePath, `${JSON.stringify(state)}\n`, 'utf8'); + + const reopened = await openFileSessionRepository({ storageRoot: root }); + await assert.rejects( + reopened.checkoutCurrent('session-a'), + hasRepositoryCode('integrity_mismatch'), + ); + }); +}); + +test('fails closed when a persisted commit receipt contradicts the current head', async () => { + await withTemporaryDirectory(async (root) => { + const repository = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + repository, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const next = await publishCheckpoint( + repository, + await writeArtifact(root, 'next.tar.zst', 'next bytes'), + ); + await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + commitId: 'commit-a', + }); + + const statePath = join(root, 'session-repository-v1.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')) as { + commits: Array<{ result: { checkpoint: unknown } }>; + sessions: Array<{ createdRevision: { checkpoint: unknown } }>; + }; + state.commits[0].result.checkpoint = state.sessions[0].createdRevision.checkpoint; + await writeFile(statePath, `${JSON.stringify(state)}\n`, 'utf8'); + + const reopened = await openFileSessionRepository({ storageRoot: root }); + await assert.rejects( + reopened.checkoutCurrent('session-a'), + hasRepositoryCode('integrity_mismatch'), + ); + }); +}); + +test('uses Session and CAS preflight errors before checking an unrelated candidate object', async () => { + await withTemporaryDirectory(async (root) => { + const repository = await openFileSessionRepository({ storageRoot: root }); + const initial = await publishCheckpoint( + repository, + await writeArtifact(root, 'initial.tar.zst', 'initial bytes'), + ); + const created = await repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-a', + checkpoint: initial, + }); + const unreadableCreateCandidate = await publishCheckpoint( + repository, + await writeArtifact(root, 'create-candidate.tar.zst', 'create candidate bytes'), + ); + await removeLocalObject(root, unreadableCreateCandidate.manifest); + await assert.rejects( + repository.createSession({ + sessionId: 'session-a', + agentId: 'agent-other', + checkpoint: unreadableCreateCandidate, + }), + hasRepositoryCode('session_already_exists'), + ); + + const next = await publishCheckpoint( + repository, + await writeArtifact(root, 'next.tar.zst', 'next bytes'), + ); + await repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: next, + }); + const unreadableCommitCandidate = await publishCheckpoint( + repository, + await writeArtifact(root, 'commit-candidate.tar.zst', 'commit candidate bytes'), + ); + await removeLocalObject(root, unreadableCommitCandidate.manifest); + await assert.rejects( + repository.commit({ + sessionId: created.ref.sessionId, + expectedRevision: created.ref.revision, + checkpoint: unreadableCommitCandidate, + }), + hasRepositoryCode('revision_conflict'), + ); + }); +}); + +test('streams immutable object publication and materialization across concurrent adapters', async () => { + await withTemporaryDirectory(async (root) => { + const left = await openFileSessionRepository({ storageRoot: root }); + const right = await openFileSessionRepository({ storageRoot: root }); + const payload = Buffer.alloc(1024 * 1024 + 17, 0x61); + const artifactPath = join(root, 'large.tar.zst'); + await writeFile(artifactPath, payload); + const artifact: SessionBundleArtifact = { + path: artifactPath, + archiveDigest: digest(payload), + compressedBytes: payload.byteLength, + decompressedTarBytes: payload.byteLength, + payloadBytes: payload.byteLength, + entryCount: 1, + }; + + const [leftCheckpoint, rightCheckpoint] = await Promise.all([ + publishCheckpoint(left, artifact), + publishCheckpoint(right, artifact), + ]); + assert.deepEqual(leftCheckpoint, rightCheckpoint); + + const destination = join(root, 'materialized-large.tar.zst'); + await left.objectStore.materialize({ + ref: leftCheckpoint.value.compatibilityBundle, + destination, + maxBytes: payload.byteLength, + }); + assert.deepEqual(await readFile(destination), payload); + await assert.rejects( + left.objectStore.materialize({ + ref: leftCheckpoint.value.compatibilityBundle, + destination: join(root, 'too-small.tar.zst'), + maxBytes: payload.byteLength - 1, + }), + hasRepositoryCode('quota_exceeded'), + ); + await (await openFileSessionRepository({ storageRoot: root })).objectStore.assertReadable( + leftCheckpoint.value.compatibilityBundle, + ); + }); +}); + +function publishCheckpoint(repository: FileSessionRepository, artifact: SessionBundleArtifact) { + return publishSessionCheckpointV1({ + objectStore: repository.objectStore, + compatibilityBundle: artifact, + }); +} + +async function withTemporaryDirectory(operation: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-file-session-repository-')); + try { + await operation(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeArtifact( + directory: string, + name: string, + contents: string, +): Promise { + const bytes = Buffer.from(contents); + const path = join(directory, name); + await writeFile(path, bytes); + return { + path, + archiveDigest: digest(bytes), + compressedBytes: bytes.byteLength, + decompressedTarBytes: bytes.byteLength, + payloadBytes: bytes.byteLength, + entryCount: 1, + }; +} + +function digest(value: Uint8Array): Sha256Digest { + return `sha256:${createHash('sha256').update(value).digest('hex')}` as Sha256Digest; +} + +async function removeLocalObject(root: string, ref: { readonly objectRef: string }): Promise { + const id = ref.objectRef.slice('maka-local-object://v1/'.length); + await rm(join(root, 'objects', id.slice(0, 2), id)); +} + +function hasRepositoryCode(code: SessionRepositoryError['code']): (error: unknown) => boolean { + return (error): boolean => error instanceof SessionRepositoryError && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/562b3617d44f32142eb3ce38e334def04df6337b178871ba8ba0672f367bb4a8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/562b3617d44f32142eb3ce38e334def04df6337b178871ba8ba0672f367bb4a8.source new file mode 100644 index 0000000000..ef1ebe732c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/562b3617d44f32142eb3ce38e334def04df6337b178871ba8ba0672f367bb4a8.source @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + isPromptRecord, + resolveTranscriptLineage, + transcriptParentUuid, + type TranscriptRecord, +} from '../claude-code-transcript-lineage.js'; + +function user(uuid: string, parentUuid: string | null, text: string): TranscriptRecord { + return { type: 'user', uuid, parentUuid, message: { role: 'user', content: text } }; +} + +function assistant( + uuid: string, + parentUuid: string | null, + messageId: string, + content: readonly Record[], +): TranscriptRecord { + return { + type: 'assistant', + uuid, + parentUuid, + message: { role: 'assistant', id: messageId, content }, + }; +} + +function toolResult(uuid: string, parentUuid: string, toolUseId: string): TranscriptRecord { + return { + type: 'user', + uuid, + parentUuid, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId }] }, + }; +} + +/** The boundary as Claude Code writes it: no parent, and a *forward* logical link. */ +function compactBoundary(uuid: string, preservedTailUuid: string): TranscriptRecord { + return { + type: 'system', + subtype: 'compact_boundary', + uuid, + parentUuid: null, + logicalParentUuid: preservedTailUuid, + compactMetadata: { trigger: 'manual', preservedSegment: { tailUuid: preservedTailUuid } }, + }; +} + +const uuids = (result: { records: readonly TranscriptRecord[] }): readonly unknown[] => + result.records.map((record) => record.uuid); + +describe('resolveTranscriptLineage', () => { + test('a withdrawn prompt and its answer are dropped, the edit kept', () => { + // The corpus shape, 72 times: the user typed a prompt, edited it, and + // resubmitted. Both are written under the same parent. Importing both + // presents a question the user withdrew, and its answer, as conversation. + const result = resolveTranscriptLineage([ + { type: 'system', uuid: 'p0', parentUuid: null }, + user('u1', 'p0', 'StreamVByte 是什么类型?我忘了'), + assistant('a1', 'u1', 'msg_1', [{ type: 'text', text: 'answer to the withdrawn one' }]), + user('u2', 'p0', 'StreamVByte 是什么方式?我忘了'), + assistant('a2', 'u2', 'msg_2', [{ type: 'text', text: 'answer to the one asked' }]), + ]); + assert.deepEqual(uuids(result), ['p0', 'u2', 'a2']); + assert.equal(result.withdrawnPrompts, 1); + assert.equal(result.abandoned, 2); + }); + + test('the whole withdrawn subtree goes, not just its head', () => { + const result = resolveTranscriptLineage([ + { type: 'system', uuid: 'p0', parentUuid: null }, + user('u1', 'p0', 'withdrawn'), + assistant('a1', 'u1', 'msg_1', [{ type: 'tool_use', id: 'call_dead', name: 'Read' }]), + toolResult('r1', 'a1', 'call_dead'), + assistant('a2', 'r1', 'msg_2', [{ type: 'text', text: 'abandoned' }]), + user('u2', 'p0', 'asked'), + assistant('a3', 'u2', 'msg_3', [{ type: 'text', text: 'kept' }]), + ]); + assert.deepEqual(uuids(result), ['p0', 'u2', 'a3']); + assert.equal(result.abandoned, 4); + }); + + test('three siblings leave only the last', () => { + // Measured 3 times in the corpus: a prompt edited twice. + const result = resolveTranscriptLineage([ + { type: 'system', uuid: 'p0', parentUuid: null }, + user('u1', 'p0', 'first try'), + user('u2', 'p0', 'second try'), + user('u3', 'p0', 'third try'), + assistant('a3', 'u3', 'msg_3', [{ type: 'text', text: 'kept' }]), + ]); + assert.deepEqual(uuids(result), ['p0', 'u3', 'a3']); + assert.equal(result.withdrawnPrompts, 2); + }); + + test('an ordinary tool fork survives whole', () => { + // 281 of 358 forks in the corpus are this: a `tool_use` record has two + // children — the next fragment of the same response, and the result of + // the call it just made. Nothing was abandoned, and a walk that picked + // one child would strand the other. + const result = resolveTranscriptLineage([ + user('u1', null, 'prompt'), + assistant('a1', 'u1', 'msg_1', [{ type: 'tool_use', id: 'call_1', name: 'Read' }]), + assistant('a2', 'a1', 'msg_1', [{ type: 'tool_use', id: 'call_2', name: 'Read' }]), + toolResult('r1', 'a1', 'call_1'), + toolResult('r2', 'a2', 'call_2'), + ]); + assert.deepEqual(uuids(result), ['u1', 'a1', 'a2', 'r1', 'r2']); + assert.equal(result.abandoned, 0); + }); + + test('two tool results under one parent are both kept', () => { + // Sibling `user` records are a rewind only when they are prompts. A tool + // result is the harness answering the model, not the user asking again. + const result = resolveTranscriptLineage([ + user('u1', null, 'prompt'), + assistant('a1', 'u1', 'msg_1', [ + { type: 'tool_use', id: 'call_1', name: 'Read' }, + { type: 'tool_use', id: 'call_2', name: 'Read' }, + ]), + toolResult('r1', 'a1', 'call_1'), + toolResult('r2', 'a1', 'call_2'), + ]); + assert.deepEqual(uuids(result), ['u1', 'a1', 'r1', 'r2']); + assert.equal(result.abandoned, 0); + assert.equal(result.withdrawnPrompts, 0); + }); + + test('an assistant fork with no prompt in it is left alone', () => { + const result = resolveTranscriptLineage([ + user('u1', null, 'prompt'), + assistant('a1', 'u1', 'msg_1', [{ type: 'text', text: 'one' }]), + assistant('a2', 'u1', 'msg_2', [{ type: 'text', text: 'two' }]), + ]); + assert.deepEqual(uuids(result), ['u1', 'a1', 'a2']); + assert.equal(result.abandoned, 0); + }); + + test('a tool result is not a prompt', () => { + assert.equal(isPromptRecord(user('u1', null, 'ask')), true); + assert.equal(isPromptRecord(toolResult('r1', 'a1', 'call_1')), false); + assert.equal(isPromptRecord(assistant('a1', 'u1', 'm', [])), false); + }); + + test('both sides of a compaction boundary survive', () => { + // The boundary starts a new root because the model's context restarted + // there. Both sides are conversation that happened. + const result = resolveTranscriptLineage([ + user('u1', null, 'before compaction'), + assistant('a1', 'u1', 'msg_1', [{ type: 'text', text: 'pre-boundary reply' }]), + compactBoundary('b1', 'a1'), + { type: 'user', uuid: 's1', parentUuid: 'b1', isCompactSummary: true, message: {} }, + user('u2', 's1', 'after compaction'), + assistant('a2', 'u2', 'msg_2', [{ type: 'text', text: 'post-boundary reply' }]), + ]); + assert.deepEqual(uuids(result), ['u1', 'a1', 'b1', 's1', 'u2', 'a2']); + assert.equal(result.abandoned, 0); + assert.equal(result.compactBoundaries, 1); + }); + + test("a boundary's logicalParentUuid is not followed as a parent", () => { + // It holds `preservedSegment.tailUuid` — a record written AFTER the + // boundary. Reading it as a parent points forward and closes a cycle + // through the summary. + assert.equal(transcriptParentUuid(compactBoundary('b1', 'later')), undefined); + const result = resolveTranscriptLineage([ + user('u1', null, 'before'), + assistant('a1', 'u1', 'msg_1', [{ type: 'text', text: 'pre' }]), + compactBoundary('b1', 'u2'), + { type: 'user', uuid: 's1', parentUuid: 'b1', isCompactSummary: true, message: {} }, + user('u2', 's1', 'after'), + ]); + assert.deepEqual(uuids(result), ['u1', 'a1', 'b1', 's1', 'u2']); + assert.equal(result.abandoned, 0); + }); + + test('a rewind before a compaction boundary does not disturb what follows', () => { + const result = resolveTranscriptLineage([ + { type: 'system', uuid: 'p0', parentUuid: null }, + user('u1', 'p0', 'withdrawn before'), + assistant('a_dead', 'u1', 'msg_dead', [{ type: 'text', text: 'abandoned pre-boundary' }]), + user('u1b', 'p0', 'asked before'), + assistant('a1', 'u1b', 'msg_1', [{ type: 'text', text: 'kept pre-boundary' }]), + compactBoundary('b1', 'a1'), + user('u2', 'b1', 'after'), + assistant('a2', 'u2', 'msg_2', [{ type: 'text', text: 'kept post-boundary' }]), + ]); + assert.deepEqual(uuids(result), ['p0', 'u1b', 'a1', 'b1', 'u2', 'a2']); + assert.equal(result.abandoned, 2); + }); + + test('a repeated uuid is kept once', () => { + const first = user('u1', null, 'prompt'); + const result = resolveTranscriptLineage([first, { ...first }, assistant('a1', 'u1', 'm', [])]); + assert.deepEqual(uuids(result), ['u1', 'a1']); + assert.equal(result.duplicates, 1); + }); + + test('a record the graph cannot place is kept', () => { + // Silence from the graph is not evidence of abandonment. Dropping history + // is the failure that cannot be undone once it is canonical. + const result = resolveTranscriptLineage([ + { type: 'user', message: { role: 'user', content: 'no uuid' } }, + user('u1', null, 'prompt'), + assistant('a1', 'u1', 'msg_1', [{ type: 'text', text: 'reply' }]), + ]); + assert.equal(result.records.length, 3); + assert.equal(result.abandoned, 0); + }); + + test('a sidechain record is passed through untouched', () => { + const result = resolveTranscriptLineage([ + user('u1', null, 'prompt'), + { type: 'assistant', uuid: 'sc1', parentUuid: 'nothing', isSidechain: true, message: {} }, + ]); + assert.equal(result.records.length, 2); + assert.equal(result.abandoned, 0); + }); + + test('a parent cycle terminates instead of hanging the import', () => { + // These links are written by another process; a cycle has to fail the + // walk, not spin in it. + const result = resolveTranscriptLineage([ + { type: 'user', uuid: 'x', parentUuid: 'y', message: { role: 'user', content: 'a' } }, + { type: 'user', uuid: 'y', parentUuid: 'x', message: { role: 'user', content: 'b' } }, + ]); + assert.equal(result.records.length, 2); + }); + + test('an empty transcript resolves to nothing rather than throwing', () => { + const result = resolveTranscriptLineage([]); + assert.deepEqual(result.records, []); + assert.equal(result.abandoned, 0); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/57c2e6f87ce97dcbd3a71abf2034acd839cea24bd06f0806560f4eaa5d895e92.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/57c2e6f87ce97dcbd3a71abf2034acd839cea24bd06f0806560f4eaa5d895e92.source new file mode 100644 index 0000000000..ec75094988 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/57c2e6f87ce97dcbd3a71abf2034acd839cea24bd06f0806560f4eaa5d895e92.source @@ -0,0 +1,465 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; + +export const MANAGED_SECRET_REFERENCE_SCHEMA_VERSION = 1 as const; +export const MANAGED_SECRET_MAX_VALUE_BYTES = 64 * 1024; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +export interface ManagedSecretReference { + readonly schemaVersion: typeof MANAGED_SECRET_REFERENCE_SCHEMA_VERSION; + readonly secretId: string; +} + +export type ManagedSecretStatus = 'active' | 'revoked' | 'deleted'; + +/** + * Public metadata deliberately omits the secret value and encrypted envelope. + * `revision` changes on rotate and revoke while the reference remains stable, + * so portable Session state never needs rewriting for those changes. A + * successful delete returns terminal metadata but does not persist a tombstone. + */ +export interface ManagedSecretMetadata { + readonly reference: ManagedSecretReference; + readonly ownerPrincipalId: string; + readonly revision: number; + readonly status: ManagedSecretStatus; + readonly createdAt: number; + readonly updatedAt: number; +} + +/** + * Trusted internal identity established before entering the Secret Store. + * + * The store does not authenticate this value. A control-plane composition must + * derive it from its authenticated request context; it must never decode it + * directly from an Activation payload, Renderer message, or other client input. + * V1 uses one principal as the ownership boundary. Organization/project sharing + * requires a separate, explicit authorization model. + */ +export interface ManagedSecretPrincipalContext { + readonly principalId: string; +} + +/** + * A Cloud Session authority already checked by the calling control plane. + * Constructing this context asserts that `cloudSessionId` belongs to, or has + * explicitly been delegated to, `principalId`; the Secret Store does not own + * the Cloud Session repository needed to prove that relation. + */ +export interface ManagedSecretSessionContext extends ManagedSecretPrincipalContext { + readonly cloudSessionId: string; +} + +export interface ManagedSecretActivationContext extends ManagedSecretSessionContext { + readonly activationId: string; +} + +export interface ManagedSecretMaterial { + readonly reference: ManagedSecretReference; + readonly revision: number; + /** Sensitive: only the Activation injection boundary may consume this value. */ + readonly value: string; +} + +export type ManagedSecretMutationResult = + | { readonly kind: 'committed'; readonly secret: ManagedSecretMetadata } + | { readonly kind: 'revision_conflict'; readonly actualRevision: number }; + +export interface CreateManagedSecretInput extends ManagedSecretPrincipalContext { + readonly value: string; +} + +export interface GetManagedSecretMetadataInput extends ManagedSecretPrincipalContext { + readonly reference: ManagedSecretReference; +} + +export interface MutateManagedSecretInput extends ManagedSecretPrincipalContext { + readonly reference: ManagedSecretReference; + readonly expectedRevision: number; +} + +export interface RotateManagedSecretInput extends MutateManagedSecretInput { + readonly value: string; +} + +export interface AuthorizeManagedSecretSessionInput extends ManagedSecretSessionContext { + readonly reference: ManagedSecretReference; +} + +export interface ResolveManagedSecretsForActivationInput { + readonly context: ManagedSecretActivationContext; + readonly references: readonly ManagedSecretReference[]; +} + +/** + * Control-plane authority for values and Session grants. + * + * A Session Bundle may carry `ManagedSecretReference` values, but a copied + * reference is inert until this store has a grant for the target Cloud Session. + * That is the fork/restore re-authorization boundary. + * + * This is a trusted internal control-plane interface, not a network, IPC, or + * Renderer boundary. Every principal and Session context must be established by + * the caller before invoking it. + */ +export interface ManagedSecretStore { + createSecret(input: CreateManagedSecretInput): Promise; + getSecretMetadata(input: GetManagedSecretMetadataInput): Promise; + rotateSecret(input: RotateManagedSecretInput): Promise; + revokeSecret(input: MutateManagedSecretInput): Promise; + deleteSecret(input: MutateManagedSecretInput): Promise; + authorizeSession(input: AuthorizeManagedSecretSessionInput): Promise; + revokeSessionAuthorization(input: AuthorizeManagedSecretSessionInput): Promise; + /** Resolves the complete set or fails before returning any material. */ + resolveForActivation( + input: ResolveManagedSecretsForActivationInput, + ): Promise; +} + +export type ManagedSecretErrorCode = + | 'invalid_input' + | 'secret_not_found' + | 'unauthorized' + | 'secret_revoked' + | 'key_unavailable' + | 'integrity_failure' + | 'storage_failure' + | 'injection_failed' + | 'cleanup_failed'; + +export class ManagedSecretError extends Error { + constructor( + readonly code: ManagedSecretErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'ManagedSecretError'; + } +} + +interface InMemorySecretRecord extends ManagedSecretMetadata { + value?: string; +} + +export interface InMemoryManagedSecretStoreOptions { + readonly now?: () => number; + readonly newSecretId?: () => string; +} + +/** Deterministic control-plane fake used by Activation and Fork coordinators. */ +export class InMemoryManagedSecretStore implements ManagedSecretStore { + readonly #records = new Map(); + readonly #grants = new Set(); + readonly #now: () => number; + readonly #newSecretId: () => string; + + constructor(options: InMemoryManagedSecretStoreOptions = {}) { + this.#now = options.now ?? Date.now; + this.#newSecretId = options.newSecretId ?? randomUUID; + } + + async createSecret(input: CreateManagedSecretInput): Promise { + const principalId = managedSecretIdentifier(input.principalId, 'principalId'); + const value = managedSecretValue(input.value); + const secretId = this.#newSecretId(); + assertManagedSecretId(secretId); + if (this.#records.has(secretId)) { + throw new ManagedSecretError('storage_failure', 'Managed Secret identity collision'); + } + const now = managedSecretTimestamp(this.#now()); + const record: InMemorySecretRecord = { + reference: managedSecretReference(secretId), + ownerPrincipalId: principalId, + revision: 1, + status: 'active', + createdAt: now, + updatedAt: now, + value, + }; + this.#records.set(secretId, record); + return publicMetadata(record); + } + + async getSecretMetadata( + input: GetManagedSecretMetadataInput, + ): Promise { + const principal = managedSecretIdentifier(input.principalId, 'principalId'); + const normalized = decodeManagedSecretReference(input.reference); + const record = this.#records.get(normalized.secretId); + if (!record) return null; + assertOwner(record, principal); + return publicMetadata(record); + } + + async rotateSecret(input: RotateManagedSecretInput): Promise { + const prepared = this.#prepareMutation(input); + if (prepared.kind === 'revision_conflict') return prepared; + assertActive(prepared.record); + const record: InMemorySecretRecord = { + ...prepared.record, + revision: prepared.record.revision + 1, + updatedAt: mutationTimestamp(prepared.record, this.#now()), + value: managedSecretValue(input.value), + }; + this.#records.set(record.reference.secretId, record); + return { kind: 'committed', secret: publicMetadata(record) }; + } + + async revokeSecret(input: MutateManagedSecretInput): Promise { + const prepared = this.#prepareMutation(input); + if (prepared.kind === 'revision_conflict') return prepared; + assertActive(prepared.record); + const record: InMemorySecretRecord = { + ...prepared.record, + revision: prepared.record.revision + 1, + status: 'revoked', + updatedAt: mutationTimestamp(prepared.record, this.#now()), + value: undefined, + }; + this.#records.set(record.reference.secretId, record); + return { kind: 'committed', secret: publicMetadata(record) }; + } + + async deleteSecret(input: MutateManagedSecretInput): Promise { + const prepared = this.#prepareMutation(input); + if (prepared.kind === 'revision_conflict') return prepared; + const record: InMemorySecretRecord = { + ...prepared.record, + revision: prepared.record.revision + 1, + status: 'deleted', + updatedAt: mutationTimestamp(prepared.record, this.#now()), + value: undefined, + }; + this.#records.delete(record.reference.secretId); + for (const grant of this.#grants) { + if (grant.startsWith(`${record.reference.secretId}\0`)) this.#grants.delete(grant); + } + return { kind: 'committed', secret: publicMetadata(record) }; + } + + async authorizeSession(input: AuthorizeManagedSecretSessionInput): Promise { + const normalized = normalizeAuthorizationInput(input); + const record = requiredRecord(this.#records, normalized.reference); + assertOwner(record, normalized.principalId); + assertActive(record); + this.#grants.add(grantKey(record.reference.secretId, normalized)); + } + + async revokeSessionAuthorization(input: AuthorizeManagedSecretSessionInput): Promise { + const normalized = normalizeAuthorizationInput(input); + const record = requiredRecord(this.#records, normalized.reference); + assertOwner(record, normalized.principalId); + this.#grants.delete(grantKey(record.reference.secretId, normalized)); + } + + async resolveForActivation( + input: ResolveManagedSecretsForActivationInput, + ): Promise { + const context = normalizeActivationContext(input.context); + if (!Array.isArray(input.references) || input.references.length > 128) { + throw invalidInput('Managed Secret references must be a bounded array'); + } + return input.references.map((candidate) => { + const reference = decodeManagedSecretReference(candidate); + const record = requiredRecord(this.#records, reference); + assertOwner(record, context.principalId); + assertActive(record); + if (!this.#grants.has(grantKey(reference.secretId, context))) { + throw new ManagedSecretError( + 'unauthorized', + 'Managed Secret is not authorized for this Cloud Session', + ); + } + if (record.value === undefined) { + throw new ManagedSecretError('integrity_failure', 'Managed Secret material is unavailable'); + } + return { + reference: { ...record.reference }, + revision: record.revision, + value: record.value, + }; + }); + } + + #prepareMutation( + input: MutateManagedSecretInput, + ): + | { readonly kind: 'ready'; readonly record: InMemorySecretRecord } + | Extract { + const principalId = managedSecretIdentifier(input.principalId, 'principalId'); + const reference = decodeManagedSecretReference(input.reference); + const expectedRevision = managedSecretRevision(input.expectedRevision); + const record = requiredRecord(this.#records, reference); + assertOwner(record, principalId); + if (record.revision !== expectedRevision) { + return { kind: 'revision_conflict', actualRevision: record.revision }; + } + return { kind: 'ready', record }; + } +} + +export function decodeManagedSecretReference(value: unknown): ManagedSecretReference { + if (!isRecord(value)) throw invalidInput('Managed Secret reference must be an object'); + if (Object.keys(value).length !== 2 || value.schemaVersion !== 1) { + throw invalidInput('Managed Secret reference schema is unsupported'); + } + assertManagedSecretId(value.secretId); + return managedSecretReference(value.secretId); +} + +export function decodeManagedSecretEnvironmentName(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 128 || + !ENVIRONMENT_NAME_PATTERN.test(value) + ) { + throw invalidInput('Managed Secret environment target is invalid'); + } + return value; +} + +export function managedSecretIdentifier(value: unknown, field: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value !== value.trim() || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw invalidInput(`Managed Secret ${field} is invalid`); + } + return value; +} + +export function managedSecretRevision(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw invalidInput('Managed Secret revision is invalid'); + } + return value as number; +} + +export function managedSecretTimestamp(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw invalidInput('Managed Secret timestamp is invalid'); + } + return value as number; +} + +export function managedSecretValue(value: unknown): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > MANAGED_SECRET_MAX_VALUE_BYTES + ) { + throw invalidInput('Managed Secret value must be non-empty and within the size limit'); + } + return value; +} + +export function managedSecretReference(secretId: string): ManagedSecretReference { + assertManagedSecretId(secretId); + return { schemaVersion: MANAGED_SECRET_REFERENCE_SCHEMA_VERSION, secretId }; +} + +export function publicManagedSecretMetadata(record: ManagedSecretMetadata): ManagedSecretMetadata { + return publicMetadata(record); +} + +function normalizeAuthorizationInput(input: AuthorizeManagedSecretSessionInput) { + return { + principalId: managedSecretIdentifier(input.principalId, 'principalId'), + reference: decodeManagedSecretReference(input.reference), + cloudSessionId: managedSecretIdentifier(input.cloudSessionId, 'cloudSessionId'), + }; +} + +function normalizeActivationContext( + context: ManagedSecretActivationContext, +): ManagedSecretActivationContext { + return { + principalId: managedSecretIdentifier(context.principalId, 'principalId'), + cloudSessionId: managedSecretIdentifier(context.cloudSessionId, 'cloudSessionId'), + activationId: managedSecretIdentifier(context.activationId, 'activationId'), + }; +} + +function requiredRecord( + records: ReadonlyMap, + reference: ManagedSecretReference, +): InMemorySecretRecord { + const record = records.get(reference.secretId); + if (!record) { + throw new ManagedSecretError('secret_not_found', 'Managed Secret was not found'); + } + return record; +} + +function assertOwner(record: ManagedSecretMetadata, principalId: string): void { + if (record.ownerPrincipalId !== principalId) { + throw new ManagedSecretError('unauthorized', 'Managed Secret access is not authorized'); + } +} + +function assertActive(record: ManagedSecretMetadata): void { + if (record.status !== 'active') { + throw new ManagedSecretError('secret_revoked', 'Managed Secret is revoked'); + } +} + +function grantKey( + secretId: string, + input: Pick, +): string { + return `${secretId}\0${input.principalId}\0${input.cloudSessionId}`; +} + +function publicMetadata(record: ManagedSecretMetadata): ManagedSecretMetadata { + return { + reference: { ...record.reference }, + ownerPrincipalId: record.ownerPrincipalId, + revision: record.revision, + status: record.status, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +function mutationTimestamp(record: Pick, now: number): number { + return Math.max(record.updatedAt, managedSecretTimestamp(now)); +} + +function assertManagedSecretId(value: unknown): asserts value is string { + if (typeof value !== 'string' || !UUID_PATTERN.test(value)) { + throw invalidInput('Managed Secret identity is invalid'); + } +} + +function invalidInput(message: string): ManagedSecretError { + return new ManagedSecretError('invalid_input', message); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/587426d324fa60958636d3275163a4f83ad8521d0cc7834d4e709614602039e1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/587426d324fa60958636d3275163a4f83ad8521d0cc7834d4e709614602039e1.source new file mode 100644 index 0000000000..0f94ac1577 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/587426d324fa60958636d3275163a4f83ad8521d0cc7834d4e709614602039e1.source @@ -0,0 +1,2036 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { + closeSync, + constants as fsConstants, + fchmodSync, + fstatSync, + lstatSync, + openSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import type { DatabaseSync } from 'node:sqlite'; +import { + MemoryItemStoreConflictError, + isMemoryItemKind, + isMemoryItemOrigin, + isMemoryKeyOrigin, + isMemoryKeyType, + isMemoryLifecycleState, + isMemoryScopeType, + isMemoryStatementType, + isMemoryTemporalType, + normalizeLongTermMemoryContent, + validateMemoryTemporalBounds, + type ApplyMemoryMutationsRequest, + type CommitMemoryExtractionRequest, + type MemoryExtractionCommitResult, + type MemoryCompactionPolicyDenial, + type MemoryExtractionCursor, + type MemoryExtractionFailureClass, + type MemoryExtractionReceipt, + type MemoryItem, + type MemoryItemKey, + type MemoryItemKeyInput, + type MemoryItemMutation, + type MemoryItemRecord, + type MemoryItemSource, + type MemoryItemStore, + type MemoryItemWrite, + type MemoryMutationResult, + type MemoryWriteOperationResult, + type PendingMemoryExtractionFailure, + type SearchMemoryItemsByKeyRequest, + type SettleMemoryExtractionFailureRequest, + type SettleMemoryExtractionFailureResult, +} from '@maka/core/long-term-memory'; +import { + assertSupportedSqliteLongTermMemorySchemaVersion, + configureSqliteLongTermMemoryDatabase, + migrateSqliteLongTermMemoryDatabase, + readSqliteLongTermMemorySchemaVersion, + type SqliteLongTermMemoryMigrationFailpoint, +} from './sqlite-long-term-memory-schema.js'; + +const MAX_MUTATIONS_PER_OPERATION = 32; +const MAX_KEYS_PER_ITEM = 32; +const MAX_SOURCES_PER_ITEM = 256; +const MAX_SEARCH_TERMS = 32; +const MAX_SEARCH_RESULTS = 100; +const MAX_IDENTIFIER_CODE_POINTS = 512; +const MAX_KEY_CODE_POINTS = 256; +const MAX_OPERATION_RESULT_JSON_CODE_UNITS = 128 * 1_024; + +const require = createRequire(import.meta.url); + +export type SqliteMemoryItemStoreFailpoint = + | 'after_item_write' + | 'after_keys_write' + | 'after_sources_write' + | 'after_cursor_write' + | 'before_operation_write' + | 'after_commit'; + +export interface SqliteMemoryItemStoreOptions { + readonly now?: () => number; + readonly idFactory?: () => string; + readonly failpoint?: (point: SqliteMemoryItemStoreFailpoint) => void; + readonly migrationFailpoint?: (point: SqliteLongTermMemoryMigrationFailpoint) => void; +} + +interface NormalizedMemoryWrite { + readonly content: string; + readonly kind: MemoryItem['kind']; + readonly statementType: MemoryItem['statementType']; + readonly temporalType: MemoryItem['temporalType']; + readonly scopeType: MemoryItem['scopeType']; + readonly scopeKey: string | null; + readonly eventStartedAt: number | null; + readonly eventEndedAt: number | null; + readonly observedAt: number; + readonly origin: MemoryItem['origin']; + readonly contentHash: string; + readonly keys: readonly MemoryItemKey[]; + readonly sources: readonly MemoryItemSource[]; +} + +type NormalizedMutation = + | { readonly type: 'create'; readonly item: NormalizedMemoryWrite } + | { + readonly type: 'update'; + readonly itemId: string; + readonly expectedVersion: number; + readonly item: NormalizedMemoryWrite; + } + | { readonly type: 'archive'; readonly itemId: string; readonly expectedVersion: number } + | { readonly type: 'restore'; readonly itemId: string; readonly expectedVersion: number }; + +interface MemoryItemRow { + item_id: unknown; + version: unknown; + content: unknown; + kind: unknown; + statement_type: unknown; + temporal_type: unknown; + scope_type: unknown; + scope_key: unknown; + event_started_at: unknown; + event_ended_at: unknown; + observed_at: unknown; + lifecycle_state: unknown; + origin: unknown; + content_hash: unknown; + created_at: unknown; + updated_at: unknown; +} + +interface MemoryKeyRow { + key_text: unknown; + normalized_key: unknown; + key_type: unknown; + key_origin: unknown; +} + +interface MemorySourceRow { + session_id: unknown; + run_id: unknown; + turn_id: unknown; + event_id: unknown; +} + +interface MemoryOperationRow { + operation_id: unknown; + operation_type: unknown; + request_hash: unknown; + result_json: unknown; + committed_at: unknown; +} + +interface MemoryExtractionCursorRow { + session_id: unknown; + processed_ordinal: unknown; + updated_at: unknown; +} + +interface MemoryExtractionFailureRow { + session_id: unknown; + from_ordinal: unknown; + through_ordinal: unknown; + coverage_hash: unknown; + first_operation_id: unknown; + first_trigger: unknown; + compaction_checkpoint_id: unknown; + first_failure_class: unknown; + failed_at: unknown; +} + +interface MemoryExtractionReceiptRow { + operation_id: unknown; + session_id: unknown; + request_hash: unknown; + result_json: unknown; + committed_at: unknown; +} + +interface SqliteMemoryKeySearchQuery { + readonly sql: string; + readonly parameters: readonly (string | number)[]; +} + +/** Low-level implementation; production callers must use the StorageRoot authority facade. */ +export class SqliteMemoryItemStore implements MemoryItemStore { + readonly #database: DatabaseSync; + readonly #options: SqliteMemoryItemStoreOptions; + #closed = false; + + constructor(path: string, options: SqliteMemoryItemStoreOptions = {}) { + if (path.trim() === '') throw new Error('Long-term memory SQLite path cannot be empty'); + this.#options = options; + if (path !== ':memory:') preparePrivateDatabaseFiles(path); + const Database = loadDatabaseSync(); + this.#database = new Database(path); + try { + assertSupportedSqliteLongTermMemorySchemaVersion(this.#database); + configureSqliteLongTermMemoryDatabase(this.#database); + migrateSqliteLongTermMemoryDatabase(this.#database, { + failpoint: options.migrationFailpoint, + }); + if (path !== ':memory:') secureExistingDatabaseFiles(path); + } catch (error) { + this.#database.close(); + this.#closed = true; + throw error; + } + } + + schemaVersion(): number { + this.#assertOpen(); + return readSqliteLongTermMemorySchemaVersion(this.#database); + } + + journalMode(): string { + this.#assertOpen(); + const row = this.#database.prepare('PRAGMA journal_mode').get() as + | { journal_mode?: unknown } + | undefined; + return typeof row?.journal_mode === 'string' ? row.journal_mode.toLowerCase() : ''; + } + + foreignKeysEnabled(): boolean { + this.#assertOpen(); + const row = this.#database.prepare('PRAGMA foreign_keys').get() as + | { foreign_keys?: unknown } + | undefined; + return row?.foreign_keys === 1; + } + + async applyMutations(request: ApplyMemoryMutationsRequest): Promise { + this.#assertOpen(); + const committedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time'); + const operationId = normalizeIdentifier(request.operationId, 'operationId'); + const mutations = normalizeMutations(request.mutations); + const requestHash = hashCanonical(mutations); + const operationType = mutations.length === 1 ? mutations[0]!.type : 'batch'; + + this.#database.exec('BEGIN IMMEDIATE'); + try { + const existing = this.#readOperationRow(operationId); + if (existing) { + if (requiredHash(existing.request_hash, 'request_hash') !== requestHash) { + throw new MemoryItemStoreConflictError( + 'operation_reused', + `Memory operation ${operationId} was already used for a different request`, + ); + } + this.#database.exec('COMMIT'); + return { ...decodeOperation(existing), replayed: true }; + } + + validateObservedAtForCommit(mutations, committedAt); + + const results: MemoryMutationResult[] = []; + for (let index = 0; index < mutations.length; index += 1) { + const result = this.#applyMutation(mutations[index]!, index, committedAt); + results.push(result); + } + + this.#options.failpoint?.('before_operation_write'); + this.#database + .prepare( + `INSERT INTO memory_write_operations( + operation_id, operation_type, request_hash, result_json, committed_at + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run(operationId, operationType, requestHash, JSON.stringify(results), committedAt); + this.#database.exec('COMMIT'); + this.#options.failpoint?.('after_commit'); + return { operationId, operationType, replayed: false, committedAt, results }; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + async commitExtraction( + request: CommitMemoryExtractionRequest, + ): Promise { + this.#assertOpen(); + const committedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time'); + const operationId = normalizeIdentifier(request.operationId, 'operationId'); + const sessionId = normalizeIdentifier(request.sessionId, 'sessionId'); + const expectedCursorOrdinal = normalizeCursorOrdinal( + request.expectedCursorOrdinal, + 'expectedCursorOrdinal', + true, + ); + const nextCursorOrdinal = normalizeCursorOrdinal( + request.nextCursorOrdinal, + 'nextCursorOrdinal', + false, + ); + const coverageHash = requiredHash(request.coverageHash, 'coverageHash'); + if (nextCursorOrdinal <= expectedCursorOrdinal) { + throw new Error('Memory extraction Cursor must advance'); + } + const items = normalizeExtractionItems(request.items); + const requestedItemIndexes = normalizeRequestedItemIndexes( + request.requestedItemIndexes, + items.length, + ); + const noOpReason = normalizeExtractionNoOpReason(request.noOpReason); + const skipReason = normalizeExtractionSkipReason(request.skipReason); + const trigger = normalizeExtractionTrigger(request.trigger); + const compactionCheckpointId = normalizeCompactionCheckpointId( + trigger, + request.compactionCheckpointId, + ); + if (trigger !== 'remember' && requestedItemIndexes.length > 0) { + throw new Error('Incidental extraction cannot expose requested Items'); + } + if ( + noOpReason && + (trigger !== 'remember' || items.length > 0 || requestedItemIndexes.length > 0) + ) { + throw new Error('A rejected explicit Memory request must commit as an empty no-op'); + } + if ( + skipReason && + (trigger !== 'compaction' || items.length > 0 || requestedItemIndexes.length > 0) + ) { + throw new Error('A policy-skipped Memory extraction must be an empty Compaction commit'); + } + validateExtractionObservedAtForCommit(items, committedAt); + const requestHash = hashCanonical({ + kind: 'memory_extraction', + sessionId, + expectedCursorOrdinal, + nextCursorOrdinal, + coverageHash, + items, + requestedItemIndexes, + noOpReason: noOpReason ?? null, + skipReason: skipReason ?? null, + trigger, + compactionCheckpointId: compactionCheckpointId ?? null, + }); + + this.#database.exec('BEGIN IMMEDIATE'); + try { + const existingReceipt = this.#readExtractionReceiptRow(operationId); + if (existingReceipt) { + if (requiredHash(existingReceipt.request_hash, 'request_hash') !== requestHash) { + throw new MemoryItemStoreConflictError( + 'operation_reused', + `Memory operation ${operationId} was already used for a different request`, + ); + } + const existing = this.#readOperationRow(operationId); + if (!existing) throw new Error(`Memory extraction ${operationId} is missing its operation`); + const decoded = decodeOperation(existing); + const receipt = decodeExtractionReceipt(existingReceipt); + this.#database.exec('COMMIT'); + return { + ...decoded, + replayed: true, + cursor: { + sessionId, + processedOrdinal: nextCursorOrdinal, + updatedAt: decoded.committedAt, + }, + receipt, + }; + } + + const currentCursor = this.#readExtractionCursorRow(sessionId); + const currentOrdinal = currentCursor + ? requiredPositiveInteger(currentCursor.processed_ordinal, 'processed_ordinal') + : 0; + if (currentOrdinal !== expectedCursorOrdinal) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction Cursor for Session ${sessionId} is ${currentOrdinal}, expected ${expectedCursorOrdinal}`, + ); + } + + const pendingFailure = this.#readPendingExtractionFailureRow(sessionId); + if (pendingFailure) { + const pending = decodePendingExtractionFailure(pendingFailure); + const pendingMatchesCommit = skipReason + ? pending.firstTrigger === 'compaction' && + pending.fromOrdinal === expectedCursorOrdinal + 1 && + pending.throughOrdinal <= nextCursorOrdinal + : pending.firstOperationId !== operationId && + pending.fromOrdinal === expectedCursorOrdinal + 1 && + pending.throughOrdinal === nextCursorOrdinal && + pending.coverageHash === coverageHash && + pending.firstTrigger === trigger && + pending.compactionCheckpointId === compactionCheckpointId; + if (!pendingMatchesCommit) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction pending range for Session ${sessionId} does not match the commit`, + ); + } + } + + const results = items.map((item, index) => this.#createItem(item, index, committedAt)); + const requestedItems = requestedItemIndexes.map((index) => { + const result = results[index]!; + return { itemId: result.itemId, content: items[index]!.content }; + }); + const receipt: MemoryExtractionReceipt = { + operationId, + sessionId, + status: skipReason + ? 'skipped' + : trigger !== 'remember' + ? 'extracted' + : requestedItems.length > 0 + ? 'remembered' + : 'not_applicable', + requestedItems, + ...(noOpReason ? { noOpReason } : {}), + ...(skipReason ? { skipReason } : {}), + committedAt, + }; + + if (currentCursor) { + const updated = this.#database + .prepare( + `UPDATE memory_extraction_cursors + SET processed_ordinal = ?, updated_at = ? + WHERE session_id = ? AND processed_ordinal = ?`, + ) + .run(nextCursorOrdinal, committedAt, sessionId, expectedCursorOrdinal); + if (updated.changes !== 1) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction Cursor for Session ${sessionId} changed during commit`, + ); + } + } else { + this.#database + .prepare( + `INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at) + VALUES (?, ?, ?)`, + ) + .run(sessionId, nextCursorOrdinal, committedAt); + } + if (pendingFailure) { + this.#database + .prepare('DELETE FROM memory_extraction_failures WHERE session_id = ?') + .run(sessionId); + } + this.#options.failpoint?.('after_cursor_write'); + + this.#options.failpoint?.('before_operation_write'); + this.#database + .prepare( + `INSERT INTO memory_write_operations( + operation_id, operation_type, request_hash, result_json, committed_at + ) VALUES (?, 'batch', ?, ?, ?)`, + ) + .run(operationId, requestHash, JSON.stringify(results), committedAt); + this.#database + .prepare( + `INSERT INTO memory_extraction_receipts( + operation_id, session_id, request_hash, result_json, committed_at + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run(operationId, sessionId, requestHash, JSON.stringify(receipt), committedAt); + this.#database.exec('COMMIT'); + this.#options.failpoint?.('after_commit'); + return { + operationId, + operationType: 'batch', + replayed: false, + committedAt, + results, + cursor: { sessionId, processedOrdinal: nextCursorOrdinal, updatedAt: committedAt }, + receipt, + }; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + async recordCompactionPolicyDenial( + denial: MemoryCompactionPolicyDenial, + ): Promise { + this.#assertOpen(); + const normalized = { + sessionId: normalizeIdentifier(denial.sessionId, 'sessionId'), + compactionCheckpointId: normalizeIdentifier( + denial.compactionCheckpointId, + 'compactionCheckpointId', + ), + deniedAt: normalizeTimestamp(denial.deniedAt, 'deniedAt'), + }; + this.#database + .prepare( + `INSERT INTO memory_compaction_policy_denials( + session_id, compaction_checkpoint_id, denied_at + ) VALUES (?, ?, ?) + ON CONFLICT(session_id, compaction_checkpoint_id) DO NOTHING`, + ) + .run(normalized.sessionId, normalized.compactionCheckpointId, normalized.deniedAt); + const row = this.#database + .prepare( + `SELECT session_id, compaction_checkpoint_id, denied_at + FROM memory_compaction_policy_denials + WHERE session_id = ? AND compaction_checkpoint_id = ?`, + ) + .get(normalized.sessionId, normalized.compactionCheckpointId) as + | { session_id: unknown; compaction_checkpoint_id: unknown; denied_at: unknown } + | undefined; + if (!row) throw new Error('Compaction policy denial was not persisted'); + return decodeCompactionPolicyDenial(row); + } + + async readCompactionPolicyDenials( + sessionId: string, + ): Promise { + this.#assertOpen(); + const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId'); + return ( + this.#database + .prepare( + `SELECT session_id, compaction_checkpoint_id, denied_at + FROM memory_compaction_policy_denials + WHERE session_id = ? + ORDER BY denied_at ASC, compaction_checkpoint_id ASC`, + ) + .all(normalizedSessionId) as Array<{ + session_id: unknown; + compaction_checkpoint_id: unknown; + denied_at: unknown; + }> + ).map(decodeCompactionPolicyDenial); + } + + async initializeExtractionCursor( + sessionId: string, + processedOrdinal: number, + ): Promise { + this.#assertOpen(); + const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId'); + const normalizedOrdinal = normalizeCursorOrdinal(processedOrdinal, 'processedOrdinal', false); + const updatedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time'); + this.#database.exec('BEGIN IMMEDIATE'); + try { + const existing = this.#readExtractionCursorRow(normalizedSessionId); + if (existing) { + const decoded = decodeExtractionCursor(existing); + this.#database.exec('COMMIT'); + return decoded; + } + if (this.#readPendingExtractionFailureRow(normalizedSessionId)) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction for Session ${normalizedSessionId} has a pending failed range`, + ); + } + this.#database + .prepare( + `INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at) + VALUES (?, ?, ?)`, + ) + .run(normalizedSessionId, normalizedOrdinal, updatedAt); + this.#database.exec('COMMIT'); + return { sessionId: normalizedSessionId, processedOrdinal: normalizedOrdinal, updatedAt }; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + async readExtractionCursor(sessionId: string): Promise { + this.#assertOpen(); + const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId'); + return this.#readSnapshot(() => { + const row = this.#readExtractionCursorRow(normalizedSessionId); + return row ? decodeExtractionCursor(row) : undefined; + }); + } + + async readPendingExtractionFailure( + sessionId: string, + ): Promise { + this.#assertOpen(); + const normalizedSessionId = normalizeIdentifier(sessionId, 'sessionId'); + return this.#readSnapshot(() => { + const row = this.#readPendingExtractionFailureRow(normalizedSessionId); + return row ? decodePendingExtractionFailure(row) : undefined; + }); + } + + async settleExtractionFailure( + request: SettleMemoryExtractionFailureRequest, + ): Promise { + this.#assertOpen(); + const operationId = normalizeIdentifier(request.operationId, 'operationId'); + const sessionId = normalizeIdentifier(request.sessionId, 'sessionId'); + const expectedCursorOrdinal = normalizeCursorOrdinal( + request.expectedCursorOrdinal, + 'expectedCursorOrdinal', + true, + ); + const failedThroughOrdinal = normalizeCursorOrdinal( + request.failedThroughOrdinal, + 'failedThroughOrdinal', + false, + ); + if (failedThroughOrdinal <= expectedCursorOrdinal) { + throw new Error('Memory extraction failed range must advance beyond the Cursor'); + } + const coverageHash = requiredHash(request.coverageHash, 'coverageHash'); + const failureClass = normalizeExtractionFailureClass(request.failureClass); + const trigger = normalizeExtractionTrigger(request.trigger); + const compactionCheckpointId = normalizeCompactionCheckpointId( + trigger, + request.compactionCheckpointId, + ); + const recordedAt = normalizeTimestamp((this.#options.now ?? Date.now)(), 'current time'); + + this.#database.exec('BEGIN IMMEDIATE'); + try { + const existingReceipt = this.#readExtractionReceiptRow(operationId); + if (existingReceipt) { + const receipt = decodeExtractionReceipt(existingReceipt); + if (receipt.status !== 'discarded') { + throw new MemoryItemStoreConflictError( + 'operation_reused', + `Memory operation ${operationId} already completed successfully`, + ); + } + const discarded = receipt.discardedRange!; + const replayHash = hashCanonical({ + kind: 'memory_extraction_discard', + sessionId, + trigger, + compactionCheckpointId: compactionCheckpointId ?? null, + discardedRange: discarded, + }); + if ( + receipt.sessionId !== sessionId || + requiredHash(existingReceipt.request_hash, 'request_hash') !== replayHash || + discarded.fromOrdinal !== expectedCursorOrdinal + 1 || + discarded.throughOrdinal !== failedThroughOrdinal || + discarded.coverageHash !== coverageHash || + discarded.finalFailureClass !== failureClass + ) { + throw new MemoryItemStoreConflictError( + 'operation_reused', + `Memory operation ${operationId} was already used for a different failed range`, + ); + } + const cursor = this.#readExtractionCursorRow(sessionId); + if (!cursor) throw new Error(`Discarded Memory extraction ${operationId} lost its Cursor`); + this.#database.exec('COMMIT'); + return { + status: 'discarded', + replayed: true, + receipt, + cursor: decodeExtractionCursor(cursor), + }; + } + + const cursorRow = this.#readExtractionCursorRow(sessionId); + const currentOrdinal = cursorRow + ? requiredPositiveInteger(cursorRow.processed_ordinal, 'processed_ordinal') + : 0; + if (currentOrdinal !== expectedCursorOrdinal) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction Cursor for Session ${sessionId} is ${currentOrdinal}, expected ${expectedCursorOrdinal}`, + ); + } + + const pendingRow = this.#readPendingExtractionFailureRow(sessionId); + if (!pendingRow) { + const pending: PendingMemoryExtractionFailure = { + sessionId, + fromOrdinal: expectedCursorOrdinal + 1, + throughOrdinal: failedThroughOrdinal, + coverageHash, + firstOperationId: operationId, + firstTrigger: trigger, + ...(compactionCheckpointId ? { compactionCheckpointId } : {}), + firstFailureClass: failureClass, + failedAt: recordedAt, + }; + this.#database + .prepare( + `INSERT INTO memory_extraction_failures( + session_id, from_ordinal, through_ordinal, coverage_hash, + first_operation_id, first_trigger, compaction_checkpoint_id, + first_failure_class, failed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + sessionId, + pending.fromOrdinal, + pending.throughOrdinal, + pending.coverageHash, + pending.firstOperationId, + pending.firstTrigger, + pending.compactionCheckpointId ?? null, + pending.firstFailureClass, + pending.failedAt, + ); + this.#database.exec('COMMIT'); + return { status: 'retry_later', replayed: false, pending }; + } + + const pending = decodePendingExtractionFailure(pendingRow); + if (pending.firstOperationId === operationId) { + if ( + pending.fromOrdinal !== expectedCursorOrdinal + 1 || + pending.throughOrdinal !== failedThroughOrdinal || + pending.coverageHash !== coverageHash || + pending.firstTrigger !== trigger || + pending.compactionCheckpointId !== compactionCheckpointId || + pending.firstFailureClass !== failureClass + ) { + throw new MemoryItemStoreConflictError( + 'operation_reused', + `Memory operation ${operationId} was already used for a different failed range`, + ); + } + this.#database.exec('COMMIT'); + return { status: 'retry_later', replayed: true, pending }; + } + if ( + pending.fromOrdinal !== expectedCursorOrdinal + 1 || + pending.throughOrdinal !== failedThroughOrdinal || + pending.coverageHash !== coverageHash || + pending.firstTrigger !== trigger || + pending.compactionCheckpointId !== compactionCheckpointId + ) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction failed range for Session ${sessionId} changed before discard`, + ); + } + + const discardedRange = { + fromOrdinal: pending.fromOrdinal, + throughOrdinal: pending.throughOrdinal, + coverageHash: pending.coverageHash, + firstFailureClass: pending.firstFailureClass, + finalFailureClass: failureClass, + } as const; + const receipt: MemoryExtractionReceipt = { + operationId, + sessionId, + status: 'discarded', + requestedItems: [], + discardedRange, + committedAt: recordedAt, + }; + const requestHash = hashCanonical({ + kind: 'memory_extraction_discard', + sessionId, + trigger, + compactionCheckpointId: compactionCheckpointId ?? null, + discardedRange, + }); + + if (cursorRow) { + const updated = this.#database + .prepare( + `UPDATE memory_extraction_cursors + SET processed_ordinal = ?, updated_at = ? + WHERE session_id = ? AND processed_ordinal = ?`, + ) + .run(failedThroughOrdinal, recordedAt, sessionId, expectedCursorOrdinal); + if (updated.changes !== 1) { + throw new MemoryItemStoreConflictError( + 'cursor_conflict', + `Memory extraction Cursor for Session ${sessionId} changed during discard`, + ); + } + } else { + this.#database + .prepare( + `INSERT INTO memory_extraction_cursors(session_id, processed_ordinal, updated_at) + VALUES (?, ?, ?)`, + ) + .run(sessionId, failedThroughOrdinal, recordedAt); + } + this.#options.failpoint?.('after_cursor_write'); + this.#database + .prepare('DELETE FROM memory_extraction_failures WHERE session_id = ?') + .run(sessionId); + this.#options.failpoint?.('before_operation_write'); + this.#database + .prepare( + `INSERT INTO memory_write_operations( + operation_id, operation_type, request_hash, result_json, committed_at + ) VALUES (?, 'batch', ?, '[]', ?)`, + ) + .run(operationId, requestHash, recordedAt); + this.#database + .prepare( + `INSERT INTO memory_extraction_receipts( + operation_id, session_id, request_hash, result_json, committed_at + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run(operationId, sessionId, requestHash, JSON.stringify(receipt), recordedAt); + this.#database.exec('COMMIT'); + this.#options.failpoint?.('after_commit'); + return { + status: 'discarded', + replayed: false, + receipt, + cursor: { sessionId, processedOrdinal: failedThroughOrdinal, updatedAt: recordedAt }, + }; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + async readExtractionReceipt(operationId: string): Promise { + this.#assertOpen(); + const normalizedOperationId = normalizeIdentifier(operationId, 'operationId'); + return this.#readSnapshot(() => { + const row = this.#readExtractionReceiptRow(normalizedOperationId); + return row ? decodeExtractionReceipt(row) : undefined; + }); + } + + async readItem(itemId: string): Promise { + this.#assertOpen(); + return this.#readSnapshot(() => this.#readItemRecord(normalizeIdentifier(itemId, 'itemId'))); + } + + async searchByKeys(request: SearchMemoryItemsByKeyRequest): Promise { + this.#assertOpen(); + if (request.match !== 'exact' && request.match !== 'prefix') { + throw new Error('Memory key match must be exact or prefix'); + } + if (!Array.isArray(request.terms) || request.terms.length === 0) { + throw new Error('Memory key search requires at least one term'); + } + if (request.terms.length > MAX_SEARCH_TERMS) { + throw new Error(`Memory key search accepts at most ${MAX_SEARCH_TERMS} terms`); + } + if (request.includeArchived !== undefined && typeof request.includeArchived !== 'boolean') { + throw new Error('Memory key includeArchived must be a boolean'); + } + const terms = [...new Set(request.terms.map(normalizeSearchTerm))].sort(compareText); + const workspaceKey = + request.workspaceKey === undefined + ? undefined + : normalizeIdentifier(request.workspaceKey, 'workspaceKey'); + const limit = request.limit ?? 20; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SEARCH_RESULTS) { + throw new Error(`Memory key search limit must be between 1 and ${MAX_SEARCH_RESULTS}`); + } + + const query = buildSqliteMemoryKeySearchQuery({ + terms, + match: request.match, + workspaceKey, + includeArchived: request.includeArchived ?? false, + limit, + }); + return this.#readSnapshot(() => { + const rows = this.#database.prepare(query.sql).all(...query.parameters) as Array<{ + item_id?: unknown; + }>; + + return rows.map((row) => { + if (typeof row.item_id !== 'string') throw new Error('Invalid Memory Item search result'); + const record = this.#readItemRecord(row.item_id); + if (!record) throw new Error(`Memory Item ${row.item_id} disappeared during read`); + return record; + }); + }); + } + + async readOperation(operationId: string): Promise { + this.#assertOpen(); + const row = this.#readOperationRow(normalizeIdentifier(operationId, 'operationId')); + return row ? decodeOperation(row) : undefined; + } + + close(): void { + if (this.#closed) return; + this.#database.close(); + this.#closed = true; + } + + #applyMutation( + mutation: NormalizedMutation, + mutationIndex: number, + committedAt: number, + ): MemoryMutationResult { + switch (mutation.type) { + case 'create': + return this.#createItem(mutation.item, mutationIndex, committedAt); + case 'update': + return this.#updateItem(mutation, mutationIndex, committedAt); + case 'archive': + return this.#changeLifecycle(mutation, mutationIndex, committedAt, 'archived'); + case 'restore': + return this.#changeLifecycle(mutation, mutationIndex, committedAt, 'active'); + } + } + + #createItem( + write: NormalizedMemoryWrite, + mutationIndex: number, + committedAt: number, + ): MemoryMutationResult { + const itemId = normalizeIdentifier( + (this.#options.idFactory ?? randomUUID)(), + 'generated itemId', + ); + this.#database + .prepare( + `INSERT INTO memory_items( + item_id, version, content, kind, statement_type, temporal_type, + scope_type, scope_key, event_started_at, event_ended_at, observed_at, + lifecycle_state, origin, content_hash, created_at, updated_at + ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`, + ) + .run( + itemId, + write.content, + write.kind, + write.statementType, + write.temporalType, + write.scopeType, + write.scopeKey, + write.eventStartedAt, + write.eventEndedAt, + write.observedAt, + write.origin, + write.contentHash, + committedAt, + committedAt, + ); + this.#options.failpoint?.('after_item_write'); + this.#replaceKeys(itemId, write.keys); + this.#options.failpoint?.('after_keys_write'); + this.#replaceSources(itemId, write.sources); + this.#options.failpoint?.('after_sources_write'); + return mutationResult(mutationIndex, 'create', this.#requireItemRecord(itemId).item, 'created'); + } + + #updateItem( + mutation: Extract, + mutationIndex: number, + committedAt: number, + ): MemoryMutationResult { + const current = this.#requireVersion(mutation.itemId, mutation.expectedVersion); + const currentRecord = this.#requireItemRecord(mutation.itemId); + if (recordMatchesWrite(currentRecord, mutation.item)) { + return mutationResult(mutationIndex, 'update', current, 'noop'); + } + const updatedAt = Math.max(committedAt, current.updatedAt); + const result = this.#database + .prepare( + `UPDATE memory_items + SET version = version + 1, + content = ?, kind = ?, statement_type = ?, temporal_type = ?, + scope_type = ?, scope_key = ?, event_started_at = ?, event_ended_at = ?, + observed_at = ?, origin = ?, content_hash = ?, updated_at = ? + WHERE item_id = ? AND version = ?`, + ) + .run( + mutation.item.content, + mutation.item.kind, + mutation.item.statementType, + mutation.item.temporalType, + mutation.item.scopeType, + mutation.item.scopeKey, + mutation.item.eventStartedAt, + mutation.item.eventEndedAt, + mutation.item.observedAt, + mutation.item.origin, + mutation.item.contentHash, + updatedAt, + mutation.itemId, + mutation.expectedVersion, + ); + assertChanged(result.changes, mutation.itemId); + this.#options.failpoint?.('after_item_write'); + this.#replaceKeys(mutation.itemId, mutation.item.keys); + this.#options.failpoint?.('after_keys_write'); + this.#replaceSources(mutation.itemId, mutation.item.sources); + this.#options.failpoint?.('after_sources_write'); + return mutationResult( + mutationIndex, + 'update', + this.#requireItemRecord(mutation.itemId).item, + 'updated', + ); + } + + #changeLifecycle( + mutation: Extract, + mutationIndex: number, + committedAt: number, + target: MemoryItem['lifecycleState'], + ): MemoryMutationResult { + const current = this.#requireVersion(mutation.itemId, mutation.expectedVersion); + const expected = target === 'archived' ? 'active' : 'archived'; + if (current.lifecycleState !== expected) { + throw new MemoryItemStoreConflictError( + 'invalid_lifecycle_transition', + `Memory Item ${mutation.itemId} is ${current.lifecycleState}, expected ${expected}`, + mutation.itemId, + ); + } + const updatedAt = Math.max(committedAt, current.updatedAt); + const result = this.#database + .prepare( + `UPDATE memory_items + SET version = version + 1, lifecycle_state = ?, updated_at = ? + WHERE item_id = ? AND version = ? AND lifecycle_state = ?`, + ) + .run(target, updatedAt, mutation.itemId, mutation.expectedVersion, expected); + assertChanged(result.changes, mutation.itemId); + this.#options.failpoint?.('after_item_write'); + return mutationResult( + mutationIndex, + mutation.type, + this.#requireItemRecord(mutation.itemId).item, + target === 'active' ? 'restored' : 'archived', + ); + } + + #requireVersion(itemId: string, expectedVersion: number): MemoryItem { + const record = this.#readItemRecord(itemId); + if (!record) { + throw new MemoryItemStoreConflictError( + 'item_not_found', + `Memory Item ${itemId} does not exist`, + itemId, + ); + } + if (record.item.version !== expectedVersion) { + throw new MemoryItemStoreConflictError( + 'version_conflict', + `Memory Item ${itemId} is version ${record.item.version}, expected ${expectedVersion}`, + itemId, + ); + } + return record.item; + } + + #replaceKeys(itemId: string, keys: readonly MemoryItemKey[]): void { + this.#database.prepare('DELETE FROM memory_item_keys WHERE item_id = ?').run(itemId); + const insert = this.#database.prepare( + `INSERT INTO memory_item_keys(item_id, key_text, normalized_key, key_type, key_origin) + VALUES (?, ?, ?, ?, ?)`, + ); + for (const key of keys) { + insert.run(itemId, key.key, key.normalizedKey, key.keyType, key.keyOrigin); + } + } + + #replaceSources(itemId: string, sources: readonly MemoryItemSource[]): void { + this.#database.prepare('DELETE FROM memory_item_sources WHERE item_id = ?').run(itemId); + const insert = this.#database.prepare( + `INSERT INTO memory_item_sources(item_id, session_id, run_id, turn_id, event_id) + VALUES (?, ?, ?, ?, ?)`, + ); + for (const source of sources) { + insert.run(itemId, source.sessionId, source.runId, source.turnId, source.eventId); + } + } + + #readItemRecord(itemId: string): MemoryItemRecord | undefined { + const row = this.#database + .prepare('SELECT * FROM memory_items WHERE item_id = ?') + .get(itemId) as MemoryItemRow | undefined; + if (!row) return undefined; + const keys = this.#database + .prepare( + `SELECT key_text, normalized_key, key_type, key_origin + FROM memory_item_keys WHERE item_id = ? ORDER BY normalized_key ASC + LIMIT ${MAX_KEYS_PER_ITEM + 1}`, + ) + .all(itemId) as unknown as MemoryKeyRow[]; + const sources = this.#database + .prepare( + `SELECT session_id, run_id, turn_id, event_id + FROM memory_item_sources WHERE item_id = ? ORDER BY event_id ASC + LIMIT ${MAX_SOURCES_PER_ITEM + 1}`, + ) + .all(itemId) as unknown as MemorySourceRow[]; + assertChildCardinality('keys', keys.length, MAX_KEYS_PER_ITEM); + assertChildCardinality('sources', sources.length, MAX_SOURCES_PER_ITEM); + return { + item: decodeItem(row), + keys: keys.map(decodeKey), + sources: sources.map(decodeSource), + }; + } + + #requireItemRecord(itemId: string): MemoryItemRecord { + const record = this.#readItemRecord(itemId); + if (!record) throw new Error(`Memory Item ${itemId} disappeared during transaction`); + return record; + } + + #readOperationRow(operationId: string): MemoryOperationRow | undefined { + return this.#database + .prepare( + `SELECT operation_id, operation_type, request_hash, result_json, committed_at + FROM memory_write_operations WHERE operation_id = ?`, + ) + .get(operationId) as MemoryOperationRow | undefined; + } + + #readExtractionCursorRow(sessionId: string): MemoryExtractionCursorRow | undefined { + return this.#database + .prepare( + `SELECT session_id, processed_ordinal, updated_at + FROM memory_extraction_cursors WHERE session_id = ?`, + ) + .get(sessionId) as MemoryExtractionCursorRow | undefined; + } + + #readPendingExtractionFailureRow(sessionId: string): MemoryExtractionFailureRow | undefined { + return this.#database + .prepare( + `SELECT session_id, from_ordinal, through_ordinal, coverage_hash, + first_operation_id, first_trigger, compaction_checkpoint_id, + first_failure_class, failed_at + FROM memory_extraction_failures WHERE session_id = ?`, + ) + .get(sessionId) as MemoryExtractionFailureRow | undefined; + } + + #readExtractionReceiptRow(operationId: string): MemoryExtractionReceiptRow | undefined { + return this.#database + .prepare( + `SELECT operation_id, session_id, request_hash, result_json, committed_at + FROM memory_extraction_receipts WHERE operation_id = ?`, + ) + .get(operationId) as MemoryExtractionReceiptRow | undefined; + } + + #assertOpen(): void { + if (this.#closed) throw new Error('SQLite Memory Item Store is closed'); + } + + #readSnapshot(operation: () => T): T { + this.#database.exec('BEGIN'); + try { + const result = operation(); + this.#database.exec('COMMIT'); + return result; + } catch (error) { + rollback(this.#database); + throw error; + } + } +} + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; +} + +export function buildSqliteMemoryKeySearchQuery(input: { + readonly terms: readonly string[]; + readonly match: 'exact' | 'prefix'; + readonly workspaceKey?: string; + readonly includeArchived: boolean; + readonly limit: number; +}): SqliteMemoryKeySearchQuery { + const parameters: Array = []; + let matchingKeysSql: string; + if (input.match === 'exact') { + matchingKeysSql = ` + SELECT item_id, normalized_key AS matched_term + FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key + WHERE normalized_key IN (${placeholders(input.terms.length)})`; + parameters.push(...input.terms); + } else { + matchingKeysSql = input.terms + .map((term, index) => { + const upperBound = prefixUpperBound(term); + parameters.push(term); + if (upperBound) { + parameters.push(upperBound); + return ` + SELECT item_id, ${index} AS matched_term + FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key + WHERE normalized_key >= ? AND normalized_key < ?`; + } + return ` + SELECT item_id, ${index} AS matched_term + FROM memory_item_keys INDEXED BY memory_item_keys_by_normalized_key + WHERE normalized_key >= ?`; + }) + .join('\nUNION ALL\n'); + } + const scopeClause = input.workspaceKey + ? `(i.scope_type = 'global' OR (i.scope_type = 'workspace' AND i.scope_key = ?))` + : `i.scope_type = 'global'`; + if (input.workspaceKey) parameters.push(input.workspaceKey); + parameters.push(input.limit); + return { + sql: ` + WITH matching_keys AS MATERIALIZED ( + ${matchingKeysSql} + ) + SELECT i.item_id + FROM matching_keys m + JOIN memory_items i ON i.item_id = m.item_id + WHERE ${scopeClause} + AND ${input.includeArchived ? '1 = 1' : `i.lifecycle_state = 'active'`} + GROUP BY i.item_id + ORDER BY COUNT(DISTINCT m.matched_term) DESC, i.updated_at DESC, i.item_id ASC + LIMIT ?`, + parameters, + }; +} + +function normalizeMutations( + mutations: readonly MemoryItemMutation[], +): readonly NormalizedMutation[] { + if (!Array.isArray(mutations) || mutations.length === 0) { + throw new Error('Memory operation requires at least one mutation'); + } + if (mutations.length > MAX_MUTATIONS_PER_OPERATION) { + throw new Error(`Memory operation accepts at most ${MAX_MUTATIONS_PER_OPERATION} mutations`); + } + return mutations.map((mutation): NormalizedMutation => { + if (!mutation || typeof mutation !== 'object') throw new Error('Invalid Memory mutation'); + switch (mutation.type) { + case 'create': + return { type: 'create', item: normalizeWrite(mutation.item) }; + case 'update': + return { + type: 'update', + itemId: normalizeIdentifier(mutation.itemId, 'itemId'), + expectedVersion: normalizeVersion(mutation.expectedVersion), + item: normalizeWrite(mutation.item), + }; + case 'archive': + case 'restore': + return { + type: mutation.type, + itemId: normalizeIdentifier(mutation.itemId, 'itemId'), + expectedVersion: normalizeVersion(mutation.expectedVersion), + }; + default: + throw new Error('Unknown Memory mutation type'); + } + }); +} + +function normalizeExtractionItems( + items: readonly MemoryItemWrite[], +): readonly NormalizedMemoryWrite[] { + if (!Array.isArray(items)) throw new Error('Memory extraction items must be an array'); + if (items.length > MAX_MUTATIONS_PER_OPERATION) { + throw new Error(`Memory extraction accepts at most ${MAX_MUTATIONS_PER_OPERATION} Items`); + } + return items.map(normalizeWrite); +} + +function assertChildCardinality(child: 'keys' | 'sources', count: number, maximum: number): void { + if (count < 1 || count > maximum) { + throw new Error( + `Invalid Memory Item ${child} cardinality: expected 1..${maximum}, got ${count}`, + ); + } +} + +function validateObservedAtForCommit( + mutations: readonly NormalizedMutation[], + committedAt: number, +): void { + for (const mutation of mutations) { + if ( + (mutation.type === 'create' || mutation.type === 'update') && + mutation.item.observedAt > committedAt + ) { + throw new Error('observedAt cannot be later than commit time'); + } + } +} + +function validateExtractionObservedAtForCommit( + items: readonly NormalizedMemoryWrite[], + committedAt: number, +): void { + for (const item of items) { + if (item.observedAt > committedAt) { + throw new Error('observedAt cannot be later than commit time'); + } + } +} + +function normalizeWrite(input: MemoryItemWrite): NormalizedMemoryWrite { + if (!input || typeof input !== 'object') throw new Error('Memory Item write must be an object'); + const content = normalizeLongTermMemoryContent(input.content); + if (!content.ok) throw new Error(content.message); + if (!isMemoryItemKind(input.kind)) throw new Error('Invalid Memory Item kind'); + if (!isMemoryStatementType(input.statementType)) throw new Error('Invalid Memory statement type'); + if (!isMemoryTemporalType(input.temporalType)) throw new Error('Invalid Memory temporal type'); + if (!isMemoryScopeType(input.scopeType)) throw new Error('Invalid Memory scope type'); + if (!isMemoryItemOrigin(input.origin)) throw new Error('Invalid Memory Item origin'); + + const scopeKey = normalizeScopeKey(input.scopeType, input.scopeKey); + const eventStartedAt = normalizeOptionalTimestamp(input.eventStartedAt, 'eventStartedAt'); + const eventEndedAt = normalizeOptionalTimestamp(input.eventEndedAt, 'eventEndedAt'); + validateMemoryTemporalBounds({ + temporalType: input.temporalType, + eventStartedAt, + eventEndedAt, + }); + const observedAt = normalizeTimestamp(input.observedAt, 'observedAt'); + return { + content: content.value, + kind: input.kind, + statementType: input.statementType, + temporalType: input.temporalType, + scopeType: input.scopeType, + scopeKey, + eventStartedAt, + eventEndedAt, + observedAt, + origin: input.origin, + contentHash: hashText(content.value), + keys: normalizeKeys(input.keys), + sources: normalizeSources(input.sources), + }; +} + +function normalizeScopeKey( + scopeType: MemoryItem['scopeType'], + input: string | null | undefined, +): string | null { + if (scopeType === 'global') { + if (input !== undefined && input !== null) { + throw new Error('Global Memory Item cannot have a scopeKey'); + } + return null; + } + return normalizeIdentifier(input, 'workspace scopeKey'); +} + +function normalizeKeys(input: readonly MemoryItemKeyInput[]): readonly MemoryItemKey[] { + if (!Array.isArray(input) || input.length === 0) { + throw new Error('Memory Item requires at least one search key'); + } + if (input.length > MAX_KEYS_PER_ITEM) { + throw new Error(`Memory Item accepts at most ${MAX_KEYS_PER_ITEM} search keys`); + } + const winners = new Map(); + for (const candidate of input) { + if (!candidate || typeof candidate !== 'object') throw new Error('Invalid Memory search key'); + if (!isMemoryKeyType(candidate.keyType)) throw new Error('Invalid Memory key type'); + if (!isMemoryKeyOrigin(candidate.keyOrigin)) throw new Error('Invalid Memory key origin'); + const key = normalizeVisibleText(candidate.key, 'Memory search key', MAX_KEY_CODE_POINTS); + const normalized: MemoryItemKey = { + key, + normalizedKey: normalizeSearchTerm(key), + keyType: candidate.keyType, + keyOrigin: candidate.keyOrigin, + }; + const existing = winners.get(normalized.normalizedKey); + if (!existing || keyPriority(normalized) > keyPriority(existing)) { + winners.set(normalized.normalizedKey, normalized); + } else if ( + existing && + keyPriority(normalized) === keyPriority(existing) && + compareText(normalized.key, existing.key) < 0 + ) { + winners.set(normalized.normalizedKey, normalized); + } + } + return [...winners.values()].sort((left, right) => + compareText(left.normalizedKey, right.normalizedKey), + ); +} + +function normalizeSources(input: readonly MemoryItemSource[]): readonly MemoryItemSource[] { + if (!Array.isArray(input) || input.length === 0) { + throw new Error('Memory Item requires at least one source Event'); + } + if (input.length > MAX_SOURCES_PER_ITEM) { + throw new Error(`Memory Item accepts at most ${MAX_SOURCES_PER_ITEM} sources`); + } + const sources = new Map(); + for (const candidate of input) { + if (!candidate || typeof candidate !== 'object') throw new Error('Invalid Memory Item source'); + const source: MemoryItemSource = { + sessionId: normalizeIdentifier(candidate.sessionId, 'source sessionId'), + runId: normalizeIdentifier(candidate.runId, 'source runId'), + turnId: normalizeIdentifier(candidate.turnId, 'source turnId'), + eventId: normalizeIdentifier(candidate.eventId, 'source eventId'), + }; + const existing = sources.get(source.eventId); + if (existing && !sameSource(existing, source)) { + throw new Error(`Source eventId ${source.eventId} has conflicting provenance`); + } + sources.set(source.eventId, source); + } + return [...sources.values()].sort((left, right) => compareText(left.eventId, right.eventId)); +} + +function sameSource(left: MemoryItemSource, right: MemoryItemSource): boolean { + return ( + left.sessionId === right.sessionId && + left.runId === right.runId && + left.turnId === right.turnId && + left.eventId === right.eventId + ); +} + +function normalizeIdentifier(input: unknown, name: string): string { + if (typeof input !== 'string') throw new Error(`${name} must be a string`); + if (input.normalize('NFC') !== input || input.trim() !== input) { + throw new Error(`${name} must already be NFC-normalized without surrounding whitespace`); + } + return normalizeVisibleText(input, name, MAX_IDENTIFIER_CODE_POINTS); +} + +function normalizeVisibleText(input: unknown, name: string, maxCodePoints: number): string { + if (typeof input !== 'string') throw new Error(`${name} must be a string`); + const value = input.normalize('NFC').trim(); + if (value === '') throw new Error(`${name} cannot be empty`); + if (/[\p{Cc}\p{Cs}\u200B\u200C\u200D\uFEFF]/u.test(value)) { + throw new Error(`${name} cannot contain control or zero-width characters`); + } + if (Array.from(value).length > maxCodePoints) { + throw new Error(`${name} must be ${maxCodePoints} code points or fewer`); + } + return value; +} + +function normalizeSearchTerm(input: unknown): string { + return normalizeVisibleText(input, 'Memory search term', MAX_KEY_CODE_POINTS) + .replace(/\s+/gu, ' ') + .toLowerCase(); +} + +function normalizeVersion(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error('expectedVersion must be a positive safe integer'); + } + return value as number; +} + +function normalizeCursorOrdinal(value: unknown, name: string, allowZero: boolean): number { + if (!Number.isSafeInteger(value) || (value as number) < (allowZero ? 0 : 1)) { + throw new Error(`${name} must be ${allowZero ? 'a non-negative' : 'a positive'} safe integer`); + } + return value as number; +} + +function normalizeExtractionFailureClass(value: unknown): MemoryExtractionFailureClass { + if ( + value !== 'provider' && + value !== 'schema' && + value !== 'evidence' && + value !== 'localization' && + value !== 'requested_admission' + ) { + throw new Error('Invalid Memory extraction failure class'); + } + return value; +} + +function normalizeExtractionTrigger(value: unknown): 'remember' | 'extract' | 'compaction' { + if (value !== 'remember' && value !== 'extract' && value !== 'compaction') { + throw new Error('Invalid Memory extraction trigger'); + } + return value; +} + +function normalizeCompactionCheckpointId( + trigger: 'remember' | 'extract' | 'compaction', + value: unknown, +): string | undefined { + if (trigger === 'compaction') { + return normalizeIdentifier(value, 'compactionCheckpointId'); + } + if (value !== undefined) { + throw new Error('Only Compaction extraction may carry a checkpoint ID'); + } + return undefined; +} + +function normalizeExtractionNoOpReason(value: unknown): 'sensitive_information' | undefined { + if (value === undefined) return undefined; + if (value !== 'sensitive_information') { + throw new Error('Invalid Memory extraction no-op reason'); + } + return value; +} + +function normalizeExtractionSkipReason(value: unknown): 'policy_denied' | undefined { + if (value === undefined) return undefined; + if (value !== 'policy_denied') { + throw new Error('Invalid Memory extraction skip reason'); + } + return value; +} + +function normalizeRequestedItemIndexes(value: unknown, itemCount: number): number[] { + if (!Array.isArray(value)) throw new Error('requestedItemIndexes must be an array'); + const indexes = [...new Set(value)]; + for (const index of indexes) { + if (!Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= itemCount) { + throw new Error('requestedItemIndexes contains an out-of-range index'); + } + } + return (indexes as number[]).sort((left, right) => left - right); +} + +function normalizeTimestamp(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${name} must be a non-negative integer UTC millisecond timestamp`); + } + return value as number; +} + +function normalizeOptionalTimestamp(value: unknown, name: string): number | null { + if (value === undefined || value === null) return null; + return normalizeTimestamp(value, name); +} + +function keyPriority(key: MemoryItemKey): number { + const origin = { user: 3, deterministic: 2, llm: 1 } as const; + const type = { code: 5, exact: 4, entity: 3, concept: 2, alias: 1 } as const; + return origin[key.keyOrigin] * 10 + type[key.keyType]; +} + +function recordMatchesWrite(record: MemoryItemRecord, write: NormalizedMemoryWrite): boolean { + return hashCanonical(writeFromRecord(record)) === hashCanonical(write); +} + +function writeFromRecord(record: MemoryItemRecord): NormalizedMemoryWrite { + return { + content: record.item.content, + kind: record.item.kind, + statementType: record.item.statementType, + temporalType: record.item.temporalType, + scopeType: record.item.scopeType, + scopeKey: record.item.scopeKey, + eventStartedAt: record.item.eventStartedAt, + eventEndedAt: record.item.eventEndedAt, + observedAt: record.item.observedAt, + origin: record.item.origin, + contentHash: record.item.contentHash, + keys: record.keys, + sources: record.sources, + }; +} + +function mutationResult( + mutationIndex: number, + mutationType: MemoryMutationResult['mutationType'], + item: MemoryItem, + outcome: MemoryMutationResult['outcome'], +): MemoryMutationResult { + return { + mutationIndex, + mutationType, + itemId: item.itemId, + version: item.version, + lifecycleState: item.lifecycleState, + outcome, + }; +} + +function decodeItem(row: MemoryItemRow): MemoryItem { + const kind = requiredString(row.kind, 'kind'); + const statementType = requiredString(row.statement_type, 'statement_type'); + const temporalType = requiredString(row.temporal_type, 'temporal_type'); + const scopeType = requiredString(row.scope_type, 'scope_type'); + const lifecycleState = requiredString(row.lifecycle_state, 'lifecycle_state'); + const origin = requiredString(row.origin, 'origin'); + if (!isMemoryItemKind(kind)) throw invalidColumn('kind'); + if (!isMemoryStatementType(statementType)) throw invalidColumn('statement_type'); + if (!isMemoryTemporalType(temporalType)) throw invalidColumn('temporal_type'); + if (!isMemoryScopeType(scopeType)) throw invalidColumn('scope_type'); + if (!isMemoryLifecycleState(lifecycleState)) throw invalidColumn('lifecycle_state'); + if (!isMemoryItemOrigin(origin)) throw invalidColumn('origin'); + const itemId = requiredIdentifierString(row.item_id, 'item_id'); + const version = requiredPositiveInteger(row.version, 'version'); + const content = requiredNonEmptyString(row.content, 'content'); + const normalizedContent = normalizeLongTermMemoryContent(content); + if (!normalizedContent.ok || normalizedContent.value !== content) throw invalidColumn('content'); + const scopeKey = nullableIdentifierString(row.scope_key, 'scope_key'); + const eventStartedAt = nullableNonNegativeInteger(row.event_started_at, 'event_started_at'); + const eventEndedAt = nullableNonNegativeInteger(row.event_ended_at, 'event_ended_at'); + const observedAt = requiredNonNegativeInteger(row.observed_at, 'observed_at'); + const contentHash = requiredHash(row.content_hash, 'content_hash'); + const createdAt = requiredNonNegativeInteger(row.created_at, 'created_at'); + const updatedAt = requiredNonNegativeInteger(row.updated_at, 'updated_at'); + if ( + (scopeType === 'global' && scopeKey !== null) || + (scopeType === 'workspace' && (scopeKey === null || scopeKey.length === 0)) + ) { + throw invalidColumn('scope_key'); + } + validateMemoryTemporalBounds({ temporalType, eventStartedAt, eventEndedAt }); + if (createdAt > updatedAt || observedAt > updatedAt) throw invalidColumn('timestamps'); + if (hashText(content) !== contentHash) throw invalidColumn('content_hash'); + return { + itemId, + version, + content, + kind, + statementType, + temporalType, + scopeType, + scopeKey, + eventStartedAt, + eventEndedAt, + observedAt, + lifecycleState, + origin, + contentHash, + createdAt, + updatedAt, + }; +} + +function decodeKey(row: MemoryKeyRow): MemoryItemKey { + const keyType = requiredString(row.key_type, 'key_type'); + const keyOrigin = requiredString(row.key_origin, 'key_origin'); + if (!isMemoryKeyType(keyType)) throw invalidColumn('key_type'); + if (!isMemoryKeyOrigin(keyOrigin)) throw invalidColumn('key_origin'); + const key = requiredNonEmptyString(row.key_text, 'key_text'); + const normalizedKey = requiredNonEmptyString(row.normalized_key, 'normalized_key'); + if (normalizeSearchTerm(key) !== normalizedKey) throw invalidColumn('normalized_key'); + return { + key, + normalizedKey, + keyType, + keyOrigin, + }; +} + +function decodeSource(row: MemorySourceRow): MemoryItemSource { + return { + sessionId: requiredIdentifierString(row.session_id, 'session_id'), + runId: requiredIdentifierString(row.run_id, 'run_id'), + turnId: requiredIdentifierString(row.turn_id, 'turn_id'), + eventId: requiredIdentifierString(row.event_id, 'event_id'), + }; +} + +function decodeExtractionCursor(row: MemoryExtractionCursorRow): MemoryExtractionCursor { + return { + sessionId: requiredIdentifierString(row.session_id, 'session_id'), + processedOrdinal: requiredPositiveInteger(row.processed_ordinal, 'processed_ordinal'), + updatedAt: requiredNonNegativeInteger(row.updated_at, 'updated_at'), + }; +} + +function decodeCompactionPolicyDenial(row: { + readonly session_id: unknown; + readonly compaction_checkpoint_id: unknown; + readonly denied_at: unknown; +}): MemoryCompactionPolicyDenial { + return { + sessionId: requiredIdentifierString(row.session_id, 'session_id'), + compactionCheckpointId: requiredIdentifierString( + row.compaction_checkpoint_id, + 'compaction_checkpoint_id', + ), + deniedAt: requiredNonNegativeInteger(row.denied_at, 'denied_at'), + }; +} + +function decodePendingExtractionFailure( + row: MemoryExtractionFailureRow, +): PendingMemoryExtractionFailure { + return { + sessionId: requiredIdentifierString(row.session_id, 'session_id'), + fromOrdinal: requiredPositiveInteger(row.from_ordinal, 'from_ordinal'), + throughOrdinal: requiredPositiveInteger(row.through_ordinal, 'through_ordinal'), + coverageHash: requiredHash(row.coverage_hash, 'coverage_hash'), + firstOperationId: requiredIdentifierString(row.first_operation_id, 'first_operation_id'), + firstTrigger: normalizeExtractionTrigger(row.first_trigger), + ...(row.compaction_checkpoint_id === null + ? {} + : { + compactionCheckpointId: requiredIdentifierString( + row.compaction_checkpoint_id, + 'compaction_checkpoint_id', + ), + }), + firstFailureClass: normalizeExtractionFailureClass(row.first_failure_class), + failedAt: requiredNonNegativeInteger(row.failed_at, 'failed_at'), + }; +} + +function decodeExtractionReceipt(row: MemoryExtractionReceiptRow): MemoryExtractionReceipt { + const operationId = requiredIdentifierString(row.operation_id, 'operation_id'); + const sessionId = requiredIdentifierString(row.session_id, 'session_id'); + requiredHash(row.request_hash, 'request_hash'); + const committedAt = requiredNonNegativeInteger(row.committed_at, 'committed_at'); + const encoded = requiredString(row.result_json, 'result_json'); + if (encoded.length > MAX_OPERATION_RESULT_JSON_CODE_UNITS) { + throw new Error(`Memory extraction ${operationId} result JSON is too large`); + } + let value: unknown; + try { + value = JSON.parse(encoded); + } catch (error) { + throw new Error(`Invalid result JSON for Memory extraction ${operationId}`, { cause: error }); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid receipt for Memory extraction ${operationId}`); + } + const receipt = value as Record; + if ( + receipt.operationId !== operationId || + receipt.sessionId !== sessionId || + !['remembered', 'not_applicable', 'extracted', 'discarded', 'skipped'].includes( + String(receipt.status), + ) || + receipt.committedAt !== committedAt || + !Array.isArray(receipt.requestedItems) + ) { + throw new Error(`Invalid receipt for Memory extraction ${operationId}`); + } + const requestedItems = receipt.requestedItems.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new Error(`Invalid requested Item in Memory extraction ${operationId}`); + } + const record = item as Record; + return { + itemId: normalizeIdentifier(record.itemId, 'receipt itemId'), + content: requiredString(record.content, 'receipt content'), + }; + }); + if (requestedItems.length > MAX_MUTATIONS_PER_OPERATION) { + throw new Error(`Memory extraction ${operationId} has too many requested Items`); + } + if ( + (receipt.status === 'remembered' && requestedItems.length === 0) || + (receipt.status !== 'remembered' && requestedItems.length > 0) + ) { + throw new Error(`Memory extraction ${operationId} has inconsistent requested Items`); + } + const noOpReason = normalizeExtractionNoOpReason(receipt.noOpReason); + if (noOpReason && receipt.status !== 'not_applicable') { + throw new Error(`Memory extraction ${operationId} has an invalid no-op reason`); + } + const skipReason = normalizeExtractionSkipReason(receipt.skipReason); + if ((receipt.status === 'skipped') !== Boolean(skipReason)) { + throw new Error(`Memory extraction ${operationId} has an invalid skip reason`); + } + let discardedRange: MemoryExtractionReceipt['discardedRange']; + if (receipt.status === 'discarded') { + const value = receipt.discardedRange; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Memory extraction ${operationId} is missing its discarded range`); + } + const range = value as Record; + discardedRange = { + fromOrdinal: requiredPositiveInteger(range.fromOrdinal, 'discarded fromOrdinal'), + throughOrdinal: requiredPositiveInteger(range.throughOrdinal, 'discarded throughOrdinal'), + coverageHash: requiredHash(range.coverageHash, 'discarded coverageHash'), + firstFailureClass: normalizeExtractionFailureClass(range.firstFailureClass), + finalFailureClass: normalizeExtractionFailureClass(range.finalFailureClass), + }; + if (discardedRange.throughOrdinal < discardedRange.fromOrdinal) { + throw new Error(`Memory extraction ${operationId} has an invalid discarded range`); + } + } else if (receipt.discardedRange !== undefined) { + throw new Error(`Memory extraction ${operationId} has an unexpected discarded range`); + } + return { + operationId, + sessionId, + status: receipt.status as MemoryExtractionReceipt['status'], + requestedItems, + ...(noOpReason ? { noOpReason } : {}), + ...(skipReason ? { skipReason } : {}), + ...(discardedRange ? { discardedRange } : {}), + committedAt, + }; +} + +function decodeOperation(row: MemoryOperationRow): MemoryWriteOperationResult { + const operationId = requiredIdentifierString(row.operation_id, 'operation_id'); + const operationType = requiredString(row.operation_type, 'operation_type'); + if (!['create', 'update', 'archive', 'restore', 'batch'].includes(operationType)) { + throw invalidColumn('operation_type'); + } + requiredHash(row.request_hash, 'request_hash'); + const resultJson = requiredString(row.result_json, 'result_json'); + if (resultJson.length > MAX_OPERATION_RESULT_JSON_CODE_UNITS) { + throw new Error(`Memory operation ${operationId} result JSON is too large`); + } + let results: unknown; + try { + results = JSON.parse(resultJson); + } catch (error) { + throw new Error(`Invalid result JSON for Memory operation ${operationId}`, { cause: error }); + } + if (!Array.isArray(results)) + throw new Error(`Invalid results for Memory operation ${operationId}`); + if (results.length > MAX_MUTATIONS_PER_OPERATION) { + throw new Error( + `Memory operation results accept at most ${MAX_MUTATIONS_PER_OPERATION} mutations`, + ); + } + const decodedResults = results.map((result, index) => decodeMutationResult(result, index)); + if ( + operationType !== 'batch' && + (decodedResults.length !== 1 || decodedResults[0]?.mutationType !== operationType) + ) { + throw new Error(`Invalid results for Memory operation ${operationId}`); + } + for (const result of decodedResults) validateMutationResultOutcome(result); + return { + operationId, + operationType: operationType as MemoryWriteOperationResult['operationType'], + replayed: false, + committedAt: requiredNonNegativeInteger(row.committed_at, 'committed_at'), + results: decodedResults, + }; +} + +function decodeMutationResult(value: unknown, expectedIndex: number): MemoryMutationResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid Memory mutation result'); + } + const result = value as Record; + const mutationIndex = requiredNonNegativeInteger(result.mutationIndex, 'mutationIndex'); + const mutationType = requiredString(result.mutationType, 'mutationType'); + const lifecycleState = requiredString(result.lifecycleState, 'lifecycleState'); + const outcome = requiredString(result.outcome, 'outcome'); + if (mutationIndex !== expectedIndex) throw invalidColumn('mutationIndex'); + if (!['create', 'update', 'archive', 'restore'].includes(mutationType)) { + throw invalidColumn('mutationType'); + } + if (!isMemoryLifecycleState(lifecycleState)) throw invalidColumn('lifecycleState'); + if (!['created', 'updated', 'archived', 'restored', 'noop'].includes(outcome)) { + throw invalidColumn('outcome'); + } + return { + mutationIndex, + mutationType: mutationType as MemoryMutationResult['mutationType'], + itemId: requiredIdentifierString(result.itemId, 'itemId'), + version: requiredPositiveInteger(result.version, 'version'), + lifecycleState, + outcome: outcome as MemoryMutationResult['outcome'], + }; +} + +function validateMutationResultOutcome(result: MemoryMutationResult): void { + const valid = + (result.mutationType === 'create' && + result.outcome === 'created' && + result.lifecycleState === 'active') || + (result.mutationType === 'update' && + (result.outcome === 'updated' || result.outcome === 'noop')) || + (result.mutationType === 'archive' && + result.outcome === 'archived' && + result.lifecycleState === 'archived') || + (result.mutationType === 'restore' && + result.outcome === 'restored' && + result.lifecycleState === 'active'); + if (!valid) throw new Error('Invalid Memory mutation result outcome'); +} + +function requiredString(value: unknown, column: string): string { + if (typeof value !== 'string') throw invalidColumn(column); + return value; +} + +function requiredNonEmptyString(value: unknown, column: string): string { + const result = requiredString(value, column); + if (result.length === 0) throw invalidColumn(column); + return result; +} + +function requiredIdentifierString(value: unknown, column: string): string { + const result = requiredNonEmptyString(value, column); + try { + return normalizeIdentifier(result, column); + } catch { + throw invalidColumn(column); + } +} + +function nullableIdentifierString(value: unknown, column: string): string | null { + return value === null ? null : requiredIdentifierString(value, column); +} + +function requiredInteger(value: unknown, column: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw invalidColumn(column); + return value; +} + +function requiredNonNegativeInteger(value: unknown, column: string): number { + const result = requiredInteger(value, column); + if (result < 0) throw invalidColumn(column); + return result; +} + +function requiredPositiveInteger(value: unknown, column: string): number { + const result = requiredInteger(value, column); + if (result < 1) throw invalidColumn(column); + return result; +} + +function nullableNonNegativeInteger(value: unknown, column: string): number | null { + return value === null ? null : requiredNonNegativeInteger(value, column); +} + +function requiredHash(value: unknown, column: string): string { + const result = requiredString(value, column); + if (!/^[0-9a-f]{64}$/u.test(result)) throw invalidColumn(column); + return result; +} + +function invalidColumn(column: string): Error { + return new Error(`Invalid long-term memory SQLite column ${column}`); +} + +function assertChanged(changes: number | bigint, itemId: string): void { + if (Number(changes) !== 1) { + throw new MemoryItemStoreConflictError( + 'version_conflict', + `Memory Item ${itemId} changed concurrently`, + itemId, + ); + } +} + +function hashText(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function hashCanonical(value: unknown): string { + return hashText(JSON.stringify(value)); +} + +function placeholders(count: number): string { + return Array.from({ length: count }, () => '?').join(', '); +} + +/** Smallest Unicode string strictly greater than every string with this prefix. */ +function prefixUpperBound(prefix: string): string | undefined { + const points = Array.from(prefix, (character) => character.codePointAt(0)!); + for (let index = points.length - 1; index >= 0; index -= 1) { + const point = points[index]!; + if (point < 0x10ffff) { + const successor = point === 0xd7ff ? 0xe000 : point + 1; + return String.fromCodePoint(...points.slice(0, index), successor); + } + } + return undefined; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function preparePrivateDatabaseFiles(path: string): void { + secureFile(path, true, false); + for (const sidecar of databaseSidecars(path)) secureFile(sidecar, false, true); +} + +function secureExistingDatabaseFiles(path: string): void { + secureFile(path, false, false); + for (const sidecar of databaseSidecars(path)) secureFile(sidecar, false, true); +} + +function secureFile(path: string, create: boolean, allowUnlinked: boolean): void { + try { + if (lstatSync(path).isSymbolicLink()) { + throw new Error(`Long-term memory SQLite path must not be a symbolic link: ${path}`); + } + } catch (error) { + if (!(isNodeError(error) && error.code === 'ENOENT')) throw error; + } + const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + let descriptor: number | undefined; + try { + descriptor = openSync( + path, + fsConstants.O_RDWR | noFollow | (create ? fsConstants.O_CREAT : 0), + 0o600, + ); + const status = fstatSync(descriptor); + if (!status.isFile()) { + throw new Error(`Long-term memory SQLite path is not a regular file: ${path}`); + } + if (status.nlink === 0 && allowUnlinked) return; + if (status.nlink !== 1) { + throw new Error(`Long-term memory SQLite path must not be hard-linked: ${path}`); + } + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); + } catch (error) { + if (!create && isNodeError(error) && error.code === 'ENOENT') return; + throw error; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function databaseSidecars(path: string): readonly string[] { + return [`${path}-wal`, `${path}-shm`, `${path}-journal`]; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +function rollback(database: DatabaseSync): void { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the write failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a8ae5c838ca8ae9045824d2953a7daefefa835f5de4fa00a0a3cad3418c40f8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a8ae5c838ca8ae9045824d2953a7daefefa835f5de4fa00a0a3cad3418c40f8.source new file mode 100644 index 0000000000..cc6c70454a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a8ae5c838ca8ae9045824d2953a7daefefa835f5de4fa00a0a3cad3418c40f8.source @@ -0,0 +1,1197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork, spawnSync, type ChildProcess } from 'node:child_process'; +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + symlink, + utimes, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { withArtifactWriterLock } from '../artifact-writer-lock.js'; +import { + adoptStorageRootOnImport, + assertStorageRootCapability, + assertStorageRootLease, + discoverMarkedStorageRoot, + prepareArtifactWriterBootstrapAuthority, + prepareStorageRootControlDirectory, + prepareStorageRootIdentityRepair, + repairStorageRootAfterRemount, + repairStorageRootIdentity, + resolveExistingStorageRoot, + resolveExistingStorageRootControlDirectory, + resolveRootControlNamespace, + resolveRootOwnershipNamespace, + resolveStorageRoot, + runWithStorageRootLease, + STORAGE_ROOT_MARKER_FILE, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + tryAcquireInteractiveRootReader, + type StorageRootCapability, + type StorageRootLease, +} from '../root-authority.js'; + +// These probes launch native-lock holders and deliberately exercise abnormal +// process exits. Keep them on the owning storage seam's stress route; the +// default suite covers the same-process lease and lock boundaries below. +const RUN_PROCESS_LOCK_TESTS = process.env.MAKA_STORAGE_STRESS === '1'; + +describe('storage root authority', () => { + test('discovers only marked roots without creating or changing filesystem state', async () => { + await withRoots(async ({ base, root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const payloadPath = join(root, 'payload.txt'); + await writeFile(payloadPath, 'preserve me'); + const fixedTime = new Date('2020-01-02T03:04:05.000Z'); + await utimes(join(root, STORAGE_ROOT_MARKER_FILE), fixedTime, fixedTime); + await utimes(payloadPath, fixedTime, fixedTime); + await utimes(root, fixedTime, fixedTime); + const before = await snapshotFlatRoot(root); + + const discovered = await discoverMarkedStorageRoot({ path: root }); + assert.equal(discovered.kind, 'interactive'); + assert.equal(discovered.rootId, initialized.rootId); + assert.equal(discovered.canonicalPath, initialized.canonicalPath); + assert.deepEqual(await snapshotFlatRoot(root), before); + + const unmarked = join(base, 'unmarked'); + await mkdir(unmarked); + const unmarkedBefore = await snapshotFlatRoot(unmarked); + await assert.rejects( + () => discoverMarkedStorageRoot({ path: unmarked }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_unmarked', + ); + assert.deepEqual(await snapshotFlatRoot(unmarked), unmarkedBefore); + + const missing = join(base, 'missing'); + await assert.rejects( + () => discoverMarkedStorageRoot({ path: missing }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_not_found', + ); + await assert.rejects(lstat(missing), { code: 'ENOENT' }); + }); + }); + + test('rejects unsupported root kinds without transient marker writes', async () => { + await withRoots(async ({ root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const fixedTime = new Date('2020-01-02T03:04:05.000Z'); + await utimes(join(root, STORAGE_ROOT_MARKER_FILE), fixedTime, fixedTime); + await utimes(root, fixedTime, fixedTime); + const before = await snapshotFlatRoot(root); + + await assert.rejects( + () => resolveStorageRoot({ path: root, kind: 'retired' as never }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_root_kind', + ); + assert.deepEqual(await snapshotFlatRoot(root), before); + }); + }); + + test('canonicalizes aliases and gives them one ownership identity', async () => { + await withRoots(async ({ base, root }) => { + const alias = join(base, 'alias'); + await symlink(root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + + const direct = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const throughAlias = await resolveStorageRoot({ path: alias, kind: 'interactive' }); + + assert.equal(throughAlias.canonicalPath, direct.canonicalPath); + assert.equal(throughAlias.rootId, direct.rootId); + }); + }); + + test('derives Artifact writer bootstrap authority from stable filesystem identity', async () => { + await withRoots(async ({ base, root }) => { + const alias = join(base, 'alias'); + const movedRoot = join(base, 'moved-root'); + await symlink(root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + + const direct = await prepareArtifactWriterBootstrapAuthority(root); + const throughAlias = await prepareArtifactWriterBootstrapAuthority(alias); + assert.equal(throughAlias.lockPath, direct.lockPath); + assert.equal(throughAlias.canonicalPath, direct.canonicalPath); + assert.deepEqual(await readdir(root), []); + + await rename(root, movedRoot); + await assert.rejects(() => direct.assertCurrentRoot()); + const moved = await prepareArtifactWriterBootstrapAuthority(movedRoot); + assert.equal(moved.lockPath, direct.lockPath); + + await mkdir(root); + const replacement = await prepareArtifactWriterBootstrapAuthority(root); + assert.notEqual(replacement.lockPath, direct.lockPath); + assert.deepEqual(await readdir(root), []); + + await Promise.all([ + rm(direct.lockPath, { force: true }), + rm(replacement.lockPath, { force: true }), + ]); + }); + }); + + test('rejects a marker from another device even when its inode matches', async () => { + await withRoots(async ({ root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + + await assert.rejects( + () => resolveStorageRoot({ path: root, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_collision', + ); + }); + }); + + test('explicitly repairs a stale root identity without changing its root id', async () => { + await withRoots(async ({ root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + + const candidate = await prepareStorageRootIdentityRepair({ + path: root, + kind: 'interactive', + }); + assert.ok(candidate); + const repaired = await repairStorageRootIdentity(candidate); + const rootStat = await lstat(root, { bigint: true }); + const repairedMarker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootId: string; + rootIdentity: { dev: string; ino: string }; + }; + + assert.equal(repaired.rootId, initialized.rootId); + assert.equal(repairedMarker.rootId, initialized.rootId); + assert.deepEqual(repairedMarker.rootIdentity, { + dev: rootStat.dev.toString(), + ino: rootStat.ino.toString(), + }); + assert.equal( + (await resolveStorageRoot({ path: root, kind: 'interactive' })).rootId, + initialized.rootId, + ); + }); + }); + + test('repairs a remounted device but refuses a different directory inode', async () => { + await withRoots(async ({ base, root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + + await repairStorageRootAfterRemount({ + path: root, + kind: 'interactive', + expectedRootId: initialized.rootId, + }); + assert.equal( + (await resolveStorageRoot({ path: root, kind: 'interactive' })).rootId, + initialized.rootId, + ); + + const replacement = join(base, 'replacement'); + await mkdir(replacement); + const replacementMarkerPath = join(replacement, STORAGE_ROOT_MARKER_FILE); + const repairedMarker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string; ino: string }; + }; + const replacementStat = await lstat(replacement, { bigint: true }); + repairedMarker.rootIdentity.ino = (replacementStat.ino + 1n).toString(); + await writeFile(replacementMarkerPath, `${JSON.stringify(repairedMarker)}\n`); + await assert.rejects( + () => repairStorageRootAfterRemount({ path: replacement, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_changed', + ); + }); + }); + + test('rejects a prepared repair when its marker changes before commit', async () => { + await withRoots(async ({ root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootId: string; + rootIdentity: { dev: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + const candidate = await prepareStorageRootIdentityRepair({ + path: root, + kind: 'interactive', + }); + assert.ok(candidate); + + marker.rootId = '0'.repeat(64); + const replacedMarker = `${JSON.stringify(marker)}\n`; + await writeFile(markerPath, replacedMarker); + + await assert.rejects( + () => repairStorageRootIdentity(candidate), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_changed', + ); + assert.equal(await readFile(markerPath, 'utf8'), replacedMarker); + }); + }); + + test('serializes concurrent repairs across marker replacement', async () => { + await withRoots(async ({ root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + + const [first, second] = await Promise.all([ + prepareStorageRootIdentityRepair({ path: root, kind: 'interactive' }), + prepareStorageRootIdentityRepair({ path: root, kind: 'interactive' }), + ]); + assert.ok(first); + assert.ok(second); + + const results = await Promise.allSettled([ + repairStorageRootIdentity(first), + repairStorageRootIdentity(second), + ]); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + const rejected = results.find((result) => result.status === 'rejected'); + assert.ok(rejected); + assert.ok(rejected.reason instanceof StorageRootAuthorityError); + assert.notEqual(rejected.reason.code, 'invalid_marker'); + assert.ok( + rejected.reason.code === 'root_identity_changed' || + rejected.reason.code === 'root_identity_collision', + ); + await resolveStorageRoot({ path: root, kind: 'interactive' }); + }); + }); + + test('serializes identity repair behind the Artifact bootstrap writer lock', async () => { + await withRoots(async ({ root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + let releaseWriter!: () => void; + const writerBlocked = new Promise((resolve) => { + releaseWriter = resolve; + }); + let writerAdmitted!: () => void; + const admitted = new Promise((resolve) => { + writerAdmitted = resolve; + }); + const writer = withArtifactWriterLock(root, async () => { + writerAdmitted(); + await writerBlocked; + }); + await admitted; + + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { + rootIdentity: { dev: string }; + }; + marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + const candidate = await prepareStorageRootIdentityRepair({ + path: root, + kind: 'interactive', + }); + assert.ok(candidate); + + let repairSettled = false; + const repair = repairStorageRootIdentity(candidate).finally(() => { + repairSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(repairSettled, false); + + releaseWriter(); + await writer; + await repair; + await resolveStorageRoot({ path: root, kind: 'interactive' }); + }); + }); + + test('rejects a copied initialized root before it can share authority', async () => { + await withRoots(async ({ base, root }) => { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + const copiedRoot = join(base, 'copied-root'); + await cp(root, copiedRoot, { recursive: true }); + + await assert.rejects( + () => resolveStorageRoot({ path: copiedRoot, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_collision', + ); + }); + }); + + test('adopts the host-local identity of an explicitly imported storage root', async () => { + await withRoots(async ({ base, root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const copiedRoot = join(base, 'copied-root'); + await cp(root, copiedRoot, { recursive: true }); + + await assert.rejects( + () => resolveStorageRoot({ path: copiedRoot, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_collision', + ); + const markerBeforeConflict = await readFile( + join(copiedRoot, STORAGE_ROOT_MARKER_FILE), + 'utf8', + ); + await assert.rejects( + () => + adoptStorageRootOnImport({ + path: copiedRoot, + kind: 'interactive', + expectedRootId: '0'.repeat(64), + }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_collision', + ); + assert.equal( + await readFile(join(copiedRoot, STORAGE_ROOT_MARKER_FILE), 'utf8'), + markerBeforeConflict, + ); + await rm(root, { recursive: true, force: true }); + + const adopted = await adoptStorageRootOnImport({ + path: copiedRoot, + kind: 'interactive', + expectedRootId: initialized.rootId, + }); + assert.equal(adopted.rootId, initialized.rootId); + assert.equal( + ( + await adoptStorageRootOnImport({ + path: copiedRoot, + kind: 'interactive', + expectedRootId: initialized.rootId, + }) + ).rootId, + initialized.rootId, + ); + assert.equal( + (await resolveStorageRoot({ path: copiedRoot, kind: 'interactive' })).rootId, + initialized.rootId, + ); + }); + }); + + test('resolves only an existing expected root without initializing a replacement', async () => { + await withRoots(async ({ base, root }) => { + const initialized = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const existing = await resolveExistingStorageRoot({ + path: root, + kind: 'interactive', + expectedRootId: initialized.rootId, + }); + assert.equal(existing.rootId, initialized.rootId); + + await rename(root, join(base, 'original-root')); + await mkdir(root); + await assert.rejects( + () => + resolveExistingStorageRoot({ + path: root, + kind: 'interactive', + expectedRootId: initialized.rootId, + }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_unmarked', + ); + await assert.rejects(readFile(join(root, STORAGE_ROOT_MARKER_FILE)), { code: 'ENOENT' }); + }); + }); + + test('rejects replacement before opening the temporary marker', async () => { + await withRoots(async ({ base, root }) => { + const originalRoot = join(base, 'original-root'); + const child = fork( + new URL('./fixtures/root-initialization-race.js', import.meta.url), + [root, STORAGE_ROOT_MARKER_FILE], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }, + ); + try { + await waitForChildMessage( + child, + (message): message is { type: 'marker_open_pending' } => + message.type === 'marker_open_pending', + 'marker_open_pending', + ); + await rename(root, originalRoot); + await mkdir(root); + + const outcomePromise = waitForChildMessage( + child, + (message): message is RootResolverMessage => + message.type === 'resolved' || message.type === 'error', + 'resolver outcome', + ); + child.send('resume'); + assert.deepEqual(await outcomePromise, { + type: 'error', + code: 'root_identity_changed', + }); + child.disconnect(); + await waitForExit(child); + + await assert.rejects(lstat(join(root, STORAGE_ROOT_MARKER_FILE)), { code: 'ENOENT' }); + const replacement = await resolveStorageRoot({ path: root, kind: 'interactive' }); + await assert.doesNotReject(() => assertStorageRootCapability(replacement, 'interactive')); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await waitForExit(child); + } + } + }); + }); + + test('rejects a regular file as a typed invalid root', async () => { + await withRoots( + async ({ root }) => { + await writeFile(root, 'not a directory'); + await assert.rejects( + () => resolveStorageRoot({ path: root, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_root', + ); + }, + { createRoot: false }, + ); + }); + + test('rejects an unsupported root kind before creating filesystem state', async () => { + await withRoots( + async ({ root }) => { + await assert.rejects( + () => resolveStorageRoot({ path: root, kind: 'unsupported' as 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_root_kind', + ); + await assert.rejects(lstat(root), { code: 'ENOENT' }); + }, + { createRoot: false }, + ); + }); + + test('normalizes root filesystem failures at the public authority boundary', async () => { + await withRoots( + async ({ base }) => { + const blockingFile = join(base, 'blocking-file'); + await writeFile(blockingFile, 'not a directory'); + + await assert.rejects( + () => resolveStorageRoot({ path: join(blockingFile, 'root'), kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && + error.code === 'root_io_failed' && + error.cause instanceof Error, + ); + }, + { createRoot: false }, + ); + }); + + test('preserves unexpected marker I/O failures at the public authority boundary', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to make the marker unreadable' + : typeof process.getuid === 'function' && process.getuid() === 0, + }, async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + await chmod(markerPath, 0o000); + try { + await assert.rejects( + () => assertStorageRootCapability(capability, 'interactive'), + (error: unknown) => + error instanceof StorageRootAuthorityError && + error.code === 'root_io_failed' && + error.cause instanceof Error && + 'code' in error.cause && + (error.cause as NodeJS.ErrnoException).code === 'EACCES', + ); + } finally { + await chmod(markerPath, 0o600); + } + }); + }); + + test('keeps one owner when a live root moves behind a new alias', async () => { + await withRoots(async ({ base, root }) => { + const firstCapability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const firstOwner = await tryAcquireInteractiveRootOwner(firstCapability); + assert.ok(firstOwner); + + const movedRoot = join(base, 'moved-root'); + await rename(root, movedRoot); + await symlink(movedRoot, root, process.platform === 'win32' ? 'junction' : 'dir'); + + const movedCapability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + assert.equal(movedCapability.rootId, firstCapability.rootId); + assert.equal(await tryAcquireInteractiveRootOwner(movedCapability), undefined); + + await firstOwner.close(); + const nextOwner = await tryAcquireInteractiveRootOwner(movedCapability); + assert.ok(nextOwner); + await nextOwner.close(); + }); + }); + + test('concurrent initialization resolves one interactive root identity', async () => { + await withRoots( + async ({ root }) => { + const outcomes = await Promise.all([ + resolveStorageRoot({ path: root, kind: 'interactive' }), + resolveStorageRoot({ path: root, kind: 'interactive' }), + ]); + assert.equal(outcomes[0].rootId, outcomes[1].rootId); + }, + { createRoot: false }, + ); + }); + + test('rejects an unbounded root marker before parsing it', async () => { + await withRoots(async ({ root }) => { + await writeFile(join(root, STORAGE_ROOT_MARKER_FILE), Buffer.alloc(1_025, 0x20)); + await assert.rejects( + () => resolveStorageRoot({ path: root, kind: 'interactive' }), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_marker', + ); + }); + }); + + test('rejects FIFO marker paths without blocking root resolution', { + skip: process.platform === 'win32', + }, async () => { + await withRoots(async ({ base, root }) => { + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + createFifo(markerPath); + assert.deepEqual(await resolveRootInChild(root), { + type: 'error', + code: 'invalid_marker', + }); + + await rm(markerPath); + const fifoPath = join(base, 'marker.fifo'); + createFifo(fifoPath); + await symlink(fifoPath, markerPath); + assert.deepEqual(await resolveRootInChild(root), { + type: 'error', + code: 'invalid_marker', + }); + }); + }); + + test('rejects forged capabilities and invalidates a lease when its OS lock closes', async () => { + await withRoots(async ({ root }) => { + const forged = { + kind: 'interactive', + canonicalPath: root, + rootId: 'forged', + } as StorageRootCapability<'interactive'>; + await assert.rejects( + () => assertStorageRootCapability(forged, 'interactive'), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_capability', + ); + + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + await assertStorageRootLease(owner.lease, 'interactive', 'write'); + await owner.close(); + await assert.rejects( + () => assertStorageRootLease(owner.lease, 'interactive', 'write'), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + + const forgedLease = { + kind: 'interactive', + access: 'write', + canonicalPath: root, + rootId: capability.rootId, + } as StorageRootLease<'interactive', 'write'>; + await assert.rejects( + () => assertStorageRootLease(forgedLease, 'interactive', 'write'), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + }); + }); + + test('keeps the owner lock until an admitted lease operation drains', async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + + let releaseOperation!: () => void; + const operationBlocked = new Promise((resolve) => { + releaseOperation = resolve; + }); + let operationAdmitted!: () => void; + const admitted = new Promise((resolve) => { + operationAdmitted = resolve; + }); + const operation = runWithStorageRootLease(owner.lease, 'interactive', 'write', async () => { + operationAdmitted(); + await operationBlocked; + }); + await admitted; + + const closing = owner.close(); + assert.equal(owner.closed, true); + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + await assert.rejects( + () => runWithStorageRootLease(owner.lease, 'interactive', 'write', async () => {}), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + + releaseOperation(); + await Promise.all([operation, closing]); + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + await successor?.close(); + }); + }); + + test('enforces exclusive/shared lock arbitration across processes', { + skip: !RUN_PROCESS_LOCK_TESTS, + }, async () => { + await withRoots(async ({ root }) => { + const writer = spawnHolder(root, 'write'); + try { + assert.equal(await waitForHolder(writer), 'locked'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + assert.equal(await tryAcquireInteractiveRootReader(capability), undefined); + } finally { + await closeHolder(writer); + } + + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const firstReader = spawnHolder(root, 'read'); + const secondReader = spawnHolder(root, 'read'); + try { + assert.deepEqual( + await Promise.all([waitForHolder(firstReader), waitForHolder(secondReader)]), + ['locked', 'locked'], + ); + const localReader = await tryAcquireInteractiveRootReader(capability); + assert.ok(localReader); + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + await localReader?.close(); + } finally { + await Promise.all([closeHolder(firstReader), closeHolder(secondReader)]); + } + + const nextOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(nextOwner); + await nextOwner.close(); + }); + }); + + test('rejects a lock path that aliases another filesystem object', { + skip: + process.platform === 'win32' + ? 'Windows file-symlink permissions are not guaranteed in CI' + : false, + }, async () => { + await withRoots(async ({ base, root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + const foreignLock = join(base, 'foreign.lock'); + await writeFile(foreignLock, 'not an authority\n'); + await symlink(foreignLock, join(controlDirectory, 'owner.lock')); + + await assert.rejects( + () => tryAcquireInteractiveRootOwner(capability), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lock_artifact', + ); + }); + }); + + test('rejects a directory at the owner lock path', async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + await mkdir(join(controlDirectory, 'owner.lock')); + + await assert.rejects( + () => tryAcquireInteractiveRootOwner(capability), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lock_artifact', + ); + }); + }); + + test('does not create a missing control directory while resolving an existing Host', async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const controlDirectory = join(resolveRootControlNamespace(), capability.rootId); + await rm(controlDirectory, { recursive: true, force: true }); + + await assert.rejects( + () => resolveExistingStorageRootControlDirectory(capability), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'control_io_failed', + ); + await assert.rejects(lstat(controlDirectory), { code: 'ENOENT' }); + }); + }); + + test('cache deletion cannot create a second State Root owner', { + skip: process.platform === 'win32' ? 'Windows does not unlink an open native lock file' : false, + }, async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + await rm(owner.controlDirectory, { recursive: true, force: true }); + + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + await owner.close(); + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + await successor.close(); + }); + }); + + test('validates an existing control directory without repairing its permissions', { + skip: process.platform === 'win32', + }, async () => { + await withRoots(async ({ root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const { controlDirectory } = await prepareStorageRootControlDirectory(capability); + await chmod(controlDirectory, 0o755); + + await assert.rejects( + () => resolveExistingStorageRootControlDirectory(capability), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'insecure_control_directory', + ); + assert.equal((await lstat(controlDirectory)).mode & 0o777, 0o755); + }); + }); + + test('kernel releases a process lock after normal, uncaught, abort, and forced exits', { + skip: !RUN_PROCESS_LOCK_TESTS, + }, async () => { + await withRoots(async ({ root }) => { + const modes = ['close', 'throw', 'abort', 'SIGKILL'] as const; + for (const mode of modes) { + const holder = spawnHolder(root, 'write'); + assert.equal(await waitForHolder(holder), 'locked'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + assert.equal(await tryAcquireInteractiveRootOwner(capability), undefined); + + const exited = waitForExit(holder); + if (mode === 'SIGKILL') holder.kill(mode); + else holder.send(mode); + await exited; + + const owner = await retryAcquire(capability); + assert.ok(owner); + await owner?.close(); + } + }); + }); + + test('does not inherit the owner lock into a surviving descendant', { + skip: !RUN_PROCESS_LOCK_TESTS, + }, async () => { + await withRoots(async ({ root }) => { + const holder = spawnHolder(root, 'write'); + let descendantPid: number | undefined; + try { + assert.equal(await waitForHolder(holder), 'locked'); + const descendant = waitForDescendant(holder); + const holderExited = waitForExit(holder); + holder.send('spawn-descendant'); + descendantPid = await descendant; + await holderExited; + assert.equal(isProcessAlive(descendantPid), true); + + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await retryAcquire(capability); + assert.ok(owner); + await owner?.close(); + } finally { + terminateProcess(descendantPid); + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + } + }); + }); + + test('fails closed when a capability root is replaced', async () => { + await withRoots(async ({ base, root }) => { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + await rename(root, join(base, 'old-root')); + await mkdir(root); + await assert.rejects( + () => assertStorageRootCapability(capability, 'interactive'), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'root_identity_changed', + ); + }); + }); +}); + +async function snapshotFlatRoot(root: string): Promise<{ + entries: Array<{ name: string; content: string; mtimeNs: bigint }>; + mtimeNs: bigint; +}> { + const names = (await readdir(root)).sort(); + const entries = await Promise.all( + names.map(async (name) => { + const path = join(root, name); + const stats = await lstat(path, { bigint: true }); + return { + name, + content: stats.isFile() ? await readFile(path, 'utf8') : '', + mtimeNs: stats.mtimeNs, + }; + }), + ); + const rootStats = await lstat(root, { bigint: true }); + return { entries, mtimeNs: rootStats.mtimeNs }; +} + +async function withRoots( + run: (input: { base: string; root: string }) => Promise, + options: { createRoot?: boolean } = {}, +): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-root-authority-')); + const root = join(base, 'root'); + if (options.createRoot !== false) await mkdir(root); + try { + await run({ base, root }); + } finally { + await removeControlDirectoriesForRootsUnder(base); + await rm(base, { recursive: true, force: true }); + } +} + +function waitForHolder(child: ChildProcess): Promise<'locked' | 'denied'> { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('lock holder did not report readiness')), + 5_000, + ); + child.once('error', reject); + child.once('message', (message) => { + clearTimeout(timer); + if (isHolderMessage(message)) resolve(message.type); + else reject(new Error(`unexpected holder message: ${JSON.stringify(message)}`)); + }); + child.once('exit', (code, signal) => { + clearTimeout(timer); + reject(new Error(`lock holder exited early: ${code ?? signal}`)); + }); + }); +} + +function isHolderMessage(value: unknown): value is { type: 'locked' | 'denied' } { + return ( + !!value && + typeof value === 'object' && + ((value as { type?: unknown }).type === 'locked' || + (value as { type?: unknown }).type === 'denied') + ); +} + +function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => child.once('exit', () => resolve())); +} + +function spawnHolder(root: string, access: 'read' | 'write'): ChildProcess { + return fork(new URL('./fixtures/root-lock-holder.js', import.meta.url), [root, access], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); +} + +type RootResolverMessage = { type: 'resolved' } | { type: 'error'; code: string }; + +type InitializationRaceMessage = RootResolverMessage | { type: 'marker_open_pending' }; + +function createFifo(path: string): void { + const result = spawnSync('mkfifo', [path], { encoding: 'utf8' }); + if (result.error) throw result.error; + assert.equal(result.status, 0, result.stderr || `mkfifo exited with status ${result.status}`); +} + +function resolveRootInChild(root: string): Promise { + const child = fork(new URL('./fixtures/root-resolver.js', import.meta.url), [root], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + return new Promise((resolve, reject) => { + let message: RootResolverMessage | undefined; + const timer = setTimeout(() => { + cleanup(); + child.kill('SIGKILL'); + reject(new Error('storage root resolution blocked on a non-regular marker')); + }, 1_000); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + child.off('message', onMessage); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + if (code !== 0 || !message) { + reject(new Error(`storage root resolver exited before reporting: ${code ?? signal}`)); + } else { + resolve(message); + } + }; + const onMessage = (value: unknown) => { + if (!isRootResolverMessage(value)) { + cleanup(); + child.kill('SIGKILL'); + reject(new Error(`unexpected storage root resolver message: ${JSON.stringify(value)}`)); + return; + } + message = value; + }; + child.once('error', onError); + child.once('exit', onExit); + child.on('message', onMessage); + }); +} + +function waitForChildMessage( + child: ChildProcess, + matches: (message: InitializationRaceMessage) => message is T, + expected: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`initialization race fixture did not report ${expected}`)); + }, 5_000); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + child.off('message', onMessage); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`initialization race fixture exited before reporting: ${code ?? signal}`)); + }; + const onMessage = (value: unknown) => { + if (!isInitializationRaceMessage(value)) { + cleanup(); + reject(new Error(`unexpected initialization race message: ${JSON.stringify(value)}`)); + } else if (matches(value)) { + cleanup(); + resolve(value); + } else { + cleanup(); + reject( + new Error( + `initialization race fixture reported ${value.type} while waiting for ${expected}`, + ), + ); + } + }; + child.once('error', onError); + child.once('exit', onExit); + child.on('message', onMessage); + }); +} + +function isRootResolverMessage(value: unknown): value is RootResolverMessage { + if (!value || typeof value !== 'object') return false; + const message = value as Record; + return ( + message.type === 'resolved' || (message.type === 'error' && typeof message.code === 'string') + ); +} + +function isInitializationRaceMessage(value: unknown): value is InitializationRaceMessage { + return ( + isRootResolverMessage(value) || + (!!value && + typeof value === 'object' && + (value as { type?: unknown }).type === 'marker_open_pending') + ); +} + +async function closeHolder(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = waitForExit(child); + child.send('close'); + await exited; +} + +function waitForDescendant(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('lock holder did not report its descendant')), + 5_000, + ); + child.once('message', (message) => { + if ( + !message || + typeof message !== 'object' || + (message as { type?: unknown }).type !== 'descendant' + ) + return; + clearTimeout(timer); + resolve((message as { pid: number }).pid); + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + clearTimeout(timer); + reject(new Error(`lock holder exited before reporting its descendant: ${code ?? signal}`)); + }); + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ) { + return false; + } + throw error; + } +} + +function terminateProcess(pid: number | undefined): void { + if (pid === undefined || !isProcessAlive(pid)) return; + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if ( + !( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ) + ) { + throw error; + } + } +} + +async function retryAcquire( + capability: StorageRootCapability<'interactive'>, +): Promise>> { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const owner = await tryAcquireInteractiveRootOwner(capability); + if (owner) return owner; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return undefined; +} + +async function removeControlDirectoriesForRootsUnder(base: string): Promise { + const rootIds = new Set(); + await collectRootIds(base, rootIds); + await Promise.all( + [...rootIds].flatMap((rootId) => [ + rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }), + rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }), + ]), + ); +} + +async function collectRootIds(directory: string, rootIds: Set): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const path = join(directory, entry.name); + const markerPath = join(path, STORAGE_ROOT_MARKER_FILE); + const markerStat = await lstat(markerPath).catch(() => undefined); + const marker = markerStat?.isFile() + ? await readFile(markerPath, 'utf8').catch(() => undefined) + : undefined; + if (marker) { + try { + const rootId = (JSON.parse(marker) as { rootId?: unknown }).rootId; + if (typeof rootId === 'string' && /^[a-f0-9]{64}$/.test(rootId)) rootIds.add(rootId); + } catch { + // Invalid marker tests never create a control directory. + } + } + await collectRootIds(path, rootIds); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a9bb0f57602903fec0dd86de9c659b46cb66202bc2725379affeeea49fdfd03.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a9bb0f57602903fec0dd86de9c659b46cb66202bc2725379affeeea49fdfd03.source new file mode 100644 index 0000000000..a712b3b01e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5a9bb0f57602903fec0dd86de9c659b46cb66202bc2725379affeeea49fdfd03.source @@ -0,0 +1,1127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, test } from 'node:test'; +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + type StoredMessage, +} from '@maka/core/session'; +import { + EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS, + EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS, + createSessionStore, + isSessionNotFoundError, + normalizeSessionHeader, +} from '../session-store.js'; +import type { SessionConversationCopy, SessionHeader } from '@maka/core/session'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; + +describe('SQLite SessionStore', () => { + test('requires the reserved WorkHub Coordination identity and role together', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-coordination-identity-role-')); + const store = createSessionStore(root); + try { + await assert.rejects( + store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: makeInput({ cwd: root, projectId: null, name: 'Reserved without role' }), + }), + /identity and role must be claimed together/, + ); + await assert.rejects( + store.createStableSession({ + sessionId: 'ordinary-with-coordination-role', + requestFingerprint: `sha256:${'b'.repeat(64)}`, + input: { + ...makeInput({ cwd: root, projectId: null, name: 'Role without identity' }), + role: WORKHUB_COORDINATION_SESSION_ROLE, + }, + }), + /identity and role must be claimed together/, + ); + assert.deepEqual(await store.listHeaders(), []); + + // The invariant lives in the header builder, so the creators that share + // it inherit it even though their inputs carry no role today. + await assert.rejects( + store.createSubagent({ + ...makeInput({ cwd: root, name: 'Subagent claiming the role' }), + role: WORKHUB_COORDINATION_SESSION_ROLE, + } as Parameters[0]), + /identity and role must be claimed together/, + ); + await assert.rejects( + store.createAgentGraphOperator( + { + ...makeInput({ cwd: root, name: 'Operator claiming the role' }), + role: WORKHUB_COORDINATION_SESSION_ROLE, + } as Parameters[0], + { + schemaVersion: 1, + provisionId: `graph_provision_${'4'.repeat(32)}`, + provisionFingerprint: `sha256:${'5'.repeat(64)}`, + graphId: 'graph-1', + workId: `graph_work_${'3'.repeat(32)}`, + agentId: 'local-read', + operatorId: `graph_operator_${'6'.repeat(32)}`, + initialTurnId: 'graph-turn', + initialRunId: 'graph-run', + edges: [], + }, + 0, + ), + /identity and role must be claimed together/, + ); + assert.deepEqual(await store.listHeaders(), []); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps the WorkHub Coordination Session durable but outside ordinary catalogs and route candidates', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-coordination-session-')); + const store = createSessionStore(root); + try { + const created = await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + ...makeInput({ cwd: root, projectId: null, name: 'WorkHub' }), + role: WORKHUB_COORDINATION_SESSION_ROLE, + }, + }); + assert.equal(created.kind, 'created'); + + assert.deepEqual(await store.list(), []); + const page = await store.listCatalogPage(undefined, undefined, 10); + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') assert.fail('expected a catalog page'); + assert.deepEqual(page.records, []); + await assert.rejects(store.readCatalogRecord(WORKHUB_COORDINATION_SESSION_ID), (error) => + isSessionNotFoundError(error), + ); + + const recovery = await store.listForRecovery(); + assert.equal(recovery.length, 1); + assert.equal(recovery[0]?.id, WORKHUB_COORDINATION_SESSION_ID); + assert.equal(recovery[0]?.role, WORKHUB_COORDINATION_SESSION_ROLE); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('quarantines the reserved Coordination identity when its role is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-coordination-missing-role-')); + const store = createSessionStore(root); + try { + const ordinary = await store.create(makeInput({ name: 'Keep me' })); + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + ...makeInput({ cwd: root, projectId: null, name: 'WorkHub' }), + role: WORKHUB_COORDINATION_SESSION_ROLE, + }, + }); + const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + database + .prepare( + `UPDATE session_metadata + SET payload_json = json_remove(payload_json, '$.role') + WHERE session_id = ?`, + ) + .run(WORKHUB_COORDINATION_SESSION_ID); + } finally { + database.close(); + } + + assert.deepEqual( + (await store.list()).map((session) => session.id), + [ordinary.id], + ); + const page = await store.listCatalogPage(undefined, undefined, 10); + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') assert.fail('expected a catalog page'); + assert.deepEqual( + page.records.map((record) => record.header.id), + [ordinary.id], + ); + await assert.rejects(store.readCatalogRecord(WORKHUB_COORDINATION_SESSION_ID), (error) => + isSessionNotFoundError(error), + ); + assert.deepEqual( + (await store.listForRecovery()).map((session) => session.id), + [ordinary.id], + ); + assert.equal( + (await store.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID)).name, + 'WorkHub', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('ordinary Sessions have no external origin and provenance metadata is immutable', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-external-origin-')); + const store = createSessionStore(root); + try { + const ordinary = await store.create(makeInput()); + + assert.equal(ordinary.externalOrigin, undefined); + await assert.rejects( + store.updateHeader(ordinary.id, { externalOrigin: undefined }), + /external.*origin.*immutable/i, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('folds retired Session and transcript values only on persisted reads', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-persisted-decode-')); + const store = createSessionStore(root); + const currentMessage = { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'call-1', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Explore', + turnId: 'child-turn-1', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: [], + }, + } as const satisfies StoredMessage; + let sessionId: string; + try { + const session = await store.create(makeInput({ permissionMode: 'ask' })); + sessionId = session.id; + await store.appendMessage(session.id, currentMessage); + await assert.rejects( + () => + store.appendMessage(session.id, { + ...currentMessage, + id: 'result-retired', + content: { ...currentMessage.content, permissionMode: 'execute' }, + } as unknown as StoredMessage), + /Invalid tool result content/, + ); + } finally { + await store.close?.(); + } + + const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + database.exec(` + UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.permissionMode', 'execute') + WHERE session_id = '${sessionId!}'; + UPDATE session_messages + SET record_json = json_set(record_json, '$.content.permissionMode', 'execute') + WHERE session_id = '${sessionId!}'; + `); + } finally { + database.close(); + } + + const reopened = createSessionStore(root); + try { + assert.equal((await reopened.readHeaderSnapshot(sessionId!)).permissionMode, 'ask'); + const [message] = await reopened.readMessages(sessionId!); + assert.equal( + message?.type === 'tool_result' && message.content.kind === 'subagent' + ? message.content.permissionMode + : undefined, + 'ask', + ); + } finally { + await reopened.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('looks up complete published import counts with bounded newest Session ids', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-external-origin-lookup-')); + const store = createSessionStore(root); + const importSession = async (sourceSessionId: string) => + store.createImportedSession(makeInput(), [], { + adapterId: 'fake', + sourceSessionId, + }); + try { + const duplicates = await Promise.all([ + importSession('duplicate'), + importSession('duplicate'), + importSession('duplicate'), + ]); + await Promise.all( + duplicates.map((session, index) => + store.updateHeader(session.id, { + createdAt: index === 0 ? 100 : 200, + transcriptLedgerVersion: 1, + }), + ), + ); + const archived = await importSession('archived'); + await store.updateHeader(archived.id, { transcriptLedgerVersion: 1 }); + const archivedSnapshot = await store.readHeaderRecordSnapshot(archived.id); + await store.setSessionsArchivedVersioned( + [{ sessionId: archived.id, expectedVersion: archivedSnapshot.revision }], + true, + ); + await importSession('staging'); + const deleted = await importSession('deleted'); + await store.updateHeader(deleted.id, { transcriptLedgerVersion: 1 }); + await store.remove(deleted.id); + await store.create(makeInput({ parentSessionId: duplicates[0]!.id })); + + const result = await store.lookupExternalSessionImports( + 'fake', + ['duplicate', 'archived', 'staging', 'deleted', 'ordinary', 'missing'], + 2, + ); + const newestDuplicateIds = duplicates + .slice(1) + .map(({ id }) => id) + .sort((left, right) => left.localeCompare(right)); + + assert.deepEqual(result, [ + { + sourceSessionId: 'duplicate', + livePublishedImportCount: 3, + recentSessionIds: newestDuplicateIds, + }, + { + sourceSessionId: 'archived', + livePublishedImportCount: 1, + recentSessionIds: [archived.id], + }, + ]); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('bounds external import lookup source and recent-id requests', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-external-origin-bounds-')); + const store = createSessionStore(root); + try { + await assert.rejects( + store.lookupExternalSessionImports( + 'fake', + Array.from( + { length: EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS + 1 }, + (_, index) => `source-${index}`, + ), + 1, + ), + /at most .* source ids/, + ); + await assert.rejects( + store.lookupExternalSessionImports( + 'fake', + ['source-1'], + EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS + 1, + ), + /recent id limit must be between/, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('fails closed when persisted external origin metadata is malformed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-external-origin-invalid-')); + const store = createSessionStore(root); + let sessionId = ''; + try { + const session = await store.create(makeInput()); + sessionId = session.id; + } finally { + await store.close?.(); + } + + const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + database + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.externalOrigin', json(?)) + WHERE session_id = ?`, + ) + .run(JSON.stringify({ adapterId: '', sourceSessionId: 42 }), sessionId); + } finally { + database.close(); + } + + const reopened = createSessionStore(root); + try { + await assert.rejects(reopened.readHeaderSnapshot(sessionId), /malformed fields/); + } finally { + await reopened.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('persists session metadata and messages in one SQLite authority', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-sqlite-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 10, + text: 'hello from SQLite', + }); + + assert.equal((await store.readMessages(session.id))[0]?.id, 'message-1'); + const page = await store.listCatalogPage(undefined, undefined, 10); + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') assert.fail('expected a catalog page'); + assert.equal(page.records[0]?.summary.lastMessagePreview, 'hello from SQLite'); + assert.equal(page.records[0]?.activityAt, 10); + } finally { + await store.close?.(); + } + + const reopened = createSessionStore(root); + try { + const [session] = await reopened.listHeaders(); + assert.ok(session); + assert.equal((await reopened.readMessages(session.id))[0]?.id, 'message-1'); + } finally { + await reopened.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a replayed older message latches the connection without moving the preview back', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-preview-replay-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + const prompt = { + type: 'user' as const, + id: 'message-prompt', + turnId: 'turn-1', + ts: 10, + text: 'the original prompt', + }; + await store.commitMessageCatalogProjection(session.id, prompt); + await store.commitMessageCatalogProjection(session.id, { + ...prompt, + id: 'message-steering', + ts: 20, + text: 'the steering said later', + }); + + // Recovery replays the prompt when the ledger holds it but the catalog + // does not; on a Turn still running, a steering line is already on show. + await store.updateHeader(session.id, { connectionLocked: false }); + await store.commitMessageCatalogProjection(session.id, prompt); + + const page = await store.listCatalogPage(undefined, undefined, 10); + if (page.kind !== 'page') assert.fail('expected a catalog page'); + assert.equal(page.records[0]?.summary.lastMessagePreview, 'the steering said later'); + assert.equal(page.records[0]?.activityAt, 20); + assert.equal((await store.readHeader(session.id)).connectionLocked, true); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps staging imports outside the catalog pagination domain', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-staging-catalog-')); + const store = createSessionStore(root); + try { + const visible = await store.create(makeInput({ name: 'Visible Session' })); + const staging = await Promise.all( + Array.from({ length: 32 }, (_, index) => + store.createImportedSession(makeInput({ name: `Staging Session ${index}` }), [], { + adapterId: 'fake', + sourceSessionId: `source-${index}`, + }), + ), + ); + + const page = await store.listCatalogPage(undefined, undefined, 32); + + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') assert.fail('expected a catalog page'); + assert.deepEqual( + page.records.map((record) => record.header.id), + [visible.id], + ); + assert.equal(page.hasMore, false); + await assert.rejects(store.readCatalogRecord(staging[0]!.id), (error) => + isSessionNotFoundError(error), + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a generated title fills an absence and never overwrites a rename', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-generated-title-')); + const store = createSessionStore(root); + try { + const unnamed = await store.create(makeInput({ cwd: root, name: DEFAULT_SESSION_NAME })); + assert.equal( + (await store.setGeneratedTitleIfAbsent(unnamed.id, 'draft the release notes'))?.name, + 'draft the release notes', + ); + assert.equal((await store.readHeaderSnapshot(unnamed.id)).name, 'draft the release notes'); + // An already-named Session is never renamed by a later generation. + assert.equal(await store.setGeneratedTitleIfAbsent(unnamed.id, 'a second guess'), null); + + // A rename landing between the check and the write wins: the write is + // conditional on the revision the check read. + const raced = await store.create(makeInput({ cwd: root, name: DEFAULT_SESSION_NAME })); + const readHeaderRecordSnapshot = store.readHeaderRecordSnapshot.bind(store); + let renamed = false; + store.readHeaderRecordSnapshot = async (sessionId: string) => { + const record = await readHeaderRecordSnapshot(sessionId); + if (sessionId === raced.id && !renamed) { + renamed = true; + await store.rename(sessionId, '我自己起的名字'); + } + return record; + }; + assert.equal(await store.setGeneratedTitleIfAbsent(raced.id, 'generated loses'), null); + const header = await readHeaderRecordSnapshot(raced.id); + assert.equal(header.header.name, '我自己起的名字'); + assert.equal(header.header.titleIsManual, true); + + // A revision that moved for any other reason is re-read, not mistaken + // for a rename. + const flagged = await store.create(makeInput({ cwd: root, name: DEFAULT_SESSION_NAME })); + let flaggedOnce = false; + store.readHeaderRecordSnapshot = async (sessionId: string) => { + const record = await readHeaderRecordSnapshot(sessionId); + if (sessionId === flagged.id && !flaggedOnce) { + flaggedOnce = true; + await store.setFlagged(sessionId, true); + } + return record; + }; + assert.equal( + (await store.setGeneratedTitleIfAbsent(flagged.id, 'generated survives'))?.name, + 'generated survives', + ); + + // A Session whose revision moves under every attempt answers null like any + // other lost race, so a caller reading null never has to also expect a throw. + const busy = await store.create(makeInput({ cwd: root, name: DEFAULT_SESSION_NAME })); + let flips = 0; + store.readHeaderRecordSnapshot = async (sessionId: string) => { + const record = await readHeaderRecordSnapshot(sessionId); + if (sessionId === busy.id) { + flips += 1; + await store.setFlagged(sessionId, flips % 2 === 1); + } + return record; + }; + assert.equal(await store.setGeneratedTitleIfAbsent(busy.id, 'never lands'), null); + assert.equal((await readHeaderRecordSnapshot(busy.id)).header.name, DEFAULT_SESSION_NAME); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a Session freezes its route on the first user message, a subagent at birth', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-route-freeze-')); + const store = createSessionStore(root); + try { + const ordinary = await store.create(makeInput({ cwd: root })); + assert.equal(ordinary.connectionLocked, false); + + // A subagent's route is chosen by the spawn that created it and is never + // re-targeted, so it needs no first Message to be frozen. + const child = await store.createSubagent( + makeInput({ + cwd: root, + name: 'Child', + subagentParent: { + kind: 'subagent', + parentSessionId: ordinary.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + }), + ); + assert.equal(child.header.connectionLocked, true); + assert.equal((await store.readHeaderSnapshot(child.header.id)).connectionLocked, true); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('appending the first user message locks the session before any read', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-lock-heal-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 10, + text: 'legacy message', + }); + assert.equal((await store.readHeaderSnapshot(session.id)).connectionLocked, true); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('commits message and catalog projection atomically', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-atomic-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + const metadata = createSqliteSessionMetadataStore( + join(root, OPERATIONAL_STATE_DATABASE_NAME), + ); + try { + await store.appendMessage(session.id, { + type: 'assistant', + id: 'message-1', + turnId: 'turn-1', + ts: 20, + text: 'atomic preview', + modelId: 'fake-model', + }); + assert.equal((await metadata.readMessages(session.id))[0]?.id, 'message-1'); + assert.equal( + (await metadata.listCatalogPage({}, undefined, 10)).records[0]?.lastMessagePreview, + 'atomic preview', + ); + } finally { + metadata.close(); + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('bounds durable message lookups by a fixed transcript watermark', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-pages-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + const messages = ['zero', 'one', 'two', '三🙂'].map((text, index) => ({ + type: 'user' as const, + id: `message-${index}`, + turnId: `turn-${index}`, + ts: index + 1, + text, + })); + await store.appendMessages(session.id, messages); + + await store.appendMessage(session.id, { + type: 'user', + id: 'message-4', + turnId: 'turn-4', + ts: 5, + text: 'appended after the watermark', + }); + assert.deepEqual( + await store.readTranscriptMessagesSnapshot(session.id, { + messageIds: ['message-4'], + throughSequence: 3, + maxBytes: 1024, + maxMessages: 1, + }), + [], + ); + assert.deepEqual( + await store.readTranscriptMessagesSnapshot(session.id, { + messageIds: ['message-4'], + throughSequence: null, + maxBytes: 1024, + maxMessages: 1, + }), + [], + ); + assert.deepEqual( + await store.readTranscriptMessagesSnapshot(session.id, { + messageIds: ['message-4'], + throughSequence: 4, + maxBytes: 1024, + maxMessages: 1, + }), + [ + { + type: 'user', + id: 'message-4', + turnId: 'turn-4', + ts: 5, + text: 'appended after the watermark', + }, + ], + ); + assert.deepEqual(await store.readMessages(session.id), [ + ...messages, + { + type: 'user', + id: 'message-4', + turnId: 'turn-4', + ts: 5, + text: 'appended after the watermark', + }, + ]); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('reads both new chunked messages and legacy inline v22 records', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-chunks-')); + const message = { + type: 'user' as const, + id: 'message-large', + turnId: 'turn-large', + ts: 1, + text: '三🙂x'.repeat(40_000), + }; + const smallMessage = { + type: 'user' as const, + id: 'message-small', + turnId: 'turn-small', + ts: 2, + text: 'small inline record', + }; + let sessionId = ''; + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + sessionId = session.id; + await store.appendMessages(session.id, [message, smallMessage]); + assert.deepEqual(await store.readMessages(session.id), [message, smallMessage]); + // The paged scan the transcript conversion reads through must reassemble + // a chunked record too: inline it is only a marker, which decodes as + // nothing a transcript can carry. + const page = await store.readMessagesAfter(session.id, { + maxMessages: 8, + maxStoredBytes: 4 * 1024 * 1024, + }); + assert.deepEqual( + page.records.map((record) => record.message), + [message, smallMessage], + ); + } finally { + await store.close?.(); + } + + const path = join(root, OPERATIONAL_STATE_DATABASE_NAME); + const legacy = new DatabaseSync(path); + const legacyRecord = JSON.stringify(message); + legacy + .prepare( + ` + UPDATE session_messages SET record_json = ? + WHERE session_id = ? AND sequence = 0 + `, + ) + .run(legacyRecord, sessionId); + legacy.exec(` + DROP INDEX session_metadata_one_workhub_coordination_session; + DROP TABLE agent_graph_epochs; + DROP TABLE session_message_chunks; + DROP TABLE session_message_payloads; + ALTER TABLE session_metadata ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + DROP INDEX session_metadata_by_external_origin; + ALTER TABLE session_metadata DROP COLUMN external_adapter_id; + ALTER TABLE session_metadata DROP COLUMN external_source_session_id; + UPDATE session_metadata_schema SET version = 22 WHERE scope = 'session_metadata'; + `); + legacy.close(); + + const migrated = createSessionStore(root); + try { + assert.deepEqual(await migrated.readMessages(sessionId), [message, smallMessage]); + } finally { + await migrated.close?.(); + } + + await rm(root, { recursive: true, force: true }); + }); + + test('rejects corrupt chunked messages on ordinary reads', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-corruption-')); + const store = createSessionStore(root); + let sessionId = ''; + try { + const session = await store.create(makeInput()); + sessionId = session.id; + await store.appendMessage(sessionId, { + type: 'user', + id: 'message-large', + turnId: 'turn-large', + ts: 1, + text: 'x'.repeat(128 * 1024), + }); + } finally { + await store.close?.(); + } + + const path = join(root, OPERATIONAL_STATE_DATABASE_NAME); + const inspect = new DatabaseSync(path); + try { + inspect + .prepare( + ` + UPDATE session_message_chunks + SET data = zeroblob(length(data)) + WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 + `, + ) + .run(sessionId); + } finally { + inspect.close(); + } + + const corrupted = createSessionStore(root); + try { + await assert.rejects(corrupted.readMessages(sessionId), /incompatible/i); + } finally { + await corrupted.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('bounds transcript identity reconciliation before message materialization', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-reconciliation-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + const messages = Array.from({ length: 257 }, (_, index) => ({ + type: 'user' as const, + id: `message-${index}`, + turnId: `turn-${index}`, + ts: index + 1, + text: `text-${index}`, + })); + await store.appendMessages(session.id, messages); + + assert.deepEqual( + await store.readTranscriptMessagesSnapshot(session.id, { + messageIds: [...messages.map(({ id }) => id), messages[0]!.id], + throughSequence: 256, + maxBytes: 64 * 1024, + maxMessages: 257, + }), + messages, + ); + await assert.rejects( + store.readTranscriptMessagesSnapshot(session.id, { + messageIds: messages.map(({ id }) => id), + throughSequence: 256, + maxBytes: 64 * 1024, + maxMessages: 256, + }), + /exceeds its message limit/, + ); + await assert.rejects( + store.readTranscriptMessagesSnapshot(session.id, { + messageIds: [messages[0]!.id], + throughSequence: 256, + maxBytes: 1, + maxMessages: 1, + }), + /exceeds its byte limit/, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('notifies transcript observers only after successful durable appends', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-observer-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + const changed: string[] = []; + const unsubscribe = store.subscribeTranscriptChanges((sessionId) => changed.push(sessionId)); + await store.appendMessages(session.id, [ + { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'one' }, + { type: 'user', id: 'message-2', turnId: 'turn-2', ts: 2, text: 'two' }, + ]); + assert.deepEqual(changed, [session.id]); + await assert.rejects( + store.appendMessage('missing-session', { + type: 'user', + id: 'message-2', + turnId: 'turn-duplicate', + ts: 3, + text: 'duplicate', + }), + ); + assert.deepEqual(changed, [session.id]); + unsubscribe(); + await store.appendMessage(session.id, { + type: 'user', + id: 'message-3', + turnId: 'turn-3', + ts: 3, + text: 'three', + }); + assert.deepEqual(changed, [session.id]); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('reads back a legacy fake-backend session instead of migrating or rejecting it', async () => { + // #3211: `'fake'` was retired as a live backend but never migrated out of + // storage. Narrowing the header validator would make these rows decode as + // malformed and rewriting them to `'ai-sdk'` would make an unrunnable task + // look runnable, since `llmConnectionSlug` still points at nothing. + // + // The legacy row is seeded under the writer, not through `create`: `'fake'` + // is a value only an older build could write, so a test that asks today's + // creation path for one would be asserting a write that must not exist. + const root = await mkdtemp(join(tmpdir(), 'maka-session-legacy-fake-')); + const store = createSessionStore(root); + let sessionId: string; + try { + sessionId = (await store.create(makeInput())).id; + } finally { + await store.close?.(); + } + + const legacy = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + const row = legacy + .prepare(`SELECT payload_json FROM session_metadata WHERE session_id = ?`) + .get(sessionId) as { payload_json: string }; + const payload = JSON.parse(row.payload_json) as Record; + payload.backend = 'fake'; + payload.llmConnectionSlug = 'fake'; + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = ?, backend = ?, llm_connection_slug = ? + WHERE session_id = ?`, + ) + .run(JSON.stringify(payload), 'fake', 'fake', sessionId); + } finally { + legacy.close(); + } + + const reopened = createSessionStore(root); + try { + const [header] = await reopened.listHeaders(); + assert.equal(header?.backend, 'fake'); + assert.equal(header?.llmConnectionId, undefined); + assert.equal(header?.llmConnectionSlug, 'fake'); + assert.equal((await reopened.readHeaderSnapshot(sessionId)).backend, 'fake'); + } finally { + await reopened.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('persists an immutable Connection identity when supplied by Host admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-connection-identity-')); + const store = createSessionStore(root); + try { + const created = await store.create( + makeInput({ llmConnectionId: '11111111-1111-4111-8111-111111111111' }), + ); + assert.equal(created.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.equal( + (await store.readHeader(created.id)).llmConnectionId, + '11111111-1111-4111-8111-111111111111', + ); + assert.equal( + (await store.readCatalogRecord(created.id)).summary.llmConnectionId, + '11111111-1111-4111-8111-111111111111', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('deletes metadata and messages through the same transaction boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-delete-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 30, + text: 'delete me', + }); + await store.remove(session.id); + await assert.rejects(store.readHeaderSnapshot(session.id), (error) => { + assert.equal(isSessionNotFoundError(error), true); + return true; + }); + await assert.rejects(store.readMessages(session.id), (error) => { + assert.equal(isSessionNotFoundError(error), true); + return true; + }); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('normalizeSessionHeader accepts an empty side-conversation copy but rejects a fabricated branch turn', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-empty-copy-lineage-')); + const store = createSessionStore(root); + try { + const source = await store.create(makeInput({ cwd: root, name: 'Source' })); + const base = await store.create(makeInput({ cwd: root, name: 'Side chat' })); + const emptyCopy: SessionConversationCopy = { + kind: 'branch', + sourceSessionId: source.id, + // sourceTurnId intentionally absent: an empty copy carries no source turn. + requestFingerprint: `sha256:${'a'.repeat(64)}`, + state: 'committed', + intent: 'side_conversation', + }; + const emptyHeader: SessionHeader = { + ...base, + parentSessionId: source.id, + conversationCopy: emptyCopy, + }; + + // An empty side-conversation copy records provenance (parentSessionId) + // without fabricating a branchOfTurnId, and round-trips unchanged. + const normalized = normalizeSessionHeader(emptyHeader); + assert.equal(normalized.conversationCopy?.sourceTurnId, undefined); + assert.equal(normalized.branchOfTurnId, undefined); + assert.equal(normalized.parentSessionId, source.id); + + // An empty copy must not fabricate a branchOfTurnId. + assert.throws( + () => normalizeSessionHeader({ ...emptyHeader, branchOfTurnId: 'fabricated-turn' }), + /malformed fields/, + ); + + // An empty copy is only valid for the side_conversation intent. + assert.throws( + () => + normalizeSessionHeader({ + ...emptyHeader, + conversationCopy: { ...emptyCopy, intent: undefined }, + }), + /malformed fields/, + ); + + // A through-turn copy must anchor its branchOfTurnId to the source turn: + // absent here, so it is rejected... + assert.throws( + () => + normalizeSessionHeader({ + ...emptyHeader, + conversationCopy: { ...emptyCopy, sourceTurnId: 'source-turn' }, + }), + /malformed fields/, + ); + // ...and accepted once the header anchors to the same turn. + assert.equal( + normalizeSessionHeader({ + ...emptyHeader, + branchOfTurnId: 'source-turn', + conversationCopy: { ...emptyCopy, sourceTurnId: 'source-turn' }, + }).conversationCopy?.sourceTurnId, + 'source-turn', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function makeInput(overrides: Partial = {}): CreateSessionInput { + return { + cwd: '/tmp/cwd', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + name: 'Session', + labels: [], + ...overrides, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5d68580fdc8228647ca90095b49ee0a7cc91f00b5463465a8d862234cf032343.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5d68580fdc8228647ca90095b49ee0a7cc91f00b5463465a8d862234cf032343.source new file mode 100644 index 0000000000..9ed64f356d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5d68580fdc8228647ca90095b49ee0a7cc91f00b5463465a8d862234cf032343.source @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { + withLegacyFileUpdateLockLease, + withProcessLifetimeFileUpdateLock, +} from '../../process-lifetime-file-update-lock.js'; + +const targetPath = process.argv[2]; +if (!targetPath) throw new Error('Missing file update lock target'); + +const hold = async () => { + process.send?.('locked'); + await new Promise(() => setInterval(() => undefined, 1_000)); +}; + +if (process.argv[3] === 'legacy') { + await withLegacyFileUpdateLockLease(targetPath, async (inheritedFd) => { + if (inheritedFd <= 2) throw new Error('Legacy lock lease is not inheritable'); + await mkdir(`${targetPath}.lock`); + await hold(); + }); +} else if (process.argv[3] === 'inherit') { + await withProcessLifetimeFileUpdateLock(targetPath, async (inheritedFd) => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)'], { + stdio: ['ignore', 'ignore', 'inherit', inheritedFd], + }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + process.send?.({ kind: 'locked', inheritorPid: child.pid }); + await new Promise(() => setInterval(() => undefined, 1_000)); + }); +} else { + await withProcessLifetimeFileUpdateLock(targetPath, hold); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5df1f4b103cac7d9d6915c6188f7e18f6bf56d5a4ba0013fb02e8853ef2946a6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5df1f4b103cac7d9d6915c6188f7e18f6bf56d5a4ba0013fb02e8853ef2946a6.source new file mode 100644 index 0000000000..3eec8899bc --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5df1f4b103cac7d9d6915c6188f7e18f6bf56d5a4ba0013fb02e8853ef2946a6.source @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/// + +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, type FileHandle } from 'node:fs/promises'; +import { unlock, waitForLock } from 'fs-native-extensions'; + +const lockGates = new Map>(); + +export async function withArtifactWriterBootstrapLock( + lockPath: string, + operation: () => Promise, +): Promise { + const previous = lockGates.get(lockPath); + let releaseGate!: () => void; + const current = new Promise((resolve) => { + releaseGate = resolve; + }); + lockGates.set(lockPath, current); + await previous?.catch(() => {}); + try { + const handle = await open( + lockPath, + fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, + 0o600, + ); + let locked = false; + try { + await assertStableRegularFile(handle, lockPath); + if (process.platform !== 'win32') await handle.chmod(0o600); + await waitForLock(handle.fd); + locked = true; + await assertStableRegularFile(handle, lockPath); + return await operation(); + } finally { + if (locked) releaseLock(handle); + await handle.close(); + } + } finally { + releaseGate(); + if (lockGates.get(lockPath) === current) lockGates.delete(lockPath); + } +} + +async function assertStableRegularFile(handle: FileHandle, lockPath: string): Promise { + const [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(lockPath, { bigint: true }), + ]); + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw new Error(`Artifact writer bootstrap lock is not one stable file: ${lockPath}`); + } +} + +function releaseLock(handle: FileHandle): void { + try { + unlock(handle.fd); + } catch { + // Closing the OS handle is the authoritative release path. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5dffef014972693a9310c35d631e4ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5dffef014972693a9310c35d631e4ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source new file mode 100644 index 0000000000..b392c8870d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5dffef014972693a9310c35d631e4ad2d52d6e31c4b2d0d4badd7e40f3338e1f.source @@ -0,0 +1,260 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { migrateSqliteUsageDatabase } from '../sqlite-usage-schema.js'; +import { + MODEL_CALL_NOW as NOW, + modelCallAttempt as attempt, + wideModelCallAttempt as wideAttempt, +} from './fixtures/model-call-attempt.js'; +import { MODEL_CALL_COLUMNS } from '../sqlite-usage-schema.js'; + +/** A ledger as it stood before the record was spread into columns. */ +function blobLedger(database: DatabaseSync): void { + database.exec(` + CREATE TABLE usage_model_call_attempts ( + attempt_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + session_id TEXT + ); + `); +} + +function insertBlob( + database: DatabaseSync, + attemptId: string, + record: unknown, + sessionId?: string, +) { + database + .prepare('INSERT INTO usage_model_call_attempts VALUES (?, ?, ?, ?)') + .run( + attemptId, + NOW - 500, + typeof record === 'string' ? record : JSON.stringify(record), + sessionId ?? null, + ); +} + +function storedRow(database: DatabaseSync, attemptId: string): Record { + return database + .prepare('SELECT * FROM usage_model_call_attempts WHERE attempt_id = ?') + .get(attemptId) as Record; +} + +test('usage migration backfills Session identity for existing ledger rows', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec(` + CREATE TABLE usage_llm_calls ( + storage_key TEXT PRIMARY KEY, + id TEXT NOT NULL, + ts INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + CREATE TABLE usage_model_call_attempts ( + attempt_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + INSERT INTO usage_llm_calls(storage_key, id, ts, record_json) + VALUES ('legacy', 'legacy', 1, '{"sessionId":"session-a"}'); + INSERT INTO usage_model_call_attempts(attempt_id, completed_at, record_json) + VALUES ('canonical', 1, '{"sessionId":"session-b"}'); + `); + + migrateSqliteUsageDatabase(database); + + assert.equal( + database.prepare("SELECT session_id FROM usage_llm_calls WHERE id = 'legacy'").get() + ?.session_id, + 'session-a', + ); + assert.equal( + database + .prepare("SELECT session_id FROM usage_model_call_attempts WHERE attempt_id = 'canonical'") + .get()?.session_id, + 'session-b', + ); + assert.ok( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = 'usage_llm_calls_session_ts'", + ) + .get(), + ); + assert.ok( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = 'usage_model_call_attempts_session_completed_at'", + ) + .get(), + ); + } finally { + database.close(); + } +}); + +test('the migration spreads a stored record into the columns a cost answer sums', () => { + const database = new DatabaseSync(':memory:'); + try { + blobLedger(database); + const wide = wideAttempt(); + insertBlob(database, wide.attemptId, wide, wide.sessionId); + + migrateSqliteUsageDatabase(database); + + const row = storedRow(database, wide.attemptId); + assert.deepEqual(Object.keys(row), [...MODEL_CALL_COLUMNS]); + // Every number a Usage total is built from reads the same after the spread. + assert.equal(row.cost_usd, 0.004); + assert.equal(row.cost_basis, 'priced'); + assert.equal(row.input_tokens, 100); + assert.equal(row.output_tokens, 20); + assert.equal(row.provider_id, 'anthropic'); + assert.equal(row.session_id, 'session-1'); + } finally { + database.close(); + } +}); + +test('a record the migration cannot read whole keeps its identity and loses the rest', () => { + // A row that ends up half-filled would make the table's own CHECK + // unsatisfiable and take the whole migration with it, so conversion is + // all-or-nothing per row. What is left says a call happened and its cost is + // gone — which is what a read reports as unreadable. + const database = new DatabaseSync(':memory:'); + try { + blobLedger(database); + insertBlob(database, 'damaged', '{"schemaVersion":1,', 'session-1'); + insertBlob(database, 'alien', { sessionId: 'session-2' }); + const priced = attempt({ attemptId: 'priced' }); + insertBlob(database, priced.attemptId, priced, priced.sessionId); + + migrateSqliteUsageDatabase(database); + + assert.deepEqual( + database + .prepare( + 'SELECT attempt_id, session_id FROM usage_model_call_attempts WHERE cost_basis IS NULL ORDER BY attempt_id', + ) + .all() + .map((row) => ({ ...row })), + [ + { attempt_id: 'alien', session_id: 'session-2' }, + { attempt_id: 'damaged', session_id: 'session-1' }, + ], + ); + assert.equal(storedRow(database, 'priced').cost_usd, 0.004); + } finally { + database.close(); + } +}); + +test('the migration is a no-op once the ledger already holds columns', () => { + const database = new DatabaseSync(':memory:'); + try { + blobLedger(database); + const wide = wideAttempt(); + insertBlob(database, wide.attemptId, wide, wide.sessionId); + migrateSqliteUsageDatabase(database); + const once = storedRow(database, wide.attemptId); + + migrateSqliteUsageDatabase(database); + + assert.deepEqual(storedRow(database, wide.attemptId), once); + } finally { + database.close(); + } +}); + +test('the migration converts every row, however many a workspace holds', () => { + const database = new DatabaseSync(':memory:'); + try { + blobLedger(database); + for (let index = 0; index < 1_200; index += 1) { + const row = attempt({ attemptId: `attempt-${String(index).padStart(5, '0')}` }); + insertBlob(database, row.attemptId, row, row.sessionId); + } + + migrateSqliteUsageDatabase(database); + + assert.equal( + database + .prepare('SELECT COUNT(*) AS count FROM usage_model_call_attempts WHERE cost_basis IS NULL') + .get()?.count, + 0, + ); + } finally { + database.close(); + } +}); + +test('the ledger refuses a row that would make a total dishonest', () => { + const database = new DatabaseSync(':memory:'); + try { + migrateSqliteUsageDatabase(database); + const insert = (values: Record) => { + const columns = Object.keys(values); + database + .prepare( + `INSERT INTO usage_model_call_attempts(${columns.join(', ')}) VALUES (${columns + .map(() => '?') + .join(', ')})`, + ) + .run(...(Object.values(values) as (string | number | null)[])); + }; + const base = { + completed_at: NOW, + logical_call_id: 'call-1', + turn_id: 'turn-1', + call_kind: 'main', + provider_id: 'anthropic', + model_id: 'claude-opus-5', + latency_ms: 10, + status: 'completed', + usage_basis: 'reported', + }; + // A price nobody could resolve must never surface as an amount. + assert.throws(() => + insert({ ...base, attempt_id: 'a', cost_basis: 'unpriced', cost_usd: 0.004 }), + ); + // A priced call must carry one; zero is legal and means genuinely free. + assert.throws(() => insert({ ...base, attempt_id: 'b', cost_basis: 'priced' })); + // "No usage reported" and "zero tokens" are different facts. + assert.throws(() => + insert({ + ...base, + attempt_id: 'c', + usage_basis: 'missing', + input_tokens: 0, + cost_basis: 'priced', + cost_usd: 0, + }), + ); + // Half a record is not a record. + assert.throws(() => insert({ attempt_id: 'd', completed_at: NOW, cost_basis: 'unpriced' })); + } finally { + database.close(); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5e51a2c71b3353234ea0326839b930cfe6fb48d798ddd37c31f7c0564388042a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5e51a2c71b3353234ea0326839b930cfe6fb48d798ddd37c31f7c0564388042a.source new file mode 100644 index 0000000000..0144408fa2 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/5e51a2c71b3353234ea0326839b930cfe6fb48d798ddd37c31f7c0564388042a.source @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { test } from 'node:test'; + +const run = promisify(execFile); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * Entrypoints whose module graph reaches `node:sqlite` at load time, so + * importing one emits Node's SQLite ExperimentalWarning. + * + * This list is the package's SQLite boundary, stated out loud. `@maka/storage` + * publishes no barrel: a consumer that needs a durable store imports the entry + * that owns it and accepts the warning, and a consumer that needs + * `workspace-root` or `credential-store` pays nothing. Issue #1257 came from + * the opposite arrangement, where one `export *` barrel made every consumer — + * `maka --help` included — load SQLite. + * + * Adding an entry here is a deliberate widening of that boundary. Removing one + * means an entrypoint became SQLite-free. Either way, update this list in the + * same change and say why. + */ +const SQLITE_BACKED_ENTRYPOINTS = [ + './agent-graph-control-store', + './agent-run-store', + './artifact-stores', + './daily-review-authority', + './deep-research-authority', + './deep-research-store', + './execution-stores', + './git-worktree-child-executor', + './goal-authority', + './interaction-store', + './model-call-ledger', + './operational-state-store', + './plan-authority', + // Production snapshot composition exports one filtered runtime SQLite database. + './production-session-snapshot', + './project-catalog', + './project-catalog-authority', + // Snapshot staging cleanup persists a lease through Session-copy cleanup. + './quiescent-session-snapshot', + './runtime-event-persistence', + './scheduled-task-store', + './session-bundle-policy', + './session-copy-cleanup', + './session-store', + './session-todo-authority', + './shell-run-authority', + './shell-run-store', + './sqlite-session-metadata-store', + './storage-writer-composition', + './usage-stores', + './work-board-store', +]; + +/** + * Loads an entrypoint in a child process and reports whether `node:sqlite` + * entered its module graph, observed through a `module.registerHooks` resolve + * hook. Matching Node's ExperimentalWarning text instead would tie this guard + * to a string Node owns and has already reworded once. + */ +async function loadsSqlite(target: string): Promise { + const specifier = pathToFileURL(resolve(packageRoot, target)).href; + const probe = [ + "import { registerHooks } from 'node:module';", + 'let sawSqlite = false;', + 'registerHooks({', + ' resolve(request, context, nextResolve) {', + ' const resolved = nextResolve(request, context);', + " if (resolved.url === 'node:sqlite') sawSqlite = true;", + ' return resolved;', + ' },', + '});', + `await import(${JSON.stringify(specifier)});`, + "process.stdout.write(sawSqlite ? '\\nSQLITE_IN_GRAPH=yes' : '\\nSQLITE_IN_GRAPH=no');", + ].join('\n'); + const { stdout } = await run(process.execPath, ['--input-type=module', '--eval', probe], { + encoding: 'utf8', + }); + const verdict = /SQLITE_IN_GRAPH=(yes|no)$/u.exec(stdout); + assert.ok(verdict, `probe for ${target} produced no verdict; stdout was: ${stdout}`); + return verdict[1] === 'yes'; +} + +async function publishedEntrypoints(): Promise> { + const manifest = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as { + exports: Record; + }; + return manifest.exports; +} + +test('the package publishes no barrel entrypoint', async () => { + const exports = await publishedEntrypoints(); + assert.equal('.' in exports, false); + const manifest = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as { + main?: string; + types?: string; + }; + assert.equal(manifest.main, undefined, 'a `main` field would re-advertise the barrel'); + assert.equal(manifest.types, undefined, 'a `types` field would re-advertise the barrel'); +}); + +test('every published entrypoint target is emitted by the build', async () => { + const exports = await publishedEntrypoints(); + for (const [subpath, target] of Object.entries(exports)) { + assert.ok( + existsSync(resolve(packageRoot, target)), + `"${subpath}" points at ${target}, which the build did not emit`, + ); + } +}); + +/** + * Published entrypoints no file outside this package imports today. Every one + * of them predates this change, so retiring them is a separate compatibility + * decision. The assertion is exact in both directions — gaining a consumer + * means removing the entry, and publishing a *new* consumer-less entrypoint + * fails outright, which is the direction this guard exists to hold. + * + * `./model-call-ledger` is on the list without this change touching it: it was + * already published, and `repairPendingModelCallProjections` lost its last + * caller when `canonical-usage-reader` was rewritten on `main`. It is listed + * here rather than unpublished for the same reason as the rest — retiring a + * subpath that already shipped is a compatibility call of its own. + */ +const PREEXISTING_UNCONSUMED_ENTRYPOINTS = [ + './activation-secret-injector', + './encrypted-file-managed-secret-store', + './managed-secret-store', + './model-call-ledger', + './write-queue', +]; + +interface StorageImportScan { + bareImporters: string[]; + subpathImporters: Map; + externalSubpaths: Set; +} + +let storageImportScan: Promise | undefined; + +/** Collects every `@maka/storage` import specifier in the repository's sources, once. */ +function scanStorageImports(): Promise { + storageImportScan ??= runStorageImportScan(); + return storageImportScan; +} + +async function runStorageImportScan(): Promise { + const repoRoot = resolve(packageRoot, '../..'); + const specifierPattern = + /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"]@maka\/storage(\/[^'"]*)?['"]/gu; + const sourceExtensions = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/u; + const skipped = new Set(['node_modules', 'dist', '.git']); + const scan: StorageImportScan = { + bareImporters: [], + subpathImporters: new Map(), + externalSubpaths: new Set(), + }; + async function walk(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (skipped.has(entry.name)) return; + const path = join(directory, entry.name); + if (entry.isDirectory()) return walk(path); + if (!sourceExtensions.test(entry.name)) return; + const source = await readFile(path, 'utf8'); + for (const match of source.matchAll(specifierPattern)) { + if (!match[1]) { + scan.bareImporters.push(path); + continue; + } + const subpath = `.${match[1]}`; + const importers = scan.subpathImporters.get(subpath) ?? []; + importers.push(path); + scan.subpathImporters.set(subpath, importers); + if (relative(packageRoot, path).startsWith('..')) scan.externalSubpaths.add(subpath); + } + }), + ); + } + await Promise.all( + ['packages', 'apps', 'scripts'].map((directory) => walk(join(repoRoot, directory))), + ); + return scan; +} + +test('no source file imports the retired bare specifier', { timeout: 60_000 }, async () => { + const { bareImporters } = await scanStorageImports(); + assert.deepEqual( + bareImporters, + [], + 'bare `@maka/storage` imports resolve to the removed `.` entrypoint and fail at runtime', + ); +}); + +test('published entrypoints and their consumers match exactly', { timeout: 60_000 }, async () => { + const exports = await publishedEntrypoints(); + const { subpathImporters, externalSubpaths } = await scanStorageImports(); + const unpublished = [...subpathImporters.keys()].filter((subpath) => !(subpath in exports)); + assert.deepEqual(unpublished.sort(), [], 'imported subpaths missing from the exports map'); + const unconsumed = Object.keys(exports).filter((subpath) => !externalSubpaths.has(subpath)); + assert.deepEqual( + unconsumed.sort(), + PREEXISTING_UNCONSUMED_ENTRYPOINTS, + 'each published subpath is a compatibility promise — publish it when a consumer exists', + ); +}); + +/** Caps concurrent probe children at `limit`; the outer test runner is already concurrent. */ +async function mapWithConcurrency( + items: Item[], + limit: number, + task: (item: Item) => Promise, +): Promise { + const results: Result[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await task(items[index]); + } + }), + ); + return results; +} + +test('only the declared entrypoints load node:sqlite', { timeout: 120_000 }, async () => { + const exports = await publishedEntrypoints(); + const results = await mapWithConcurrency( + Object.entries(exports), + 4, + async ([subpath, target]) => ({ + subpath, + sqlite: await loadsSqlite(target), + }), + ); + const actual = results + .filter((entry) => entry.sqlite) + .map((entry) => entry.subpath) + .sort(); + assert.deepEqual(actual, [...SQLITE_BACKED_ENTRYPOINTS].sort()); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/61598d16b430d9cbca2719113acfcd593513dc7f761568a5ea4c8595e3d53fad.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/61598d16b430d9cbca2719113acfcd593513dc7f761568a5ea4c8595e3d53fad.source new file mode 100644 index 0000000000..02c450382a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/61598d16b430d9cbca2719113acfcd593513dc7f761568a5ea4c8595e3d53fad.source @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createDefaultRuntimePolicy, + decodeCanonicalRuntimePolicy, + decodeRuntimePolicyV2, + normalizeRuntimePolicyMutation, + type MutateRuntimePolicyInput, + type MutateRuntimePolicyResult, + type RuntimePolicy, + type RuntimePolicyMutation, + type RuntimePolicySnapshot, +} from '@maka/core/runtime-policy'; +import { deepFreeze, nextRevision, record, revision } from './codec.js'; +import { + codecError, + decodePersistedDomain, + decodePolicyInput, + RuntimePolicyStoreError, +} from './errors.js'; +import { + POLICY_DOCUMENT_MAX_BYTES, + readBoundedJsonDocument, + serializeJsonDocument, + writeJsonDocument, +} from './document-io.js'; + +const FILE = 'runtime-policy.json'; +const SCHEMA_VERSION = 3 as const; + +export interface RuntimePolicyDocument { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly revision: number; + readonly policy: RuntimePolicy; +} + +export interface PreparedRuntimePolicyMutation { + readonly kind: 'ready'; + readonly current: RuntimePolicyDocument; + readonly next: RuntimePolicyDocument; +} + +export class RuntimePolicyDocumentOwner { + async read(root: string): Promise { + const value = await readBoundedJsonDocument(root, FILE, POLICY_DOCUMENT_MAX_BYTES); + if (value === undefined) { + return { schemaVersion: SCHEMA_VERSION, revision: 0, policy: createDefaultRuntimePolicy() }; + } + const document = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'revision', + 'policy', + ]); + if (document.schemaVersion !== 2 && document.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + return { + schemaVersion: SCHEMA_VERSION, + revision: revision(document.revision, `${FILE}.revision`, 'invalid_document'), + policy: decodePersistedDomain(() => + document.schemaVersion === 2 + ? decodeRuntimePolicyV2(document.policy) + : decodeCanonicalRuntimePolicy(document.policy), + ), + }; + } + + async mutate( + root: string, + rawInput: MutateRuntimePolicyInput, + ): Promise { + const current = await this.read(root); + const prepared = this.prepareMutation(current, rawInput); + if (prepared.kind !== 'ready') return prepared; + return this.commitMutation(root, prepared); + } + + prepareMutation( + current: RuntimePolicyDocument, + rawInput: MutateRuntimePolicyInput, + ): + | PreparedRuntimePolicyMutation + | Exclude { + const input = decodePolicyInput(() => normalizeRuntimePolicyMutation(rawInput)); + if (current.revision !== input.expectedRevision) { + return deepFreeze({ + kind: 'revision_conflict', + expectedRevision: input.expectedRevision, + actualRevision: current.revision, + }); + } + const next = { + schemaVersion: SCHEMA_VERSION, + revision: nextRevision(current.revision), + policy: applyMutation(current.policy, input.operation), + }; + if (serializeJsonDocument(next).length > POLICY_DOCUMENT_MAX_BYTES) { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + `runtime policy exceeds its ${POLICY_DOCUMENT_MAX_BYTES} byte limit`, + ); + } + return { kind: 'ready', current, next }; + } + + async commitMutation( + root: string, + prepared: PreparedRuntimePolicyMutation, + ): Promise> { + await writeJsonDocument(root, FILE, prepared.next, POLICY_DOCUMENT_MAX_BYTES); + return deepFreeze({ kind: 'committed', snapshot: policySnapshot(prepared.next) }); + } +} + +export function policySnapshot(document: RuntimePolicyDocument): RuntimePolicySnapshot { + return deepFreeze({ revision: document.revision, policy: structuredClone(document.policy) }); +} + +function applyMutation(policy: RuntimePolicy, operation: RuntimePolicyMutation): RuntimePolicy { + switch (operation.kind) { + case 'set_network_proxy': + return { ...policy, networkProxy: operation.value }; + case 'set_personalization': + return { ...policy, personalization: operation.value }; + case 'set_memory': + return { ...policy, memory: operation.value }; + case 'set_workspace_instructions': + return { ...policy, workspaceInstructions: operation.value }; + case 'set_privacy': + return { ...policy, privacy: operation.value }; + case 'set_chat_defaults': + return { ...policy, chatDefaults: operation.value }; + case 'set_web_search': + return { ...policy, webSearch: operation.value }; + case 'set_subagents': + return { ...policy, subagents: operation.value }; + case 'set_shell': + return { ...policy, shell: operation.value }; + case 'patch_agent_settings': + return { + ...policy, + ...(operation.value.personalization + ? { personalization: { ...policy.personalization, ...operation.value.personalization } } + : {}), + ...(operation.value.memory + ? { memory: { ...policy.memory, ...operation.value.memory } } + : {}), + ...(operation.value.workspaceInstructions + ? { + workspaceInstructions: { + ...policy.workspaceInstructions, + ...operation.value.workspaceInstructions, + }, + } + : {}), + ...(operation.value.privacy + ? { privacy: { ...policy.privacy, ...operation.value.privacy } } + : {}), + ...(operation.value.webSearch + ? { webSearch: { ...policy.webSearch, ...operation.value.webSearch } } + : {}), + }; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/632572a9dd209b21ac9be2428815573aa2dd763de1312b2ceee6820839058642.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/632572a9dd209b21ac9be2428815573aa2dd763de1312b2ceee6820839058642.source new file mode 100644 index 0000000000..75b2fd1443 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/632572a9dd209b21ac9be2428815573aa2dd763de1312b2ceee6820839058642.source @@ -0,0 +1,367 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { + WORK_BOARD_DEFAULT_PAGE_SIZE, + applyWorkBoardItemPatch, + archiveWorkBoardItem, + decodeWorkBoardItem, + isSafeWorkBoardId, + normalizeCreateWorkBoardItemInput, + normalizeUpdateWorkBoardItemInput, + normalizeWorkBoardListQuery, + unarchiveWorkBoardItem, + type WorkBoardItem, + type WorkBoardPage, +} from '@maka/core/work-board'; +import { + buildWorkBoardListStatement, + encodeWorkBoardCursor, + workBoardFilterFingerprint, +} from './work-board-list-query.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseOptions, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { chainWrite } from './write-queue.js'; +import { WorkBoardStoreError, type WorkBoardStoreErrorCode } from './work-board-store-error.js'; + +export { WorkBoardStoreError } from './work-board-store-error.js'; +export type { WorkBoardStoreErrorCode } from './work-board-store-error.js'; + +const WORK_BOARD_WRITE_KEY = 'work-board'; + +export interface WorkBoardMutationOptions { + /** Optimistic concurrency guard. When provided, the mutation fails if the stored revision differs. */ + expectedRevision?: number; +} + +export interface WorkBoardStore { + list(query?: unknown): Promise; + get(id: string): Promise; + create(input: unknown, now?: number): Promise; + update( + id: string, + patch: unknown, + options?: WorkBoardMutationOptions, + now?: number, + ): Promise; + archive(id: string, options?: WorkBoardMutationOptions, now?: number): Promise; + unarchive(id: string, options?: WorkBoardMutationOptions, now?: number): Promise; + remove(id: string, options?: WorkBoardMutationOptions): Promise; + close(): void; +} + +export function createWorkBoardStore( + workspaceRoot: string, + databaseOptions: OperationalStateDatabaseOptions = {}, +): WorkBoardStore { + return new SqliteWorkBoardStore(workspaceRoot, databaseOptions); +} + +interface WorkBoardRow { + item_id: string; + revision: number; + created_at: number; + updated_at: number; + scope_kind: string; + project_id: string | null; + archived: number; + record_json: string; +} + +class SqliteWorkBoardStore implements WorkBoardStore { + readonly #lease: OperationalStateDatabaseLease; + private readonly writeQueues = new Map>(); + + constructor(workspaceRoot: string, databaseOptions: OperationalStateDatabaseOptions) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot), databaseOptions); + } + + close(): void { + this.#lease.close(); + } + + async list(query: unknown = {}): Promise { + const normalized = normalizeWorkBoardListQuery(query); + if (!normalized.ok) throw storeError('invalid_input', normalized.message); + const value = normalized.value; + const filterFingerprint = workBoardFilterFingerprint(value); + const limit = value.limit ?? WORK_BOARD_DEFAULT_PAGE_SIZE; + const statement = buildWorkBoardListStatement(value, limit); + const rows = this.#lease.database + .prepare(statement.sql) + .all(...statement.params) as unknown as WorkBoardRow[]; + const items = rows.slice(0, limit).map((row) => this.#decodeRow(row)); + const last = items[items.length - 1]; + return { + items, + ...(rows.length > limit && last + ? { nextCursor: encodeWorkBoardCursor(last, filterFingerprint) } + : {}), + }; + } + + async get(id: string): Promise { + const row = this.#readRow(id); + return row ? this.#decodeRow(row) : undefined; + } + + async create(input: unknown, now = Date.now()): Promise { + const normalized = normalizeCreateWorkBoardItemInput(input); + if (!normalized.ok) throw storeError('invalid_input', normalized.message); + const value = normalized.value; + assertValidNow(now); + const item: WorkBoardItem = { + schemaVersion: 1, + id: randomUUID(), + revision: 1, + scope: value.scope, + title: value.title, + ...(value.notes === undefined ? {} : { notes: value.notes }), + state: 'todo', + archived: false, + creator: value.creator, + provenance: value.provenance, + createdAt: now, + updatedAt: now, + }; + await chainWrite(this.writeQueues, WORK_BOARD_WRITE_KEY, async () => { + this.#writeItem(item); + }); + return item; + } + + async update( + id: string, + patch: unknown, + options: WorkBoardMutationOptions = {}, + now = Date.now(), + ): Promise { + const normalizedPatch = normalizeUpdateWorkBoardItemInput(patch); + if (!normalizedPatch.ok) throw storeError('invalid_input', normalizedPatch.message); + const expectedRevision = normalizeExpectedRevision(options); + assertValidNow(now); + let result: WorkBoardItem | undefined; + await chainWrite(this.writeQueues, WORK_BOARD_WRITE_KEY, async () => { + // chainWrite only serializes writers inside this process. The write + // transaction keeps the read/check/write sequence atomic against other + // processes, so a stale revision can never be overwritten silently. + this.#lease.transaction('write', () => { + const current = this.#requireItem(id); + assertExpectedRevision(expectedRevision, current); + const effectiveNow = Math.max(now, current.updatedAt); + const applied = applyWorkBoardItemPatch(current, normalizedPatch.value, effectiveNow); + if (applied.changed) { + this.#writeItem(applied.item); + result = applied.item; + } else { + result = current; + } + }); + }); + return result!; + } + + async archive( + id: string, + options: WorkBoardMutationOptions = {}, + now = Date.now(), + ): Promise { + const expectedRevision = normalizeExpectedRevision(options); + assertValidNow(now); + let result: WorkBoardItem | undefined; + await chainWrite(this.writeQueues, WORK_BOARD_WRITE_KEY, async () => { + // See update(): the revision check must share the write transaction so + // concurrent processes cannot both pass the CAS check on revision 1. + this.#lease.transaction('write', () => { + const current = this.#requireItem(id); + assertExpectedRevision(expectedRevision, current); + const effectiveNow = Math.max(now, current.updatedAt); + const applied = archiveWorkBoardItem(current, effectiveNow); + if (applied.changed) { + this.#writeItem(applied.item); + result = applied.item; + } else { + result = current; + } + }); + }); + return result!; + } + + async unarchive( + id: string, + options: WorkBoardMutationOptions = {}, + now = Date.now(), + ): Promise { + const expectedRevision = normalizeExpectedRevision(options); + assertValidNow(now); + let result: WorkBoardItem | undefined; + await chainWrite(this.writeQueues, WORK_BOARD_WRITE_KEY, async () => { + this.#lease.transaction('write', () => { + const current = this.#requireItem(id); + assertExpectedRevision(expectedRevision, current); + const effectiveNow = Math.max(now, current.updatedAt); + const applied = unarchiveWorkBoardItem(current, effectiveNow); + if (applied.changed) { + this.#writeItem(applied.item); + result = applied.item; + } else { + result = current; + } + }); + }); + return result!; + } + + async remove(id: string, options: WorkBoardMutationOptions = {}): Promise { + const expectedRevision = normalizeExpectedRevision(options); + await chainWrite(this.writeQueues, WORK_BOARD_WRITE_KEY, async () => { + this.#lease.transaction('write', () => { + const current = this.#requireItem(id); + assertExpectedRevision(expectedRevision, current); + if (!current.archived) { + throw storeError('must_archive_first', 'Only archived Work Board items can be deleted'); + } + this.#lease.database + .prepare('DELETE FROM workflow_work_board_items WHERE item_id = ?') + .run(id); + }); + }); + } + + #readRow(id: string): WorkBoardRow | undefined { + if (!isSafeWorkBoardId(id)) { + throw storeError('invalid_input', 'Work Board item id is invalid'); + } + return this.#lease.database + .prepare( + `SELECT item_id, revision, created_at, updated_at, scope_kind, project_id, archived, record_json + FROM workflow_work_board_items + WHERE item_id = ?`, + ) + .get(id) as WorkBoardRow | undefined; + } + + #requireItem(id: string): WorkBoardItem { + const row = this.#readRow(id); + if (!row) throw storeError('not_found', `Work Board item ${id} was not found`); + return this.#decodeRow(row); + } + + #decodeRow(row: WorkBoardRow): WorkBoardItem { + let parsed: unknown; + try { + parsed = JSON.parse(row.record_json); + } catch { + throw storeError('corrupt_record', `Work Board item ${row.item_id} has invalid record_json`); + } + const item = decodeWorkBoardItem(parsed); + if (!item) { + throw storeError( + 'corrupt_record', + `Work Board item ${row.item_id} failed contract validation`, + ); + } + const expectedScopeKind = item.scope.kind; + const expectedProjectId = item.scope.kind === 'project' ? item.scope.projectId : null; + if ( + row.item_id !== item.id || + row.revision !== item.revision || + row.created_at !== item.createdAt || + row.updated_at !== item.updatedAt || + row.scope_kind !== expectedScopeKind || + row.project_id !== expectedProjectId || + row.archived !== (item.archived ? 1 : 0) + ) { + throw storeError( + 'corrupt_record', + `Work Board item ${row.item_id} has indexed columns that disagree with record_json`, + ); + } + return item; + } + + #writeItem(item: WorkBoardItem): void { + this.#lease.transaction('write', () => { + this.#lease.database + .prepare(` + INSERT INTO workflow_work_board_items( + item_id, revision, created_at, updated_at, scope_kind, project_id, archived, record_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(item_id) DO UPDATE SET + revision = excluded.revision, + updated_at = excluded.updated_at, + scope_kind = excluded.scope_kind, + project_id = excluded.project_id, + archived = excluded.archived, + record_json = excluded.record_json + `) + .run( + item.id, + item.revision, + item.createdAt, + item.updatedAt, + item.scope.kind, + item.scope.kind === 'project' ? item.scope.projectId : null, + item.archived ? 1 : 0, + JSON.stringify(item), + ); + }); + } +} + +function normalizeExpectedRevision(options: WorkBoardMutationOptions): number | undefined { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw storeError('invalid_input', 'Work Board mutation options must be an object'); + } + if (Object.keys(options).some((key) => key !== 'expectedRevision')) { + throw storeError('invalid_input', 'Work Board mutation options contain unknown fields'); + } + const expected = options.expectedRevision; + if (expected === undefined) return undefined; + if (!Number.isSafeInteger(expected) || expected < 1) { + throw storeError('invalid_input', 'expectedRevision must be a positive integer'); + } + return expected; +} + +function assertValidNow(now: number): void { + if (!Number.isSafeInteger(now) || now < 0) { + throw storeError('invalid_input', 'now must be a non-negative integer'); + } +} + +function assertExpectedRevision(expected: number | undefined, item: WorkBoardItem): void { + if (expected !== undefined && item.revision !== expected) { + throw storeError( + 'operation_conflict', + `Work Board item ${item.id} revision changed from ${expected} to ${item.revision}`, + ); + } +} + +function storeError(code: WorkBoardStoreErrorCode, message: string): WorkBoardStoreError { + return new WorkBoardStoreError(code, message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/63a8249af19a4b26abb7e6e829c01e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/63a8249af19a4b26abb7e6e829c01e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source new file mode 100644 index 0000000000..dfee70a10a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/63a8249af19a4b26abb7e6e829c01e31a5b6a4a9bfd6a48ccc6ed0ef97bc6b98.source @@ -0,0 +1,378 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test, type TestContext } from 'node:test'; +import { createSessionStore } from '../session-store.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { openInteractiveContextOffloadStoreForWrite } from '../context-offload-store.js'; +import { createReadImageSnapshotStore } from '../read-image-snapshot-store.js'; +import { SqliteContextOffloadStore } from '../sqlite-context-offload-store.js'; +import { + createOperationalStateBackup, + restoreOperationalStateBackup, + validateOperationalStateBackup, +} from '../operational-state-backup.js'; +import { exportSessionBundleState } from '../session-bundle-policy.js'; +import { withOfflineContextSnapshot } from '../context-offload-snapshot.js'; +import { + trackControlDirectory, + removeTrackedControlDirectories, +} from './fixtures/control-directory-hygiene.js'; +import { after } from 'node:test'; + +after(removeTrackedControlDirectories); +const limits = { + ownerMaxBytes: { read_image_snapshot: 5 * 1024 * 1024, tool_result_archive: 4 * 1024 * 1024 }, + sessionLogicalBytes: 1024 * 1024 * 1024, + workspacePhysicalBytes: 20 * 1024 * 1024 * 1024, +}; + +async function fixture(t: TestContext) { + const base = await mkdtemp(join(tmpdir(), 'maka-context-snapshot-')); + t.after(() => rm(base, { recursive: true, force: true })); + const root = join(base, 'source'); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + t.after(() => owner.close()); + const sessions = createSessionStore(root); + const selected = await sessions.create({ + cwd: base, + llmConnectionSlug: 'fake', + model: 'fake', + permissionMode: 'ask', + }); + const other = await sessions.create({ + cwd: base, + llmConnectionSlug: 'fake', + model: 'fake', + permissionMode: 'ask', + }); + const writer = await openInteractiveContextOffloadStoreForWrite(owner.lease, { limits }); + const bytes = Buffer.from('snapshot bytes that cannot be recovered from the changed workspace'); + const ref = await createReadImageSnapshotStore(writer, selected.id).snapshot({ + ownerId: 'image-1', + bytes, + mimeType: 'image/png', + }); + await createReadImageSnapshotStore(writer, other.id).snapshot({ + ownerId: 'shared', + bytes, + mimeType: 'image/png', + }); + const privateBytes = Buffer.from('OTHER-SESSION-PRIVATE-CONTEXT'); + const inlinePrivate = Buffer.from('OTHER-SESSION-PRIVATE-INLINE'); + assert.equal( + ( + await writer.put({ + sessionId: other.id, + owner: { kind: 'tool_result_archive', ownerId: 'inline' }, + bytes: inlinePrivate, + mediaType: 'application/json', + }) + ).ok, + true, + ); + const otherRef = await createReadImageSnapshotStore(writer, other.id).snapshot({ + ownerId: 'private', + bytes: privateBytes, + mimeType: 'image/png', + }); + const orphan = await createReadImageSnapshotStore(writer, selected.id).snapshot({ + ownerId: 'orphan', + bytes: Buffer.from('orphan bytes'), + mimeType: 'image/png', + }); + await writer.releaseReference({ sessionId: selected.id, refId: orphan.refId }); + await sessions.appendMessage(selected.id, { + type: 'tool_result', + id: 'image-result', + turnId: 'turn', + ts: 1, + toolUseId: 'read', + isError: false, + content: { kind: 'image', mimeType: 'image/png', ref }, + }); + await sessions.close?.(); + const runtime = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + await runtime.appendRuntimeEvent(selected.id, 'run-image', { + id: 'call-event-image', + invocationId: 'invocation-image', + runId: 'run-image', + sessionId: selected.id, + turnId: 'turn-image', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'call-image', + name: 'Read', + args: { path: 'image.png' }, + }, + }); + await runtime.appendRuntimeEvent(selected.id, 'run-image', { + id: 'event-image', + invocationId: 'invocation-image', + runId: 'run-image', + sessionId: selected.id, + turnId: 'turn-image', + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-image', + name: 'Read', + result: { kind: 'image', mimeType: 'image/png', ref }, + modelProjection: { + version: 1, + kind: 'content', + parts: [{ kind: 'artifact', mediaType: 'image/png', ref }], + }, + }, + }); + } finally { + runtime.close(); + } + const close = async () => { + await writer.close(); + await owner.close(); + }; + t.after(close); + const imagePath = (data: Buffer) => { + const hash = createHash('sha256').update(data).digest('hex'); + return join('context-offload-values', 'sha256', hash.slice(0, 2), hash); + }; + return { + base, + root, + owner, + capability, + close, + bytes, + privateBytes, + inlinePrivate, + ref, + otherRef, + imagePath, + selected, + other, + }; +} + +test('offline backup restores context refs and verified managed bytes into an empty root', async (t) => { + const f = await fixture(t); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'busy') }), + /offline Storage Root/, + ); + await f.close(); + const backupRoot = join(f.base, 'backup'); + const manifest = await createOperationalStateBackup({ + stateRoot: f.root, + destinationRoot: backupRoot, + }); + assert.equal(manifest.schemaVersion, 4); + assert.ok(manifest.files.some((file) => file.path === 'context-offload.sqlite')); + const restored = join(f.base, 'restored'); + await restoreOperationalStateBackup({ backupRoot, destinationRoot: restored }); + const store = new SqliteContextOffloadStore(join(restored, 'context-offload.sqlite'), { limits }); + try { + const read = await store.read({ sessionId: f.selected.id, refId: f.ref.refId, maxBytes: 1024 }); + assert.equal(read.ok, true); + if (read.ok) assert.deepEqual(Buffer.from(read.bytes), f.bytes); + assert.equal((await store.usage()).references, 4); + assert.equal( + (await store.usage()).physicalBytes, + f.bytes.length + f.privateBytes.length + f.inlinePrivate.length, + ); + } finally { + store.close(); + } +}); + +test('single-Session export contains only its refs and shared payload once, with no private free-page bytes', async (t) => { + const f = await fixture(t); + const input = { + stateRoot: f.root, + configRoot: join(f.base, 'config'), + destinationRoot: join(f.base, 'bundle'), + sessionId: f.selected.id, + }; + await assert.rejects(exportSessionBundleState(input), /offline Storage Root/); + await f.close(); + const plan = await exportSessionBundleState(input); + assert.ok(plan.includedEntries.includes('context-offload.sqlite')); + const store = new SqliteContextOffloadStore( + join(input.destinationRoot, 'context-offload.sqlite'), + { limits }, + ); + try { + const read = await store.read({ sessionId: f.selected.id, refId: f.ref.refId, maxBytes: 1024 }); + assert.equal(read.ok, true); + if (read.ok) assert.deepEqual(Buffer.from(read.bytes), f.bytes); + assert.equal((await store.usage()).references, 1); + assert.equal((await store.usage()).physicalBytes, f.bytes.length); + assert.equal( + (await store.read({ sessionId: f.other.id, refId: f.otherRef.refId, maxBytes: 1024 })).ok, + false, + ); + } finally { + store.close(); + } + const database = await readFile(join(input.destinationRoot, 'context-offload.sqlite')); + assert.equal(database.includes(Buffer.from(f.other.id)), false); + assert.equal(database.includes(f.privateBytes), false); + assert.equal(database.includes(f.inlinePrivate), false); + await assert.rejects(readFile(join(input.destinationRoot, f.imagePath(f.privateBytes))), { + code: 'ENOENT', + }); +}); + +test('missing or corrupt context bytes never publish a successful backup', async (t) => { + const f = await fixture(t); + await f.close(); + const path = join(f.root, f.imagePath(f.bytes)); + await writeFile(path, Buffer.alloc(f.bytes.length)); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'corrupt') }), + /size\/hash mismatch/, + ); + await rm(path); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'missing') }), + ); + assert.deepEqual((await readdir(f.base)).sort(), ['source']); +}); + +test('restore rejects tampering even if the manifest is recomputed', async (t) => { + const f = await fixture(t); + await f.close(); + const backupRoot = join(f.base, 'backup'); + await createOperationalStateBackup({ stateRoot: f.root, destinationRoot: backupRoot }); + const path = f.imagePath(f.bytes).split('\\').join('/'); + const changed = Buffer.alloc(f.bytes.length); + await writeFile(join(backupRoot, path), changed); + const manifestPath = join(backupRoot, 'operational-backup.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const file = manifest.files.find((entry: { path: string }) => entry.path === path); + file.sha256 = 'sha256:' + createHash('sha256').update(changed).digest('hex'); + await writeFile(manifestPath, JSON.stringify(manifest)); + await assert.rejects( + restoreOperationalStateBackup({ backupRoot, destinationRoot: join(f.base, 'restore') }), + /size\/hash mismatch/, + ); +}); + +test('a missing context database with a durable image reference fails closed', async (t) => { + const f = await fixture(t); + await f.close(); + await rm(join(f.root, 'context-offload.sqlite')); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'backup') }), + /missing or cross-Session/, + ); + await assert.rejects( + exportSessionBundleState({ + stateRoot: f.root, + configRoot: join(f.base, 'config'), + destinationRoot: join(f.base, 'bundle'), + sessionId: f.selected.id, + }), + /missing or cross-Session/, + ); +}); + +test('validates typed projection refs without interpreting opaque JSON as storage refs', async (t) => { + const f = await fixture(t); + await f.close(); + const database = new DatabaseSync(join(f.root, 'runtime.sqlite')); + try { + const row = database + .prepare("SELECT payload_json FROM runtime_events WHERE event_id = 'event-image'") + .get()!; + const event = JSON.parse(String(row.payload_json)); + event.content.result = { + kind: 'json', + value: { + attachments: [{ ref: { kind: 'session_context', sessionId: 'fake', refId: 'opaque' } }], + }, + }; + database + .prepare("UPDATE runtime_events SET payload_json = ? WHERE event_id = 'event-image'") + .run(JSON.stringify(event)); + await createOperationalStateBackup({ + stateRoot: f.root, + destinationRoot: join(f.base, 'opaque'), + }); + event.content.modelProjection.parts[0].ref = f.otherRef; + database + .prepare("UPDATE runtime_events SET payload_json = ? WHERE event_id = 'event-image'") + .run(JSON.stringify(event)); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'foreign') }), + /cross-Session/, + ); + } finally { + database.close(); + } +}); + +test('offline snapshot authority blocks a new Host owner and releases after failure', async (t) => { + const f = await fixture(t); + await f.close(); + await assert.rejects( + withOfflineContextSnapshot(f.root, async (locked) => { + assert.equal(locked, true); + assert.equal(await tryAcquireInteractiveRootOwner(f.capability), undefined); + throw new Error('injected snapshot failure'); + }), + /injected snapshot failure/, + ); + const owner = await tryAcquireInteractiveRootOwner(f.capability); + assert.ok(owner); + await owner.close(); +}); + +test('context snapshot refuses a symlinked managed-value ancestor', async (t) => { + const f = await fixture(t); + await f.close(); + const values = join(f.root, 'context-offload-values'); + const outside = join(f.base, 'outside'); + const { rename } = await import('node:fs/promises'); + await rename(values, outside); + await symlink(outside, values, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects( + createOperationalStateBackup({ stateRoot: f.root, destinationRoot: join(f.base, 'backup') }), + /symlinks/, + ); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65932f1580159e63438031504db0442b1617ecb8f4511ec008aea2df7730f946.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65932f1580159e63438031504db0442b1617ecb8f4511ec008aea2df7730f946.source new file mode 100644 index 0000000000..df4094ef66 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65932f1580159e63438031504db0442b1617ecb8f4511ec008aea2df7730f946.source @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'node:path'; +import type { ArtifactRecord } from '@maka/core/artifacts'; +import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; + +export interface ArtifactMetadataChanges { + readonly upserts?: readonly ArtifactRecord[]; + readonly deleteIds?: readonly string[]; +} + +export function createSqliteArtifactMetadataRepository(workspaceRoot: string) { + return new SqliteArtifactMetadataRepository(workspaceRoot); +} + +class SqliteArtifactMetadataRepository { + readonly #lease: OperationalStateDatabaseLease; + #closed = false; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + } + + readAll(): ArtifactRecord[] { + this.assertOpen(); + const rows = this.#lease.database + .prepare(` + SELECT record_json + FROM artifact_records + ORDER BY created_at, artifact_id + `) + .all() as Array<{ record_json: string }>; + return decodeRows(rows); + } + + applyChanges(changes: ArtifactMetadataChanges): void { + this.assertOpen(); + this.#lease.transaction('write', () => { + const remove = this.#lease.database.prepare( + 'DELETE FROM artifact_records WHERE artifact_id = ?', + ); + for (const id of changes.deleteIds ?? []) remove.run(id); + + const upsert = this.#lease.database.prepare(` + INSERT INTO artifact_records( + artifact_id, + session_id, + created_at, + relative_path, + record_json + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + session_id = excluded.session_id, + created_at = excluded.created_at, + relative_path = excluded.relative_path, + record_json = excluded.record_json + WHERE session_id IS NOT excluded.session_id + OR created_at IS NOT excluded.created_at + OR relative_path IS NOT excluded.relative_path + OR record_json IS NOT excluded.record_json + `); + for (const record of changes.upserts ?? []) { + upsert.run( + record.id, + record.sessionId, + record.createdAt, + record.relativePath, + JSON.stringify(record), + ); + } + }); + } + + readUpgradeOrphanPaths(after: string, limit: number): string[] { + this.assertOpen(); + const rows = this.#lease.database + .prepare(`SELECT relative_path FROM artifact_upgrade_orphan_paths + WHERE relative_path > ? ORDER BY relative_path LIMIT ?`) + .all(after, limit) as Array<{ relative_path: string }>; + return rows.map((row) => row.relative_path); + } + + hasRelativePath(relativePath: string): boolean { + this.assertOpen(); + return Boolean( + this.#lease.database + .prepare('SELECT 1 FROM artifact_records WHERE relative_path = ?') + .get(relativePath), + ); + } + + readRelativePathsByCaseFoldedArtifactIds(artifactIds: readonly string[]): string[] { + this.assertOpen(); + if (artifactIds.length === 0) return []; + const placeholders = artifactIds.map(() => '?').join(', '); + const rows = this.#lease.database + .prepare( + `SELECT relative_path FROM artifact_records + WHERE artifact_id COLLATE NOCASE IN (${placeholders})`, + ) + .all(...artifactIds) as Array<{ relative_path: string }>; + return rows.map((row) => row.relative_path); + } + + forgetUpgradeOrphanPaths(relativePaths: readonly string[]): void { + this.assertOpen(); + this.#lease.transaction('write', () => { + const forget = this.#lease.database.prepare( + 'DELETE FROM artifact_upgrade_orphan_paths WHERE relative_path = ?', + ); + for (const relativePath of relativePaths) forget.run(relativePath); + }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#lease.close(); + } + + private assertOpen(): void { + if (this.#closed) throw new Error('Artifact metadata repository is closed'); + } +} + +function decodeRows(rows: readonly { record_json: string }[]): ArtifactRecord[] { + return decodeArtifactRecordJsons(rows.map((row) => row.record_json)); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65c368e22ce7610014a262af4ed3381ee647f76b32f9d7e987c19f14569ac35f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65c368e22ce7610014a262af4ed3381ee647f76b32f9d7e987c19f14569ac35f.source new file mode 100644 index 0000000000..b3b3091bf6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/65c368e22ce7610014a262af4ed3381ee647f76b32f9d7e987c19f14569ac35f.source @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { acquireProcessLifetimeOwner } from '../../process-lifetime-owner.js'; + +const root = process.argv[2]; +if (!root) throw new Error('Missing process lifetime owner root'); + +const owner = await acquireProcessLifetimeOwner(root); +process.send?.({ reference: owner.reference }); +await new Promise(() => setInterval(() => undefined, 1_000)); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/661a90f061b2d6359d0605b14d48509c912df4817620f9e64905edead4230cd5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/661a90f061b2d6359d0605b14d48509c912df4817620f9e64905edead4230cd5.source new file mode 100644 index 0000000000..9bd514a85f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/661a90f061b2d6359d0605b14d48509c912df4817620f9e64905edead4230cd5.source @@ -0,0 +1,888 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { + clientCapabilityScopeIdentity, + decodeClientCapabilitySessionGrant, + decodeClientCapabilitySessionGrantKey, + type ClientCapabilitySessionGrant, + type ClientCapabilitySessionGrantKey, +} from '@maka/core/client-capability-grant'; +import { + decodeInteractionCanonicalOutcome, + decodeInteractionRequest, + interactionCanonicalOutcomesEquivalent, + isInteractionCanonicalOutcomeValidForRequest, + projectInteractionFormRequest, + projectInteractionQuestionRequest, + type InteractionCanonicalOutcome, + type InteractionRequest, +} from '@maka/core/interaction'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { isSafeStorageId } from './storage-id.js'; + +const REMEMBER_SCOPE_ID = /^[0-9a-f]{64}$/; +export const STORED_INTERACTION_REQUEST_MAX_BYTES = 20 * 1024; +export const STORED_INTERACTION_OUTCOME_MAX_BYTES = 12 * 1024; +export const STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES = 12 * 1024; + +export interface InteractionIdentity { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly requestId: string; +} + +export interface StoredInteractionRequest extends InteractionIdentity { + readonly createdAt: number; + readonly request: InteractionRequest; + readonly rememberScopeId?: string; +} + +export interface StoredInteractionOutcome extends InteractionIdentity { + readonly outcome: InteractionCanonicalOutcome; +} + +export interface InteractionRecord { + readonly request: StoredInteractionRequest; + readonly outcome?: StoredInteractionOutcome; +} + +export interface PendingInteractionFilter { + readonly sessionId?: string; + readonly turnId?: string; + readonly runId?: string; + readonly kind?: InteractionRequest['kind']; +} + +export type InteractionStoreErrorCode = + | 'invalid_input' + | 'invalid_record' + | 'request_not_found' + | 'io_failed'; + +export class InteractionStoreError extends Error { + constructor( + readonly code: InteractionStoreErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'InteractionStoreError'; + } +} + +export type InteractionMutationFailureResult = + | { + readonly status: 'definitely_not_published'; + readonly failure: InteractionStoreError; + } + | { readonly status: 'unresolved'; readonly failure: InteractionStoreError }; + +export type EstablishInteractionRequestResult = + | { + readonly status: 'stable'; + readonly matches: boolean; + readonly record: InteractionRecord; + } + | InteractionMutationFailureResult; + +export type CommitInteractionOutcomeResult = + | { + readonly status: 'stable'; + readonly matches: boolean; + readonly record: InteractionRecord & { + readonly outcome: StoredInteractionOutcome; + }; + } + | InteractionMutationFailureResult; + +export interface InteractionStoreReader { + readInteraction(requestId: string): Promise; + listSessionPending(sessionId: string): Promise; + listPending(filter?: PendingInteractionFilter): Promise; + readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): Promise; +} + +export interface InteractionStoreWriter extends InteractionStoreReader { + establishRequest(input: StoredInteractionRequest): Promise; + commitOutcome( + requestId: string, + outcome: InteractionCanonicalOutcome, + ): Promise; + commitClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + ): Promise; + commitClientCapabilityOutcome( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ): Promise; +} + +export interface InteractiveInteractionStoreReaderFacade extends InteractionStoreReader { + readonly kind: 'interactive'; + readonly access: 'read'; +} + +export interface InteractiveInteractionStoreWriterFacade extends InteractionStoreWriter { + readonly kind: 'interactive'; + readonly access: 'write'; +} + +const readers = new WeakSet(); +const writers = new WeakSet(); +const sqliteWritersByLease = new WeakMap(); +const sqliteWriterOpeningsByLease = new WeakMap< + object, + Promise +>(); +const sqliteFacadeClosers = new WeakMap void>(); + +export function authenticateInteractionStoreReader( + store: InteractiveInteractionStoreReaderFacade, +): InteractiveInteractionStoreReaderFacade { + if (!readers.has(store)) throw invalidFacade('read'); + return store; +} + +export function authenticateInteractionStoreWriter( + store: InteractiveInteractionStoreWriterFacade, +): InteractiveInteractionStoreWriterFacade { + if (!writers.has(store)) throw invalidFacade('write'); + return store; +} + +export async function openSqliteInteractiveInteractionStoreForRead( + lease: StorageRootLease<'interactive', 'read'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'read'); + const store = new SqliteInteractionStore(lease.canonicalPath); + await store.ready(); + const run = (operation: () => Promise) => + runWithStorageRootLease(lease, 'interactive', 'read', operation); + const facade = Object.freeze({ + kind: 'interactive' as const, + access: 'read' as const, + readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), + listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), + listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), + readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => + run(() => store.readClientCapabilitySessionGrant(key)), + }); + readers.add(facade); + sqliteFacadeClosers.set(facade, () => store.close()); + return facade; +} + +export async function openSqliteInteractiveInteractionStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = sqliteWritersByLease.get(lease); + if (existing) return existing; + const opening = sqliteWriterOpeningsByLease.get(lease); + if (opening) return opening; + const pending = Promise.resolve().then(async () => { + const store = new SqliteInteractionStore(lease.canonicalPath); + await store.ready(); + const run = (operation: () => Promise) => + runWithStorageRootLease(lease, 'interactive', 'write', operation); + const recoveredExisting = sqliteWritersByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const facade = Object.freeze({ + kind: 'interactive' as const, + access: 'write' as const, + readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)), + listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)), + listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)), + readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) => + run(() => store.readClientCapabilitySessionGrant(key)), + establishRequest: (input: StoredInteractionRequest) => + run(() => store.establishRequest(input)), + commitOutcome: (requestId: string, outcome: InteractionCanonicalOutcome) => + run(() => store.commitOutcome(requestId, outcome)), + commitClientCapabilitySessionGrant: (grant: ClientCapabilitySessionGrant) => + run(() => store.commitClientCapabilitySessionGrant(grant)), + commitClientCapabilityOutcome: ( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ) => run(() => store.commitClientCapabilityOutcome(requestId, outcome, grant)), + }); + writers.add(facade); + sqliteWritersByLease.set(lease, facade); + sqliteFacadeClosers.set(facade, () => store.close()); + return facade; + }); + sqliteWriterOpeningsByLease.set(lease, pending); + try { + return await pending; + } finally { + if (sqliteWriterOpeningsByLease.get(lease) === pending) { + sqliteWriterOpeningsByLease.delete(lease); + } + } +} + +export function closeSqliteInteractionStoreFacade( + store: InteractiveInteractionStoreReaderFacade | InteractiveInteractionStoreWriterFacade, +): void { + const close = sqliteFacadeClosers.get(store); + if (!close) return; + sqliteFacadeClosers.delete(store); + close(); +} + +class SqliteInteractionStore implements InteractionStoreWriter { + readonly #lease: OperationalStateDatabaseLease; + + constructor(root: string) { + this.#lease = acquireOperationalStateDatabase(resolve(root)); + } + + ready(): Promise { + return Promise.resolve(); + } + + async establishRequest( + input: StoredInteractionRequest, + ): Promise { + const candidate = normalizeRequest(input, 'input'); + const encoded = encode(candidate, STORED_INTERACTION_REQUEST_MAX_BYTES).toString('utf8').trim(); + try { + return this.#lease.transaction('write', () => { + const existing = readSqliteInteraction(this.#lease, candidate.requestId); + if (existing) { + return { + status: 'stable', + matches: isDeepStrictEqual(existing.request, candidate), + record: existing, + }; + } + this.#lease.database + .prepare(` + INSERT INTO core_interaction_requests( + request_id, session_id, turn_id, run_id, request_kind, created_at, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + .run( + candidate.requestId, + candidate.sessionId, + candidate.turnId, + candidate.runId, + candidate.request.kind, + candidate.createdAt, + encoded, + ); + return { + status: 'stable', + matches: true, + record: deepFreeze({ request: candidate }), + }; + }); + } catch (error) { + return { + status: 'unresolved', + failure: failure(error, 'Request publication could not be stabilized'), + }; + } + } + + async commitOutcome( + requestId: string, + outcome: InteractionCanonicalOutcome, + ): Promise { + assertId(requestId); + return this.#lease.transaction('write', () => { + const record = readSqliteInteraction(this.#lease, requestId); + if (!record) { + throw new InteractionStoreError( + 'request_not_found', + `Interaction request '${requestId}' does not exist`, + ); + } + let canonical: InteractionCanonicalOutcome; + try { + canonical = decodeInteractionCanonicalOutcome(outcome); + } catch (error) { + decodeFailure('input', 'Invalid Interaction outcome', error); + } + if (!isInteractionCanonicalOutcomeValidForRequest(record.request.request, canonical)) { + throw new InteractionStoreError('invalid_input', 'Outcome is not valid for its request'); + } + const candidate: StoredInteractionOutcome = { + ...identity(record.request), + outcome: canonical, + }; + const encoded = encode(candidate, STORED_INTERACTION_OUTCOME_MAX_BYTES) + .toString('utf8') + .trim(); + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_interaction_outcomes(request_id, record_json) + VALUES (?, ?) + `) + .run(requestId, encoded); + const settled = readSqliteInteraction(this.#lease, requestId); + if (!settled?.outcome) { + throw new InteractionStoreError('io_failed', 'Outcome publication produced no record'); + } + return { + status: 'stable', + matches: interactionCanonicalOutcomesEquivalent(settled.outcome.outcome, canonical), + record: settled as InteractionRecord & { readonly outcome: StoredInteractionOutcome }, + }; + }); + } + + async commitClientCapabilityOutcome( + requestId: string, + outcome: Extract< + InteractionCanonicalOutcome, + { kind: 'client_capability_decision' | 'closure' } + >, + grant?: ClientCapabilitySessionGrant, + ): Promise { + assertId(requestId); + return this.#lease.transaction('write', () => { + const record = readSqliteInteraction(this.#lease, requestId); + if (!record) { + throw new InteractionStoreError( + 'request_not_found', + `Interaction request '${requestId}' does not exist`, + ); + } + if (record.request.request.kind !== 'client_capability') { + throw new InteractionStoreError( + 'invalid_input', + 'Client Capability outcome requires a Client Capability request', + ); + } + const canonical = decodeClientCapabilityOutcome(outcome); + if (!isInteractionCanonicalOutcomeValidForRequest(record.request.request, canonical)) { + throw new InteractionStoreError('invalid_input', 'Outcome is not valid for its request'); + } + const candidateGrant = grant === undefined ? undefined : decodeGrant(grant, 'input'); + const shouldGrant = + canonical.kind === 'client_capability_decision' && canonical.decision === 'allow'; + if (shouldGrant !== (candidateGrant !== undefined)) { + throw new InteractionStoreError( + 'invalid_input', + 'Allowed Client Capability outcome requires exactly one Session Grant', + ); + } + if ( + candidateGrant && + (!isDeepStrictEqual(decodeGrantKey(candidateGrant, 'input'), { + sessionId: record.request.sessionId, + ...record.request.request.target, + }) || + candidateGrant.grantedAt !== canonical.committedAt) + ) { + throw new InteractionStoreError( + 'invalid_input', + 'Client Capability Session Grant does not match its Interaction request', + ); + } + const candidate: StoredInteractionOutcome = { + ...identity(record.request), + outcome: canonical, + }; + const encoded = encode(candidate, STORED_INTERACTION_OUTCOME_MAX_BYTES) + .toString('utf8') + .trim(); + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_interaction_outcomes(request_id, record_json) + VALUES (?, ?) + `) + .run(requestId, encoded); + const settled = readSqliteInteraction(this.#lease, requestId); + if (!settled?.outcome) { + throw new InteractionStoreError('io_failed', 'Outcome publication produced no record'); + } + const matches = interactionCanonicalOutcomesEquivalent(settled.outcome.outcome, canonical); + if (matches && candidateGrant) this.#commitClientCapabilitySessionGrant(candidateGrant); + return { + status: 'stable', + matches, + record: settled as InteractionRecord & { readonly outcome: StoredInteractionOutcome }, + }; + }); + } + + async readInteraction(requestId: string): Promise { + assertId(requestId); + return readSqliteInteraction(this.#lease, requestId); + } + + async listSessionPending(sessionId: string): Promise { + return this.listPending({ sessionId }); + } + + async listPending(filter: PendingInteractionFilter = {}): Promise { + normalizeFilter(filter); + const rows = this.#lease.database + .prepare(` + SELECT request_id + FROM core_interaction_requests AS request + WHERE NOT EXISTS ( + SELECT 1 FROM core_interaction_outcomes AS outcome + WHERE outcome.request_id = request.request_id + ) + ORDER BY request.created_at, request.request_id + `) + .all() as Array<{ request_id?: unknown }>; + const requests: StoredInteractionRequest[] = []; + for (const row of rows) { + if (typeof row.request_id !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid SQLite Interaction identity'); + } + const record = readSqliteInteraction(this.#lease, row.request_id); + if (record && !record.outcome && matches(record.request, filter)) { + requests.push(record.request); + } + } + return sortPending(requests); + } + + async readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): Promise { + const candidate = decodeGrantKey(key, 'input'); + const scope = clientCapabilityScopeIdentity(candidate.scope); + const row = this.#lease.database + .prepare(` + SELECT record_json + FROM core_client_capability_session_grants + WHERE session_id = ? + AND provider_id = ? + AND contract_id = ? + AND capability = ? + AND scope_kind = ? + AND scope_value = ? + `) + .get( + candidate.sessionId, + candidate.providerId, + candidate.contractId, + candidate.capability, + candidate.scope.kind, + scope, + ) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); + } + const grant = decodeGrant( + parseJsonRecord(row.record_json, 'Client Capability Session Grant'), + 'record', + ); + if (!sameGrantAuthority(decodeGrantKey(grant, 'record'), candidate)) { + throw new InteractionStoreError( + 'invalid_record', + 'Client Capability Session Grant identity does not match row', + ); + } + return deepFreeze(grant); + } + + async commitClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + ): Promise { + const candidate = decodeGrant(grant, 'input'); + const scope = clientCapabilityScopeIdentity(candidate.scope); + const encoded = encode(candidate, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES) + .toString('utf8') + .trim(); + return this.#lease.transaction('write', () => { + this.#insertClientCapabilitySessionGrant(candidate, scope, encoded); + const stored = this.#readClientCapabilitySessionGrant(candidate); + if (!stored) { + throw new InteractionStoreError( + 'io_failed', + 'Client Capability Session Grant publication produced no record', + ); + } + return stored; + }); + } + + #commitClientCapabilitySessionGrant(grant: ClientCapabilitySessionGrant): void { + const scope = clientCapabilityScopeIdentity(grant.scope); + const encoded = encode(grant, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES) + .toString('utf8') + .trim(); + this.#insertClientCapabilitySessionGrant(grant, scope, encoded); + } + + #insertClientCapabilitySessionGrant( + grant: ClientCapabilitySessionGrant, + scope: string, + encoded: string, + ): void { + this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_client_capability_session_grants( + session_id, provider_id, contract_id, server_id, tool_name, + capability, scope_kind, scope_value, granted_at, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + grant.sessionId, + grant.providerId, + grant.contractId, + grant.serverId, + grant.toolName, + grant.capability, + grant.scope.kind, + scope, + grant.grantedAt, + encoded, + ); + } + + #readClientCapabilitySessionGrant( + key: ClientCapabilitySessionGrantKey, + ): ClientCapabilitySessionGrant | undefined { + const scope = clientCapabilityScopeIdentity(key.scope); + const row = this.#lease.database + .prepare(` + SELECT record_json + FROM core_client_capability_session_grants + WHERE session_id = ? AND provider_id = ? AND contract_id = ? + AND capability = ? + AND scope_kind = ? AND scope_value = ? + `) + .get(key.sessionId, key.providerId, key.contractId, key.capability, key.scope.kind, scope) as + | { record_json?: unknown } + | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant'); + } + return deepFreeze( + decodeGrant(parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record'), + ); + } + + close(): void { + this.#lease.close(); + } +} + +function sameGrantAuthority( + left: ClientCapabilitySessionGrantKey, + right: ClientCapabilitySessionGrantKey, +): boolean { + return ( + left.sessionId === right.sessionId && + left.providerId === right.providerId && + left.contractId === right.contractId && + left.capability === right.capability && + left.scope.kind === right.scope.kind && + clientCapabilityScopeIdentity(left.scope) === clientCapabilityScopeIdentity(right.scope) + ); +} + +function readSqliteInteraction( + lease: Pick, + requestId: string, +): InteractionRecord | undefined { + const row = lease.database + .prepare(` + SELECT request.record_json AS request_json, outcome.record_json AS outcome_json + FROM core_interaction_requests AS request + LEFT JOIN core_interaction_outcomes AS outcome ON outcome.request_id = request.request_id + WHERE request.request_id = ? + `) + .get(requestId) as { request_json?: unknown; outcome_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.request_json !== 'string') { + throw new InteractionStoreError('invalid_record', 'Invalid SQLite Interaction request'); + } + const request = normalizeRequest(JSON.parse(row.request_json), 'record'); + if (request.requestId !== requestId) { + throw new InteractionStoreError('invalid_record', 'Request identity does not match row'); + } + const outcome = + row.outcome_json === null || row.outcome_json === undefined + ? undefined + : typeof row.outcome_json === 'string' + ? normalizeOutcome(JSON.parse(row.outcome_json), request) + : decodeFailure('record', 'Invalid SQLite Interaction outcome'); + return deepFreeze({ request, ...(outcome ? { outcome } : {}) }); +} + +function sortPending(requests: StoredInteractionRequest[]): StoredInteractionRequest[] { + return requests.sort( + (a, b) => a.createdAt - b.createdAt || a.requestId.localeCompare(b.requestId), + ); +} + +type DecodeSource = 'input' | 'record'; + +function normalizeRequest(value: unknown, source: DecodeSource): StoredInteractionRequest { + const record = closedRecord( + value, + ['sessionId', 'turnId', 'runId', 'requestId', 'createdAt', 'request'], + ['rememberScopeId'], + source, + ); + const createdAt = record.createdAt; + if (!Number.isSafeInteger(createdAt) || (createdAt as number) < 0) + decodeFailure(source, 'createdAt must be a non-negative safe integer'); + let request: InteractionRequest; + try { + request = decodeInteractionRequest(record.request); + if (request.kind === 'question') { + const canonical = projectInteractionQuestionRequest({ + toolUseId: request.toolUseId, + questions: request.questions, + }); + if (!isDeepStrictEqual(request, canonical)) + decodeFailure(source, 'Interaction question request is not canonical safe text'); + request = canonical; + } else if (request.kind === 'form') { + const canonical = projectInteractionFormRequest({ + toolUseId: request.toolUseId, + message: request.message, + requester: request.requester, + fields: request.fields, + }); + if (!isDeepStrictEqual(request, canonical)) { + decodeFailure(source, 'Interaction form request is not canonical'); + } + request = canonical; + } + } catch (error) { + if (error instanceof InteractionStoreError) throw error; + decodeFailure(source, 'Invalid Interaction request', error); + } + const rememberScopeId = + record.rememberScopeId === undefined + ? undefined + : assertRememberScopeId(record.rememberScopeId, source); + if (rememberScopeId !== undefined && !isRememberScopeEligible(request)) + decodeFailure(source, 'rememberScopeId requires a rememberable tool permission request'); + return { + sessionId: assertId(record.sessionId, source), + turnId: assertId(record.turnId, source), + runId: assertId(record.runId, source), + requestId: assertId(record.requestId, source), + createdAt: createdAt as number, + request, + ...(rememberScopeId === undefined ? {} : { rememberScopeId }), + }; +} + +function normalizeOutcome( + value: unknown, + request: StoredInteractionRequest, +): StoredInteractionOutcome { + const record = closedRecord( + value, + ['sessionId', 'turnId', 'runId', 'requestId', 'outcome'], + [], + 'record', + ); + const storedIdentity: InteractionIdentity = { + sessionId: assertId(record.sessionId, 'record'), + turnId: assertId(record.turnId, 'record'), + runId: assertId(record.runId, 'record'), + requestId: assertId(record.requestId, 'record'), + }; + if (!isDeepStrictEqual(storedIdentity, identity(request))) + throw new InteractionStoreError('invalid_record', 'Outcome identity does not match request'); + let outcome: InteractionCanonicalOutcome; + try { + outcome = decodeInteractionCanonicalOutcome(record.outcome); + } catch (error) { + decodeFailure('record', 'Invalid stored Interaction outcome', error); + } + if (!isInteractionCanonicalOutcomeValidForRequest(request.request, outcome)) + throw new InteractionStoreError('invalid_record', 'Stored outcome is invalid for request'); + return { ...identity(request), outcome }; +} + +function decodeGrant(value: unknown, source: DecodeSource): ClientCapabilitySessionGrant { + try { + return decodeClientCapabilitySessionGrant(value); + } catch (error) { + decodeFailure(source, 'Invalid Client Capability Session Grant', error); + } +} + +function decodeGrantKey(value: unknown, source: DecodeSource): ClientCapabilitySessionGrantKey { + try { + return decodeClientCapabilitySessionGrantKey(value); + } catch (error) { + decodeFailure(source, 'Invalid Client Capability Session Grant key', error); + } +} + +function decodeClientCapabilityOutcome( + value: unknown, +): Extract { + let outcome: InteractionCanonicalOutcome; + try { + outcome = decodeInteractionCanonicalOutcome(value); + } catch (error) { + decodeFailure('input', 'Invalid Client Capability Interaction outcome', error); + } + if (outcome.kind !== 'client_capability_decision' && outcome.kind !== 'closure') { + decodeFailure('input', 'Invalid Client Capability Interaction outcome kind'); + } + return outcome; +} + +function identity(value: InteractionIdentity): InteractionIdentity { + return { + sessionId: value.sessionId, + turnId: value.turnId, + runId: value.runId, + requestId: value.requestId, + }; +} +function assertId( + value: unknown, + source: DecodeSource = 'input', + message = 'Invalid Interaction identity', +): string { + if (!isSafeStorageId(value)) decodeFailure(source, message); + return value; +} + +function assertRememberScopeId(value: unknown, source: DecodeSource): string { + if (typeof value !== 'string' || !REMEMBER_SCOPE_ID.test(value)) + decodeFailure(source, 'rememberScopeId must be a lowercase 64-character SHA-256 digest'); + return value; +} + +function isRememberScopeEligible(request: InteractionRequest): boolean { + return ( + request.kind === 'permission' && + request.prompt.kind === 'tool_permission' && + request.prompt.rememberForTurnAllowed + ); +} + +function closedRecord( + value: unknown, + required: readonly string[], + optional: readonly string[], + source: DecodeSource, +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + decodeFailure(source, 'Stored Interaction request must be a plain object'); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + decodeFailure(source, 'Stored Interaction request must be a plain object'); + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if ( + Reflect.ownKeys(record).some((key) => { + if (typeof key !== 'string' || !allowed.has(key)) return true; + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor === undefined || !('value' in descriptor); + }) || + required.some((key) => !Object.hasOwn(record, key)) + ) + decodeFailure(source, 'Stored Interaction request has invalid fields'); + return record; +} + +function parseJsonRecord(serialized: string, context: string): unknown { + try { + return JSON.parse(serialized); + } catch (error) { + throw new InteractionStoreError('invalid_record', `Invalid stored Interaction ${context}`, { + cause: error, + }); + } +} + +function decodeFailure(source: DecodeSource, message: string, cause?: unknown): never { + throw new InteractionStoreError( + source === 'input' ? 'invalid_input' : 'invalid_record', + message, + { + cause, + }, + ); +} +function encode(value: unknown, limit: number): Buffer { + const bytes = Buffer.from(`${JSON.stringify(value)}\n`); + if (bytes.length > limit) + throw new InteractionStoreError('invalid_input', 'Interaction document exceeds size limit'); + return bytes; +} +function failure(error: unknown, message: string): InteractionStoreError { + return error instanceof InteractionStoreError + ? error + : new InteractionStoreError('io_failed', message, { cause: error }); +} +function normalizeFilter(filter: PendingInteractionFilter): void { + for (const value of [filter.sessionId, filter.turnId, filter.runId]) + if (value !== undefined) assertId(value); +} +function matches(request: StoredInteractionRequest, filter: PendingInteractionFilter): boolean { + return ( + (filter.sessionId === undefined || filter.sessionId === request.sessionId) && + (filter.turnId === undefined || filter.turnId === request.turnId) && + (filter.runId === undefined || filter.runId === request.runId) && + (filter.kind === undefined || filter.kind === request.request.kind) + ); +} +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + return value; +} + +function invalidFacade(access: 'read' | 'write'): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + `Expected authentic interactive ${access} Interaction Store`, + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6639bdf8a8b878b7838782cbc99bf35796f08df75d7e3b8aecf6d68cef35eb00.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6639bdf8a8b878b7838782cbc99bf35796f08df75d7e3b8aecf6d68cef35eb00.source new file mode 100644 index 0000000000..4f2db39c18 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6639bdf8a8b878b7838782cbc99bf35796f08df75d7e3b8aecf6d68cef35eb00.source @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readdirSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const [root, resultPath] = process.argv.slice(2); +if (root === undefined || resultPath === undefined) process.exit(2); + +process.stdout.write('ready\n'); +const deadline = Date.now() + 10_000; +while (Date.now() < deadline) { + const name = readdirSync(root).find((candidate) => + candidate.includes('.maka-session-bundle-pack-'), + ); + if (name === undefined) continue; + const temporaryPath = join(root, name); + const capturedPath = `${temporaryPath}.captured`; + try { + renameSync(temporaryPath, capturedPath); + writeFileSync(temporaryPath, 'EVIL'); + writeFileSync(resultPath, JSON.stringify({ capturedPath, temporaryPath })); + process.exit(0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} +process.exit(3); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/666276a97a577f5026adde38e873776ba3ca048e69a7ce7ab1bc35550f07f7c2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/666276a97a577f5026adde38e873776ba3ca048e69a7ce7ab1bc35550f07f7c2.source new file mode 100644 index 0000000000..ea07460294 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/666276a97a577f5026adde38e873776ba3ca048e69a7ce7ab1bc35550f07f7c2.source @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { + CONFIG_TRANSFER_SCHEMA_VERSION, + buildConfigBundle, + parseConfigBundle, + planConnectionMerge, + serializeConfigBundle, +} from '../config-transfer.js'; + +function conn(slug: string, extra: Partial = {}): LlmConnection { + return { + slug, + name: slug, + providerType: 'deepseek', + defaultModel: 'deepseek-v4-pro', + enabled: true, + createdAt: 1, + updatedAt: 1, + ...extra, + }; +} + +describe('config-transfer', () => { + it('records only the selected categories in includedData and round-trips', () => { + const bundle = buildConfigBundle({ + appVersion: '0.1.0', + data: { + connections: [conn('deepseek-main')], + settings: { theme: 'dark' }, + }, + now: () => new Date('2026-07-02T00:00:00.000Z'), + }); + assert.equal(bundle.schemaVersion, CONFIG_TRANSFER_SCHEMA_VERSION); + assert.deepEqual(bundle.includedData, ['connections', 'settings']); + assert.equal(bundle.exportedAt, '2026-07-02T00:00:00.000Z'); + + const parsed = parseConfigBundle(serializeConfigBundle(bundle)); + assert.ok(parsed.ok); + assert.deepEqual(parsed.bundle.includedData, ['connections', 'settings']); + assert.deepEqual(parsed.bundle.data.connections, [conn('deepseek-main')]); + assert.deepEqual(parsed.bundle.data.settings, { theme: 'dark' }); + assert.equal(parsed.bundle.data.memory, undefined); + }); + + it('carries credentials only when the user opted into that category', () => { + const withCreds = buildConfigBundle({ + appVersion: '0.1.0', + data: { credentials: [{ slug: 'deepseek-main', kind: 'api_key', value: 'sk-real' }] }, + }); + assert.deepEqual(withCreds.includedData, ['credentials']); + const parsed = parseConfigBundle(serializeConfigBundle(withCreds)); + assert.ok(parsed.ok); + assert.deepEqual(parsed.bundle.data.credentials, [ + { slug: 'deepseek-main', kind: 'api_key', value: 'sk-real' }, + ]); + }); + + it('drops a credentials payload that is not declared in includedData', () => { + // Hand-edited / mislabeled file: data has credentials but manifest omits it. + const raw = JSON.stringify({ + schemaVersion: 1, + includedData: ['connections'], + data: { + connections: [conn('deepseek-main')], + credentials: [{ slug: 'x', kind: 'api_key', value: 'sk-should-not-import' }], + }, + }); + const parsed = parseConfigBundle(raw); + assert.ok(parsed.ok); + assert.equal(parsed.bundle.data.credentials, undefined); + assert.ok(!parsed.bundle.includedData.includes('credentials')); + assert.ok(!serializeConfigBundle(parsed.bundle).includes('sk-should-not-import')); + }); + + it('fails closed on an unknown schema version', () => { + const raw = JSON.stringify({ schemaVersion: 999, includedData: [], data: {} }); + const parsed = parseConfigBundle(raw); + assert.equal(parsed.ok, false); + assert.equal(parsed.ok === false && parsed.reason, 'unsupported_version'); + }); + + it('rejects non-JSON and malformed payloads', () => { + assert.equal((parseConfigBundle('not json') as { reason: string }).reason, 'not_json'); + assert.equal( + (parseConfigBundle(JSON.stringify({ schemaVersion: 1, data: {} })) as { reason: string }) + .reason, + 'malformed', + 'missing includedData is malformed', + ); + assert.equal( + ( + parseConfigBundle( + JSON.stringify({ schemaVersion: 1, includedData: ['bogus'], data: {} }), + ) as { reason: string } + ).reason, + 'malformed', + 'unknown category in includedData is malformed', + ); + }); + + it('plans connection merges with skip vs overwrite conflict strategies', () => { + const existing = [conn('a'), conn('b')]; + const incoming = [conn('a', { name: 'A-new' }), conn('c')]; + + const skip = planConnectionMerge(existing, incoming, 'skip'); + assert.deepEqual(skip.skipped, [{ slug: 'a', reason: 'exists' }]); + assert.deepEqual( + skip.create.map((c) => c.slug), + ['c'], + ); + assert.equal(skip.overwrite.length, 0); + + const overwrite = planConnectionMerge(existing, incoming, 'overwrite'); + assert.deepEqual( + overwrite.overwrite.map((c) => c.slug), + ['a'], + ); + assert.deepEqual( + overwrite.create.map((c) => c.slug), + ['c'], + ); + assert.equal(overwrite.skipped.length, 0); + }); + + it('de-dupes repeated slugs within the imported set', () => { + const plan = planConnectionMerge([], [conn('x'), conn('x'), conn('y')], 'skip'); + assert.deepEqual( + plan.create.map((c) => c.slug), + ['x', 'y'], + ); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/66dbb381f656aeefac2a6d656264f23e499e00ba6f05254cdadfbbd0199ea795.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/66dbb381f656aeefac2a6d656264f23e499e00ba6f05254cdadfbbd0199ea795.source new file mode 100644 index 0000000000..7b29ba6c3e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/66dbb381f656aeefac2a6d656264f23e499e00ba6f05254cdadfbbd0199ea795.source @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { assertSafeStorageId, isSafeStorageId } from '../storage-id.js'; + +test('accepts the complete safe storage identifier boundary', () => { + assert.equal(isSafeStorageId('a'), true); + assert.equal(isSafeStorageId('A0_-'), true); + assert.equal(isSafeStorageId('x'.repeat(128)), true); +}); + +test('rejects unsafe storage identifier values', () => { + for (const value of [ + '', + 'x'.repeat(129), + ' leading', + 'trailing ', + 'nested/path', + 'punctuation.', + undefined, + null, + 42, + {}, + ]) { + assert.equal(isSafeStorageId(value), false, `expected rejection for ${String(value)}`); + } + assert.throws(() => assertSafeStorageId('bad/id', 'custom storage id error'), { + name: 'TypeError', + message: 'custom storage id error', + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/675ce68b42b8ba76f109a6ef9249bd90c750c18b2088b034e8288da1cac979b6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/675ce68b42b8ba76f109a6ef9249bd90c750c18b2088b034e8288da1cac979b6.source new file mode 100644 index 0000000000..285e04413a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/675ce68b42b8ba76f109a6ef9249bd90c750c18b2088b034e8288da1cac979b6.source @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + PricingConfig, + UsageBucket, + UsageGroupBy, + UsageLogRow, + UsageQuery, + UsageSummaryV2, +} from '@maka/core/usage-stats/types'; +import { + type PersistedLlmCallRecord, + type PersistedToolInvocationRecord, +} from './telemetry-file-schema.js'; + +export type { + PersistedLlmCallRecord, + PersistedToolInvocationRecord, +} from './telemetry-file-schema.js'; + +export interface ToolUsageQuery { + readonly range: UsageQuery['range']; + readonly toolName?: string; + readonly status?: UsageQuery['status']; +} + +export interface TelemetryRepo { + insertLlmCall(record: PersistedLlmCallRecord): Promise; + insertToolInvocation(record: PersistedToolInvocationRecord): Promise; + summary(query: UsageQuery): UsageSummaryV2; + /** + * Session- and range-scoped totals over the tool-invocation ledger — the one + * aggregate the LLM summary cannot answer, because tool executions live in + * their own table with no canonical counterpart to merge. + */ + toolSummary(query: UsageQuery): { requests: number; durationMs: number }; + buckets(query: UsageQuery, groupBy: UsageGroupBy): UsageBucket[]; + logs(query: UsageQuery, offset?: number, limit?: number): { rows: UsageLogRow[]; total: number }; + toolLogs( + query: ToolUsageQuery, + offset?: number, + limit?: number, + ): { rows: PersistedToolInvocationRecord[]; total: number }; + latestLlmRuntimeProbe(connectionSlug: string, modelId?: string): UsageLogRow | undefined; + listPricingOverrides(): PricingConfig[]; + upsertPricing(pricing: PricingConfig): Promise; + deletePricing(modelKey: string): Promise; + load(): Promise; + flush(): Promise; + close(): Promise; +} + +export interface CreateTelemetryRepoOptions { + readonly createIfMissing?: boolean; + readonly managePricing?: boolean; +} + +export class TelemetryRepoClosedError extends Error { + constructor() { + super('Telemetry repository is draining or closed'); + this.name = 'TelemetryRepoClosedError'; + } +} + +export class TelemetryRepoNotLoadedError extends Error { + constructor() { + super('Telemetry repository has not been loaded'); + this.name = 'TelemetryRepoNotLoadedError'; + } +} + +export class TelemetryQueryValidationError extends Error { + constructor(message: string) { + super(`Invalid telemetry query: ${message}`); + this.name = 'TelemetryQueryValidationError'; + } +} + +export class TelemetryRepoPublicationError extends Error { + readonly domain = 'telemetry_authority'; + + constructor( + readonly commitUnknown: boolean, + options: { cause: unknown }, + ) { + super( + commitUnknown + ? 'Telemetry publication outcome is unknown; reopen before retrying' + : 'Unable to publish telemetry', + options, + ); + this.name = 'TelemetryRepoPublicationError'; + } +} + +export function resolveRange(range: UsageQuery['range']): { from: number; to: number } { + if (typeof range === 'object') return range; + const now = Date.now(); + switch (range) { + case '24h': + return { from: now - 24 * 60 * 60 * 1000, to: now }; + case '7d': + return { from: now - 7 * 24 * 60 * 60 * 1000, to: now }; + case '30d': + return { from: now - 30 * 24 * 60 * 60 * 1000, to: now }; + case 'all': + return { from: 0, to: now }; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6815ab75d190954e6d19490c6980185561bfaea64cc2ea227149e2a6ed1d509b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6815ab75d190954e6d19490c6980185561bfaea64cc2ea227149e2a6ed1d509b.source new file mode 100644 index 0000000000..c5aa7c9048 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6815ab75d190954e6d19490c6980185561bfaea64cc2ea227149e2a6ed1d509b.source @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeAgentRunEvent as decodeCanonicalAgentRunEvent, + type AgentRunEvent, +} from '@maka/core/agent-run'; + +import { + decodeRuntimeEvent as decodeCanonicalRuntimeEvent, + type RuntimeEvent, +} from '@maka/core/runtime-event'; + +import { + decodeStoredMessage as decodePersistedStoredMessage, + type StoredMessage, +} from '@maka/core/session'; +import { markPersisted } from '@maka/core/persisted-value'; + +export function decodeStoredMessage(value: unknown): StoredMessage { + return decodePersistedStoredMessage(markPersisted(value)); +} + +export function decodeAgentRunEvent( + value: unknown, + expected: { sessionId: string; runId: string; turnId: string }, +): AgentRunEvent { + const event = decodeCanonicalAgentRunEvent(value); + if ( + event.sessionId !== expected.sessionId || + event.runId !== expected.runId || + event.turnId !== expected.turnId + ) { + throw new Error('AgentRun event identity does not match its run'); + } + return event; +} + +export function decodeRuntimeEvent( + value: unknown, + expected: { sessionId: string; runId: string; turnId: string; invocationId?: string }, +): RuntimeEvent { + const event = decodeCanonicalRuntimeEvent(value); + if ( + event.sessionId !== expected.sessionId || + event.runId !== expected.runId || + event.turnId !== expected.turnId || + (expected.invocationId !== undefined && event.invocationId !== expected.invocationId) + ) { + throw new Error('RuntimeEvent identity does not match its run'); + } + return event; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/68d2881c319452721d336b8aadf67943ecc3814d7b2a3a31bf144c6ceeb5748d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/68d2881c319452721d336b8aadf67943ecc3814d7b2a3a31bf144c6ceeb5748d.source new file mode 100644 index 0000000000..15c4a5dea3 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/68d2881c319452721d336b8aadf67943ecc3814d7b2a3a31bf144c6ceeb5748d.source @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + AbandonPlanProposalInput, + ApprovePlanProposalInput, + CancelPlanExecutionInput, + PlanStore, + RequestPlanRevisionInput, + SubmitPlanProposalInput, + UpdatePlanExecutionInput, +} from '@maka/core/plan'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { createSqlitePlanStore, type SqlitePlanStore } from './plan-store.js'; + +const writerBrand: unique symbol = Symbol('InteractivePlanStoreWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractivePlanStoreWriter extends PlanStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + purgeSessionState(sessionId: string): Promise; + close(): void; +} + +export function authenticateInteractivePlanStoreWriter( + writer: InteractivePlanStoreWriter, +): InteractivePlanStoreWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive Plan Store writer', + ); + } + return writer; +} + +export async function openInteractivePlanStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let store: SqlitePlanStore | undefined; + try { + store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const opened = createSqlitePlanStore(root); + try { + await opened.ready(); + return opened; + } catch (error) { + opened.close(); + throw error; + } + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: SqlitePlanStore, +): InteractivePlanStoreWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Plan Store writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractivePlanStoreWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + readState: (sessionId) => run(() => store.readState(sessionId)), + readOperationReceipt: (sessionId, operationId, operationInput) => + run(() => + store.readOperationReceipt(sessionId, operationId, structuredClone(operationInput)), + ), + submitProposal: (input) => run(() => store.submitProposal(cloneSubmit(input))), + requestRevision: (input) => run(() => store.requestRevision(cloneInput(input))), + abandonProposal: (input) => run(() => store.abandonProposal(cloneInput(input))), + approveProposal: (input) => run(() => store.approveProposal(cloneInput(input))), + updateExecution: (input) => run(() => store.updateExecution(cloneUpdate(input))), + cancelExecution: (input) => run(() => store.cancelExecution(cloneInput(input))), + interruptActiveExecution: (sessionId, reason, operationId) => + run(() => store.interruptActiveExecution(sessionId, reason, operationId)), + resumeExecution: (sessionId, executionId, operationId) => + run(() => store.resumeExecution(sessionId, executionId, operationId)), + purgeSessionState: (sessionId) => run(() => store.purgeSessionState(sessionId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + return Object.freeze(writer); +} + +function cloneSubmit(input: SubmitPlanProposalInput): SubmitPlanProposalInput { + return structuredClone(input); +} + +function cloneUpdate(input: UpdatePlanExecutionInput): UpdatePlanExecutionInput { + return structuredClone(input); +} + +function cloneInput< + T extends + | RequestPlanRevisionInput + | AbandonPlanProposalInput + | ApprovePlanProposalInput + | CancelPlanExecutionInput, +>(input: T): T { + return structuredClone(input); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/694904532eacceb0389fbcbce31d02207910e14e06166c2dd49e1b607ccd51e1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/694904532eacceb0389fbcbce31d02207910e14e06166c2dd49e1b607ccd51e1.source new file mode 100644 index 0000000000..488dddd58e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/694904532eacceb0389fbcbce31d02207910e14e06166c2dd49e1b607ccd51e1.source @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000; +const DEFAULT_TEXT_MAX_BUFFER = 1024 * 1024; +const DEFAULT_BYTES_MAX_BUFFER = Number.MAX_SAFE_INTEGER; + +export interface GitExecOptions { + readonly timeoutMs?: number; + readonly maxBuffer?: number; + readonly gitIndexFile?: string; +} + +/** + * Runs Git with repository discovery isolated from ambient Git environment + * variables. Callers may provide a temporary index for patch construction. + */ +export async function execGitText( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { + env: gitEnvironment(options), + encoding: 'utf8', + maxBuffer: options.maxBuffer ?? DEFAULT_TEXT_MAX_BUFFER, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + windowsHide: true, + }); + return stdout; +} + +export async function execGitBytes( + cwd: string, + args: readonly string[], + options: GitExecOptions = {}, +): Promise { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { + env: gitEnvironment(options), + encoding: 'buffer', + maxBuffer: options.maxBuffer ?? DEFAULT_BYTES_MAX_BUFFER, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + windowsHide: true, + }); + return new Uint8Array(stdout); +} + +function gitEnvironment(options: GitExecOptions): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0' }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + delete env.GIT_COMMON_DIR; + if (options.gitIndexFile === undefined) { + delete env.GIT_INDEX_FILE; + } else { + env.GIT_INDEX_FILE = options.gitIndexFile; + } + return env; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6b5e3837d390b0d3ba4ab082f6c2a83591d7bb20b1c621c9d56ec575934019fa.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6b5e3837d390b0d3ba4ab082f6c2a83591d7bb20b1c621c9d56ec575934019fa.source new file mode 100644 index 0000000000..cebfb41c80 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6b5e3837d390b0d3ba4ab082f6c2a83591d7bb20b1c621c9d56ec575934019fa.source @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, it } from 'node:test'; +import { + SQLITE_RUNTIME_SCHEMA_VERSION, + migrateSqliteRuntimeDatabase, +} from '../sqlite-runtime-schema.js'; + +describe('SQLite runtime schema migration', () => { + it('uses the locked current version after an optimistic stale read', () => { + const executed: string[] = []; + let versionReads = 0; + const db = { + prepare(sql: string) { + assert.equal(sql, 'PRAGMA user_version'); + return { + get() { + versionReads += 1; + return { + user_version: versionReads === 1 ? 4 : SQLITE_RUNTIME_SCHEMA_VERSION, + }; + }, + }; + }, + exec(sql: string) { + executed.push(sql); + }, + } as unknown as DatabaseSync; + + migrateSqliteRuntimeDatabase(db); + + assert.equal(versionReads, 2); + assert.deepEqual(executed, ['BEGIN IMMEDIATE', 'COMMIT']); + assert.equal( + executed.some((sql) => sql.includes('runtime_capabilities')), + false, + ); + }); + + it('re-reads user_version under the write lock before applying migrations', () => { + const real = new DatabaseSync(':memory:'); + let migrationLocked = false; + let lockedVersionRead = false; + const db = new Proxy(real, { + get(target, property) { + if (property === 'exec') { + return (sql: string) => { + const statement = sql.trim().toUpperCase(); + if (statement === 'BEGIN IMMEDIATE') migrationLocked = true; + if (statement.includes('CREATE TABLE RUNTIME_EVENTS')) { + assert.equal( + lockedVersionRead, + true, + 'pending migrations require a fresh user_version read under the write lock', + ); + } + try { + return target.exec(sql); + } finally { + if (statement === 'COMMIT' || statement === 'ROLLBACK') { + migrationLocked = false; + } + } + }; + } + if (property === 'prepare') { + return (sql: string) => { + if (sql.trim().toUpperCase() === 'PRAGMA USER_VERSION' && migrationLocked) { + lockedVersionRead = true; + } + return target.prepare(sql); + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as DatabaseSync; + + try { + migrateSqliteRuntimeDatabase(db); + assert.equal(lockedVersionRead, true); + } finally { + real.close(); + } + }); + + it('preserves v1 continuation claims while admitting the v2 replay projection', () => { + const db = new DatabaseSync(':memory:'); + try { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE runtime_events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + event_seq INTEGER NOT NULL, + event_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + committed_at INTEGER NOT NULL + ); + CREATE TABLE runtime_continuation_claims ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version = 1), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_run_header_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), + start_kind TEXT CHECK (start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair')), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE (source_session_id, source_run_id, source_event_high_water, source_prefix_digest), + UNIQUE (target_session_id, target_turn_id) + ); + INSERT INTO runtime_continuation_claims VALUES ( + 'claim-v1', 'session', 'source-invocation', 'source-run', 'source-turn', 1, + 'sha256:source', 'sha256:boundary-v1', '{}', 1, 'sha256:replay-v1', + 'session', 'target-invocation-v1', 'target-run-v1', 'target-turn-v1', + '{"runId": "target-run-v1", "invocationId": "target-invocation-v1", "sessionId": "session", "turnId": "target-turn-v1", "status": "created", "backendKind": "fake", "llmConnectionSlug": "connection-1", "modelId": "model-1", "cwd": "/workspace", "permissionMode": "ask", "createdAt": 1, "updatedAt": 1}', + 1, NULL, NULL, 1 + ); + PRAGMA user_version = 14; + `); + + migrateSqliteRuntimeDatabase(db); + + assert.equal( + (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + assert.equal( + ( + db + .prepare( + "SELECT provider_projection_version AS version FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", + ) + .get() as { version: number } + ).version, + 1, + ); + assert.equal( + JSON.parse( + ( + db + .prepare( + "SELECT target_opening_json AS opening FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", + ) + .get() as { opening: string } + ).opening, + ).kind, + 'invocation_opened', + 'an open claim carries the opening it always implied, not a copy of the Run header', + ); + db.exec(` + INSERT INTO runtime_continuation_claims VALUES ( + 'claim-v2', 'session', 'source-invocation', 'source-run', 'source-turn', 2, + 'sha256:source-2', 'sha256:boundary-v2', '{}', 2, 'sha256:replay-v2', + 'session', 'target-invocation-v2', 'target-run-v2', 'target-turn-v2', '{}', + 2, NULL, NULL, 1 + ); + `); + assert.throws(() => + db.exec(` + UPDATE runtime_continuation_claims + SET provider_projection_version = 3 + WHERE claim_id = 'claim-v2' + `), + ); + } finally { + db.close(); + } + }); + + it('builds the terminal index over a ledger holding an undecodable payload', () => { + const db = new DatabaseSync(':memory:'); + try { + migrateSqliteRuntimeDatabase(db); + db.prepare( + 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ).run('event', 'session', 'invocation', 'run', 'turn', 1, 'text', '{', 1); + // A partial index is rebuilt by evaluating its predicate over every row, + // so one such row would otherwise fail this migration — and the failure + // rolls the version back, leaving the next open to fail the same way. + db.exec( + `DROP INDEX runtime_events_terminal; PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`, + ); + migrateSqliteRuntimeDatabase(db); + + assert.equal( + (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + assert.ok( + db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'runtime_events_terminal'").get(), + ); + } finally { + db.close(); + } + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6d09b7f7d703b8abfef183e157cc60df77a2cfd59bc33e843177b5ecc0456762.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6d09b7f7d703b8abfef183e157cc60df77a2cfd59bc33e843177b5ecc0456762.source new file mode 100644 index 0000000000..66ee467daa --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6d09b7f7d703b8abfef183e157cc60df77a2cfd59bc33e843177b5ecc0456762.source @@ -0,0 +1,1129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { constants, type BigIntStats } from 'node:fs'; +import { chmod, link, lstat, mkdir, open, readdir, rename, rm, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { syncDirectory } from './stable-storage.js'; +import { + bundleSnapshot, + bundleTarget, + invalidMemoryDocument, + isRevision, + memoryBundleIoFailed, + memoryBundleRecoveryConflict, + missingDocument, + MEMORY_DOCUMENT_MAX_BYTES, + MemoryBundleBackupRevisionConflictError, + MemoryBundleBackupNotFoundError, + MemoryBundleRevisionConflictError, + MemoryBundleStoreError, + revision, + snapshotForBytes, + type CommitMemoryBundleInput, + type MemoryBackupKind, + type MemoryBackupSnapshot, + type MemoryBundleMutationResult, + type MemoryBundleSnapshot, + type MemoryBundleTarget, + type MemoryDocumentName, + type MemoryRevision, + type MemoryDocumentSnapshot, + type RestoreMemoryBackupInput, + validateDocumentBytes, +} from './memory-bundle-model.js'; + +const MEMORY_DIRECTORY = 'memory'; +const MEMORY_FILE = 'MEMORY.md'; +const PENDING_FILE = 'PENDING.md'; +const BACKUP_FILES = { + save: 'MEMORY.md.bak', + reset: 'MEMORY.md.reset.bak', + restore: 'MEMORY.md.restore.bak', +} as const; +const RESTORE_HISTORY_LIMIT = 5; +const TRANSACTION_DIRECTORY = '.memory-bundle-transaction'; +const DECISION_FILE = 'decision.json'; +const BACKUP_TEMP_PATTERN = + /^MEMORY\.md\.(?:bak|reset\.bak|restore\.bak)\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; +const TRANSACTION_SCHEMA_VERSION = 1 as const; +const TRANSACTION_DECISION_MAX_BYTES = 2 * 1024; +const DOCUMENT_SCAN_MAX_BYTES = 1024 * 1024; +const READ_CHUNK_BYTES = 64 * 1024; + +interface DirectoryBinding { + readonly path: string; + readonly dev: bigint; + readonly ino: bigint; +} + +interface TransactionDocumentDecision { + readonly kind: MemoryDocumentSnapshot['kind']; + readonly byteLength: number; + readonly revision: MemoryRevision | null; + readonly reason: 'invalid_utf8' | 'oversize' | null; +} + +interface TransactionBundleDecision { + readonly revision: MemoryRevision; + readonly memory: TransactionDocumentDecision; + readonly pending: TransactionDocumentDecision; +} + +interface TransactionDecision { + readonly schemaVersion: typeof TRANSACTION_SCHEMA_VERSION; + readonly basis: TransactionBundleDecision; + readonly target: TransactionBundleDecision; +} + +export async function readMemoryBundle(root: string): Promise { + const directory = await bindMemoryDirectory(root); + if (!directory) return bundleSnapshot(missingDocument(), missingDocument()); + if (await bindTransactionDirectory(directory)) { + throw new MemoryBundleStoreError( + 'io_failed', + 'Memory bundle requires writer recovery before it can be read', + ); + } + return readBoundMemoryBundle(directory); +} + +async function readBoundMemoryBundle(directory: DirectoryBinding): Promise { + const memory = await readDocument(directory, 'memory'); + const pending = await readDocument(directory, 'pending'); + await assertDirectoryBinding(directory); + return bundleSnapshot(memory, pending); +} + +export async function readMemoryBackups(root: string): Promise { + const directory = await bindMemoryDirectory(root); + if (!directory) return []; + const candidates = await Promise.all( + (Object.keys(BACKUP_FILES) as MemoryBackupKind[]).map((kind) => + readBackupCandidate(directory, kind), + ), + ); + return candidates + .filter((candidate): candidate is MemoryBackupSnapshot => candidate !== undefined) + .sort( + (left, right) => + right.updatedAt - left.updatedAt || backupPriority(right.kind) - backupPriority(left.kind), + ); +} + +export async function recoverMemoryBundle(root: string): Promise { + const directory = await bindMemoryDirectory(root); + if (!directory) return; + await cleanupBackupTemps(directory); + const transaction = await bindTransactionDirectory(directory); + if (!transaction) return; + + const decision = await readTransactionDecision(transaction); + if (!decision) { + await removeTransactionDirectory(directory, transaction); + return; + } + await finishDecision(directory, transaction, decision); +} + +export async function commitMemoryBundle( + root: string, + input: CommitMemoryBundleInput, +): Promise { + const memoryBytes = validateDocumentBytes('memory', input.memory); + const pendingBytes = + input.pending === null ? null : validateDocumentBytes('pending', input.pending); + const current = await readMemoryBundle(root); + if (current.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, current); + } + + const target = bundleTarget(memoryBytes, pendingBytes); + if (target.snapshot.revision === current.revision) { + if (!input.backup) return { changed: false, snapshot: current }; + const directory = await prepareMemoryDirectory(root); + const currentBeforeBackup = await readMemoryBundle(root); + if (currentBeforeBackup.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentBeforeBackup); + } + await writeMemoryBackup(directory, input.backup); + const currentAfterBackup = await readMemoryBundle(root); + if (currentAfterBackup.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentAfterBackup); + } + return { changed: false, snapshot: currentAfterBackup }; + } + + const directory = await prepareMemoryDirectory(root); + await assertDirectoryBinding(directory); + const currentBeforePublication = await readMemoryBundle(root); + if (currentBeforePublication.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentBeforePublication); + } + if (input.backup) { + await writeMemoryBackup(directory, input.backup); + const currentAfterBackup = await readMemoryBundle(root); + if (currentAfterBackup.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentAfterBackup); + } + } + + await publishTransaction(directory, target, input.expectedRevision); + const committed = await readMemoryBundle(root); + if (committed.revision !== target.snapshot.revision) { + throw commitOutcomeUnknown( + 'Memory bundle publication could not be verified', + target.snapshot.revision, + new Error(`Expected ${target.snapshot.revision}, read ${committed.revision}`), + ); + } + return { changed: true, snapshot: committed }; +} + +export async function restoreMemoryBackup( + root: string, + input: RestoreMemoryBackupInput, +): Promise { + const current = await readMemoryBundle(root); + if (current.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, current); + } + if (current.pending.kind === 'safe_mode') { + throw invalidMemoryDocument('Cannot restore Memory while PENDING.md is in safe mode'); + } + const directory = await bindMemoryDirectory(root); + if (!directory) throw new MemoryBundleBackupNotFoundError(input.kind); + const selected = await readBackupBytes(directory, input.kind); + if (!selected) throw new MemoryBundleBackupNotFoundError(input.kind); + const selectedRevision = revision(selected); + if (selectedRevision !== input.expectedBackupRevision) { + throw new MemoryBundleBackupRevisionConflictError( + input.kind, + input.expectedBackupRevision, + selectedRevision, + ); + } + + const currentBeforePublication = await readMemoryBundle(root); + if (currentBeforePublication.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentBeforePublication); + } + await rotateRestoreHistory(directory); + await writeMemoryBackup(directory, 'restore'); + const currentAfterBackup = await readMemoryBundle(root); + if (currentAfterBackup.revision !== input.expectedRevision) { + throw new MemoryBundleRevisionConflictError(input.expectedRevision, currentAfterBackup); + } + + const target = bundleTarget(selected, documentBytesOrNull(currentAfterBackup.pending), true); + if (target.snapshot.revision === currentAfterBackup.revision) { + return { changed: false, snapshot: currentAfterBackup }; + } + await publishTransaction(directory, target, input.expectedRevision); + const committed = await readMemoryBundle(root); + if (committed.revision !== target.snapshot.revision) { + throw commitOutcomeUnknown( + 'Memory backup restoration could not be verified', + target.snapshot.revision, + new Error(`Expected ${target.snapshot.revision}, read ${committed.revision}`), + ); + } + return { changed: true, snapshot: committed }; +} + +async function publishTransaction( + directory: DirectoryBinding, + target: MemoryBundleTarget, + expectedRevision: MemoryRevision, +): Promise { + const transactionPath = join(directory.path, TRANSACTION_DIRECTORY); + let transaction: DirectoryBinding | undefined; + let decisionPublished = false; + let failure: unknown; + try { + await mkdir(transactionPath, { mode: 0o700 }); + transaction = await requireDirectoryBinding( + transactionPath, + 'Memory transaction path must be a directory', + ); + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); + + await stageDocument(transaction, 'memory', target.snapshot.memory, target.memory); + await stageDocument(transaction, 'pending', target.snapshot.pending, target.pending); + const current = await readBoundMemoryBundle(directory); + if (current.revision !== expectedRevision) { + throw new MemoryBundleRevisionConflictError(expectedRevision, current); + } + const decision = decisionFromSnapshots(current, target.snapshot); + await publishDecision(transaction, decision, () => { + decisionPublished = true; + }); + await finishDecision(directory, transaction, decision); + } catch (error) { + failure = error; + } + + if (failure === undefined) return; + if (!decisionPublished) { + if (transaction) { + try { + await removeTransactionDirectory(directory, transaction); + } catch (cleanupError) { + throw memoryBundleIoFailed( + 'Memory transaction failed before publication and cleanup also failed', + new AggregateError([failure, cleanupError]), + ); + } + } + if ( + failure instanceof MemoryBundleStoreError || + failure instanceof MemoryBundleRevisionConflictError + ) { + throw failure; + } + throw memoryBundleIoFailed('Memory transaction failed before publication', failure); + } + throw commitOutcomeUnknown( + 'Memory bundle commit outcome is unknown; read before deciding whether to retry', + target.snapshot.revision, + failure, + ); +} + +async function publishDecision( + transaction: DirectoryBinding, + decision: TransactionDecision, + onPublished: () => void, +): Promise { + const bytes = Buffer.from(`${JSON.stringify(decision)}\n`, 'utf8'); + if (bytes.byteLength > TRANSACTION_DECISION_MAX_BYTES) { + throw invalidMemoryDocument('Memory transaction decision exceeds its byte limit'); + } + const temporaryPath = join(transaction.path, `${DECISION_FILE}.${randomUUID()}.tmp`); + const decisionPath = join(transaction.path, DECISION_FILE); + let handle: Awaited> | undefined; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await assertDirectoryBinding(transaction); + await rename(temporaryPath, decisionPath); + onPublished(); + await syncDirectory(transaction.path); + await assertDirectoryBinding(transaction); + } finally { + if (handle) await handle.close(); + await rm(temporaryPath, { force: true }); + } +} + +async function stageDocument( + transaction: DirectoryBinding, + name: MemoryDocumentName, + document: MemoryDocumentSnapshot, + bytes: Buffer | null, +): Promise { + if (document.kind === 'missing') return; + if (!bytes || bytes.byteLength !== document.byteLength || revision(bytes) !== document.revision) { + throw invalidMemoryDocument(`Transaction bytes for ${displayName(name)} are inconsistent`); + } + const handle = await open(stagePath(transaction.path, name), 'wx', 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + await assertDirectoryBinding(transaction); +} + +async function materializeDecision( + directory: DirectoryBinding, + transaction: DirectoryBinding, + decision: TransactionDecision, +): Promise { + await materializeDocument( + directory, + transaction, + 'memory', + decision.basis.memory, + decision.target.memory, + ); + await materializeDocument( + directory, + transaction, + 'pending', + decision.basis.pending, + decision.target.pending, + ); + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); +} + +async function materializeDocument( + directory: DirectoryBinding, + transaction: DirectoryBinding, + name: MemoryDocumentName, + basis: TransactionDocumentDecision, + target: TransactionDocumentDecision, +): Promise { + const stablePath = documentPath(directory.path, name); + const capturedPath = displacedPath(transaction.path, name); + const captured = await readDisplacedDocument(transaction, name); + if (captured && (basis.kind === 'missing' || !sameDocumentDecision(captured, basis))) { + await restoreUnexpectedDisplaced(directory, transaction, name); + throw recoveryConflict(name); + } + + let current = await readDocument(directory, name); + if (sameDocumentDecision(current, target)) return; + + const stagedPath = stagePath(transaction.path, name); + let staged: Buffer | undefined; + if (target.kind !== 'missing') { + staged = await readExactFile(stagedPath, displayName(name), DOCUMENT_SCAN_MAX_BYTES); + const stagedSnapshot = snapshotForBytes(staged); + if (!sameDocumentDecision(stagedSnapshot, target)) { + throw invalidMemoryDocument(`Staged ${displayName(name)} does not match its commit decision`); + } + } + + if (captured) { + if (current.kind !== 'missing') throw recoveryConflict(name); + } else if (basis.kind === 'missing') { + if (current.kind !== 'missing') throw recoveryConflict(name); + } else { + if (!sameDocumentDecision(current, basis)) throw recoveryConflict(name); + await assertDirectoryBinding(directory); + await assertDirectoryBinding(transaction); + try { + await rename(stablePath, capturedPath); + } catch (error) { + if (isNodeError(error, 'ENOENT')) throw recoveryConflict(name); + throw error; + } + await syncDirectory(directory.path); + await syncDirectory(transaction.path); + await assertDirectoryBinding(directory); + await assertDirectoryBinding(transaction); + + const capturedAfterRename = await readDisplacedDocument(transaction, name); + if (!capturedAfterRename || !sameDocumentDecision(capturedAfterRename, basis)) { + if (capturedAfterRename) { + await restoreUnexpectedDisplaced(directory, transaction, name); + } + throw recoveryConflict(name); + } + current = await readDocument(directory, name); + if (sameDocumentDecision(current, target)) return; + if (current.kind !== 'missing') throw recoveryConflict(name); + } + + if (target.kind === 'missing') return; + if (!staged) { + throw invalidMemoryDocument(`Staged ${displayName(name)} is missing`); + } + + const candidatePath = join(transaction.path, `${displayName(name)}.${randomUUID()}.publish`); + let handle: Awaited> | undefined; + try { + handle = await open(candidatePath, 'wx', 0o600); + await handle.writeFile(staged); + await handle.sync(); + await handle.close(); + handle = undefined; + await assertDirectoryBinding(transaction); + try { + await link(candidatePath, stablePath); + } catch (error) { + if (isNodeError(error, 'EEXIST')) throw recoveryConflict(name); + throw error; + } + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); + + const materialized = await readDocument(directory, name); + if (!sameDocumentDecision(materialized, target)) throw recoveryConflict(name); + } finally { + if (handle) await handle.close(); + await rm(candidatePath, { force: true }); + } +} + +async function readDisplacedDocument( + transaction: DirectoryBinding, + name: MemoryDocumentName, +): Promise { + try { + const bytes = await readExactFile( + displacedPath(transaction.path, name), + `${displayName(name)} displaced state`, + DOCUMENT_SCAN_MAX_BYTES, + ); + await assertDirectoryBinding(transaction); + return snapshotForBytes(bytes); + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(transaction); + return undefined; + } + throw error; + } +} + +async function restoreUnexpectedDisplaced( + directory: DirectoryBinding, + transaction: DirectoryBinding, + name: MemoryDocumentName, +): Promise { + const current = await readDocument(directory, name); + if (current.kind !== 'missing') return; + await assertDirectoryBinding(transaction); + try { + await link(displacedPath(transaction.path, name), documentPath(directory.path, name)); + } catch (error) { + if (isNodeError(error, 'EEXIST')) return; + throw error; + } + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); +} + +async function finishDecision( + directory: DirectoryBinding, + transaction: DirectoryBinding, + decision: TransactionDecision, +): Promise { + await materializeDecision(directory, transaction, decision); + await verifyMaterializedDecision(directory, decision); + // An old file descriptor follows the displaced inode across rename. This last check catches + // writes completed before cleanup; later non-cooperating writes require a different storage model. + await verifyDisplacedBasisBeforeCleanup(transaction, decision); + await removeTransactionDirectory(directory, transaction); +} + +async function verifyDisplacedBasisBeforeCleanup( + transaction: DirectoryBinding, + decision: TransactionDecision, +): Promise { + for (const name of ['memory', 'pending'] as const) { + const captured = await readDisplacedDocument(transaction, name); + if ( + captured && + (decision.basis[name].kind === 'missing' || + !sameDocumentDecision(captured, decision.basis[name])) + ) { + throw recoveryConflict(name); + } + } +} + +function recoveryConflict(name: MemoryDocumentName): MemoryBundleStoreError { + return memoryBundleRecoveryConflict( + `${displayName(name)} changed after the Memory bundle decision was committed`, + ); +} + +async function readTransactionDecision( + transaction: DirectoryBinding, +): Promise { + const path = join(transaction.path, DECISION_FILE); + let bytes: Buffer; + try { + bytes = await readExactFile(path, DECISION_FILE, TRANSACTION_DECISION_MAX_BYTES); + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(transaction); + return undefined; + } + throw error; + } + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (error) { + throw invalidMemoryDocument('Memory transaction decision is invalid JSON', error); + } + const decision = decodeDecision(decoded); + await assertDirectoryBinding(transaction); + return decision; +} + +function decodeDecision(input: unknown): TransactionDecision { + if (!isRecord(input)) { + throw invalidMemoryDocument('Memory transaction decision must be an object'); + } + const keys = Object.keys(input).sort(); + if (keys.join(',') !== ['basis', 'schemaVersion', 'target'].sort().join(',')) { + throw invalidMemoryDocument('Memory transaction decision has unknown or missing fields'); + } + if (input.schemaVersion !== TRANSACTION_SCHEMA_VERSION) { + throw invalidMemoryDocument('Memory transaction decision schema is unsupported'); + } + const decision = { + schemaVersion: TRANSACTION_SCHEMA_VERSION, + basis: decodeBundleDecision(input.basis, 'basis'), + target: decodeBundleDecision(input.target, 'target'), + }; + return decision; +} + +function decodeBundleDecision( + input: unknown, + label: 'basis' | 'target', +): TransactionBundleDecision { + if (!isRecord(input)) { + throw invalidMemoryDocument(`Memory transaction ${label} must be an object`); + } + const keys = Object.keys(input).sort(); + if (keys.join(',') !== ['memory', 'pending', 'revision'].sort().join(',')) { + throw invalidMemoryDocument(`Memory transaction ${label} has unknown or missing fields`); + } + if (!isRevision(input.revision)) { + throw invalidMemoryDocument(`Memory transaction ${label} revision is invalid`); + } + const decision = { + revision: input.revision, + memory: decodeDocumentDecision(input.memory), + pending: decodeDocumentDecision(input.pending), + }; + const projected = bundleSnapshot( + snapshotFromDecision(decision.memory), + snapshotFromDecision(decision.pending), + ); + if (projected.revision !== decision.revision) { + throw invalidMemoryDocument(`Memory transaction ${label} revision is inconsistent`); + } + return decision; +} + +async function verifyMaterializedDecision( + directory: DirectoryBinding, + decision: TransactionDecision, +): Promise { + const materialized = await readBoundMemoryBundle(directory); + for (const name of ['memory', 'pending'] as const) { + if (!sameDocumentDecision(materialized[name], decision.target[name])) { + throw recoveryConflict(name); + } + } +} + +function decodeDocumentDecision(input: unknown): TransactionDocumentDecision { + if (!isRecord(input)) { + throw invalidMemoryDocument('Memory transaction document decision must be an object'); + } + const keys = Object.keys(input).sort(); + if (keys.join(',') !== ['byteLength', 'kind', 'reason', 'revision'].sort().join(',')) { + throw invalidMemoryDocument('Memory transaction document decision is not exact'); + } + if (input.kind !== 'missing' && input.kind !== 'document' && input.kind !== 'safe_mode') { + throw invalidMemoryDocument('Memory transaction document kind is invalid'); + } + if ( + typeof input.byteLength !== 'number' || + !Number.isSafeInteger(input.byteLength) || + input.byteLength < 0 || + input.byteLength > DOCUMENT_SCAN_MAX_BYTES + ) { + throw invalidMemoryDocument('Memory transaction document length is invalid'); + } + if (input.kind === 'missing') { + if (input.revision !== null || input.byteLength !== 0 || input.reason !== null) { + throw invalidMemoryDocument('Missing Memory transaction documents must be empty'); + } + } else { + if (!isRevision(input.revision)) { + throw invalidMemoryDocument('Memory transaction document revision is invalid'); + } + const validDocument = + input.kind === 'document' && + input.reason === null && + input.byteLength <= MEMORY_DOCUMENT_MAX_BYTES; + const validInvalidUtf8 = + input.kind === 'safe_mode' && + input.reason === 'invalid_utf8' && + input.byteLength <= MEMORY_DOCUMENT_MAX_BYTES; + const validOversize = + input.kind === 'safe_mode' && + input.reason === 'oversize' && + input.byteLength > MEMORY_DOCUMENT_MAX_BYTES; + if (!validDocument && !validInvalidUtf8 && !validOversize) { + throw invalidMemoryDocument('Memory transaction document safe-mode state is invalid'); + } + } + return { + kind: input.kind, + byteLength: input.byteLength, + revision: input.revision as MemoryRevision | null, + reason: input.reason as 'invalid_utf8' | 'oversize' | null, + }; +} + +function decisionFromSnapshots( + basis: MemoryBundleSnapshot, + target: MemoryBundleSnapshot, +): TransactionDecision { + return { + schemaVersion: TRANSACTION_SCHEMA_VERSION, + basis: bundleDecision(basis), + target: bundleDecision(target), + }; +} + +function bundleDecision(snapshot: MemoryBundleSnapshot): TransactionBundleDecision { + return { + revision: snapshot.revision, + memory: documentDecision(snapshot.memory), + pending: documentDecision(snapshot.pending), + }; +} + +function documentDecision(snapshot: MemoryDocumentSnapshot): TransactionDocumentDecision { + return { + kind: snapshot.kind, + byteLength: snapshot.byteLength, + revision: snapshot.revision, + reason: snapshot.kind === 'safe_mode' ? snapshot.reason : null, + }; +} + +function snapshotFromDecision(decision: TransactionDocumentDecision): MemoryDocumentSnapshot { + switch (decision.kind) { + case 'missing': + return missingDocument(); + case 'document': + return { + kind: 'document', + revision: decision.revision!, + byteLength: decision.byteLength, + bytes: new Uint8Array(), + }; + case 'safe_mode': + return { + kind: 'safe_mode', + revision: decision.revision!, + byteLength: decision.byteLength, + reason: decision.reason!, + }; + } +} + +async function readDocument( + directory: DirectoryBinding, + name: MemoryDocumentName, +): Promise { + const path = documentPath(directory.path, name); + let bytes: Buffer; + try { + bytes = await readExactFile(path, displayName(name), DOCUMENT_SCAN_MAX_BYTES); + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(directory); + return missingDocument(); + } + throw error; + } + await assertDirectoryBinding(directory); + return snapshotForBytes(bytes); +} + +async function readExactFile(path: string, label: string, maxBytes: number): Promise { + let metadata; + try { + metadata = await lstat(path, { bigint: true }); + } catch (error) { + if (isNodeError(error, 'ENOENT')) throw error; + throw memoryBundleIoFailed(`${label} could not be inspected`, error); + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw invalidMemoryDocument(`${label} must be a regular file`); + } + if (metadata.size > BigInt(maxBytes)) { + throw invalidMemoryDocument(`${label} exceeds its ${maxBytes} byte scan limit`); + } + + const flags = + process.platform === 'win32' + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + let handle: Awaited>; + try { + handle = await open(path, flags); + } catch (error) { + if (process.platform !== 'win32' && isNodeError(error, 'ELOOP')) { + throw invalidMemoryDocument(`${label} must not be a symbolic link`, error); + } + throw error; + } + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile() || opened.size > BigInt(maxBytes)) { + throw invalidMemoryDocument(`${label} is not a bounded regular file`); + } + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const remaining = maxBytes + 1 - total; + if (remaining <= 0) { + throw invalidMemoryDocument(`${label} exceeds its ${maxBytes} byte scan limit`); + } + const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, remaining)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, total); + if (bytesRead === 0) break; + total += bytesRead; + chunks.push(buffer.subarray(0, bytesRead)); + } + return Buffer.concat(chunks, total); + } finally { + await handle.close(); + } +} + +async function readBackupCandidate( + directory: DirectoryBinding, + kind: MemoryBackupKind, +): Promise { + const bytes = await readBackupBytes(directory, kind); + if (!bytes) return undefined; + const metadata = await lstat(backupPath(directory.path, kind), { bigint: true }); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw invalidMemoryDocument(`${BACKUP_FILES[kind]} must be a regular file`); + } + await assertDirectoryBinding(directory); + const document = snapshotForBytes(bytes); + return { + kind, + revision: document.revision, + updatedAt: Math.round(Number(metadata.mtimeMs)), + document, + }; +} + +async function readBackupBytes( + directory: DirectoryBinding, + kind: MemoryBackupKind, +): Promise { + try { + const bytes = await readExactFile( + backupPath(directory.path, kind), + BACKUP_FILES[kind], + DOCUMENT_SCAN_MAX_BYTES, + ); + await assertDirectoryBinding(directory); + return bytes; + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(directory); + return undefined; + } + throw error; + } +} + +async function writeMemoryBackup( + directory: DirectoryBinding, + kind: MemoryBackupKind, +): Promise { + let bytes: Buffer; + try { + bytes = await readExactFile( + documentPath(directory.path, 'memory'), + MEMORY_FILE, + DOCUMENT_SCAN_MAX_BYTES, + ); + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(directory); + return; + } + throw error; + } + await writeBackupBytes(directory, kind, bytes); +} + +async function writeBackupBytes( + directory: DirectoryBinding, + kind: MemoryBackupKind, + bytes: Uint8Array, +): Promise { + const destination = backupPath(directory.path, kind); + await assertRegularOrMissing(destination, BACKUP_FILES[kind]); + const temporaryPath = `${destination}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await assertDirectoryBinding(directory); + await rename(temporaryPath, destination); + await chmod(destination, 0o600); + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); + } finally { + if (handle) await handle.close(); + await rm(temporaryPath, { force: true }); + } +} + +async function rotateRestoreHistory(directory: DirectoryBinding): Promise { + for (let index = RESTORE_HISTORY_LIMIT - 1; index >= 1; index -= 1) { + await renameRegularIfPresent( + directory, + restoreHistoryPath(directory.path, index), + restoreHistoryPath(directory.path, index + 1), + ); + } + await renameRegularIfPresent( + directory, + backupPath(directory.path, 'restore'), + restoreHistoryPath(directory.path, 1), + ); + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); +} + +async function renameRegularIfPresent( + directory: DirectoryBinding, + source: string, + destination: string, +): Promise { + let sourceMetadata; + try { + sourceMetadata = await lstat(source); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw error; + } + if (!sourceMetadata.isFile() || sourceMetadata.isSymbolicLink()) { + throw invalidMemoryDocument('Memory restore history must contain only regular files'); + } + await assertRegularOrMissing(destination, 'Memory restore history destination'); + await assertDirectoryBinding(directory); + await rename(source, destination); +} + +async function assertRegularOrMissing(path: string, label: string): Promise { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw invalidMemoryDocument(`${label} must be a regular file`); + } + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw error; + } +} + +async function prepareMemoryDirectory(root: string): Promise { + const existing = await bindMemoryDirectory(root); + if (existing) return existing; + const path = join(root, MEMORY_DIRECTORY); + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) { + throw memoryBundleIoFailed('Memory directory could not be created', error); + } + } + const prepared = await requireDirectoryBinding( + path, + 'Memory path must be a directory inside the storage root', + ); + await syncDirectory(root); + return prepared; +} + +async function bindMemoryDirectory(root: string): Promise { + const path = join(root, MEMORY_DIRECTORY); + try { + return await requireDirectoryBinding( + path, + 'Memory path must be a directory inside the storage root', + ); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function bindTransactionDirectory( + directory: DirectoryBinding, +): Promise { + const path = join(directory.path, TRANSACTION_DIRECTORY); + try { + const transaction = await requireDirectoryBinding( + path, + 'Memory transaction path must be a directory', + ); + await assertDirectoryBinding(directory); + return transaction; + } catch (error) { + if (isNodeError(error, 'ENOENT')) { + await assertDirectoryBinding(directory); + return undefined; + } + throw error; + } +} + +async function requireDirectoryBinding(path: string, message: string): Promise { + let metadata: BigIntStats; + try { + metadata = await lstat(path, { bigint: true }); + } catch (error) { + if (isNodeError(error, 'ENOENT')) throw error; + throw memoryBundleIoFailed(`${message}: inspection failed`, error); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw invalidMemoryDocument(message); + } + return { path, dev: metadata.dev, ino: metadata.ino }; +} + +async function assertDirectoryBinding(binding: DirectoryBinding): Promise { + const metadata = await lstat(binding.path, { bigint: true }); + if ( + !metadata.isDirectory() || + metadata.isSymbolicLink() || + metadata.dev !== binding.dev || + metadata.ino !== binding.ino + ) { + throw invalidMemoryDocument('Memory directory identity changed during the operation'); + } +} + +async function removeTransactionDirectory( + directory: DirectoryBinding, + transaction: DirectoryBinding, +): Promise { + await assertDirectoryBinding(directory); + await assertDirectoryBinding(transaction); + await rm(transaction.path, { recursive: true }); + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); +} + +async function cleanupBackupTemps(directory: DirectoryBinding): Promise { + let entries: string[]; + try { + entries = await readdir(directory.path); + } catch (error) { + throw memoryBundleIoFailed('Memory directory could not be listed', error); + } + let changed = false; + for (const entry of entries) { + if (!BACKUP_TEMP_PATTERN.test(entry)) continue; + const path = join(directory.path, entry); + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw invalidMemoryDocument('Memory temporary artifacts must be regular files'); + } + await unlink(path); + changed = true; + } + if (changed) { + await syncDirectory(directory.path); + await assertDirectoryBinding(directory); + } +} + +function sameDocumentDecision( + snapshot: MemoryDocumentSnapshot, + decision: TransactionDocumentDecision, +): boolean { + return ( + snapshot.kind === decision.kind && + snapshot.byteLength === decision.byteLength && + snapshot.revision === decision.revision && + (snapshot.kind === 'safe_mode' ? snapshot.reason : null) === decision.reason + ); +} + +function documentBytesOrNull(document: MemoryDocumentSnapshot): Buffer | null { + if (document.kind === 'missing') return null; + if (document.kind === 'document') return Buffer.from(document.bytes); + throw invalidMemoryDocument( + 'Safe-mode PENDING.md bytes cannot be restored through Memory backup', + ); +} + +function documentPath(directory: string, name: MemoryDocumentName): string { + return join(directory, displayName(name)); +} + +function backupPath(directory: string, kind: MemoryBackupKind): string { + return join(directory, BACKUP_FILES[kind]); +} + +function restoreHistoryPath(directory: string, index: number): string { + return join(directory, `MEMORY.md.restore.${index}.bak`); +} + +function backupPriority(kind: MemoryBackupKind): number { + switch (kind) { + case 'save': + return 0; + case 'reset': + return 1; + case 'restore': + return 2; + } +} + +function stagePath(transaction: string, name: MemoryDocumentName): string { + return join(transaction, `${displayName(name)}.next`); +} + +function displacedPath(transaction: string, name: MemoryDocumentName): string { + return join(transaction, `${displayName(name)}.displaced`); +} + +function displayName(name: MemoryDocumentName): string { + return name === 'memory' ? MEMORY_FILE : PENDING_FILE; +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === code + ); +} + +function commitOutcomeUnknown( + message: string, + candidateRevision: MemoryRevision, + cause: unknown, +): MemoryBundleStoreError { + return new MemoryBundleStoreError('commit_outcome_unknown', message, candidateRevision, { + cause, + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6daf17a78ca79953c07dba2784695a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6daf17a78ca79953c07dba2784695a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source new file mode 100644 index 0000000000..e0ee5b154d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6daf17a78ca79953c07dba2784695a0557d7d8bc0b3a1d5b2e5d1fdb1d2d6051.source @@ -0,0 +1,1405 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, it } from 'node:test'; +import { + WORKSPACE_AUTHORITY_SESSION_ID, + buildWorkspaceBaselineAuthorityEvents, + workspaceAuthorityIdentity, + type WorkspaceBaselineAuthorityInput, + type WorkspaceSuccessorAuthorityInput, +} from '@maka/core/workspace-version-authority'; +import { type RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { createConversationOperationalStateStore } from '../conversation-operational-state.js'; +import { + createSqliteRuntimeStore, + type SqliteRuntimeStoreFailpoint, +} from '../sqlite-runtime-store.js'; +import { SQLITE_RUNTIME_SCHEMA_VERSION } from '../sqlite-runtime-schema.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitManagedMutationTerminalInternal, + commitWorkspaceBaselineInternal, + commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, + registerManagedMutationNoEffectVerifierInternal, + registerWorkspaceSuccessorCandidateVerifierInternal, + type ManagedMutationNoEffectClaimV1, + type ManagedMutationTerminalCommitInput, + type WorkspaceSuccessorCommitInput, +} from '../workspace-version-authority-internal.js'; + +const TEST_STORAGE_ROOT_ID = 'a'.repeat(64); +const TEST_CANDIDATES = new WeakMap(); +const TEST_NO_EFFECT_CLAIMS = new WeakMap(); + +function issueTestCandidate(successor: WorkspaceSuccessorAuthorityInput): object { + const capability = Object.freeze({}); + TEST_CANDIDATES.set(capability, structuredClone(successor)); + return capability; +} + +function verifyTestCandidate(capability: object): WorkspaceSuccessorAuthorityInput { + const successor = TEST_CANDIDATES.get(capability); + if (!successor) throw new Error('Unrecognized test candidate capability'); + return structuredClone(successor); +} + +function issueTestNoEffect(claim: ManagedMutationNoEffectClaimV1): object { + const capability = Object.freeze({}); + TEST_NO_EFFECT_CLAIMS.set(capability, structuredClone(claim)); + return capability; +} + +function verifyTestNoEffect(capability: object): ManagedMutationNoEffectClaimV1 { + const claim = TEST_NO_EFFECT_CLAIMS.get(capability); + if (!claim) throw new Error('Unrecognized test no-effect capability'); + return structuredClone(claim); +} + +describe('workspace version persistence authority', () => { + it('does not expose the unverified baseline writer on the public SQLite store', () => { + const store = createSqliteRuntimeStore(':memory:'); + try { + // @ts-expect-error Raw baseline authority is an internal persistence seam. + assert.equal(store.commitWorkspaceBaseline, undefined); + } finally { + store.close(); + } + }); + + it('cannot be purged through the ordinary conversation lifecycle', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-authority-purge-')); + const store = createConversationOperationalStateStore(root); + try { + await assert.rejects( + store.purge(WORKSPACE_AUTHORITY_SESSION_ID), + /cannot be purged as a conversation/i, + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it('reads canonical facts and projections from one SQLite snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-authority-snapshot-')); + const dbPath = join(root, 'runtime.sqlite'); + const input = baselineInput(); + const writer = createSqliteRuntimeStore(dbPath); + bindWorkspaceBaselineAuthorityStoreRootInternal(writer, TEST_STORAGE_ROOT_ID); + let writePromise: ReturnType | undefined; + let injected = false; + const reader = createSqliteRuntimeStore(dbPath, { + failpoint: (point) => { + if (point !== 'after_workspace_canonical_scan' || injected) return; + injected = true; + writePromise = commitWorkspaceBaselineInternal(writer, input); + }, + }); + try { + assert.equal( + await reader.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId), + undefined, + ); + assert.ok(writePromise); + await writePromise; + assert.equal( + (await reader.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId)) + ?.workspaceVersionId, + input.baseline.workspaceVersionId, + ); + } finally { + reader.close(); + writer.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it('atomically opens a baseline and makes only an exact retry idempotent', async () => { + await withDatabase(async ({ dbPath, store }) => { + const input = baselineInput(); + const first = await commitWorkspaceBaselineInternal(store, input); + assert.equal(first.created, true); + assert.equal(first.head.workspaceVersionId, input.baseline.workspaceVersionId); + assert.equal(first.head.revision, 1); + + assert.equal( + (await store.readWorkspaceEpoch(input.epoch.workspaceId, input.epoch.workspaceEpochId)) + ?.initialWorkspaceVersionId, + input.baseline.workspaceVersionId, + ); + assert.equal( + (await store.readWorkspaceVersion(input.baseline.workspaceVersionId))?.origin.kind, + 'baseline', + ); + assert.deepEqual( + await store.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId), + first.head, + ); + + const retry = await commitWorkspaceBaselineInternal(store, input); + assert.deepEqual(retry, { ...first, created: false }); + + const conflict = baselineInput({ + baselineAcceptedEventId: 'workspace-version-event-conflict', + baseline: { + ...input.baseline, + workspaceVersionId: 'version_99999999999999999999999999999999', + commitOid: '9'.repeat(40), + }, + }); + await assert.rejects( + commitWorkspaceBaselineInternal(store, conflict), + /workspace baseline authority conflict/i, + ); + + const raw = new DatabaseSync(dbPath); + try { + assert.equal(count(raw, 'runtime_workspace_epochs'), 1); + assert.equal(count(raw, 'runtime_workspace_versions'), 1); + assert.equal(count(raw, 'runtime_workspace_heads'), 1); + assert.equal( + countWhere(raw, 'runtime_events', 'session_id = ?', WORKSPACE_AUTHORITY_SESSION_ID), + 2, + ); + } finally { + raw.close(); + } + }); + }); + + it('creates one durable managed mutation reservation with T1', async () => { + await withDatabase(async ({ store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + await store.commitToolPrepared( + managedPreparedCommit(baseline, opened.head, 'operation-reservation-1'), + ); + assert.equal( + (await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId)) + ?.operationId, + 'operation-reservation-1', + ); + + await assert.rejects( + store.commitToolPrepared( + managedPreparedCommit(baseline, opened.head, 'operation-reservation-2'), + ), + /managed mutation reservation conflict/i, + ); + }); + }); + + it('rejects a managed T1 whose authorized path differs from the durable tool call', async () => { + await withDatabase(async ({ store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit( + baseline, + opened.head, + 'operation-path-authority-conflict', + ); + const mutation = prepared.dispatchRuntimeEvent.actions.toolDispatch.managedMutation; + mutation.expectedPath = 'other.txt'; + + await assert.rejects( + store.commitToolPrepared(prepared), + /managed mutation path does not match its durable tool call/i, + ); + assert.equal( + await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId), + undefined, + ); + }); + }); + + it('refuses to settle a managed mutation through the generic T2 writer', async () => { + await withDatabase(async ({ store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit(baseline, opened.head, 'operation-managed-t2'); + await store.commitToolPrepared(prepared); + + await assert.rejects( + store.commitToolOutcome({ + operationId: prepared.operationId, + journalEventId: `${prepared.operationId}_outcome`, + committedAt: baseline.committedAt + 2, + runtimeEvent: { + id: `${prepared.operationId}-outcome-event`, + sessionId: prepared.runtimeEvent.sessionId, + invocationId: prepared.runtimeEvent.invocationId, + runId: prepared.runtimeEvent.runId, + turnId: prepared.runtimeEvent.turnId, + ts: baseline.committedAt + 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: prepared.providerToolCallId, + name: prepared.toolName, + result: { kind: 'text', text: 'must not settle generically' }, + }, + refs: { + operationId: prepared.operationId, + toolCallId: prepared.providerToolCallId, + }, + }, + }), + /managed mutation outcome requires a managed mutation authority writer/i, + ); + assert.equal((await store.readToolOperation(prepared.operationId))?.currentState, 'prepared'); + }); + }); + + for (const terminalKind of ['no_workspace_change', 'operation_failed_no_effect'] as const) { + it(`atomically commits ${terminalKind} without advancing the workspace head`, async () => { + await withDatabase(async ({ dbPath, store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit(baseline, opened.head, `operation-${terminalKind}`); + await store.commitToolPrepared(prepared); + const outcomeEvent: RuntimeEvent = { + id: `${prepared.operationId}-outcome-event`, + sessionId: prepared.runtimeEvent.sessionId, + invocationId: prepared.runtimeEvent.invocationId, + runId: prepared.runtimeEvent.runId, + turnId: prepared.runtimeEvent.turnId, + ts: baseline.committedAt + 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: prepared.providerToolCallId, + name: prepared.toolName, + result: terminalKind, + ...(terminalKind === 'operation_failed_no_effect' ? { isError: true } : {}), + }, + actions: { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1', + operationId: prepared.operationId, + dispatchEventId: prepared.dispatchRuntimeEvent.id, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + terminalKind, + }, + }, + refs: { + operationId: prepared.operationId, + toolCallId: prepared.providerToolCallId, + }, + }; + const toolOutcome = { + operationId: prepared.operationId, + journalEventId: `${prepared.operationId}_outcome`, + runtimeEvent: outcomeEvent, + committedAt: baseline.committedAt + 2, + }; + const input: ManagedMutationTerminalCommitInput = { + noEffectOutcome: issueTestNoEffect({ + operationId: prepared.operationId, + dispatchEventId: prepared.dispatchRuntimeEvent.id, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + terminalKind, + }), + toolOutcome, + }; + + await assert.rejects( + async () => + commitManagedMutationTerminalInternal(store, { + toolOutcome, + } as unknown as ManagedMutationTerminalCommitInput), + /owner-issued no-effect proof/i, + ); + await assert.rejects( + commitManagedMutationTerminalInternal(store, { + ...input, + noEffectOutcome: issueTestNoEffect({ + operationId: prepared.operationId, + dispatchEventId: prepared.dispatchRuntimeEvent.id, + workspaceInstanceId: `instance_${'f'.repeat(32)}`, + terminalKind, + }), + }), + /does not match its owner-issued no-effect proof/i, + ); + const committed = await commitManagedMutationTerminalInternal(store, input); + assert.equal(committed.created, true); + assert.equal( + (await store.readToolOperation(prepared.operationId))?.currentState, + 'outcome_committed', + ); + assert.equal( + await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId), + undefined, + ); + assert.deepEqual( + await store.readWorkspaceHead( + baseline.epoch.workspaceId, + baseline.epoch.workspaceEpochId, + ), + opened.head, + ); + assert.deepEqual(await commitManagedMutationTerminalInternal(store, input), { + ...committed, + created: false, + }); + + const raw = new DatabaseSync(dbPath); + try { + raw.exec('DELETE FROM runtime_managed_mutation_reservations'); + } finally { + raw.close(); + } + await store.rebuildWorkspaceVersionProjections(); + assert.equal( + await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId), + undefined, + ); + }); + }); + } + + it('atomically commits one tool outcome with its successor workspace head', async () => { + await withDatabase(async ({ dbPath, store }) => { + const { baseline, input, successor } = await prepareSuccessorCommit(store); + const result = await commitWorkspaceSuccessorInternal(store, input); + + assert.equal(result.created, true); + assert.equal(result.committedSuccessor.revision, 2); + assert.equal( + (await store.readToolOperation(input.toolOutcome.operationId))?.currentState, + 'outcome_committed', + ); + assert.deepEqual( + await store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId), + result.committedSuccessor, + ); + assert.equal( + (await store.readWorkspaceVersion(result.committedSuccessor.workspaceVersionId))?.origin + .kind, + 'tool_mutation', + ); + + const retry = await commitWorkspaceSuccessorInternal(store, input); + assert.deepEqual(retry, { ...result, created: false }); + const raw = new DatabaseSync(dbPath); + try { + assert.equal(count(raw, 'runtime_workspace_versions'), 2); + assert.equal(count(raw, 'runtime_workspace_heads'), 1); + assert.equal( + ( + raw + .prepare(` + SELECT changed_paths_json FROM runtime_workspace_versions + WHERE workspace_version_id = ? + `) + .get(result.committedSuccessor.workspaceVersionId) as { changed_paths_json: string } + ).changed_paths_json, + '["notes.txt"]', + ); + assert.equal( + countWhere(raw, 'runtime_events', 'session_id = ?', WORKSPACE_AUTHORITY_SESSION_ID), + 3, + ); + } finally { + raw.close(); + } + + const corrupt = new DatabaseSync(dbPath); + try { + const row = corrupt + .prepare('SELECT payload_json FROM runtime_events WHERE event_id = ?') + .get(successor.acceptedEventId) as { payload_json: string }; + const event = JSON.parse(row.payload_json) as RuntimeEvent; + const fact = event.actions?.workspaceFact; + assert.equal(fact?.kind, 'maka.workspace.version_accepted'); + if (fact?.kind === 'maka.workspace.version_accepted') { + fact.payload.origin.outcomeEventId = 'other-outcome-event'; + } + corrupt + .prepare('UPDATE runtime_events SET payload_json = ? WHERE event_id = ?') + .run(JSON.stringify(event), successor.acceptedEventId); + } finally { + corrupt.close(); + } + await assert.rejects( + store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId), + /workspace successor tool evidence: identity_conflict/i, + ); + }); + }); + + it('rejects a raw successor descriptor without an owner-issued candidate capability', async () => { + await withDatabase(async ({ store }) => { + const { input, successor } = await prepareSuccessorCommit(store); + + await assert.rejects( + async () => + commitWorkspaceSuccessorInternal(store, { + ...input, + candidateOutcome: { successor }, + }), + /unrecognized test candidate capability/i, + ); + assert.equal( + (await store.readToolOperation(input.toolOutcome.operationId))?.currentState, + 'prepared', + ); + }); + }); + + it('rejects a successor whose immutable changed paths exceed its T1 authorization', async () => { + await withDatabase(async ({ store }) => { + const { input, successor } = await prepareSuccessorCommit(store); + const mismatched: WorkspaceSuccessorCommitInput = { + ...input, + candidateOutcome: issueTestCandidate({ + ...successor, + successor: { + ...successor.successor, + changedPaths: ['other.txt'], + }, + }), + }; + + await assert.rejects( + commitWorkspaceSuccessorInternal(store, mismatched), + /managed mutation path authorization conflict/i, + ); + assert.equal( + (await store.readToolOperation(input.toolOutcome.operationId))?.currentState, + 'prepared', + ); + }); + }); + + it('rejects a failed Write outcome without advancing the workspace head', async () => { + await withDatabase(async ({ store }) => { + const { baseline, input, successor } = await prepareSuccessorCommit(store); + assert.equal(input.toolOutcome.runtimeEvent.content?.kind, 'function_response'); + if (input.toolOutcome.runtimeEvent.content?.kind !== 'function_response') { + throw new Error('Expected a function response fixture'); + } + input.toolOutcome.runtimeEvent.content.isError = true; + + await assert.rejects( + commitWorkspaceSuccessorInternal(store, input), + /workspace successor requires a successful tool outcome/i, + ); + assert.equal( + (await store.readToolOperation(input.toolOutcome.operationId))?.currentState, + 'prepared', + ); + assert.equal( + (await store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId)) + ?.workspaceVersionId, + baseline.baseline.workspaceVersionId, + ); + }); + }); + + it('rolls back tool outcome, successor fact, projection, and head together', async () => { + await withDatabase(async ({ dbPath, store, setFailpoint }) => { + const { baseline, input, successor } = await prepareSuccessorCommit(store); + setFailpoint('after_workspace_successor_event_insert'); + await assert.rejects(commitWorkspaceSuccessorInternal(store, input), /failpoint/); + setFailpoint(undefined); + store.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.equal( + (await reopened.readToolOperation(input.toolOutcome.operationId))?.currentState, + 'prepared', + ); + assert.equal( + ( + await reopened.readWorkspaceHead( + baseline.epoch.workspaceId, + baseline.epoch.workspaceEpochId, + ) + )?.workspaceVersionId, + baseline.baseline.workspaceVersionId, + ); + assert.equal( + await reopened.readWorkspaceVersion(successor.successor.workspaceVersionId), + undefined, + ); + } finally { + reopened.close(); + } + }); + }); + + it('rejects a stale managed mutation before it can acquire T1 ownership', async () => { + await withDatabase(async ({ store }) => { + const first = await prepareSuccessorCommit(store, 1); + await commitWorkspaceSuccessorInternal(store, first.input); + const staleHead = { + repositoryId: first.baseline.epoch.repositoryId, + workspaceId: first.baseline.epoch.workspaceId, + workspaceEpochId: first.baseline.epoch.workspaceEpochId, + workspaceVersionId: first.baseline.baseline.workspaceVersionId, + acceptedEventId: first.baseline.baselineAcceptedEventId, + commitOid: first.baseline.baseline.commitOid, + treeOid: first.baseline.baseline.treeOid, + revision: 1, + }; + await assert.rejects( + store.commitToolPrepared( + managedPreparedCommit(first.baseline, staleHead, 'operation-successor-2'), + ), + /does not match the canonical workspace head/i, + ); + assert.equal(await store.readToolOperation('operation-successor-2'), undefined); + }); + }); + + it('returns an earlier exact successor retry after the canonical head advances', async () => { + await withDatabase(async ({ store }) => { + const first = await prepareSuccessorCommit(store, 1); + const firstResult = await commitWorkspaceSuccessorInternal(store, first.input); + const second = await prepareSuccessorCommit(store, 2); + const secondResult = await commitWorkspaceSuccessorInternal(store, second.input); + assert.equal( + secondResult.committedSuccessor.revision, + firstResult.committedSuccessor.revision + 1, + ); + + const retry = await commitWorkspaceSuccessorInternal(store, first.input); + assert.deepEqual(Object.keys(retry).sort(), [ + 'committedSuccessor', + 'created', + 'outcomeRuntimeEventSeq', + ]); + assert.deepEqual(retry, { ...firstResult, created: false }); + assert.deepEqual( + await store.readWorkspaceHead( + first.baseline.epoch.workspaceId, + first.baseline.epoch.workspaceEpochId, + ), + secondResult.committedSuccessor, + ); + }); + }); + + it('rejects a failed outcome referenced by immutable successor authority', async () => { + await withDatabase(async ({ dbPath, store }) => { + const { input } = await prepareSuccessorCommit(store); + await commitWorkspaceSuccessorInternal(store, input); + + const raw = new DatabaseSync(dbPath); + try { + const row = raw + .prepare('SELECT payload_json FROM runtime_events WHERE event_id = ?') + .get(input.toolOutcome.runtimeEvent.id) as { payload_json: string }; + const outcome = JSON.parse(row.payload_json) as RuntimeEvent; + assert.equal(outcome.content?.kind, 'function_response'); + if (outcome.content?.kind !== 'function_response') { + throw new Error('Expected a function response fixture'); + } + outcome.content.isError = true; + raw + .prepare('UPDATE runtime_events SET payload_json = ? WHERE event_id = ?') + .run(JSON.stringify(outcome), input.toolOutcome.runtimeEvent.id); + } finally { + raw.close(); + } + + await assert.rejects( + store.rebuildWorkspaceVersionProjections(), + /workspace successor tool evidence: identity_conflict/i, + ); + }); + }); + + it('upgrades a populated schema 12 baseline before accepting its successor', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-schema-12-')); + const dbPath = join(root, 'runtime.sqlite'); + const baseline = baselineInput(); + const original = createSqliteRuntimeStore(dbPath); + bindWorkspaceBaselineAuthorityStoreRootInternal(original, TEST_STORAGE_ROOT_ID); + await commitWorkspaceBaselineInternal(original, baseline); + original.close(); + + try { + const legacy = new DatabaseSync(dbPath); + try { + recreateWorkspaceTablesAsSchema12(legacy); + } finally { + legacy.close(); + } + + const upgraded = createSqliteRuntimeStore(dbPath); + bindWorkspaceBaselineAuthorityStoreRootInternal(upgraded, TEST_STORAGE_ROOT_ID); + registerWorkspaceSuccessorCandidateVerifierInternal(upgraded, verifyTestCandidate); + try { + assert.equal(upgraded.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); + assert.equal( + ( + await upgraded.readWorkspaceHead( + baseline.epoch.workspaceId, + baseline.epoch.workspaceEpochId, + ) + )?.workspaceVersionId, + baseline.baseline.workspaceVersionId, + ); + + const prepared = await prepareSuccessorCommit(upgraded); + const accepted = await commitWorkspaceSuccessorInternal(upgraded, prepared.input); + assert.equal(accepted.committedSuccessor.revision, 2); + } finally { + upgraded.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('refuses to silently claim unbound workspace authority facts for another root', async () => { + await withDatabase(async ({ dbPath, store }) => { + await commitWorkspaceBaselineInternal(store, baselineInput()); + const raw = new DatabaseSync(dbPath); + try { + raw.exec('DELETE FROM runtime_storage_root_binding'); + } finally { + raw.close(); + } + await assert.rejects( + commitWorkspaceBaselineInternal(store, baselineInput()), + /durable storage-root binding changed/u, + ); + assert.throws( + () => bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'b'.repeat(64)), + /require explicit storage-root adoption/u, + ); + }); + }); + + it('refuses to silently claim an unbound database with ordinary runtime state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-unbound-operational-state-')); + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + const event: RuntimeEvent = { + id: 'ordinary-existing-runtime-event', + sessionId: 'session-existing', + invocationId: 'invocation-existing', + runId: 'run-existing', + turnId: 'turn-existing', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'existing root-owned state' }, + }; + await store.appendRuntimeEvent(event.sessionId, event.runId, event); + assert.throws( + () => bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID), + /unbound operational data require explicit storage-root adoption/iu, + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + for (const failpoint of [ + 'after_workspace_epoch_event_insert', + 'after_workspace_version_event_insert', + 'after_workspace_epoch_projection_insert', + 'after_workspace_version_projection_insert', + 'after_workspace_head_projection_insert', + ] satisfies SqliteRuntimeStoreFailpoint[]) { + it(`rolls the entire baseline back at ${failpoint}`, async () => { + await withDatabase(async ({ dbPath, store, setFailpoint }) => { + setFailpoint(failpoint); + await assert.rejects(commitWorkspaceBaselineInternal(store, baselineInput()), /failpoint/); + store.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.equal( + await reopened.readWorkspaceHead( + baselineInput().epoch.workspaceId, + baselineInput().epoch.workspaceEpochId, + ), + undefined, + ); + assert.deepEqual( + await reopened.readRuntimeEvents( + WORKSPACE_AUTHORITY_SESSION_ID, + workspaceAuthorityIdentity(baselineInput().epoch.workspaceEpochId).runId, + ), + [], + ); + } finally { + reopened.close(); + } + }); + }); + } + + it('rebuilds disposable projections from strict RuntimeEvents and detects corruption', async () => { + await withDatabase(async ({ dbPath, store }) => { + const input = baselineInput(); + const committed = await commitWorkspaceBaselineInternal(store, input); + const raw = new DatabaseSync(dbPath); + try { + raw.exec(` + DELETE FROM runtime_workspace_heads; + DELETE FROM runtime_workspace_versions; + DELETE FROM runtime_workspace_epochs; + `); + } finally { + raw.close(); + } + + await assert.rejects( + store.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId), + /projection is incomplete/i, + ); + assert.deepEqual(await store.rebuildWorkspaceVersionProjections(), { + epochs: 1, + versions: 1, + heads: 1, + }); + assert.deepEqual( + await store.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId), + committed.head, + ); + + const corrupt = new DatabaseSync(dbPath); + try { + corrupt + .prepare(`UPDATE runtime_events SET run_id = 'workspace_run_corrupt' WHERE event_id = ?`) + .run(input.epochOpenedEventId); + } finally { + corrupt.close(); + } + await assert.rejects( + store.rebuildWorkspaceVersionProjections(), + /row\/payload identity mismatch/i, + ); + const afterFailedRebuild = new DatabaseSync(dbPath); + try { + const head = afterFailedRebuild + .prepare(` + SELECT workspace_version_id, accepted_event_id + FROM runtime_workspace_heads + WHERE workspace_id = ? AND workspace_epoch_id = ? + `) + .get(input.epoch.workspaceId, input.epoch.workspaceEpochId) as + | { workspace_version_id: string; accepted_event_id: string } + | undefined; + assert.deepEqual(head && { ...head }, { + workspace_version_id: committed.head.workspaceVersionId, + accepted_event_id: committed.head.acceptedEventId, + }); + } finally { + afterFailedRebuild.close(); + } + }); + }); + + it('rebuilds an active managed mutation reservation from its immutable T1', async () => { + await withDatabase(async ({ dbPath, store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit( + baseline, + opened.head, + 'operation-rebuild-reservation', + ); + await store.commitToolPrepared(prepared); + const raw = new DatabaseSync(dbPath); + try { + raw.exec('DELETE FROM runtime_managed_mutation_reservations'); + } finally { + raw.close(); + } + + await assert.rejects( + store.commitToolPrepared(prepared), + /mutation reservation projection is incomplete/i, + ); + await assert.rejects( + store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId), + /mutation reservation projection is incomplete/i, + ); + await store.rebuildWorkspaceVersionProjections(); + const rebuilt = new DatabaseSync(dbPath); + try { + assert.equal(count(rebuilt, 'runtime_managed_mutation_reservations'), 1); + } finally { + rebuilt.close(); + } + }); + }); + + it('rejects workspace facts through generic RuntimeEvent writers', async () => { + await withDatabase(async ({ store, root }) => { + const { epochOpenedEvent } = buildWorkspaceBaselineAuthorityEvents(baselineInput()); + await assert.rejects( + store.appendRuntimeEvent( + epochOpenedEvent.sessionId, + epochOpenedEvent.runId, + epochOpenedEvent, + ), + /workspace version authority writer/i, + ); + await assert.rejects( + store.importRuntimeEventsBatch({ + sessionId: epochOpenedEvent.sessionId, + runId: epochOpenedEvent.runId, + events: [epochOpenedEvent], + }), + /workspace version authority writer/i, + ); + await assert.rejects( + store.importConversationCopyRuntimeEvents(epochOpenedEvent.sessionId, [ + { runId: epochOpenedEvent.runId, events: [epochOpenedEvent] }, + ]), + /workspace version authority writer/i, + ); + + const args = { path: 'notes.txt' }; + const argsHash = canonicalToolArgsHash('Read', args); + const boundFact = epochOpenedEvent.actions!.workspaceFact!; + await assert.rejects( + store.commitToolPrepared({ + operationId: 'workspace-bypass-operation', + journalEventId: 'workspace-bypass-prepared', + runtimeEvent: { + id: 'workspace-bypass-call', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'call-1', name: 'Read', args }, + actions: { workspaceFact: boundFact }, + refs: { operationId: 'workspace-bypass-operation', toolCallId: 'call-1' }, + }, + dispatchRuntimeEvent: { + id: 'workspace-bypass-dispatch', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'workspace-bypass-operation', + providerToolCallId: 'call-1', + toolName: 'Read', + canonicalArgsHash: argsHash, + recoveryMode: 'replay_safe', + }, + }, + refs: { operationId: 'workspace-bypass-operation', toolCallId: 'call-1' }, + }, + providerToolCallId: 'call-1', + toolName: 'Read', + canonicalArgsHash: argsHash, + recoveryMode: 'replay_safe', + committedAt: 1, + }), + /workspace version authority writer/i, + ); + await assert.rejects( + store.commitToolOutcome({ + operationId: 'workspace-bypass-operation', + journalEventId: 'workspace-bypass-outcome', + committedAt: 2, + runtimeEvent: { + id: 'workspace-bypass-response', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { kind: 'function_response', id: 'call-1', name: 'Read', result: 'ok' }, + actions: { workspaceFact: boundFact }, + refs: { operationId: 'workspace-bypass-operation', toolCallId: 'call-1' }, + }, + }), + /workspace version authority writer/i, + ); + await assert.rejects( + store.commitToolRecoveryBundle({ + operationId: 'workspace-bypass-operation', + reconcileRuntimeEvent: epochOpenedEvent, + decisionRuntimeEvent: { + ...epochOpenedEvent, + id: 'workspace-bypass-decision', + }, + }), + /workspace version authority writer/i, + ); + }); + }); + + it('rejects ordinary RuntimeEvents that try to occupy the authority stream', async () => { + await withDatabase(async ({ store }) => { + const input = baselineInput(); + const identity = workspaceAuthorityIdentity(input.epoch.workspaceEpochId); + const ordinary: RuntimeEvent = { + id: 'ordinary-authority-event', + ...identity, + ts: input.committedAt, + partial: false, + role: 'system', + author: 'system', + content: { kind: 'text', text: 'not an authority fact' }, + }; + await assert.rejects( + store.appendRuntimeEvent(ordinary.sessionId, ordinary.runId, ordinary), + /reserved workspace authority stream/i, + ); + }); + }); + + it('fails closed when the workspace authority capability is missing or unknown', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-capability-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + store.close(); + try { + for (const version of [undefined, 2] as const) { + const raw = new DatabaseSync(dbPath); + try { + raw + .prepare(` + DELETE FROM runtime_capabilities + WHERE capability = 'runtime_workspace_version_authority' + `) + .run(); + if (version !== undefined) { + raw + .prepare(` + INSERT INTO runtime_capabilities(capability, version) + VALUES ('runtime_workspace_version_authority', ?) + `) + .run(version); + } + } finally { + raw.close(); + } + assert.throws( + () => createSqliteRuntimeStore(dbPath), + /runtime_workspace_version_authority@1 is unavailable/, + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +async function withDatabase( + run: (input: { + root: string; + dbPath: string; + store: ReturnType; + setFailpoint: (failpoint: SqliteRuntimeStoreFailpoint | undefined) => void; + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-authority-')); + const dbPath = join(root, 'runtime.sqlite'); + let activeFailpoint: SqliteRuntimeStoreFailpoint | undefined; + const store = createSqliteRuntimeStore(dbPath, { + failpoint(point) { + if (point === activeFailpoint) throw new Error(`failpoint:${point}`); + }, + }); + bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID); + registerWorkspaceSuccessorCandidateVerifierInternal(store, verifyTestCandidate); + registerManagedMutationNoEffectVerifierInternal(store, verifyTestNoEffect); + try { + await run({ + root, + dbPath, + store, + setFailpoint(value) { + activeFailpoint = value; + }, + }); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +async function prepareSuccessorCommit( + store: ReturnType, + variant = 1, +): Promise<{ + baseline: WorkspaceBaselineAuthorityInput; + input: WorkspaceSuccessorCommitInput; + successor: WorkspaceSuccessorAuthorityInput; +}> { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const args = { path: 'notes.txt', content: 'successor' }; + const argsHash = canonicalToolArgsHash('Write', args); + const operationId = `operation-successor-${variant}`; + const toolCallId = `call-successor-${variant}`; + const commitDigit = variant === 1 ? '7' : '6'; + const treeDigit = variant === 1 ? '8' : '5'; + await store.commitToolPrepared({ + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: { + id: `call-successor-event-${variant}`, + sessionId: 'session-successor', + invocationId: 'invocation-successor', + runId: 'run-successor', + turnId: 'turn-successor', + ts: baseline.committedAt + 1, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: toolCallId, name: 'Write', args }, + refs: { operationId, toolCallId }, + }, + dispatchRuntimeEvent: { + id: `dispatch-successor-event-${variant}`, + sessionId: 'session-successor', + invocationId: 'invocation-successor', + runId: 'run-successor', + turnId: 'turn-successor', + ts: baseline.committedAt + 1, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile', + managedMutation: { + protocol: 'managed_mutation_v2', + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: opened.head.workspaceVersionId, + baseAcceptedEventId: opened.head.acceptedEventId, + baseHeadRevision: opened.head.revision, + baseCommitOid: opened.head.commitOid, + baseTreeOid: opened.head.treeOid, + expectedPath: 'notes.txt', + pathPolicyVersion: 3 as const, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + }, + }, + refs: { operationId, toolCallId }, + }, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile', + committedAt: baseline.committedAt + 1, + }); + + const successor: WorkspaceSuccessorAuthorityInput = { + acceptedEventId: `workspace-successor-event-${variant}`, + committedAt: baseline.committedAt + 2, + successor: { + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceVersionId: `version_${commitDigit.repeat(32)}`, + objectFormat: baseline.epoch.objectFormat, + parentWorkspaceVersionId: opened.head.workspaceVersionId, + baseAcceptedEventId: opened.head.acceptedEventId, + baseHeadRevision: opened.head.revision, + commitOid: commitDigit.repeat(40), + treeOid: treeDigit.repeat(40), + policyHash: baseline.epoch.policyHash, + treeDeltaDigest: `sha256:${'9'.repeat(64)}`, + changedPaths: ['notes.txt'], + changedFileCount: 1, + deletedFileCount: 0, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + origin: { + operationId, + dispatchEventId: `dispatch-successor-event-${variant}`, + outcomeEventId: `outcome-successor-event-${variant}`, + }, + }; + return { + baseline, + successor, + input: { + candidateOutcome: issueTestCandidate(successor), + toolOutcome: { + operationId, + journalEventId: `${operationId}_outcome`, + committedAt: baseline.committedAt + 2, + runtimeEvent: { + id: `outcome-successor-event-${variant}`, + sessionId: 'session-successor', + invocationId: 'invocation-successor', + runId: 'run-successor', + turnId: 'turn-successor', + ts: baseline.committedAt + 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: toolCallId, + name: 'Write', + result: 'Wrote notes.txt', + }, + refs: { operationId, toolCallId }, + }, + }, + }, + }; +} + +function managedPreparedCommit( + baseline: WorkspaceBaselineAuthorityInput, + head: Awaited>['head'], + operationId: string, +) { + const toolCallId = `${operationId}-call`; + const args = { path: 'notes.txt', content: operationId }; + const argsHash = canonicalToolArgsHash('Write', args); + const identity = { + sessionId: 'session-managed-reservation', + invocationId: `invocation-${operationId}`, + runId: `run-${operationId}`, + turnId: `turn-${operationId}`, + }; + return { + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: { + id: `${operationId}-call-event`, + ...identity, + ts: baseline.committedAt + 1, + partial: false, + role: 'model' as const, + author: 'agent' as const, + content: { kind: 'function_call' as const, id: toolCallId, name: 'Write', args }, + refs: { operationId, toolCallId }, + }, + dispatchRuntimeEvent: { + id: `${operationId}-dispatch-event`, + ...identity, + ts: baseline.committedAt + 1, + partial: false, + role: 'system' as const, + author: 'system' as const, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1' as const, + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile' as const, + managedMutation: { + protocol: 'managed_mutation_v2' as const, + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPath: 'notes.txt', + pathPolicyVersion: 3 as const, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + }, + }, + refs: { operationId, toolCallId }, + }, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile' as const, + committedAt: baseline.committedAt + 1, + }; +} + +function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { + database.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + + ALTER TABLE runtime_workspace_heads RENAME TO runtime_workspace_heads_schema_13; + ALTER TABLE runtime_workspace_versions RENAME TO runtime_workspace_versions_schema_13; + + CREATE TABLE runtime_workspace_versions ( + workspace_version_id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + object_format TEXT NOT NULL CHECK (object_format IN ('sha1', 'sha256')), + origin_kind TEXT NOT NULL CHECK (origin_kind = 'baseline'), + origin_event_id TEXT NOT NULL, + parents_json TEXT NOT NULL CHECK (parents_json = '[]'), + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + policy_hash TEXT NOT NULL, + tree_delta_digest TEXT NOT NULL, + changed_file_count INTEGER NOT NULL CHECK (changed_file_count >= 0), + deleted_file_count INTEGER NOT NULL CHECK (deleted_file_count = 0), + accepted_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + committed_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + UNIQUE (workspace_id, workspace_epoch_id, workspace_version_id, accepted_event_id) + ); + + INSERT INTO runtime_workspace_versions ( + workspace_version_id, repository_id, workspace_id, workspace_epoch_id, + object_format, origin_kind, origin_event_id, parents_json, + commit_oid, tree_oid, policy_hash, tree_delta_digest, + changed_file_count, deleted_file_count, accepted_event_id, + protocol_version, committed_at + ) + SELECT + workspace_version_id, repository_id, workspace_id, workspace_epoch_id, + object_format, origin_kind, origin_event_id, parents_json, + commit_oid, tree_oid, policy_hash, tree_delta_digest, + changed_file_count, deleted_file_count, accepted_event_id, + protocol_version, committed_at + FROM runtime_workspace_versions_schema_13; + + CREATE TABLE runtime_workspace_heads ( + workspace_id TEXT NOT NULL, + workspace_epoch_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + workspace_version_id TEXT NOT NULL, + accepted_event_id TEXT NOT NULL, + commit_oid TEXT NOT NULL, + tree_oid TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + PRIMARY KEY (workspace_id, workspace_epoch_id), + FOREIGN KEY (workspace_id, workspace_epoch_id) + REFERENCES runtime_workspace_epochs(workspace_id, workspace_epoch_id), + FOREIGN KEY (workspace_id, workspace_epoch_id, workspace_version_id, accepted_event_id) + REFERENCES runtime_workspace_versions( + workspace_id, workspace_epoch_id, workspace_version_id, accepted_event_id + ) + ); + + INSERT INTO runtime_workspace_heads + SELECT * FROM runtime_workspace_heads_schema_13; + + DROP TABLE runtime_workspace_heads_schema_13; + DROP TABLE runtime_workspace_versions_schema_13; + DROP TABLE runtime_managed_mutation_reservations; + DROP INDEX runtime_events_by_session_kind; + DROP INDEX runtime_events_one_opening_per_invocation; + DROP TABLE runtime_legacy_invocation_openings; + PRAGMA user_version = 12; + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + +function baselineInput( + overrides: Partial = {}, +): WorkspaceBaselineAuthorityInput { + const base: WorkspaceBaselineAuthorityInput = { + epochOpenedEventId: 'workspace-epoch-event-1', + baselineAcceptedEventId: 'workspace-version-event-1', + committedAt: 1_700_000_000_000, + epoch: { + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: '1'.repeat(40), + sourceTreeOid: '2'.repeat(40), + materializationProfileDigest: `sha256:${'3'.repeat(64)}`, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: `sha256:${'4'.repeat(64)}`, + }, + baseline: { + workspaceVersionId: 'version_55555555555555555555555555555555', + commitOid: '5'.repeat(40), + treeOid: '2'.repeat(40), + treeDeltaDigest: `sha256:${'6'.repeat(64)}`, + changedFileCount: 7, + deletedFileCount: 0, + }, + }; + return { + ...base, + ...overrides, + epoch: overrides.epoch ?? base.epoch, + baseline: overrides.baseline ?? base.baseline, + }; +} + +function count(db: DatabaseSync, table: string): number { + return (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count; +} + +function countWhere(db: DatabaseSync, table: string, where: string, value: string): number { + return ( + db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${where}`).get(value) as { + count: number; + } + ).count; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f48c827648d07a3667b7cb9d6be7bdc3693b501b1bdbc33d927ec20f68b4802.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f48c827648d07a3667b7cb9d6be7bdc3693b501b1bdbc33d927ec20f68b4802.source new file mode 100644 index 0000000000..0890db5907 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f48c827648d07a3667b7cb9d6be7bdc3693b501b1bdbc33d927ec20f68b4802.source @@ -0,0 +1,737 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import type { EmittedAgentRunEvent } from '@maka/core/agent-run'; +import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; +import type { RunCompositionSnapshot } from '@maka/core/run-composition'; +import { + MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + decodeModelCallAttempt, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; +import type { InteractionCanonicalOutcome, InteractionRequest } from '@maka/core/interaction'; +import type { ShellRunRecord } from '@maka/core/shell-run'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { + closeSqliteInteractionStoreFacade, + openSqliteInteractiveInteractionStoreForWrite, + type StoredInteractionRequest, +} from '../interaction-store.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { createSqliteShellRunStore } from '../shell-run-store.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; +import { openInvocation } from './fixtures/invocation-opening.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +describe('SQLite core execution stores', () => { + test('persists AgentRun events against the invocation that opened them', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', runEvent()); + store.close?.(); + + const reopened = createSqliteAgentRunStore(root); + try { + assert.equal((await reopened.readEvents('session-1', 'run-1'))[0]?.id, 'event-1'); + } finally { + reopened.close?.(); + } + }); + }); + + test('refuses to hang an event on a run no invocation ever opened', async () => { + await withRoot(async (root) => { + const store = createSqliteAgentRunStore(root); + try { + await assert.rejects(store.appendEvent('session-1', 'run-missing', runEvent()), { + code: 'ENOENT', + }); + } finally { + store.close?.(); + } + }); + }); + + test('advances the model-call high-water index with the authority append', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', runEvent()); + await store.appendEvent('session-1', 'run-1', { + ...runEvent(), + id: 'model-call-event', + type: 'model_call_attempt_recorded', + data: { ...modelCallAttempt() }, + }); + + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + database + .prepare(` + SELECT latest_model_call_sequence AS sequence + FROM core_agent_runs + WHERE session_id = 'session-1' AND run_id = 'run-1' + `) + .get()?.sequence, + 1, + ); + } finally { + database.close(); + store.close?.(); + } + }); + }); + + test('commits canonical authority without guessing a malformed projection order', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent( + 'session-1', + 'run-1', + { + ...runEvent(), + id: 'model-call-newer', + type: 'model_call_attempt_recorded', + ts: 100, + data: { + ...modelCallAttempt({ + attemptId: 'attempt-newer', + completedAt: 100, + latencyMs: 99, + }), + }, + }, + { + latestContext: { + attemptId: 'attempt-newer', + orderedAt: 100, + snapshot: { + schemaVersion: 2, + attemptId: 'attempt-newer', + providerId: 'openai', + modelId: 'gpt-5', + completedAt: 100, + }, + }, + }, + ); + store.close?.(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database + .prepare(` + UPDATE core_agent_run_projections + SET event_json = '{malformed' + WHERE session_id = 'session-1' AND event_type = 'latest_context' + `) + .run(); + } finally { + database.close(); + } + + const reopened = createSqliteAgentRunStore(root); + try { + await reopened.appendEvent( + 'session-1', + 'run-1', + { + ...runEvent(), + id: 'model-call-older', + type: 'model_call_attempt_recorded', + ts: 50, + data: { + ...modelCallAttempt({ + logicalCallId: 'call-older', + attemptId: 'attempt-older', + traceId: 'trace-older', + completedAt: 50, + latencyMs: 49, + }), + }, + }, + { + latestContext: { + attemptId: 'attempt-older', + orderedAt: 50, + snapshot: { + schemaVersion: 2, + attemptId: 'attempt-older', + providerId: 'openai', + modelId: 'gpt-5', + completedAt: 50, + }, + }, + }, + ); + + assert.ok( + (await reopened.readEvents('session-1', 'run-1')).some( + (event) => event.id === 'model-call-older', + ), + ); + } finally { + reopened.close?.(); + } + + const inspected = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + inspected + .prepare(` + SELECT event_json AS eventJson + FROM core_agent_run_projections + WHERE session_id = 'session-1' AND event_type = 'latest_context' + `) + .get()?.eventJson, + '{malformed', + 'unknown incumbent ordering stays untouched until a ledger rebuild can repair it', + ); + } finally { + inspected.close(); + } + }); + }); + + test('does not repair a malformed projection from a stale ledger revision', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', runEvent()); + await store.repairEventProjection( + 'session-1', + 'history_compact_checkpoint_recorded', + { + ...runEvent(), + id: 'checkpoint-a', + type: 'history_compact_checkpoint_recorded', + }, + { ifLedgerRevision: await store.readEventLedgerRevision('session-1') }, + ); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database + .prepare(` + UPDATE core_agent_run_projections + SET event_json = '{malformed' + WHERE session_id = 'session-1' + AND event_type = 'history_compact_checkpoint_recorded' + `) + .run(); + } finally { + database.close(); + } + + const staleRevision = await store.readEventLedgerRevision('session-1'); + await store.appendEvent('session-1', 'run-1', { + ...runEvent(), + id: 'event-2', + ts: 2, + }); + await store.repairEventProjection( + 'session-1', + 'history_compact_checkpoint_recorded', + { + ...runEvent(), + id: 'checkpoint-a', + type: 'history_compact_checkpoint_recorded', + }, + { ifLedgerRevision: staleRevision }, + ); + + const inspected = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + inspected + .prepare(` + SELECT event_json AS eventJson + FROM core_agent_run_projections + WHERE session_id = 'session-1' + AND event_type = 'history_compact_checkpoint_recorded' + `) + .get()?.eventJson, + '{malformed', + ); + } finally { + inspected.close(); + store.close?.(); + } + }); + }); + + test('rejects a projection repair without a canonical ledger revision', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', runEvent()); + const before = await store.readEventProjection( + 'session-1', + 'history_compact_checkpoint_recorded', + ); + + await assert.rejects( + // @ts-expect-error A repair must prove which canonical ledger revision it rebuilt. + store.repairEventProjection('session-1', 'history_compact_checkpoint_recorded', { + ...runEvent(), + id: 'checkpoint-a', + type: 'history_compact_checkpoint_recorded', + }), + /ledger revision/i, + ); + + assert.equal( + await store.readEventProjection('session-1', 'history_compact_checkpoint_recorded'), + before, + ); + store.close?.(); + }); + }); + + test('backfills the model-call high-water when upgrading existing AgentRun rows', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', { + ...runEvent(), + id: 'legacy-model-call-event', + type: 'model_call_attempt_recorded', + data: { ...modelCallAttempt() }, + }); + store.close?.(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + database.exec(` + DROP INDEX core_agent_runs_model_call_high_water; + ALTER TABLE core_agent_runs DROP COLUMN latest_model_call_sequence; + UPDATE operational_schema_migrations SET version = 3 WHERE scope = 'core_execution'; + `); + database.close(); + + const migrated = createSqliteAgentRunStore(root); + try { + const inspected = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + inspected + .prepare(` + SELECT latest_model_call_sequence AS sequence + FROM core_agent_runs + WHERE session_id = 'session-1' AND run_id = 'run-1' + `) + .get()?.sequence, + 0, + ); + } finally { + inspected.close(); + } + } finally { + migrated.close?.(); + } + }); + }); + + test('drops obsolete Host-Epoch message receipt tables on upgrade', async () => { + await withRoot(async (root) => { + createSqliteAgentRunStore(root).close?.(); + const path = join(root, 'runtime.sqlite'); + const legacy = new DatabaseSync(path); + legacy.exec(` + CREATE TABLE core_message_host_epochs (host_epoch TEXT PRIMARY KEY); + CREATE TABLE core_message_receipts ( + host_epoch TEXT NOT NULL, + operation TEXT NOT NULL, + session_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY (host_epoch, operation, session_id, operation_id) + ); + UPDATE operational_schema_migrations SET version = 4 WHERE scope = 'core_execution'; + `); + legacy.close(); + + createSqliteAgentRunStore(root).close?.(); + const migrated = new DatabaseSync(path, { readOnly: true }); + try { + assert.deepEqual( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'core_message_%'", + ) + .all(), + [], + ); + } finally { + migrated.close(); + } + }); + }); + + test('drops the obsolete AgentRun identity index on upgrade', async () => { + await withRoot(async (root) => { + createSqliteAgentRunStore(root).close?.(); + const path = join(root, 'runtime.sqlite'); + const legacy = new DatabaseSync(path); + legacy.exec(` + CREATE INDEX IF NOT EXISTS core_agent_runs_identity + ON core_agent_runs(run_id, session_id); + UPDATE operational_schema_migrations SET version = 5 WHERE scope = 'core_execution'; + `); + legacy.close(); + + createSqliteAgentRunStore(root).close?.(); + const migrated = new DatabaseSync(path, { readOnly: true }); + try { + assert.deepEqual( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'core_agent_runs_identity'", + ) + .all(), + [], + ); + } finally { + migrated.close(); + } + }); + }); + + test('preserves provider failure diagnostics in the AgentRun authority after reopen', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', { + type: 'model_call_attempt_recorded', + id: 'attempt-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 10, + data: { + ...modelCallAttempt({ + callKind: 'history_compact', + historyCompactRoute: 'provider_native', + connectionSlug: 'codex-subscription', + providerId: 'openai-codex', + modelId: 'gpt-5.6-sol', + completedAt: 10, + latencyMs: 9, + status: 'failed', + errorClass: 'RequestRejected', + httpStatus: 400, + providerCode: 'invalid_request_error', + providerRequestId: 'req-authority-1', + retryable: false, + usageBasis: 'missing', + inputTokens: undefined, + outputTokens: undefined, + costBasis: 'unpriced', + costUsd: undefined, + }), + }, + }); + store.close?.(); + + const reopened = createSqliteAgentRunStore(root); + try { + const event = (await reopened.readEvents('session-1', 'run-1'))[0]; + const attempt = decodeModelCallAttempt(event?.data); + assert.equal(attempt.historyCompactRoute, 'provider_native'); + assert.equal(attempt.httpStatus, 400); + assert.equal(attempt.providerRequestId, 'req-authority-1'); + } finally { + reopened.close?.(); + } + }); + }); + + test('commits one immutable Run Composition snapshot', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + try { + const composition = runComposition('1'); + await store.appendEvent('session-1', 'run-1', compositionEvent('event-1', composition)); + await store.appendEvent('session-1', 'run-1', compositionEvent('event-2', composition)); + const events = await store.readEvents('session-1', 'run-1'); + assert.deepEqual(agentRunCompositionFromEvents(events), composition); + assert.equal( + events.filter((event) => event.type === 'run_composition_recorded').length, + 1, + 'an identical re-append is the writer retrying, not a second composition', + ); + await assert.rejects( + store.appendEvent('session-1', 'run-1', compositionEvent('event-3', runComposition('2'))), + /AgentRun Run Composition is immutable/u, + ); + } finally { + store.close?.(); + } + }); + }); + + test('reads an AgentRun event type this build does not write', async () => { + await withRoot(async (root) => { + await openRun(root); + const store = createSqliteAgentRunStore(root); + await store.appendEvent('session-1', 'run-1', runEvent()); + store.close?.(); + + // Rewrite the stored row into what a build that still had this writer would have left + // behind. Going through the database rather than appendEvent is the point: this build + // must be able to read a record it is no longer allowed to produce (#1942). + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + const record = { + ...runEvent(), + type: 'written_by_another_version', + data: { inputTokens: 7 }, + }; + db.prepare( + `UPDATE core_agent_run_events SET event_type = ?, record_json = ? WHERE event_id = ?`, + ).run('written_by_another_version', JSON.stringify(record), 'event-1'); + } finally { + db.close(); + } + + const reopened = createSqliteAgentRunStore(root); + try { + const events = await reopened.readEvents('session-1', 'run-1'); + assert.deepEqual( + events.map((event) => event.type), + ['written_by_another_version'], + ); + assert.equal(events[0]?.data?.inputTokens, 7); + + const recovered = await reopened.readEventsForRecovery('session-1', 'run-1'); + assert.deepEqual( + recovered.map((event) => event.type), + ['written_by_another_version'], + ); + } finally { + reopened.close?.(); + } + }); + }); + + test('persists ShellRun records', async () => { + await withRoot(async (root) => { + const store = createSqliteShellRunStore(root); + await store.createShellRun(shellRun()); + store.close(); + + const reopened = createSqliteShellRunStore(root); + try { + assert.equal((await reopened.readShellRun('session-1', 'shell-1')).command, 'printf "ok"'); + } finally { + reopened.close(); + } + }); + }); + + test('reports a missing ShellRun with the ENOENT store contract', async () => { + await withRoot(async (root) => { + const store = createSqliteShellRunStore(root); + try { + await assert.rejects(store.readShellRun('session-1', 'missing-shell'), { code: 'ENOENT' }); + } finally { + store.close(); + } + }); + }); + + test('persists interaction request and outcome', async () => { + await withRoot(async (root) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const store = await openSqliteInteractiveInteractionStoreForWrite(owner.lease); + try { + await store.establishRequest(storedQuestion()); + await store.commitOutcome('request-1', questionOutcome()); + assert.equal( + (await store.readInteraction('request-1'))?.outcome?.outcome.kind, + 'question_answer', + ); + } finally { + closeSqliteInteractionStoreFacade(store); + await owner.close(); + } + }); + }); +}); + +async function withRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-execution-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function openRun(root: string): Promise { + return openInvocation(root, { sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1' }); +} + +function runEvent(): EmittedAgentRunEvent { + return { + type: 'turn_started', + id: 'event-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 2, + }; +} + +function modelCallAttempt(overrides: Partial = {}): ModelCallAttempt { + return { + schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + logicalCallId: 'call-1', + attemptId: 'attempt-1', + traceId: 'trace-1', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + step: 0, + attempt: 0, + callKind: 'main' as const, + providerId: 'openai', + modelId: 'gpt-5', + startedAt: 1, + completedAt: 2, + latencyMs: 1, + status: 'completed' as const, + usageBasis: 'reported' as const, + inputTokens: 1, + outputTokens: 1, + costBasis: 'priced' as const, + costUsd: 0.001, + ...overrides, + }; +} + +function compositionEvent(id: string, composition: RunCompositionSnapshot): EmittedAgentRunEvent { + return { + type: 'run_composition_recorded', + id, + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 5, + data: { runComposition: composition }, + }; +} + +function runComposition(seed: string): RunCompositionSnapshot { + return { + schemaVersion: 1, + composerId: 'maka.interactive', + composerRevision: '1', + sourceRevisions: [ + { id: 'runtime-policy', revision: '1' }, + { id: 'skill-catalog', revision: 'skills-1' }, + ], + baseSystemPromptHash: hash(seed), + toolCatalogHash: hash(seed), + toolAvailabilityHash: hash(seed), + baseProviderOptionsHash: hash(seed), + toolNames: ['Read'], + contextWindow: 128_000, + }; +} + +function hash(seed: string): `sha256:${string}` { + return `sha256:${seed.repeat(64)}`; +} + +function shellRun(): ShellRunRecord { + return { + shellRunId: 'shell-1', + sessionId: 'session-1', + sourceRunId: 'run-1', + sourceTurnId: 'turn-1', + sourceToolCallId: 'tool-1', + cwd: '/workspace', + command: 'printf "ok"', + status: 'running', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: { + mode: 'pipes', + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }; +} + +function storedQuestion(): StoredInteractionRequest { + return { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + requestId: 'request-1', + createdAt: 1, + request: { + kind: 'question', + toolUseId: 'tool-1', + questions: [ + { + question: 'Choose', + options: [ + { label: 'First', description: 'First' }, + { label: 'Second', description: 'Second' }, + ], + }, + ], + } as InteractionRequest, + }; +} + +function questionOutcome(): InteractionCanonicalOutcome { + return { + kind: 'question_answer', + answers: ['First'], + committedAt: 2, + } as InteractionCanonicalOutcome; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f9af7086b3b96b3b929f6bebb718fcf0fe89f730639007b454c450238da4e8c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f9af7086b3b96b3b929f6bebb718fcf0fe89f730639007b454c450238da4e8c.source new file mode 100644 index 0000000000..f03d067d7b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/6f9af7086b3b96b3b929f6bebb718fcf0fe89f730639007b454c450238da4e8c.source @@ -0,0 +1,613 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION = 5; + +const SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS = 5_000; +const SQLITE_INITIALIZATION_RETRY_DELAY_MS = 10; +const initializationRetryGate = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); + +export type SqliteLongTermMemoryMigrationFailpoint = 'after_schema_sql'; + +export interface SqliteLongTermMemoryMigrationOptions { + readonly failpoint?: (point: SqliteLongTermMemoryMigrationFailpoint) => void; +} + +const MIGRATIONS: ReadonlyMap = new Map([ + [ + 1, + ` + CREATE TABLE memory_items ( + item_id TEXT PRIMARY KEY, + version INTEGER NOT NULL CHECK (version >= 1), + content TEXT NOT NULL CHECK (length(content) > 0), + kind TEXT NOT NULL CHECK ( + kind IN ('preference', 'identity', 'context', 'knowledge', 'failure', 'note') + ), + statement_type TEXT NOT NULL CHECK (statement_type IN ('fact', 'plan', 'prediction')), + temporal_type TEXT NOT NULL CHECK ( + temporal_type IN ('undated', 'point', 'interval', 'open_ended') + ), + scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'workspace')), + scope_key TEXT, + event_started_at INTEGER, + event_ended_at INTEGER, + observed_at INTEGER NOT NULL CHECK (observed_at >= 0), + lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('active', 'archived')), + origin TEXT NOT NULL CHECK (origin IN ('agent_extracted', 'user_requested')), + content_hash TEXT NOT NULL CHECK (length(content_hash) = 64), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + CHECK ( + (scope_type = 'global' AND scope_key IS NULL) + OR + (scope_type = 'workspace' AND scope_key IS NOT NULL AND length(scope_key) > 0) + ), + CHECK ( + (temporal_type = 'undated' + AND event_started_at IS NULL + AND event_ended_at IS NULL) + OR + (temporal_type = 'point' + AND event_started_at IS NOT NULL + AND event_started_at >= 0 + AND (event_ended_at IS NULL OR event_ended_at > event_started_at)) + OR + (temporal_type = 'interval' + AND event_started_at IS NOT NULL + AND event_started_at >= 0 + AND event_ended_at IS NOT NULL + AND event_ended_at > event_started_at) + OR + (temporal_type = 'open_ended' + AND event_started_at IS NOT NULL + AND event_started_at >= 0 + AND event_ended_at IS NULL) + ), + CHECK (created_at <= updated_at), + CHECK (observed_at <= updated_at) + ); + + CREATE INDEX memory_items_by_scope_and_lifecycle + ON memory_items(scope_type, scope_key, lifecycle_state, updated_at DESC, item_id); + + CREATE TABLE memory_item_keys ( + item_id TEXT NOT NULL, + key_text TEXT NOT NULL CHECK (length(key_text) > 0), + normalized_key TEXT NOT NULL CHECK (length(normalized_key) > 0), + key_type TEXT NOT NULL CHECK (key_type IN ('exact', 'entity', 'concept', 'alias', 'code')), + key_origin TEXT NOT NULL CHECK (key_origin IN ('deterministic', 'llm', 'user')), + PRIMARY KEY(item_id, normalized_key), + FOREIGN KEY(item_id) REFERENCES memory_items(item_id) ON DELETE CASCADE + ) WITHOUT ROWID; + + CREATE INDEX memory_item_keys_by_normalized_key + ON memory_item_keys(normalized_key, item_id); + + CREATE TABLE memory_item_sources ( + item_id TEXT NOT NULL, + session_id TEXT NOT NULL CHECK (length(session_id) > 0), + run_id TEXT NOT NULL CHECK (length(run_id) > 0), + turn_id TEXT NOT NULL CHECK (length(turn_id) > 0), + event_id TEXT NOT NULL CHECK (length(event_id) > 0), + PRIMARY KEY(item_id, event_id), + FOREIGN KEY(item_id) REFERENCES memory_items(item_id) ON DELETE CASCADE + ) WITHOUT ROWID; + + CREATE INDEX memory_item_sources_by_event + ON memory_item_sources(event_id, item_id); + + CREATE INDEX memory_item_sources_by_turn + ON memory_item_sources(session_id, turn_id, item_id); + + CREATE TABLE memory_write_operations ( + operation_id TEXT PRIMARY KEY, + operation_type TEXT NOT NULL CHECK ( + operation_type IN ('create', 'update', 'archive', 'restore', 'batch') + ), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + result_json TEXT NOT NULL, + committed_at INTEGER NOT NULL CHECK (committed_at >= 0) + ); + `, + ], + [ + 2, + ` + CREATE TABLE memory_extraction_cursors ( + session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0), + processed_ordinal INTEGER NOT NULL CHECK (processed_ordinal > 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + ); + + CREATE TABLE memory_extraction_receipts ( + operation_id TEXT PRIMARY KEY CHECK (length(operation_id) > 0), + session_id TEXT NOT NULL CHECK (length(session_id) > 0), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + result_json TEXT NOT NULL, + committed_at INTEGER NOT NULL CHECK (committed_at >= 0), + FOREIGN KEY (operation_id) REFERENCES memory_write_operations(operation_id) ON DELETE CASCADE + ); + `, + ], + [ + 3, + ` + CREATE TABLE memory_extraction_failures ( + session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0), + from_ordinal INTEGER NOT NULL CHECK (from_ordinal > 0), + through_ordinal INTEGER NOT NULL CHECK (through_ordinal >= from_ordinal), + coverage_hash TEXT NOT NULL CHECK (length(coverage_hash) = 64), + first_operation_id TEXT NOT NULL UNIQUE CHECK (length(first_operation_id) > 0), + first_trigger TEXT NOT NULL CHECK (first_trigger IN ('remember', 'extract')), + first_failure_class TEXT NOT NULL CHECK ( + first_failure_class IN ( + 'provider', 'schema', 'evidence', 'localization', 'requested_admission' + ) + ), + failed_at INTEGER NOT NULL CHECK (failed_at >= 0) + ); + `, + ], + [ + 4, + ` + ALTER TABLE memory_extraction_failures RENAME TO memory_extraction_failures_v3; + + CREATE TABLE memory_extraction_failures ( + session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0), + from_ordinal INTEGER NOT NULL CHECK (from_ordinal > 0), + through_ordinal INTEGER NOT NULL CHECK (through_ordinal >= from_ordinal), + coverage_hash TEXT NOT NULL CHECK (length(coverage_hash) = 64), + first_operation_id TEXT NOT NULL UNIQUE CHECK (length(first_operation_id) > 0), + first_trigger TEXT NOT NULL CHECK ( + first_trigger IN ('remember', 'extract', 'compaction') + ), + compaction_checkpoint_id TEXT CHECK ( + compaction_checkpoint_id IS NULL OR length(compaction_checkpoint_id) > 0 + ), + first_failure_class TEXT NOT NULL CHECK ( + first_failure_class IN ( + 'provider', 'schema', 'evidence', 'localization', 'requested_admission' + ) + ), + failed_at INTEGER NOT NULL CHECK (failed_at >= 0), + CHECK ( + (first_trigger = 'compaction' AND compaction_checkpoint_id IS NOT NULL) + OR (first_trigger != 'compaction' AND compaction_checkpoint_id IS NULL) + ) + ); + + INSERT INTO memory_extraction_failures( + session_id, from_ordinal, through_ordinal, coverage_hash, + first_operation_id, first_trigger, compaction_checkpoint_id, + first_failure_class, failed_at + ) + SELECT + session_id, from_ordinal, through_ordinal, coverage_hash, + first_operation_id, first_trigger, NULL, + first_failure_class, failed_at + FROM memory_extraction_failures_v3; + + DROP TABLE memory_extraction_failures_v3; + `, + ], + [ + 5, + ` + CREATE TABLE memory_compaction_policy_denials ( + session_id TEXT NOT NULL CHECK (length(session_id) > 0), + compaction_checkpoint_id TEXT NOT NULL CHECK (length(compaction_checkpoint_id) > 0), + denied_at INTEGER NOT NULL CHECK (denied_at >= 0), + PRIMARY KEY(session_id, compaction_checkpoint_id) + ) WITHOUT ROWID; + `, + ], +]); + +interface MinimumTableShape { + readonly name: string; + readonly requiredColumns: readonly string[]; +} + +interface MinimumIndexShape { + readonly name: string; + readonly tableName: string; + readonly requiredColumnPrefix: readonly string[]; +} + +interface MinimumSchemaShape { + readonly tables: readonly MinimumTableShape[]; + readonly indexes: readonly MinimumIndexShape[]; +} + +// Each entry describes the complete minimum shape required by that schema version. Extra +// columns and indexes are allowed so additive migrations do not fail exact-DDL validation. +const VERSION_1_MINIMUM_SCHEMA_SHAPE: MinimumSchemaShape = { + tables: [ + { + name: 'memory_items', + requiredColumns: [ + 'item_id', + 'version', + 'content', + 'kind', + 'statement_type', + 'temporal_type', + 'scope_type', + 'scope_key', + 'event_started_at', + 'event_ended_at', + 'observed_at', + 'lifecycle_state', + 'origin', + 'content_hash', + 'created_at', + 'updated_at', + ], + }, + { + name: 'memory_item_keys', + requiredColumns: ['item_id', 'key_text', 'normalized_key', 'key_type', 'key_origin'], + }, + { + name: 'memory_item_sources', + requiredColumns: ['item_id', 'session_id', 'run_id', 'turn_id', 'event_id'], + }, + { + name: 'memory_write_operations', + requiredColumns: [ + 'operation_id', + 'operation_type', + 'request_hash', + 'result_json', + 'committed_at', + ], + }, + ], + indexes: [ + { + name: 'memory_item_keys_by_normalized_key', + tableName: 'memory_item_keys', + requiredColumnPrefix: ['normalized_key', 'item_id'], + }, + ], +}; + +const MINIMUM_SCHEMA_SHAPES = new Map([ + [1, VERSION_1_MINIMUM_SCHEMA_SHAPE], + [ + 2, + { + tables: [ + ...VERSION_1_MINIMUM_SCHEMA_SHAPE.tables, + { + name: 'memory_extraction_cursors', + requiredColumns: ['session_id', 'processed_ordinal', 'updated_at'], + }, + { + name: 'memory_extraction_receipts', + requiredColumns: [ + 'operation_id', + 'session_id', + 'request_hash', + 'result_json', + 'committed_at', + ], + }, + ], + indexes: VERSION_1_MINIMUM_SCHEMA_SHAPE.indexes, + }, + ], + [ + 3, + { + tables: [ + ...VERSION_1_MINIMUM_SCHEMA_SHAPE.tables, + { + name: 'memory_extraction_cursors', + requiredColumns: ['session_id', 'processed_ordinal', 'updated_at'], + }, + { + name: 'memory_extraction_receipts', + requiredColumns: [ + 'operation_id', + 'session_id', + 'request_hash', + 'result_json', + 'committed_at', + ], + }, + { + name: 'memory_extraction_failures', + requiredColumns: [ + 'session_id', + 'from_ordinal', + 'through_ordinal', + 'coverage_hash', + 'first_operation_id', + 'first_trigger', + 'first_failure_class', + 'failed_at', + ], + }, + ], + indexes: VERSION_1_MINIMUM_SCHEMA_SHAPE.indexes, + }, + ], + [ + 4, + { + tables: [ + ...VERSION_1_MINIMUM_SCHEMA_SHAPE.tables, + { + name: 'memory_extraction_cursors', + requiredColumns: ['session_id', 'processed_ordinal', 'updated_at'], + }, + { + name: 'memory_extraction_receipts', + requiredColumns: [ + 'operation_id', + 'session_id', + 'request_hash', + 'result_json', + 'committed_at', + ], + }, + { + name: 'memory_extraction_failures', + requiredColumns: [ + 'session_id', + 'from_ordinal', + 'through_ordinal', + 'coverage_hash', + 'first_operation_id', + 'first_trigger', + 'compaction_checkpoint_id', + 'first_failure_class', + 'failed_at', + ], + }, + ], + indexes: VERSION_1_MINIMUM_SCHEMA_SHAPE.indexes, + }, + ], +]); + +const version4MinimumShape = MINIMUM_SCHEMA_SHAPES.get(4)!; +MINIMUM_SCHEMA_SHAPES.set(5, { + tables: [ + ...version4MinimumShape.tables, + { + name: 'memory_compaction_policy_denials', + requiredColumns: ['session_id', 'compaction_checkpoint_id', 'denied_at'], + }, + ], + indexes: version4MinimumShape.indexes, +}); + +for (let version = 1; version <= SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION; version += 1) { + if (!MIGRATIONS.has(version)) { + throw new Error(`Missing long-term memory SQLite migration ${version}`); + } + if (!MINIMUM_SCHEMA_SHAPES.has(version)) { + throw new Error(`Missing long-term memory SQLite minimum schema shape ${version}`); + } +} + +export function configureSqliteLongTermMemoryDatabase(db: DatabaseSync): void { + db.exec(`PRAGMA busy_timeout = ${SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS}`); + ensureWalJournalMode(db); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); +} + +export function migrateSqliteLongTermMemoryDatabase( + db: DatabaseSync, + options: SqliteLongTermMemoryMigrationOptions = {}, +): void { + const observedVersion = readSqliteLongTermMemorySchemaVersion(db); + if (observedVersion > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) { + throw newerSchemaError(observedVersion); + } + if (observedVersion === SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) { + validateMinimumSchemaShape(db, observedVersion); + return; + } + + db.exec('BEGIN IMMEDIATE'); + try { + const current = readSqliteLongTermMemorySchemaVersion(db); + if (current > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) throw newerSchemaError(current); + for ( + let version = current + 1; + version <= SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION; + version += 1 + ) { + const sql = MIGRATIONS.get(version); + if (!sql) throw new Error(`Missing long-term memory SQLite migration ${version}`); + db.exec(sql); + options.failpoint?.('after_schema_sql'); + db.exec(`PRAGMA user_version = ${version}`); + } + validateMinimumSchemaShape(db, SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION); + db.exec('COMMIT'); + } catch (error) { + rollback(db); + throw error; + } +} + +export function assertSupportedSqliteLongTermMemorySchemaVersion(db: DatabaseSync): void { + const observedVersion = readSqliteLongTermMemorySchemaVersion(db); + if (observedVersion > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) { + throw newerSchemaError(observedVersion); + } +} + +export function readSqliteLongTermMemorySchemaVersion(db: DatabaseSync): number { + return retryWhileSqliteBusy(() => { + const row = db.prepare('PRAGMA user_version').get() as { user_version?: unknown } | undefined; + const value = row?.user_version; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid long-term memory SQLite schema version'); + } + return value; + }); +} + +function readJournalMode(db: DatabaseSync): string { + return retryWhileSqliteBusy(() => { + const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined; + if (typeof row?.journal_mode !== 'string') { + throw new Error('Invalid long-term memory SQLite journal mode'); + } + return row.journal_mode.toLowerCase(); + }); +} + +function ensureWalJournalMode(db: DatabaseSync): void { + const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS; + while (true) { + const journalMode = readJournalMode(db); + if (journalMode === 'wal' || journalMode === 'memory') return; + try { + db.exec('PRAGMA journal_mode = WAL'); + const configuredMode = readJournalMode(db); + if (configuredMode !== 'wal') { + throw new Error( + `Long-term memory SQLite requires WAL journal mode, received ${configuredMode}`, + ); + } + return; + } catch (error) { + if (!isSqliteBusy(error) || Date.now() >= deadline) throw error; + Atomics.wait( + initializationRetryGate, + 0, + 0, + Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())), + ); + } + } +} + +function isSqliteBusy(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = 'code' in error ? String(error.code) : ''; + return code === 'SQLITE_BUSY' || /database is locked/i.test(error.message); +} + +function retryWhileSqliteBusy(operation: () => T): T { + const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS; + while (true) { + try { + return operation(); + } catch (error) { + if (!isSqliteBusy(error) || Date.now() >= deadline) throw error; + Atomics.wait( + initializationRetryGate, + 0, + 0, + Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())), + ); + } + } +} + +function newerSchemaError(version: number): Error { + return new Error( + `Long-term memory SQLite schema ${version} is newer than supported version ${SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION}`, + ); +} + +function validateMinimumSchemaShape(db: DatabaseSync, version: number): void { + const shape = MINIMUM_SCHEMA_SHAPES.get(version); + if (!shape) { + throw new Error(`Missing long-term memory SQLite minimum schema shape ${version}`); + } + + const readSchemaObject = db.prepare('SELECT type, tbl_name FROM sqlite_schema WHERE name = ?'); + for (const table of shape.tables) { + const object = readSchemaObject.get(table.name) as + | { type?: unknown; tbl_name?: unknown } + | undefined; + if (object?.type !== 'table' || object.tbl_name !== table.name) { + throw incompleteSchemaError(`missing required table ${table.name}`); + } + + const columns = new Set( + ( + db.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table.name)})`).all() as Array<{ + name?: unknown; + }> + ) + .map((row) => row.name) + .filter((name): name is string => typeof name === 'string'), + ); + for (const column of table.requiredColumns) { + if (!columns.has(column)) { + throw incompleteSchemaError(`table ${table.name} is missing required column ${column}`); + } + } + } + + for (const index of shape.indexes) { + const object = readSchemaObject.get(index.name) as + | { type?: unknown; tbl_name?: unknown } + | undefined; + if (object?.type !== 'index' || object.tbl_name !== index.tableName) { + throw incompleteSchemaError(`missing required index ${index.name}`); + } + + const columns = ( + db.prepare(`PRAGMA index_info(${quoteSqliteIdentifier(index.name)})`).all() as Array<{ + seqno?: unknown; + name?: unknown; + }> + ) + .filter( + (row): row is { seqno: number; name: string } => + typeof row.seqno === 'number' && typeof row.name === 'string', + ) + .sort((left, right) => left.seqno - right.seqno) + .map((row) => row.name); + if (index.requiredColumnPrefix.some((column, position) => columns[position] !== column)) { + throw incompleteSchemaError(`required index ${index.name} has incompatible columns`); + } + } +} + +function quoteSqliteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function incompleteSchemaError(detail: string): Error { + return new Error(`Incomplete long-term memory SQLite schema: ${detail}`); +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the migration failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/70c53985af782f6eadfaa6da18f8c7c3756bfeb5683659ddcec9c20e34630325.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/70c53985af782f6eadfaa6da18f8c7c3756bfeb5683659ddcec9c20e34630325.source new file mode 100644 index 0000000000..25596e7bff --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/70c53985af782f6eadfaa6da18f8c7c3756bfeb5683659ddcec9c20e34630325.source @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, rm } from 'node:fs/promises'; + +const LOCK_POLL_MS = 25; +const LOCK_TIMEOUT_MS = 10_000; + +export async function withFileUpdateLock( + targetPath: string, + operation: () => Promise, + timeoutMs: number = LOCK_TIMEOUT_MS, +): Promise { + const lockPath = `${targetPath}.lock`; + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + await mkdir(lockPath); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if (Date.now() >= deadline) { + throw new Error( + `File update is locked by another process (${lockPath}). ` + + 'If no other process is using it, remove that directory and retry.', + ); + } + await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS)); + } + } + try { + return await operation(); + } finally { + await rm(lockPath, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/722a3f28b7e87563ba80b4e948cd0683f12cd3a3d732cba567be5f465ade0031.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/722a3f28b7e87563ba80b4e948cd0683f12cd3a3d732cba567be5f465ade0031.source new file mode 100644 index 0000000000..e0ed7b39d9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/722a3f28b7e87563ba80b4e948cd0683f12cd3a3d732cba567be5f465ade0031.source @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RuntimePolicyDomainDecodeError } from '@maka/core/runtime-policy'; + +export type RuntimePolicyStoreErrorCode = + | 'invalid_document' + | 'invalid_policy_input' + | 'invalid_connection_input' + | 'invalid_credential_input' + | 'revision_conflict' + | 'io_failed' + | 'commit_outcome_unknown'; + +export class RuntimePolicyStoreError extends Error { + constructor( + readonly code: RuntimePolicyStoreErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimePolicyStoreError'; + } +} + +export type CodecSource = + | 'invalid_document' + | 'invalid_policy_input' + | 'invalid_connection_input' + | 'invalid_credential_input'; + +export function codecError(source: CodecSource, message: string): RuntimePolicyStoreError { + return new RuntimePolicyStoreError(source, message); +} + +export function decodePolicyInput(decode: () => T): T { + return mapDomainError(decode, 'invalid_policy_input'); +} + +export function decodeConnectionInput(decode: () => T): T { + return mapDomainError(decode, 'invalid_connection_input'); +} + +export function decodeCredentialInput(decode: () => T): T { + return mapDomainError(decode, 'invalid_credential_input'); +} + +export function decodePersistedDomain(decode: () => T): T { + return mapDomainError(decode, 'invalid_document'); +} + +export function invalidDocument(message: string, cause?: unknown): RuntimePolicyStoreError { + return new RuntimePolicyStoreError( + 'invalid_document', + message, + cause === undefined ? undefined : { cause }, + ); +} + +export function ioFailed(message: string, cause: unknown): RuntimePolicyStoreError { + return new RuntimePolicyStoreError('io_failed', message, { cause }); +} + +export function commitOutcomeUnknown(message: string, cause: unknown): RuntimePolicyStoreError { + return new RuntimePolicyStoreError('commit_outcome_unknown', message, { cause }); +} + +function mapDomainError(decode: () => T, code: CodecSource): T { + try { + return decode(); + } catch (error) { + if (error instanceof RuntimePolicyDomainDecodeError) { + throw new RuntimePolicyStoreError(code, error.message, { cause: error }); + } + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7263490a57b317c4383785078458a63cf076787fac4d6443d30a5d651f766a3c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7263490a57b317c4383785078458a63cf076787fac4d6443d30a5d651f766a3c.source new file mode 100644 index 0000000000..100a8328df --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7263490a57b317c4383785078458a63cf076787fac4d6443d30a5d651f766a3c.source @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { + authenticateInteractiveProjectCatalogWriter, + openInteractiveProjectCatalogForWrite, +} from '../project-catalog-authority.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +test('Project Catalog writes require one live interactive root owner', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-catalog-authority-')); + const dataRoot = join(base, 'data'); + const projectRoot = join(base, 'project'); + await mkdir(projectRoot); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: dataRoot, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let writer: Awaited> | undefined; + try { + const [first, second] = await Promise.all([ + openInteractiveProjectCatalogForWrite(owner.lease), + openInteractiveProjectCatalogForWrite(owner.lease), + ]); + writer = first; + assert.equal(first, second); + assert.equal(authenticateInteractiveProjectCatalogWriter(first), first); + const project = await first.register(projectRoot); + assert.equal((await second.list())[0]?.id, project.id); + + await owner.close(); + await assert.rejects( + () => first.rename(project.id, 'Renamed'), + (error: unknown) => + error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + } finally { + writer?.close(); + if (!owner.closed) await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/72e06faa3d05727d394823b2cc266d9c8fd0035735066e32684de896bdd6ba51.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/72e06faa3d05727d394823b2cc266d9c8fd0035735066e32684de896bdd6ba51.source new file mode 100644 index 0000000000..52106d121a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/72e06faa3d05727d394823b2cc266d9c8fd0035735066e32684de896bdd6ba51.source @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { + acquireOperationalStateDatabase, + OPERATIONAL_STATE_DATABASE_NAME, +} from './operational-state-store.js'; +import { + createSqliteSessionMetadataStore, + type SqliteSessionMetadataStore, +} from './sqlite-session-metadata-store.js'; + +/** + * Open the graph-control repository owned by the operational database. + * Session messages and graph state share runtime.sqlite as one authority. + */ +export function createAgentGraphControlStore(workspaceRoot: string): SqliteSessionMetadataStore { + const databaseLease = acquireOperationalStateDatabase(workspaceRoot); + return createSqliteSessionMetadataStore(join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), { + databaseLease, + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7381e3390becc2937475260e76b0fb205714de3a9f9e635012ba6fe4c6f4d0da.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7381e3390becc2937475260e76b0fb205714de3a9f9e635012ba6fe4c6f4d0da.source new file mode 100644 index 0000000000..bdbddcaee7 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7381e3390becc2937475260e76b0fb205714de3a9f9e635012ba6fe4c6f4d0da.source @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isDeepStrictEqual } from 'node:util'; +import { + isOrchestrationMode, + isTurnOrchestrationSource, + type TurnOrchestration, +} from '@maka/core/orchestration'; + +// Mirrors the protocol's submit bounds. Storage sits below the protocol, so the +// durable side re-states them rather than importing them. +const SKILL_ID_MAX_COUNT = 50; +const SKILL_ID_MAX_LENGTH = 512; +const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/; + +/** + * What a submit asked of its Turn beyond the words: the exact Skills to load + * and the orchestration to run under. Content and placement describe neither, + * so this is the rest of what makes a submit the same submit. + * + * It is one value and every durable record keeps it whole. A record that kept + * only a part — or only a digest it could not rebuild — could not answer a + * retry that arrives after the Host recovered the Message from that record. + */ +export interface SubmittedTurnIntent { + readonly skillIds: readonly string[]; + readonly turnOrchestration?: TurnOrchestration; +} + +/** + * Validate an intent from any source — a caller, a durable record, or a JSON + * column — into the one canonical shape the equality below compares. + */ +export function normalizeSubmittedTurnIntent(value: unknown): SubmittedTurnIntent { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Invalid submitted Turn intent'); + } + const record = value as Record; + for (const key of Object.keys(record)) { + if (key !== 'skillIds' && key !== 'turnOrchestration') { + throw new Error('Invalid submitted Turn intent'); + } + } + const { skillIds, turnOrchestration } = record; + if ( + !Array.isArray(skillIds) || + skillIds.length > SKILL_ID_MAX_COUNT || + skillIds.some( + (id) => + typeof id !== 'string' || + id.length === 0 || + id.length > SKILL_ID_MAX_LENGTH || + !SKILL_ID_PATTERN.test(id), + ) + ) { + throw new Error('Invalid submitted Turn intent Skill ids'); + } + let orchestration: TurnOrchestration | undefined; + if (turnOrchestration !== undefined) { + if ( + typeof turnOrchestration !== 'object' || + turnOrchestration === null || + !isOrchestrationMode((turnOrchestration as TurnOrchestration).mode) || + !isTurnOrchestrationSource((turnOrchestration as TurnOrchestration).source) + ) { + throw new Error('Invalid submitted Turn intent orchestration'); + } + const { mode, source } = turnOrchestration as TurnOrchestration; + orchestration = Object.freeze({ mode, source }); + } + // An intent that asks for nothing is not an intent: the absent value already + // says that, and admitting a second spelling of it would make two records + // that mean the same thing compare unequal. + if (skillIds.length === 0 && orchestration === undefined) { + throw new Error('Invalid submitted Turn intent: it asks for nothing'); + } + return Object.freeze({ + skillIds: Object.freeze([...(skillIds as readonly string[])]), + ...(orchestration ? { turnOrchestration: orchestration } : {}), + }); +} + +/** Skill order is part of the request, so it is compared in order. */ +export function submittedTurnIntentsEqual( + left: SubmittedTurnIntent | undefined, + right: SubmittedTurnIntent | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return ( + left.skillIds.length === right.skillIds.length && + left.skillIds.every((id, index) => id === right.skillIds[index]) && + isDeepStrictEqual(left.turnOrchestration, right.turnOrchestration) + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74b1eb5a7812a5e24db08f66a0ec19d43b24f917f52263e3977cca6a07875700.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74b1eb5a7812a5e24db08f66a0ec19d43b24f917f52263e3977cca6a07875700.source new file mode 100644 index 0000000000..849590af67 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74b1eb5a7812a5e24db08f66a0ec19d43b24f917f52263e3977cca6a07875700.source @@ -0,0 +1,1166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { + chmod, + link as createHardLink, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + rmdir, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { afterEach, test } from 'node:test'; +import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'; +import { + SessionBundleFileError, + type SessionBundleArtifact, + type SessionBundleLimits, +} from '../session-bundle-contract.js'; +import { createSessionBundleFileService } from '../session-bundle-file-service.js'; +import { encodeSessionBundleManifestV1 } from '../session-bundle-manifest.js'; +import { decodeSessionBundleUstarHeaderV1 } from '../session-bundle-ustar.js'; + +const roots: string[] = []; +const identityBytes = Buffer.from('{"schemaVersion":1,"makaSessionId":"maka-α"}', 'utf8'); +const limits: SessionBundleLimits = { + maxCompressedBytes: 4 * 1024 * 1024, + maxDecompressedTarBytes: 8 * 1024 * 1024, + maxPayloadBytes: 4 * 1024 * 1024, + maxFileBytes: 2 * 1024 * 1024, + maxEntryCount: 100, + maxManifestBytes: 256 * 1024, + maxStateIdentityBytes: 64 * 1024, + maxPathBytes: 255, + maxPathDepth: 16, +}; +const CHILD_PROCESS_TIMEOUT_MS = 15_000; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test('packs deterministically, inspects completely, and atomically hydrates', async () => { + const fixture = await createFixture(); + const service = createSessionBundleFileService(); + const firstPath = join(fixture.root, 'first.tar.zst'); + const secondPath = join(fixture.root, 'second.tar.zst'); + + const first = await packFixture(firstPath, fixture); + const second = await packFixture(secondPath, fixture); + const firstBytes = await readFile(firstPath); + const secondBytes = await readFile(secondPath); + assert.deepEqual(secondBytes, firstBytes); + assert.equal(second.archiveDigest, first.archiveDigest); + assert.equal(first.compressedBytes, firstBytes.byteLength); + assert.equal(first.entryCount, 8); + assert.equal(first.payloadBytes, identityBytes.byteLength + 5 + 18 + Buffer.byteLength('好')); + + const tar = zstdDecompressSync(firstBytes); + assert.equal(tar.byteLength, first.decompressedTarBytes); + assert.equal( + tar.subarray(-1024).every((byte) => byte === 0), + true, + ); + // Windows cannot represent the executable bit, so run.sh is canonically packed as 0644. + assert.equal( + createHash('sha256').update(tar).digest('hex'), + process.platform === 'win32' + ? 'e84d413f59770ec6aa38edbd0c678f64ebb8354bb913309cfed8a16d71f18bc1' + : '5aed5bb2802d79826339fcc89f0c0aaf657c36d898452e22ef972fb6d5306148', + ); + assert.equal( + first.archiveDigest, + // Pinned with Node 24's bundled Zstandard encoder; Node 26 currently matches. + process.platform === 'win32' + ? 'sha256:0d5184d3c7362a2043a6bf6964b88515b81c652b4c4e59cf14653d8d8b29fca2' + : 'sha256:c7c9efbe9fc8a1c84f22ec016985457d82070001c4579d5cecb1304459fed5e1', + ); + assert.equal(firstBytes.lastIndexOf(Buffer.from('28b52ffd', 'hex')), 0); + + const inspected = await service.inspect({ + source: { path: firstPath, expectedArchiveDigest: first.archiveDigest }, + limits, + }); + assert.equal(inspected.verified, true); + assert.equal(inspected.manifest.envelope.sessionId, 'cloud-session-1'); + assert.equal(inspected.manifest.envelope.lastCommittedActivationId, 'activation-9'); + assert.deepEqual(Buffer.from(inspected.stateIdentity.bytes), identityBytes); + + const destinationRoot = join(fixture.root, 'hydrated'); + const hydrated = await service.hydrate({ + source: { path: firstPath, expectedArchiveDigest: first.archiveDigest }, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + }); + assert.equal(hydrated.destinationRoot, destinationRoot); + assert.equal(hydrated.stateRoot, join(destinationRoot, 'state')); + assert.equal(hydrated.workspaceRoot, join(destinationRoot, 'workspace')); + assert.deepEqual(await readFile(join(destinationRoot, 'state-identity.json')), identityBytes); + assert.equal(await readFile(join(destinationRoot, 'state', 'session.bin'), 'utf8'), 'state'); + assert.equal( + await readFile(join(destinationRoot, 'workspace', 'run.sh'), 'utf8'), + '#!/bin/sh\necho ok\n', + ); + assert.equal(await readFile(join(destinationRoot, 'workspace', '深', 'x.txt'), 'utf8'), '好'); + // Node exposes synthesized 0666 file modes on Windows after hydration. + const hydratedRegularFileMode = process.platform === 'win32' ? 0o666 : 0o644; + const hydratedExecutableFileMode = process.platform === 'win32' ? 0o666 : 0o755; + assert.equal( + (await stat(join(destinationRoot, 'workspace', 'run.sh'))).mode & 0o777, + hydratedExecutableFileMode, + ); + assert.equal( + (await stat(join(destinationRoot, 'state', 'session.bin'))).mode & 0o777, + hydratedRegularFileMode, + ); + await assert.rejects(lstat(join(destinationRoot, 'manifest.json')), { code: 'ENOENT' }); +}); + +test('an independent process verifies and hydrates the same archive contract', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-inspect-child.js', + ); + const destinationRoot = join(fixture.root, 'child-hydrated'); + const child = spawn( + process.execPath, + [childPath, archivePath, artifact.archiveDigest, JSON.stringify(limits), destinationRoot], + { + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const closed = waitForChildClose(child, CHILD_PROCESS_TIMEOUT_MS, 'Session Bundle child'); + const result = await closed; + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + assert.deepEqual(JSON.parse(Buffer.concat(stdout).toString('utf8')), { + sessionId: 'cloud-session-1', + archiveDigest: artifact.archiveDigest, + identityHex: identityBytes.toString('hex'), + verified: true, + destinationRoot, + }); + assert.equal(await readFile(join(destinationRoot, 'state', 'session.bin'), 'utf8'), 'state'); + assert.equal( + await readFile(join(destinationRoot, 'workspace', 'run.sh'), 'utf8'), + '#!/bin/sh\necho ok\n', + ); +}); + +test('a process crash leaves owned staging that can be reclaimed without touching decoys', { + timeout: 30_000, +}, async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'crash-bundle.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-hydration-binding-crash.js', + ); + const destinationRoot = join(fixture.root, 'crash-hydrated'); + const stagingPrefix = '.crash-hydrated.maka-session-bundle-staging-'; + const child = spawn( + process.execPath, + [childPath, archivePath, artifact.archiveDigest, JSON.stringify(limits), destinationRoot], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const closed = waitForChildClose(child, CHILD_PROCESS_TIMEOUT_MS, 'hydration crash child'); + try { + await waitForStreamText(child.stdout, 'binding-partial\n', 10_000, 'partial ownership binding'); + const stagingName = await waitForDirectoryEntry( + fixture.root, + stagingPrefix, + 10_000, + 'hydration staging', + ); + const ownershipName = `${stagingName}.owner.json`; + const ownershipContents = await readFile(join(fixture.root, ownershipName), 'utf8'); + assert.equal(ownershipContents.includes('"stagingDev"'), false); + assert.equal(ownershipContents.endsWith('\n{'), true); + assert.equal(child.kill('SIGKILL'), true); + const result = await closed; + assert.equal(result.signal, 'SIGKILL', Buffer.concat(stderr).toString('utf8')); + await assert.rejects(lstat(destinationRoot), { code: 'ENOENT' }); + const stagingPath = join(fixture.root, stagingName); + const stagingMetadata = await lstat(stagingPath); + assert.equal(stagingMetadata.isDirectory(), true); + assert.equal(stagingMetadata.isSymbolicLink(), false); + assert.equal((await lstat(join(fixture.root, ownershipName))).isFile(), true); + assert.deepEqual( + (await readdir(fixture.root)).filter((name) => name.startsWith(stagingPrefix)).sort(), + [ownershipName, stagingName].sort(), + ); + + const unrelatedRoot = await mkdtemp(join(tmpdir(), 'maka-session-bundle-unrelated-')); + roots.push(unrelatedRoot); + await writeFile(join(unrelatedRoot, 'sentinel'), 'unrelated'); + const decoyDirectory = join(fixture.root, `${stagingPrefix}unowned-directory`); + const decoyOwnership = `${decoyDirectory}.owner.json`; + await mkdir(decoyDirectory); + await writeFile(decoyOwnership, '{}\n'); + + const capturedStagingPath = `${stagingPath}.captured`; + await rename(stagingPath, capturedStagingPath); + await symlink(unrelatedRoot, stagingPath); + assert.deepEqual( + await createSessionBundleFileService().cleanupHydrationStaging({ + destinationRoot, + }), + { + destinationRoot, + removedStagingDirectories: 0, + removedOwnershipRecords: 0, + }, + ); + assert.equal((await lstat(stagingPath)).isSymbolicLink(), true); + assert.equal((await lstat(join(fixture.root, ownershipName))).isFile(), true); + assert.equal(await readFile(join(unrelatedRoot, 'sentinel'), 'utf8'), 'unrelated'); + await rm(stagingPath); + await rename(capturedStagingPath, stagingPath); + + await rename(stagingPath, capturedStagingPath); + await mkdir(stagingPath); + await writeFile(join(stagingPath, 'replacement-sentinel'), 'replacement'); + assert.deepEqual( + await createSessionBundleFileService().cleanupHydrationStaging({ + destinationRoot, + }), + { + destinationRoot, + removedStagingDirectories: 0, + removedOwnershipRecords: 0, + }, + ); + assert.equal(await readFile(join(stagingPath, 'replacement-sentinel'), 'utf8'), 'replacement'); + assert.equal((await lstat(join(fixture.root, ownershipName))).isFile(), true); + await rm(stagingPath, { recursive: true }); + await rename(capturedStagingPath, stagingPath); + + const unrelatedCleanupRoot = `${stagingPath}.cleanup`; + await mkdir(unrelatedCleanupRoot); + assert.deepEqual( + await createSessionBundleFileService().cleanupHydrationStaging({ + destinationRoot, + }), + { + destinationRoot, + removedStagingDirectories: 0, + removedOwnershipRecords: 0, + }, + ); + assert.equal((await lstat(unrelatedCleanupRoot)).isDirectory(), true); + assert.equal((await lstat(stagingPath)).isDirectory(), true); + await rmdir(unrelatedCleanupRoot); + + // Simulate a process dying after the owned staging inode was quarantined + // but before recursive removal started. + await rename(stagingPath, unrelatedCleanupRoot); + const cleanup = await createSessionBundleFileService().cleanupHydrationStaging({ + destinationRoot, + }); + assert.deepEqual(cleanup, { + destinationRoot, + removedStagingDirectories: 1, + removedOwnershipRecords: 1, + }); + await assert.rejects(lstat(stagingPath), { code: 'ENOENT' }); + await assert.rejects(lstat(unrelatedCleanupRoot), { code: 'ENOENT' }); + await assert.rejects(lstat(join(fixture.root, ownershipName)), { + code: 'ENOENT', + }); + assert.equal((await lstat(decoyDirectory)).isDirectory(), true); + assert.equal(await readFile(decoyOwnership, 'utf8'), '{}\n'); + assert.equal(await readFile(join(unrelatedRoot, 'sentinel'), 'utf8'), 'unrelated'); + assert.deepEqual( + await createSessionBundleFileService().cleanupHydrationStaging({ + destinationRoot, + }), + { + destinationRoot, + removedStagingDirectories: 0, + removedOwnershipRecords: 0, + }, + ); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed.catch(() => {}); + } +}); + +test('concurrent publications never overwrite an existing destination', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'concurrent.tar.zst'); + const packResults = await Promise.allSettled( + Array.from({ length: 8 }, () => packFixture(archivePath, fixture)), + ); + assert.equal(packResults.filter((result) => result.status === 'fulfilled').length, 1); + for (const result of packResults) { + if (result.status === 'rejected') assertBundleErrorValue(result.reason, 'destination_exists'); + } + + const artifact = packResults.find( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + )?.value; + assert.ok(artifact); + const destinationRoot = join(fixture.root, 'concurrent-hydration'); + const service = createSessionBundleFileService(); + const hydrateInput = { + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + } as const; + const hydrateResults = await Promise.allSettled([ + service.hydrate(hydrateInput), + service.hydrate(hydrateInput), + ]); + assert.equal(hydrateResults.filter((result) => result.status === 'fulfilled').length, 1); + for (const result of hydrateResults) { + if (result.status === 'rejected') assertBundleErrorValue(result.reason, 'destination_exists'); + } + assert.equal( + await readFile(join(destinationRoot, 'workspace', 'run.sh'), 'utf8'), + '#!/bin/sh\necho ok\n', + ); +}); + +test('binds pack publication and cleanup to the hashed temporary inode', async () => { + const fixture = await createFixture(); + await writeFile(join(fixture.workspaceRoot, 'large.bin'), randomBytes(8 * 1024 * 1024)); + const destination = join(fixture.root, 'inode-bound.tar.zst'); + const replacementResultPath = join(fixture.root, 'replacement-result.json'); + const replacerPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-pack-temp-replacer.js', + ); + const replacer = spawn(process.execPath, [replacerPath, fixture.root, replacementResultPath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const replacerClosed = waitForChildClose( + replacer, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle temp replacer', + ); + await waitForStreamText(replacer.stdout, 'ready\n', 5_000, 'Session Bundle temp replacer'); + const packing = packFixture(destination, fixture, { + ...limits, + maxCompressedBytes: 16 * 1024 * 1024, + maxDecompressedTarBytes: 16 * 1024 * 1024, + maxPayloadBytes: 16 * 1024 * 1024, + maxFileBytes: 10 * 1024 * 1024, + }); + const replacerResult = await replacerClosed; + assert.equal(replacerResult.code, 0); + const { capturedPath, temporaryPath } = JSON.parse( + await readFile(replacementResultPath, 'utf8'), + ) as { capturedPath: string; temporaryPath: string }; + + await assertBundleRejects(packing, 'source_changed'); + await assert.rejects(lstat(destination), { code: 'ENOENT' }); + assert.equal(await readFile(temporaryPath, 'utf8'), 'EVIL'); + assert.equal((await lstat(capturedPath)).isFile(), true); +}); + +test('cleans the inode actually linked when the pack pathname is swapped at link time', async () => { + const fixture = await createFixture(); + const destination = join(fixture.root, 'link-window.tar.zst'); + const resultPath = join(fixture.root, 'link-window-result.json'); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-pack-link-replacer.js', + ); + const child = spawn( + process.execPath, + [ + childPath, + fixture.stateRoot, + fixture.workspaceRoot, + destination, + resultPath, + JSON.stringify(limits), + identityBytes.toString('hex'), + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const result = await waitForChildClose( + child, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle link replacer', + ); + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + const replacement = JSON.parse(await readFile(resultPath, 'utf8')) as { + code: string; + capturedPath: string; + temporaryPath: string; + }; + assert.equal(replacement.code, 'source_changed'); + await assert.rejects(lstat(destination), { code: 'ENOENT' }); + assert.equal(await readFile(replacement.temporaryPath, 'utf8'), 'EVIL'); + assert.equal((await lstat(replacement.capturedPath)).isFile(), true); +}); + +test('cleans a published pack when the temporary pathname disappears after link', async () => { + const fixture = await createFixture(); + const destination = join(fixture.root, 'linked-temp-removed.tar.zst'); + const resultPath = join(fixture.root, 'linked-temp-removed-result.json'); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-pack-linked-temp-remover.js', + ); + const child = spawn( + process.execPath, + [ + childPath, + fixture.stateRoot, + fixture.workspaceRoot, + destination, + resultPath, + JSON.stringify(limits), + identityBytes.toString('hex'), + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const result = await waitForChildClose( + child, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle linked temp remover', + ); + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + const removal = JSON.parse(await readFile(resultPath, 'utf8')) as { + code: string; + capturedPath: string; + }; + assert.equal(removal.code, 'io_failure'); + await assert.rejects(lstat(destination), { code: 'ENOENT' }); + assert.equal((await lstat(removal.capturedPath)).isFile(), true); +}); + +test('does not clean an unrelated destination replacement after link', async () => { + const fixture = await createFixture(); + const destination = join(fixture.root, 'destination-replaced.tar.zst'); + const resultPath = join(fixture.root, 'destination-replaced-result.json'); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-pack-destination-replacer.js', + ); + const child = spawn( + process.execPath, + [ + childPath, + fixture.stateRoot, + fixture.workspaceRoot, + destination, + resultPath, + JSON.stringify(limits), + identityBytes.toString('hex'), + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const result = await waitForChildClose( + child, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle destination replacer', + ); + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + assert.deepEqual(JSON.parse(await readFile(resultPath, 'utf8')), { + code: 'source_changed', + destinationContents: 'UNRELATED', + }); + assert.equal(await readFile(destination, 'utf8'), 'UNRELATED'); +}); + +test('removes an ownership record when its initialization write fails', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'owner-write-failure.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const destinationRoot = join(fixture.root, 'owner-write-failure-hydrated'); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-hydration-owner-write-failure.js', + ); + const child = spawn( + process.execPath, + [childPath, archivePath, artifact.archiveDigest, JSON.stringify(limits), destinationRoot], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const result = await waitForChildClose( + child, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle ownership write failure', + ); + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + assert.deepEqual(JSON.parse(Buffer.concat(stdout).toString('utf8')), { + code: 'io_failure', + }); + await assert.rejects(lstat(destinationRoot), { code: 'ENOENT' }); + assert.deepEqual( + (await readdir(fixture.root)).filter((name) => + name.startsWith('.owner-write-failure-hydrated.maka-session-bundle-staging-'), + ), + [], + ); +}); + +test('reports read-side source changes and filesystem failures at the operation boundary', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'read-errors.tar.zst'); + await packFixture(archivePath, fixture); + const childPath = join( + dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'session-bundle-inspect-source-mutator.js', + ); + const child = spawn(process.execPath, [childPath, archivePath, JSON.stringify(limits)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const result = await waitForChildClose( + child, + CHILD_PROCESS_TIMEOUT_MS, + 'Session Bundle source mutator', + ); + assert.equal(result.code, 0, Buffer.concat(stderr).toString('utf8')); + assert.deepEqual(JSON.parse(Buffer.concat(stdout).toString('utf8')), { + code: 'source_changed', + }); + + await assert.rejects( + createSessionBundleFileService().inspect({ + source: { path: join(fixture.root, 'missing.tar.zst') }, + limits, + }), + (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, 'io_failure'); + assert.equal(error.details?.operation, 'inspect'); + return true; + }, + ); +}); + +test('rejects corrupted payloads and transport mismatches without publishing hydration', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const service = createSessionBundleFileService(); + const corruptedPath = join(fixture.root, 'corrupted.tar.zst'); + const tar = zstdDecompressSync(await readFile(archivePath)); + const payload = findTarEntry(tar, 'state/session.bin'); + tar[payload.contentOffset] ^= 0xff; + await writeFile(corruptedPath, compressTar(tar)); + + await assertBundleRejects( + service.inspect({ source: { path: corruptedPath }, limits }), + 'integrity_mismatch', + ); + const destinationRoot = join(fixture.root, 'failed-hydration'); + await assertBundleRejects( + service.hydrate({ + source: { path: archivePath, expectedArchiveDigest: `sha256:${'00'.repeat(32)}` }, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + }), + 'integrity_mismatch', + ); + await assert.rejects(lstat(destinationRoot), { code: 'ENOENT' }); + assert.equal( + (await readdir(fixture.root)).some((name) => name.includes('.maka-session-bundle-staging-')), + false, + ); + assert.notEqual(artifact.archiveDigest, `sha256:${'00'.repeat(32)}`); +}); + +test('rejects corrupted manifest, descriptor, tree digest, and Zstandard stream', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + await packFixture(archivePath, fixture); + const archiveBytes = await readFile(archivePath); + const originalTar = zstdDecompressSync(archiveBytes); + const service = createSessionBundleFileService(); + + const manifest = Buffer.from(originalTar); + const manifestEntry = findTarEntry(manifest, 'manifest.json'); + manifest[manifestEntry.contentOffset] = '['.charCodeAt(0); + await assertMutatedTarRejected(service, fixture.root, 'manifest', manifest, 'invalid_manifest'); + + const descriptor = Buffer.from(originalTar); + const descriptorEntry = findTarEntry(descriptor, 'state-identity.json'); + descriptor[descriptorEntry.contentOffset] ^= 0xff; + await assertMutatedTarRejected( + service, + fixture.root, + 'descriptor', + descriptor, + 'integrity_mismatch', + ); + + const treeDigest = Buffer.from(originalTar); + const treeManifestEntry = findTarEntry(treeDigest, 'manifest.json'); + const decodedManifest = JSON.parse( + treeDigest + .subarray( + treeManifestEntry.contentOffset, + treeManifestEntry.contentOffset + treeManifestEntry.size, + ) + .toString('utf8'), + ) as Parameters[0]; + decodedManifest.payload.treeDigest = `sha256:${'00'.repeat(32)}`; + const changedManifest = Buffer.from(encodeSessionBundleManifestV1(decodedManifest)); + assert.equal(changedManifest.byteLength, treeManifestEntry.size); + changedManifest.copy(treeDigest, treeManifestEntry.contentOffset); + await assertMutatedTarRejected( + service, + fixture.root, + 'tree-digest', + treeDigest, + 'integrity_mismatch', + ); + + const truncatedZstandardPath = join(fixture.root, 'corrupted-zstd.tar.zst'); + const corruptedZstandard = Buffer.from(archiveBytes); + corruptedZstandard[0] ^= 0xff; + await writeFile(truncatedZstandardPath, corruptedZstandard); + await assertBundleRejects( + service.inspect({ source: { path: truncatedZstandardPath }, limits }), + 'integrity_mismatch', + ); + + const trailingZstandardPath = join(fixture.root, 'trailing-zstd.tar.zst'); + await writeFile(trailingZstandardPath, Buffer.concat([archiveBytes, Buffer.from([0])])); + await assertBundleRejects( + service.inspect({ source: { path: trailingZstandardPath }, limits }), + 'integrity_mismatch', + ); + + const noncanonicalZstandardPath = join(fixture.root, 'noncanonical-zstd.tar.zst'); + await writeFile(noncanonicalZstandardPath, zstdCompressSync(originalTar)); + await assertBundleRejects( + service.inspect({ source: { path: noncanonicalZstandardPath }, limits }), + 'integrity_mismatch', + ); + + const oversizedWindowPath = join(fixture.root, 'oversized-window-zstd.tar.zst'); + const oversizedWindow = Buffer.from(archiveBytes); + assert.equal(oversizedWindow[5], 0x58); + oversizedWindow[5] = 0x60; + await writeFile(oversizedWindowPath, oversizedWindow); + await assertBundleRejects( + service.inspect({ source: { path: oversizedWindowPath }, limits }), + 'integrity_mismatch', + ); + + const emptyFrame = compressTar(Buffer.alloc(0)); + assert.equal(zstdDecompressSync(emptyFrame).byteLength, 0); + const multipleFramesPath = join(fixture.root, 'multiple-frames-zstd.tar.zst'); + await writeFile(multipleFramesPath, Buffer.concat([archiveBytes, emptyFrame])); + await assertBundleRejects( + service.inspect({ source: { path: multipleFramesPath }, limits }), + 'integrity_mismatch', + ); +}); + +test('enforces every explicit streaming quota with bounded details', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const service = createSessionBundleFileService(); + const cases: Array<[keyof SessionBundleLimits, number]> = [ + ['maxCompressedBytes', artifact.compressedBytes - 1], + ['maxDecompressedTarBytes', artifact.decompressedTarBytes - 1], + ['maxPayloadBytes', artifact.payloadBytes - 1], + ['maxFileBytes', 17], + ['maxEntryCount', artifact.entryCount - 1], + ['maxManifestBytes', 1], + ['maxStateIdentityBytes', identityBytes.byteLength - 1], + ['maxPathBytes', 18], + ['maxPathDepth', 2], + ]; + + for (const [quota, value] of cases) { + await assert.rejects( + service.inspect({ source: { path: archivePath }, limits: { ...limits, [quota]: value } }), + (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, 'quota_exceeded', quota); + assert.equal(error.details?.quota, quota); + assert.equal(error.details?.limit, value); + return true; + }, + ); + } +}); + +test('accepts exact file, entry, path-byte, and path-depth quota boundaries', async () => { + const fixture = await createFixture(); + const firstDirectory = 'a'.repeat(70); + const secondDirectory = 'b'.repeat(73); + const fileName = 'c'.repeat(100); + const boundaryDirectory = join(fixture.workspaceRoot, firstDirectory, secondDirectory); + await mkdir(boundaryDirectory, { recursive: true }); + await writeFile(join(boundaryDirectory, fileName), Buffer.alloc(128, 0x5a)); + const archivePath = join(fixture.root, 'boundary-source.tar.zst'); + const artifact = await packFixture(archivePath, fixture); + const boundaryPath = `workspace/${firstDirectory}/${secondDirectory}/${fileName}`; + assert.equal(Buffer.byteLength(boundaryPath), 255); + assert.equal(boundaryPath.split('/').length, 4); + + const exactLimits: SessionBundleLimits = { + ...limits, + maxCompressedBytes: artifact.compressedBytes, + maxDecompressedTarBytes: artifact.decompressedTarBytes, + maxPayloadBytes: artifact.payloadBytes, + maxFileBytes: 128, + maxEntryCount: artifact.entryCount, + maxPathBytes: 255, + maxPathDepth: 4, + }; + const exactPath = join(fixture.root, 'boundary-exact.tar.zst'); + const exact = await packFixture(exactPath, fixture, exactLimits); + assert.equal(exact.archiveDigest, artifact.archiveDigest); + assert.equal(exact.entryCount, exactLimits.maxEntryCount); + assert.equal(exact.payloadBytes, exactLimits.maxPayloadBytes); + assert.equal(exact.compressedBytes, exactLimits.maxCompressedBytes); + assert.equal(exact.decompressedTarBytes, exactLimits.maxDecompressedTarBytes); + assert.equal( + ( + await createSessionBundleFileService().inspect({ + source: { path: exactPath, expectedArchiveDigest: exact.archiveDigest }, + limits: exactLimits, + }) + ).verified, + true, + ); +}); + +test('rejects unsafe paths, links, bad checksums, truncation, and trailing TAR bytes', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + await packFixture(archivePath, fixture); + const originalTar = zstdDecompressSync(await readFile(archivePath)); + const service = createSessionBundleFileService(); + + const unsafe = Buffer.from(originalTar); + const unsafeEntry = findTarEntry(unsafe, 'state/session.bin'); + rewriteHeaderName( + unsafe.subarray(unsafeEntry.headerOffset, unsafeEntry.headerOffset + 512), + '../outside', + ); + await assertMutatedTarRejected(service, fixture.root, 'unsafe', unsafe, 'unsafe_path'); + + const link = Buffer.from(originalTar); + const linkEntry = findTarEntry(link, 'workspace/run.sh'); + const linkHeader = link.subarray(linkEntry.headerOffset, linkEntry.headerOffset + 512); + linkHeader[156] = '2'.charCodeAt(0); + rewriteChecksum(linkHeader); + await assertMutatedTarRejected(service, fixture.root, 'link', link, 'unsupported_entry'); + + const checksum = Buffer.from(originalTar); + const checksumEntry = findTarEntry(checksum, 'workspace/run.sh'); + checksum[checksumEntry.headerOffset + 148] ^= 1; + await assertMutatedTarRejected(service, fixture.root, 'checksum', checksum, 'integrity_mismatch'); + + const truncated = originalTar.subarray(0, originalTar.byteLength - 512); + await assertMutatedTarRejected( + service, + fixture.root, + 'truncated', + truncated, + 'integrity_mismatch', + ); + + const trailing = Buffer.concat([originalTar, Buffer.alloc(512)]); + await assertMutatedTarRejected(service, fixture.root, 'trailing', trailing, 'integrity_mismatch'); + + const duplicate = Buffer.from(originalTar); + const duplicateEntry = findTarEntry(duplicate, 'workspace/深/x.txt'); + rewriteHeaderName( + duplicate.subarray(duplicateEntry.headerOffset, duplicateEntry.headerOffset + 512), + 'workspace/run.sh', + ); + await assertMutatedTarRejected(service, fixture.root, 'duplicate', duplicate, 'unsafe_path'); +}); + +test('unsafe hydration paths cannot escape staging', async () => { + const fixture = await createFixture(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + await packFixture(archivePath, fixture); + const unsafe = zstdDecompressSync(await readFile(archivePath)); + const unsafeEntry = findTarEntry(unsafe, 'state/session.bin'); + rewriteHeaderName( + unsafe.subarray(unsafeEntry.headerOffset, unsafeEntry.headerOffset + 512), + '../outside', + ); + const unsafeArchivePath = join(fixture.root, 'unsafe-hydrate.tar.zst'); + await writeFile(unsafeArchivePath, compressTar(unsafe)); + + const outsidePath = join(fixture.root, 'outside'); + await writeFile(outsidePath, 'sentinel'); + + const destinationRoot = join(fixture.root, 'unsafe-destination'); + await assertBundleRejects( + createSessionBundleFileService().hydrate({ + source: { path: unsafeArchivePath }, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + }), + 'unsafe_path', + ); + assert.equal(await readFile(outsidePath, 'utf8'), 'sentinel'); + await assert.rejects(lstat(destinationRoot), { code: 'ENOENT' }); +}); + +test('pack enforces caller quotas and cleans unpublished output', async () => { + const fixture = await createFixture(); + const cases: Array<[keyof SessionBundleLimits, number]> = [ + ['maxCompressedBytes', 1], + ['maxDecompressedTarBytes', 1], + ['maxPayloadBytes', identityBytes.byteLength], + ['maxFileBytes', 4], + ['maxEntryCount', 1], + ['maxManifestBytes', 1], + ['maxStateIdentityBytes', identityBytes.byteLength - 1], + ['maxPathBytes', Buffer.byteLength('state-identity.json') - 1], + ['maxPathDepth', 1], + ]; + + for (const [quota, value] of cases) { + const destination = join(fixture.root, `${quota}.tar.zst`); + await assert.rejects( + packFixture(destination, fixture, { ...limits, [quota]: value }), + (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, 'quota_exceeded', quota); + assert.equal(error.details?.quota, quota); + return true; + }, + ); + await assert.rejects(lstat(destination), { code: 'ENOENT' }); + } + assert.equal( + (await readdir(fixture.root)).some((name) => name.includes('.maka-session-bundle-pack-')), + false, + ); +}); + +test('rejects source links, identity mismatch, and existing destinations', async () => { + const fixture = await createFixture(); + const service = createSessionBundleFileService(); + await symlink(join(fixture.stateRoot, 'session.bin'), join(fixture.stateRoot, 'linked.bin')); + await assertBundleRejects( + packFixture(join(fixture.root, 'linked.tar.zst'), fixture), + 'unsupported_entry', + ); + await rm(join(fixture.stateRoot, 'linked.bin')); + + const hardLinkPath = join(fixture.workspaceRoot, 'hard-linked.bin'); + await createHardLink(join(fixture.stateRoot, 'session.bin'), hardLinkPath); + await assertBundleRejects( + packFixture(join(fixture.root, 'hard-linked.tar.zst'), fixture), + 'unsupported_entry', + ); + await rm(hardLinkPath); + + if (process.platform === 'linux') { + const invalidUtf8Path = Buffer.concat([ + Buffer.from(`${fixture.stateRoot}/`, 'utf8'), + Buffer.from([0xff]), + ]); + await writeFile(invalidUtf8Path, 'invalid UTF-8 name'); + await assertBundleRejects( + packFixture(join(fixture.root, 'invalid-utf8.tar.zst'), fixture), + 'unsafe_path', + ); + await rm(invalidUtf8Path); + } + + const archivePath = join(fixture.root, 'bundle.tar.zst'); + await packFixture(archivePath, fixture); + await assertBundleRejects( + service.hydrate({ + source: { path: archivePath }, + limits, + expectedSessionId: 'wrong-session', + destinationRoot: join(fixture.root, 'identity-mismatch'), + }), + 'identity_mismatch', + ); + + const destinationRoot = join(fixture.root, 'existing'); + await mkdir(destinationRoot); + await assertBundleRejects( + service.hydrate({ + source: { path: archivePath }, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + }), + 'destination_exists', + ); +}); + +async function createFixture(): Promise<{ + root: string; + stateRoot: string; + workspaceRoot: string; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-session-bundle-codec-')); + roots.push(root); + const stateRoot = join(root, 'source-state'); + const workspaceRoot = join(root, 'source-workspace'); + await mkdir(join(stateRoot, 'empty'), { recursive: true }); + await mkdir(join(workspaceRoot, '深'), { recursive: true }); + await writeFile(join(stateRoot, 'session.bin'), 'state'); + await writeFile(join(workspaceRoot, 'run.sh'), '#!/bin/sh\necho ok\n'); + await chmod(join(workspaceRoot, 'run.sh'), 0o755); + await writeFile(join(workspaceRoot, '深', 'x.txt'), '好'); + return { root, stateRoot, workspaceRoot }; +} + +async function packFixture( + destination: string, + fixture: { stateRoot: string; workspaceRoot: string }, + packLimits: SessionBundleLimits = limits, +): Promise { + return createSessionBundleFileService().pack({ + snapshot: { + stateRoot: fixture.stateRoot, + workspaceRoot: fixture.workspaceRoot, + stateIdentity: { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: identityBytes, + }, + }, + envelope: { + sessionId: 'cloud-session-1', + lastCommittedActivationId: 'activation-9', + }, + destination, + limits: packLimits, + }); +} + +function findTarEntry( + tar: Buffer, + target: string, +): { headerOffset: number; contentOffset: number; size: number } { + let offset = 0; + while (offset + 512 <= tar.byteLength) { + const headerBytes = tar.subarray(offset, offset + 512); + if (headerBytes.every((byte) => byte === 0)) break; + const header = decodeSessionBundleUstarHeaderV1(headerBytes); + const contentOffset = offset + 512; + if (header.path === target) return { headerOffset: offset, contentOffset, size: header.size }; + offset = contentOffset + header.size + ((512 - (header.size % 512)) % 512); + } + throw new Error(`Missing TAR entry ${target}`); +} + +function rewriteHeaderName(header: Buffer, path: string): void { + assert.ok(Buffer.byteLength(path) <= 100); + header.fill(0, 0, 100); + header.write(path, 0, 'utf8'); + rewriteChecksum(header); +} + +function rewriteChecksum(header: Buffer): void { + header.fill(' '.charCodeAt(0), 148, 156); + const checksum = header + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, '0'); + header.write(checksum, 148, 6, 'ascii'); + header[154] = 0; + header[155] = ' '.charCodeAt(0); +} + +function compressTar(tar: Uint8Array): Buffer { + const compressed = zstdCompressSync(tar, { + params: { + [zlibConstants.ZSTD_c_compressionLevel]: 3, + [zlibConstants.ZSTD_c_checksumFlag]: 1, + [zlibConstants.ZSTD_c_contentSizeFlag]: 0, + [zlibConstants.ZSTD_c_dictIDFlag]: 0, + [zlibConstants.ZSTD_c_nbWorkers]: 0, + [zlibConstants.ZSTD_c_windowLog]: 21, + }, + }); + // One-shot compression shrinks the descriptor to the known input size; V1 pins 2 MiB. + compressed[5] = 0x58; + return compressed; +} + +async function assertMutatedTarRejected( + service: ReturnType, + root: string, + name: string, + tar: Uint8Array, + code: SessionBundleFileError['code'], +): Promise { + const path = join(root, `${name}.tar.zst`); + await writeFile(path, compressTar(tar)); + await assertBundleRejects(service.inspect({ source: { path }, limits }), code); +} + +async function assertBundleRejects( + action: Promise, + code: SessionBundleFileError['code'], +): Promise { + await assert.rejects(action, (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, code); + return true; + }); +} + +function assertBundleErrorValue(error: unknown, code: SessionBundleFileError['code']): void { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, code); +} + +function waitForChildClose( + child: ReturnType, + timeoutMs: number, + label: string, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolveChild, rejectChild) => { + const timeout = setTimeout(() => { + cleanup(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + rejectChild(new Error(`${label} did not close within ${timeoutMs}ms`)); + }, timeoutMs); + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolveChild({ code, signal }); + }; + const onError = (error: Error) => { + cleanup(); + rejectChild(error); + }; + const cleanup = () => { + clearTimeout(timeout); + child.off('close', onClose); + child.off('error', onError); + }; + child.once('close', onClose); + child.once('error', onError); + }); +} + +function waitForStreamText( + stream: NonNullable['stdout']>, + expected: string, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolveText, rejectText) => { + let observed = ''; + const timeout = setTimeout(() => { + cleanup(); + rejectText( + new Error(`${label} did not emit ${JSON.stringify(expected)} within ${timeoutMs}ms`), + ); + }, timeoutMs); + const onData = (chunk: Buffer | string) => { + observed += String(chunk); + if (!observed.includes(expected)) return; + cleanup(); + resolveText(); + }; + const onEnd = () => { + cleanup(); + rejectText(new Error(`${label} ended before emitting ${JSON.stringify(expected)}`)); + }; + const onError = (error: Error) => { + cleanup(); + rejectText(error); + }; + const cleanup = () => { + clearTimeout(timeout); + stream.off('data', onData); + stream.off('end', onEnd); + stream.off('error', onError); + }; + stream.on('data', onData); + stream.once('end', onEnd); + stream.once('error', onError); + }); +} + +async function waitForDirectoryEntry( + root: string, + prefix: string, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const entry of await readdir(root)) { + if (!entry.startsWith(prefix)) continue; + const metadata = await lstat(join(root, entry)); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) return entry; + } + await new Promise((resolvePoll) => setTimeout(resolvePoll, 1)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${label}`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74fa9d7e6535592124401a34c1595cc7e41d6601da66d670347f1e18a1362e3a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74fa9d7e6535592124401a34c1595cc7e41d6601da66d670347f1e18a1362e3a.source new file mode 100644 index 0000000000..f481536b5f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/74fa9d7e6535592124401a34c1595cc7e41d6601da66d670347f1e18a1362e3a.source @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync, writeSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { type RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { type ToolRecoveryFactEnvelope } from '@maka/core/tool-recovery-fact'; +import { type WorkspaceBaselineAuthorityInput } from '@maka/core/workspace-version-authority'; +import { createRuntimeBoundaryCursor, runtimePrefixSegment } from '@maka/core/runtime-boundary'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import { createSqliteRuntimeStore } from '../../sqlite-runtime-store.js'; +import { acquireOperationalStateDatabase } from '../../operational-state-store.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, +} from '../../workspace-version-authority-internal.js'; + +const mode = requiredEnv('MAKA_SQLITE_RECOVERY_CONCURRENCY_MODE'); +const dbPath = requiredEnv('MAKA_SQLITE_RECOVERY_CONCURRENCY_DB'); +const startPath = requiredEnv('MAKA_SQLITE_RECOVERY_CONCURRENCY_START'); +const stopPath = process.env.MAKA_SQLITE_RECOVERY_CONCURRENCY_STOP; + +writeSync(1, 'READY\n'); +while (!existsSync(startPath)) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); +} + +let store: ReturnType | undefined; +let operationalLease: ReturnType | undefined; +try { + if (mode === 'operational_open_only') { + operationalLease = acquireOperationalStateDatabase(dirname(dbPath)); + } else { + store = createSqliteRuntimeStore(dbPath); + } + writeSync(1, 'OPENED\n'); + if (mode === 'open_only' || mode === 'operational_open_only') { + if (!stopPath) throw new Error('Missing MAKA_SQLITE_RECOVERY_CONCURRENCY_STOP'); + while (!existsSync(stopPath)) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); + } + } else if (mode === 'completed') { + await store!.commitToolRecoveryBundle(completedBundle()); + } else if (mode === 'parked') { + await store!.commitToolRecoveryBundle(parkedBundle()); + } else if (mode === 'rebuild') { + await store!.rebuildToolProjectionsFromRuntimeEvents(); + } else if (mode === 'workspace_baseline_a' || mode === 'workspace_baseline_b') { + bindWorkspaceBaselineAuthorityStoreRootInternal(store!, 'a'.repeat(64)); + const result = await commitWorkspaceBaselineInternal( + store!, + workspaceBaselineInput(mode === 'workspace_baseline_b' ? 'b' : 'a'), + ); + writeSync(1, `BASELINE ${result.created ? 'created' : 'existing'}\n`); + } else if (mode === 'managed_mutation_a' || mode === 'managed_mutation_b') { + bindWorkspaceBaselineAuthorityStoreRootInternal(store!, 'a'.repeat(64)); + await store!.commitToolPrepared(managedMutationPreparedCommit(mode.endsWith('_b') ? 'b' : 'a')); + writeSync(1, 'MUTATION reserved\n'); + } else if (mode === 'append_source') { + await store!.ensureTerminalRuntimeEventDurable('session-1', 'run-1', { + ...baseEvent('concurrent-source-terminal', 3), + status: 'failed', + actions: { + endInvocation: true, + stateDelta: { failureClass: 'runtime_interrupted' }, + }, + }); + writeSync(1, 'APPEND committed\n'); + } else if (mode === 'append_target') { + await store!.appendRuntimeEvent('session-1', 'fixed-target-run', { + id: `target-event-${process.pid}`, + sessionId: 'session-1', + invocationId: 'fixed-target-invocation', + runId: 'fixed-target-run', + turnId: 'fixed-target-turn', + ts: process.pid, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'racing ordinary target event' }, + }); + writeSync(1, 'TARGET_APPEND committed\n'); + } else if (mode === 'claim' || mode === 'claim_fixed_target' || mode === 'claim_nonterminal') { + const sourceRunId = mode === 'claim_nonterminal' ? 'run-1' : 'continuation-source-run'; + const prefix = await store!.readImmutableRuntimePrefix({ + sessionId: 'session-1', + runId: sourceRunId, + ...(mode === 'claim_nonterminal' ? { upToEventSeq: 2 } : {}), + }); + const boundary = createRuntimeBoundaryCursor([runtimePrefixSegment(prefix)]); + const source = boundary.segments.at(-1)!; + const target = + mode === 'claim_fixed_target' + ? { + sessionId: 'session-1', + invocationId: 'fixed-target-invocation', + runId: 'fixed-target-run', + turnId: 'fixed-target-turn', + } + : { + sessionId: 'session-1', + invocationId: `invocation-${process.pid}`, + runId: `run-${process.pid}`, + turnId: `turn-${process.pid}`, + }; + const result = await store!.claimContinuation({ + claim: { + protocol: 'continuation_claim_v1', + claimId: `claim-${process.pid}`, + boundaryDigest: boundary.manifestDigest, + boundary, + providerProjectionVersion: 1, + providerReplayDigest: `sha256:${'a'.repeat(64)}`, + target, + targetOpening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'connection-1', + modelId: 'model-1', + }, + configuration: { + cwd: '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', + sourceInvocationId: source.identity.invocationId, + sourceRunId: source.identity.runId, + sourceTurnId: source.identity.turnId, + sourceRuntimeEventHighWater: source.position.lastEventSeq, + claimId: `claim-${process.pid}`, + boundaryDigest: boundary.manifestDigest, + }, + lineage: { + parentRunId: source.identity.runId, + parentTurnId: source.identity.turnId, + }, + }, + claimedAt: process.pid, + }, + }); + writeSync(1, `CLAIM ${result.kind}\n`); + } else { + throw new Error(`Unknown concurrency mode ${mode}`); + } + writeSync(1, 'RESULT ok\n'); +} catch (error) { + writeSync(2, `RESULT error ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; +} finally { + store?.close(); + operationalLease?.close(); +} + +function completedBundle() { + return { + operationId: 'operation-1', + reconcileRuntimeEvent: recoveryFact( + 'completed-reconcile', + 'maka.tool.reconcile_result', + { + protocol: 'tool_reconcile_v1', + operationId: 'operation-1', + observation: 'matches_expected_state', + observationSchema: 'state_identity_v1', + observationDigest: + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + 3, + ), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: recoveryFact( + 'completed-decision', + 'maka.tool.recovery_decision', + { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'completed', + reasonCode: 'reconcile_matches_expected_state', + outcomeEventId: 'completed-outcome', + evidenceEventIds: [ + 'call-event-1', + 'dispatch-event-1', + 'completed-reconcile', + 'completed-outcome', + ], + }, + 5, + ), + } as const; +} + +function parkedBundle() { + return { + operationId: 'operation-1', + reconcileRuntimeEvent: recoveryFact( + 'parked-reconcile', + 'maka.tool.reconcile_result', + { + protocol: 'tool_reconcile_v1', + operationId: 'operation-1', + observation: 'diverged', + observationSchema: 'state_identity_v1', + observationDigest: + 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + 3, + ), + decisionRuntimeEvent: recoveryFact( + 'parked-decision', + 'maka.tool.recovery_decision', + { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'parked', + reasonCode: 'reconcile_diverged', + evidenceEventIds: ['call-event-1', 'dispatch-event-1', 'parked-reconcile'], + }, + 4, + ), + } as const; +} + +function outcomeEvent(): RuntimeEvent { + return { + ...baseEvent('completed-outcome', 4), + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Write', + result: 'ok', + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} + +function recoveryFact( + id: string, + kind: 'maka.tool.reconcile_result' | 'maka.tool.recovery_decision', + payload: Record, + ts: number, +): RuntimeEvent { + return { + ...baseEvent(id, ts), + actions: { + toolRecovery: { kind, version: 1, payload } as unknown as ToolRecoveryFactEnvelope, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} + +function baseEvent(id: string, ts: number): RuntimeEvent { + return { + id, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts, + partial: false, + role: 'system', + author: 'system', + }; +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function workspaceBaselineInput(variant: 'a' | 'b'): WorkspaceBaselineAuthorityInput { + const alternate = variant === 'b'; + return { + epochOpenedEventId: alternate ? 'workspace-epoch-event-b' : 'workspace-epoch-event-a', + baselineAcceptedEventId: alternate ? 'workspace-version-event-b' : 'workspace-version-event-a', + committedAt: 1_700_000_000_000, + epoch: { + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: '1'.repeat(40), + sourceTreeOid: '2'.repeat(40), + materializationProfileDigest: `sha256:${'3'.repeat(64)}`, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: `sha256:${'4'.repeat(64)}`, + }, + baseline: { + workspaceVersionId: `version_${(alternate ? '9' : '5').repeat(32)}`, + commitOid: (alternate ? '9' : '5').repeat(40), + treeOid: '2'.repeat(40), + treeDeltaDigest: `sha256:${'6'.repeat(64)}`, + changedFileCount: 7, + deletedFileCount: 0, + }, + }; +} + +function managedMutationPreparedCommit(variant: 'a' | 'b') { + const operationId = `managed-mutation-${variant}`; + const toolCallId = `${operationId}-call`; + const args = { path: 'notes.txt', content: variant }; + const canonicalArgsHash = canonicalToolArgsHash('Write', args); + return { + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: { + id: `${operationId}-call-event`, + invocationId: `${operationId}-invocation`, + runId: `${operationId}-run`, + sessionId: `${operationId}-session`, + turnId: `${operationId}-turn`, + ts: 1_700_000_000_001, + partial: false, + role: 'model' as const, + author: 'agent' as const, + content: { kind: 'function_call' as const, id: toolCallId, name: 'Write', args }, + refs: { operationId, toolCallId }, + }, + dispatchRuntimeEvent: { + id: `${operationId}-dispatch-event`, + invocationId: `${operationId}-invocation`, + runId: `${operationId}-run`, + sessionId: `${operationId}-session`, + turnId: `${operationId}-turn`, + ts: 1_700_000_000_001, + partial: false, + role: 'system' as const, + author: 'system' as const, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1' as const, + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile' as const, + managedMutation: { + protocol: 'managed_mutation_v2' as const, + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: `version_${'5'.repeat(32)}`, + baseAcceptedEventId: 'workspace-version-event-a', + baseHeadRevision: 1, + baseCommitOid: '5'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'notes.txt', + pathPolicyVersion: 3 as const, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + }, + }, + refs: { operationId, toolCallId }, + }, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile' as const, + committedAt: 1_700_000_000_001, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/751ad237e2ddacf804b1fcf0fec4ba6c65818416f5e05e9983ca7d1c82cf56d3.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/751ad237e2ddacf804b1fcf0fec4ba6c65818416f5e05e9983ca7d1c82cf56d3.source new file mode 100644 index 0000000000..9427351753 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/751ad237e2ddacf804b1fcf0fec4ba6c65818416f5e05e9983ca7d1c82cf56d3.source @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { + lstat, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + AtomicFileWriteCommitUnknownError, + writeAtomicFile, + type AtomicFileWriteDependencies, + type AtomicFileWriteHandle, +} from '../atomic-file-write.js'; + +const isPosix = process.platform !== 'win32'; +const ownerOnlyFile = { fileMode: 0o600 } as const; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'maka-atomic-write-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('writeAtomicFile', () => { + test('writes the exact bytes and leaves no temp file behind', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + await writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile); + assert.equal(await readFile(path, 'utf8'), '{"a":1}\n'); + assert.deepEqual(await readdir(dir), ['settings.json']); + }); + }); + + for (const failurePhase of ['write', 'sync', 'close'] as const) { + test(`removes its temp file and rethrows after a ${failurePhase} failure`, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + const temporaryPath = join(dir, '.settings.json.fault.tmp'); + const fault = new Error(`${failurePhase} failed`); + await assert.rejects( + () => + writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile, { + randomUUID: () => 'fault', + open: faultingOpen(temporaryPath, failurePhase, fault), + }), + fault, + ); + assert.deepEqual(await readdir(dir), []); + }); + }); + } + + test('removes its temp file and rethrows after a chmod failure', { + skip: process.platform === 'win32', + }, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + const temporaryPath = join(dir, '.settings.json.fault.tmp'); + const fault = new Error('chmod failed'); + await assert.rejects( + () => + writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile, { + randomUUID: () => 'fault', + open: faultingOpen(temporaryPath, 'chmod', fault), + }), + fault, + ); + assert.deepEqual(await readdir(dir), []); + }); + }); + + test('sets the final mode before synchronizing the temp file', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + const phases: string[] = []; + await writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile, { + open: async (temporaryPath, flags, mode) => { + const handle = await open(temporaryPath, flags, mode); + return { + writeFile: async (data, encoding) => { + phases.push('write'); + await handle.writeFile(data, encoding); + }, + chmod: async (nextMode) => { + phases.push('chmod'); + await handle.chmod(nextMode); + }, + sync: async () => { + phases.push('sync'); + await handle.sync(); + }, + close: async () => { + phases.push('close'); + await handle.close(); + }, + }; + }, + syncDirectory: async () => { + phases.push('sync-directory'); + }, + }); + assert.deepEqual( + phases, + isPosix + ? ['write', 'chmod', 'sync', 'close', 'sync-directory'] + : ['write', 'sync', 'close', 'sync-directory'], + ); + }); + }); + + test('reports an unknown commit outcome when directory fsync fails after publication', async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + const fault = new Error('dirsync failed'); + await assert.rejects( + () => + writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile, { + syncDirectory: async () => { + throw fault; + }, + }), + (error: unknown) => { + assert.ok(error instanceof AtomicFileWriteCommitUnknownError); + assert.equal(error.published, true); + assert.equal(error.cause, fault); + assert.match(error.message, /reload before retrying/); + return true; + }, + ); + // rename is the commit point: the replacement is live (readers get the + // new bytes) even though its durability is not known to the caller. + assert.equal(await readFile(path, 'utf8'), '{"a":1}\n'); + assert.deepEqual(await readdir(dir), ['settings.json']); + }); + }); + + test('creates the target 0600 on POSIX', { skip: process.platform === 'win32' }, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + await writeAtomicFile(path, '{}\n', ownerOnlyFile); + assert.equal((await stat(path)).mode & 0o777, 0o600); + }); + }); + + test('re-chmods a pre-existing world-readable target to 0600 on the next write', { + skip: process.platform === 'win32', + }, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'settings.json'); + // A file created with a loose mode by an older writer. + await writeFile(path, '{}\n', { encoding: 'utf8', mode: 0o644 }); + await writeAtomicFile(path, '{"a":1}\n', ownerOnlyFile); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.equal(await readFile(path, 'utf8'), '{"a":1}\n'); + }); + }); + + test('refuses to write through a pre-planted symlink at the temp path', { + skip: process.platform === 'win32', + }, async () => { + await withTempDir(async (dir) => { + const path = join(dir, 'credentials.json'); + const plantedTarget = join(dir, 'planted-target.json'); + await writeFile(plantedTarget, 'do not touch\n', 'utf8'); + // The injected randomUUID makes the unpredictable temp path knowable, + // which is exactly the attacker model 'wx'/O_EXCL answers. + const plantedTemp = join(dir, '.credentials.json.planted.tmp'); + await symlink(plantedTarget, plantedTemp); + await assert.rejects( + () => writeAtomicFile(path, '{}\n', ownerOnlyFile, { randomUUID: () => 'planted' }), + { code: 'EEXIST' }, + ); + assert.equal(await readFile(plantedTarget, 'utf8'), 'do not touch\n'); + assert.equal(await stat(path).catch(() => null), null); + // Cleanup removes only what the writer created: the planted entry is + // still a symlink, exactly where it was. + assert.equal((await lstat(plantedTemp)).isSymbolicLink(), true); + }); + }); +}); + +function faultingOpen( + temporaryPath: string, + failurePhase: 'write' | 'chmod' | 'sync' | 'close', + fault: Error, +): AtomicFileWriteDependencies['open'] { + return async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path !== temporaryPath) return handle; + + let closeFailed = false; + const wrapped: AtomicFileWriteHandle = { + writeFile: async (data, encoding) => { + if (failurePhase === 'write') { + await handle.writeFile(data.slice(0, 1), encoding); + throw fault; + } + await handle.writeFile(data, encoding); + }, + chmod: async (mode) => { + if (failurePhase === 'chmod') throw fault; + await handle.chmod(mode); + }, + sync: async () => { + if (failurePhase === 'sync') throw fault; + await handle.sync(); + }, + close: async () => { + if (failurePhase === 'close' && !closeFailed) { + closeFailed = true; + await handle.close(); + throw fault; + } + await handle.close(); + }, + }; + return wrapped; + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76a5b8eec761364b7e838202d10f10c2d1349f3d0cfe0a7fc0ced38356f64f9e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76a5b8eec761364b7e838202d10f10c2d1349f3d0cfe0a7fc0ced38356f64f9e.source new file mode 100644 index 0000000000..4f408bc856 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76a5b8eec761364b7e838202d10f10c2d1349f3d0cfe0a7fc0ced38356f64f9e.source @@ -0,0 +1,4248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, test } from 'node:test'; +import { Worker } from 'node:worker_threads'; +import { AgentGraphClientTerminalCursorError } from '@maka/core/agent-graph-client-projection'; +import { messageContentDigest, type MessageContent } from '@maka/core/events'; +import { + canReadPath, + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; +import { + MAX_EXECUTION_BOUNDARY_SERIALIZED_BYTES, + type SandboxBoundarySettlement, +} from '@maka/core/sandbox-boundary'; +import type { SessionHeader, SessionHeaderPatch } from '@maka/core/session'; +import type { AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; +import { + createSqliteSessionMetadataStore, + SessionMetadataConflictError, + SessionMetadataVersionConflictError, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, + StoredSessionMessageIncompatibleError, + type SessionConfigurationMetadataUpdate, + type SqliteSessionMetadataStoreFailpoint, +} from '../sqlite-session-metadata-store.js'; +import type { + MarkMessagesHandedOffInput, + PendingMessageAdmission, + ProvenRootMessageHandoff, +} from '../message-admission-store.js'; +import { + createSqliteRuntimeStore, + SQLITE_RUNTIME_SCHEMA_VERSION, +} from '../sqlite-runtime-store.js'; +import { SQLITE_AGENT_GRAPH_CONTROL_TABLES } from '../sqlite-session-metadata-schema.js'; + +describe('SqliteSessionMetadataStore', () => { + test('migrates version 38 and resumes the body-free Coordination index idempotently', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-coordination-index-migration-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + const baseline = new DatabaseSync(path); + baseline.exec( + "DROP TABLE coordination_transcript_index; UPDATE session_metadata_schema SET version = 38 WHERE scope = 'session_metadata'", + ); + baseline.close(); + const migrated = createSqliteSessionMetadataStore(path); + await migrated.appendCoordinationTranscriptIndex([ + { source: 'legacy', sourceSequence: 0 }, + { source: 'runtime', sourceSequence: 8 }, + ]); + migrated.close(); + const reopened = createSqliteSessionMetadataStore(path); + try { + await reopened.appendCoordinationTranscriptIndex([ + { source: 'runtime', sourceSequence: 8 }, + { source: 'legacy', sourceSequence: 1 }, + ]); + assert.deepEqual( + { ...(await reopened.readCoordinationTranscriptIndexState()) }, + { highWater: 2, legacy: 1, runtime: 8 }, + ); + const records = await reopened.readCoordinationTranscriptIndex({ + direction: 'older', + throughSequence: 1, + position: 1, + limit: 64, + }); + assert.deepEqual( + records.map((record) => ({ ...record })), + [ + { sequence: 1, source: 'runtime', sourceSequence: 8 }, + { sequence: 0, source: 'legacy', sourceSequence: 0 }, + ], + ); + await assert.rejects( + () => + reopened.appendCoordinationTranscriptIndex( + Array.from({ length: 65 }, () => ({ source: 'legacy' as const, sourceSequence: 2 })), + ), + /batch exceeds limit/, + ); + assert.equal((await reopened.readCoordinationTranscriptIndexState()).highWater, 2); + } finally { + reopened.close(); + } + const inspect = new DatabaseSync(path); + assert.deepEqual( + inspect + .prepare('PRAGMA table_info(coordination_transcript_index)') + .all() + .map((column) => column.name), + ['sequence', 'source', 'source_sequence'], + ); + inspect.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + for (const version30Shape of ['admissions-only', 'coordination-only', 'complete'] as const) { + test(`converges the ${version30Shape} version-30 schema after the merge`, async () => { + const root = await mkdtemp(join(tmpdir(), `maka-session-v30-${version30Shape}-`)); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + + const version30 = new DatabaseSync(path); + try { + if (version30Shape === 'admissions-only') { + version30.exec('DROP INDEX session_metadata_one_workhub_coordination_session'); + } else if (version30Shape === 'coordination-only') { + version30.exec(` + DROP TABLE cancelled_message_admissions; + DROP TABLE message_admissions; + `); + } + version30 + .prepare( + `UPDATE session_metadata_schema SET version = 30 WHERE scope = 'session_metadata'`, + ) + .run(); + } finally { + version30.close(); + } + + const converged = createSqliteSessionMetadataStore(path); + try { + assert.equal(converged.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + } finally { + converged.close(); + } + + const schema = new DatabaseSync(path, { readOnly: true }); + try { + const objects = schema + .prepare( + ` + SELECT name + FROM sqlite_schema + WHERE name IN ( + 'message_admissions', + 'message_admissions_by_session_order', + 'cancelled_message_admissions', + 'session_metadata_one_workhub_coordination_session' + ) + ORDER BY name + `, + ) + .all() + .map((row) => (row as { name: string }).name); + assert.deepEqual(objects, [ + 'cancelled_message_admissions', + 'message_admissions', + 'message_admissions_by_session_order', + 'session_metadata_one_workhub_coordination_session', + ]); + } finally { + schema.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } + + test('migrates a legacy subagent Session to a frozen model route', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-v32-')); + const path = join(root, 'state.sqlite'); + const child = fullHeader({ + id: 'legacy-child', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + connectionLocked: false, + subagentParent: { + kind: 'subagent', + parentSessionId: 'parent-session', + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground', + }, + }); + const ordinary = fullHeader({ id: 'legacy-ordinary', connectionLocked: false }); + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(child); + await setup.create(ordinary); + } finally { + setup.close(); + } + // A subagent spawned before the route froze at creation, and abandoned + // before its first Message, is the one shape nothing else can lock. + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + UPDATE session_metadata_schema SET version = 32 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal((await migrated.read('legacy-child')).header.connectionLocked, true); + assert.equal((await migrated.read('legacy-ordinary')).header.connectionLocked, false); + } finally { + migrated.close(); + } + await rm(root, { recursive: true, force: true }); + }); + + test('migrates v27 metadata to the current schema without backfilling external origin', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-v27-')); + const path = join(root, 'state.sqlite'); + const legacyHeader = fullHeader({ + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + }); + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(legacyHeader); + } finally { + setup.close(); + } + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DROP INDEX session_metadata_one_workhub_coordination_session; + DROP INDEX session_metadata_by_external_origin; + ALTER TABLE session_metadata DROP COLUMN external_adapter_id; + ALTER TABLE session_metadata DROP COLUMN external_source_session_id; + UPDATE session_metadata_schema SET version = 27 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal((await migrated.read(legacyHeader.id)).header.externalOrigin, undefined); + } finally { + migrated.close(); + } + const schema = new DatabaseSync(path); + try { + const columns = schema + .prepare('PRAGMA table_info(session_metadata)') + .all() as unknown as Array<{ + readonly name: string; + }>; + assert.equal( + columns.some(({ name }) => name === 'external_adapter_id'), + true, + ); + assert.equal( + columns.some(({ name }) => name === 'external_source_session_id'), + true, + ); + assert.equal( + columns.some(({ name }) => name === 'last_used_at'), + false, + ); + const externalOriginIndex = schema + .prepare( + `SELECT sql FROM sqlite_master + WHERE type = 'index' AND name = 'session_metadata_by_external_origin'`, + ) + .get() as { readonly sql: string } | undefined; + assert.match( + externalOriginIndex?.sql ?? '', + /WHERE\s+external_adapter_id IS NOT NULL\s+AND external_source_session_id IS NOT NULL/i, + ); + } finally { + schema.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('identifies an incompatible persisted message without exposing its content', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-message-incompatible-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + await setup.create(fullHeader()); + setup.close(); + + const database = new DatabaseSync(path); + const incompatible = JSON.stringify({ + type: 'user', + id: 'message-legacy', + turnId: 'turn-legacy', + ts: 5, + text: 'private message text', + origin: { kind: 'future_trigger', triggerId: 'future-trigger' }, + }); + database + .prepare(` + INSERT INTO session_messages( + session_id, sequence, message_id, message_type, message_ts, record_json + ) VALUES (?, ?, ?, ?, ?, ?) + `) + .run('session-1', 104, 'message-legacy', 'user', 5, incompatible); + database.close(); + + const store = createSqliteSessionMetadataStore(path); + try { + await assert.rejects( + () => store.readMessages('session-1'), + (error: unknown) => + error instanceof StoredSessionMessageIncompatibleError && + error.code === 'stored_session_message_incompatible' && + error.sessionId === 'session-1' && + error.sequence === 104 && + !error.message.includes('private message text'), + ); + } finally { + store.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('round-trips every SessionHeader field and reopens the same schema', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path, { now: () => 100 }); + const header = fullHeader(); + assert.equal(store.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal(store.journalMode(), 'wal'); + assert.deepEqual(await store.create(header), { + header, + metadataVersion: 1, + committedAt: 100, + }); + store.close(); + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 200 }); + try { + assert.equal(reopened.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.deepEqual(await reopened.read(header.id), { + header, + metadataVersion: 1, + committedAt: 100, + }); + } finally { + reopened.close(); + } + const schema = new DatabaseSync(path); + try { + const graphTables = schema + .prepare(` + SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name GLOB 'agent_graph_*' + ORDER BY name + `) + .all() as unknown as Array<{ readonly name: string }>; + assert.deepEqual( + graphTables.map(({ name }) => name), + [...SQLITE_AGENT_GRAPH_CONTROL_TABLES].sort(), + ); + assert.deepEqual( + schema.prepare('PRAGMA foreign_key_list(agent_graph_client_operator_projections)').all(), + [], + ); + assert.deepEqual( + schema.prepare('PRAGMA foreign_key_list(agent_graph_client_terminal_activity)').all(), + [], + ); + assert.deepEqual( + schema + .prepare(` + SELECT name + FROM sqlite_schema + WHERE type = 'table' + AND name IN ('session_metadata_labels', 'session_catalog_label_projection') + ORDER BY name + `) + .all(), + [], + ); + } finally { + schema.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('retires an accepted steering draft when it is handed off', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-1', connectionLocked: false })); + const skillInvocation = { + loaded: [{ id: 'review', name: 'Review' }], + failed: [{ request: 'typo', reason: 'not_found' as const }], + receipts: [], + }; + const admission = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'submitted', displayText: 'submitted' }, + submittedContentDigest: messageContentDigest({ text: 'submitted' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + // Exact-Turn intent is durable and whole: recovery re-opens the Turn + // from this record and answers retries against it, and content and + // placement describe neither the Skills nor the execution mode. + submittedIntent: { + skillIds: ['review'], + turnOrchestration: { mode: 'graph', source: 'slash_command' }, + }, + skillInvocation, + admittedAt: 10, + } satisfies PendingMessageAdmission & { readonly skillInvocation: typeof skillInvocation }; + + const normalizedAdmission = { + ...admission, + content: { text: 'submitted' }, + }; + assert.deepEqual(await store.commitMessageAdmission(admission), normalizedAdmission); + assert.deepEqual( + await store.readMessageAdmission('session-1', 'message-1'), + normalizedAdmission, + ); + assert.deepEqual(await store.readMessages('session-1'), []); + assert.equal((await store.read('session-1')).header.lastMessageAt, 3); + assert.equal((await store.readCatalogRecord('session-1')).lastMessagePreview, undefined); + assert.equal((await store.read('session-1')).header.connectionLocked, false); + assert.deepEqual( + (await store.listMessageAdmissions('session-1')).map((entry) => entry.messageId), + ['message-1'], + ); + await assert.rejects( + store.commitMessageAdmission({ + ...admission, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }), + /Message admission identity conflict/, + ); + await store.markMessagesHandedOff({ + sessionId: 'session-1', + messageIds: ['message-1'], + turnId: 'turn-1', + }); + // Handoff retires the admission and nothing else: the message itself is a + // RuntimeEvent, and its catalog facts come from the run that wrote it. + assert.deepEqual(await store.readMessages('session-1'), []); + assert.equal((await store.read('session-1')).header.lastMessageAt, 3); + assert.equal((await store.readCatalogRecord('session-1')).lastMessagePreview, undefined); + assert.equal((await store.read('session-1')).header.connectionLocked, false); + assert.deepEqual(await store.listMessageAdmissions('session-1'), []); + } finally { + store.close(); + } + }); + + test('migrates v34 message admissions with an empty Skill invocation outcome', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-admission-v34-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-v34-admission' })); + await setup.commitMessageAdmission({ + sessionId: 'session-v34-admission', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'queued before the migration' }, + submittedContentDigest: messageContentDigest({ text: 'queued before the migration' }), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + ALTER TABLE message_admissions DROP COLUMN skill_invocation_json; + UPDATE session_metadata_schema SET version = 34 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.deepEqual( + (await migrated.readMessageAdmission('session-v34-admission', 'message-1')) + ?.skillInvocation, + { loaded: [], failed: [], receipts: [] }, + ); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('migrates a v36 cancellation tombstone without inventing a claim owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-v36-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-v36-cancellation' })); + const content = { text: 'cancelled before claim provenance existed' }; + await setup.commitMessageAdmission({ + sessionId: 'session-v36-cancellation', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + await setup.cancelMessageAdmissions('session-v36-cancellation', ['message-1']); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + ALTER TABLE cancelled_message_admissions DROP COLUMN cancellation_claim_id; + UPDATE session_metadata_schema SET version = 36 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal( + await migrated.hasCancelledMessageAdmission('session-v36-cancellation', 'message-1'), + true, + ); + assert.equal( + await migrated.claimMessageAdmissionCancellation( + 'session-v36-cancellation', + 'message-1', + 'later-workhub-claim', + ), + 'already_cancelled', + ); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects an admission handed off to a different Turn', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-admission-turn-conflict' })); + await store.commitMessageAdmission({ + sessionId: 'session-admission-turn-conflict', + turnId: 'turn-admission-authority', + runId: 'run-admission-turn-conflict', + messageId: 'message-admission-turn-conflict', + content: { text: 'turn-owned admission' }, + submittedContentDigest: messageContentDigest({ text: 'turn-owned admission' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 24, + }); + + await assert.rejects( + store.markMessagesHandedOff({ + sessionId: 'session-admission-turn-conflict', + messageIds: ['message-admission-turn-conflict'], + turnId: 'turn-different', + }), + /Turn conflict/, + ); + assert.deepEqual(await store.readMessages('session-admission-turn-conflict'), []); + assert.equal( + (await store.listMessageAdmissions('session-admission-turn-conflict')).length, + 1, + ); + } finally { + store.close(); + } + }); + + test('repeats a proven Root message handoff after its admission is gone', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-legacy-repeat' })); + await store.commitMessageAdmission({ + sessionId: 'session-legacy-repeat', + turnId: 'turn-legacy-repeat', + runId: 'run-legacy-repeat', + messageId: 'message-legacy-repeat', + content: { text: 'a single durable message' }, + submittedContentDigest: messageContentDigest({ text: 'a single durable message' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 18, + }); + const input = { + sessionId: 'session-legacy-repeat', + messageIds: ['message-legacy-repeat'], + turnId: 'turn-legacy-repeat', + provenRootMessages: [ + { + messageId: 'message-legacy-repeat', + content: { text: 'a single durable message' }, + admittedAt: 18, + }, + ], + }; + + await markMessagesHandedOffWithProvenRoots(store, input); + await markMessagesHandedOffWithProvenRoots(store, input); + + assert.deepEqual(await store.listMessageAdmissions('session-legacy-repeat'), []); + assert.deepEqual(await store.readMessages('session-legacy-repeat'), []); + } finally { + store.close(); + } + }); + + test('rejects an admission-less handoff without a proven Root message', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-no-legacy-proof' })); + + await assert.rejects( + store.markMessagesHandedOff({ + sessionId: 'session-no-legacy-proof', + messageIds: ['message-no-legacy-proof'], + turnId: 'turn-no-legacy-proof', + }), + /Message admission does not exist/, + ); + } finally { + store.close(); + } + }); + + test('rejects a proven Root handoff for a cancelled admission', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-legacy-cancelled' })); + await store.commitMessageAdmission({ + sessionId: 'session-legacy-cancelled', + turnId: 'turn-legacy-cancelled', + runId: 'run-legacy-cancelled', + messageId: 'message-legacy-cancelled', + content: { text: 'cancelled' }, + submittedContentDigest: messageContentDigest({ text: 'cancelled' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 19, + }); + await store.cancelMessageAdmissions('session-legacy-cancelled', ['message-legacy-cancelled']); + + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + sessionId: 'session-legacy-cancelled', + messageIds: ['message-legacy-cancelled'], + turnId: 'turn-legacy-cancelled', + provenRootMessages: [ + { + messageId: 'message-legacy-cancelled', + content: { text: 'cancelled' }, + admittedAt: 19, + }, + ], + }), + /already cancelled/, + ); + } finally { + store.close(); + } + }); + + test('rejects proven Root fallback content that drifts from an admission', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-admission-drift' })); + await store.commitMessageAdmission({ + sessionId: 'session-admission-drift', + turnId: 'turn-admission-drift', + runId: 'run-admission-drift', + messageId: 'message-admission-drift', + content: { text: 'admitted content' }, + submittedContentDigest: messageContentDigest({ text: 'admitted content' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 22, + }); + + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + sessionId: 'session-admission-drift', + messageIds: ['message-admission-drift'], + turnId: 'turn-admission-drift', + provenRootMessages: [ + { + messageId: 'message-admission-drift', + content: { text: 'drifted content' }, + admittedAt: 22, + }, + ], + }), + /fallback content conflict/, + ); + assert.deepEqual(await store.readMessages('session-admission-drift'), []); + assert.equal((await store.listMessageAdmissions('session-admission-drift')).length, 1); + } finally { + store.close(); + } + }); + + test('validates proven Root fallback identities and timestamps before handoff', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-legacy-validation' })); + const base = { + sessionId: 'session-legacy-validation', + messageIds: ['message-legacy-validation'], + turnId: 'turn-legacy-validation', + }; + + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + ...base, + provenRootMessages: [ + { + messageId: 'message-legacy-validation', + content: { text: 'first' }, + admittedAt: 23, + }, + { + messageId: 'message-legacy-validation', + content: { text: 'second' }, + admittedAt: 24, + }, + ], + }), + /duplicate identities/, + ); + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + ...base, + provenRootMessages: [ + { + messageId: 'message-not-requested', + content: { text: 'not requested' }, + admittedAt: 23, + }, + ], + }), + /not present in messageIds/, + ); + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + ...base, + provenRootMessages: [ + { + messageId: 'message-legacy-validation', + content: { text: 'bad timestamp' }, + admittedAt: -1, + }, + ], + }), + /timestamp/, + ); + await assert.rejects( + markMessagesHandedOffWithProvenRoots(store, { + ...base, + provenRootMessages: [ + { + messageId: 'message-not-requested', + content: { text: 23 } as unknown as MessageContent, + admittedAt: 23, + }, + ], + }), + /Invalid MessageContent/, + ); + } finally { + store.close(); + } + }); + + test('removes the accepted payload without writing a transcript row', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-handoff-')); + const path = join(root, 'state.sqlite'); + const store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-1' })); + await store.commitMessageAdmission({ + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'one durable copy' }, + submittedContentDigest: messageContentDigest({ text: 'one durable copy' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + await store.markMessagesHandedOff({ + sessionId: 'session-1', + messageIds: ['message-1'], + turnId: 'turn-1', + }); + } finally { + store.close(); + } + + const persisted = new DatabaseSync(path); + try { + assert.equal( + persisted + .prepare( + 'SELECT COUNT(*) AS count FROM message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get('session-1', 'message-1')?.count, + 0, + ); + assert.equal( + persisted + .prepare( + 'SELECT COUNT(*) AS count FROM session_messages WHERE session_id = ? AND message_id = ?', + ) + .get('session-1', 'message-1')?.count, + 0, + ); + } finally { + persisted.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('accepts a proof-backed steering handoff from a later execution Turn', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-cross-turn-steering-')); + const path = join(root, 'state.sqlite'); + const store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-1' })); + const content = { text: 'carried into a later successor' }; + const admission = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn' as const, + placement: 'next_turn' as const, + disposition: 'followup' as const, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }; + await store.commitMessageAdmission(admission); + await store.updateMessageAdmission({ + ...admission, + placement: 'current_turn', + disposition: 'steering', + }); + + await assert.rejects( + store.markMessagesHandedOff({ + sessionId: 'session-1', + messageIds: ['message-1'], + turnId: 'turn-2', + provenSteeringMessages: [ + { + messageId: 'message-1', + admissionTurnId: 'wrong-turn', + admissionRunId: 'run-1', + executionTurnId: 'turn-2', + eventId: 'event-message-1', + eventTs: 20, + content, + admittedAt: 10, + }, + ], + }), + /Proven steering admission identity conflict/, + ); + + await store.markMessagesHandedOff({ + sessionId: 'session-1', + messageIds: ['message-1'], + turnId: 'turn-2', + provenSteeringMessages: [ + { + messageId: 'message-1', + admissionTurnId: 'turn-1', + admissionRunId: 'run-1', + executionTurnId: 'turn-2', + eventId: 'event-message-1', + eventTs: 20, + content, + admittedAt: 10, + }, + ], + }); + + await store.markMessagesHandedOff({ + sessionId: 'session-1', + messageIds: ['message-1'], + turnId: 'turn-2', + provenSteeringMessages: [ + { + messageId: 'message-1', + admissionTurnId: 'turn-1', + admissionRunId: 'run-1', + executionTurnId: 'turn-2', + eventId: 'event-message-1', + eventTs: 20, + content, + admittedAt: 10, + }, + ], + }); + + assert.equal(await store.readMessageAdmission('session-1', 'message-1'), undefined); + assert.deepEqual(await store.readMessages('session-1'), []); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('retract replaces an accepted payload with a minimal identity tombstone', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-retract-')); + const path = join(root, 'state.sqlite'); + const store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-1' })); + const admission: PendingMessageAdmission = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'discard this draft' }, + submittedContentDigest: messageContentDigest({ text: 'discard this draft' }), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }; + await store.commitMessageAdmission(admission); + await store.cancelMessageAdmissions('session-1', ['message-1']); + assert.deepEqual(await store.listMessageAdmissions('session-1'), []); + assert.equal(await store.hasCancelledMessageAdmission('session-1', 'message-1'), true); + assert.equal(await store.hasCancelledMessageAdmission('session-1', 'message-2'), false); + await assert.rejects( + store.commitMessageAdmission(admission), + /identity is already cancelled/, + ); + } finally { + store.close(); + } + + const persisted = new DatabaseSync(path); + try { + assert.deepEqual( + persisted + .prepare( + ` + SELECT message_id, submitted_content_digest, submitted_placement + FROM cancelled_message_admissions + WHERE session_id = ? + `, + ) + .all('session-1') + .map((row) => ({ ...row })), + [ + { + message_id: 'message-1', + submitted_content_digest: messageContentDigest({ text: 'discard this draft' }), + submitted_placement: 'next_turn', + }, + ], + ); + assert.equal( + persisted + .prepare('SELECT COUNT(*) AS count FROM message_admissions WHERE session_id = ?') + .get('session-1')?.count, + 0, + ); + } finally { + persisted.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a WorkHub action identity owns one operation across store restarts', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-')); + const path = join(root, 'state.sqlite'); + const stopClaim = { + actionId: 'stop-action', + operation: 'stop' as const, + actionFingerprint: `sha256:${'a'.repeat(64)}` as const, + subject: 'whd_payments', + }; + let store = createSqliteSessionMetadataStore(path); + try { + assert.equal(await store.claimWorkHubAction(stopClaim), 'claimed'); + assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + } finally { + store.close(); + } + + store = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual(await store.readWorkHubActionClaim('stop-action'), stopClaim); + assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + // A second delegation, a second disposition, and a changed payload are + // each a different operation for the same identity. + assert.equal( + await store.claimWorkHubAction({ ...stopClaim, subject: 'whd_login' }), + 'conflict', + ); + assert.equal( + await store.claimWorkHubAction({ ...stopClaim, operation: 'delegate_existing' }), + 'conflict', + ); + assert.equal( + await store.claimWorkHubAction({ + ...stopClaim, + actionFingerprint: `sha256:${'b'.repeat(64)}`, + }), + 'conflict', + ); + assert.equal(await store.readWorkHubActionClaim('unclaimed-action'), undefined); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('cancellation tombstones retain the durable claim that created them', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-claim-')); + const path = join(root, 'state.sqlite'); + let store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-claim' })); + const content = { text: 'cancel this pending work' }; + await store.commitMessageAdmission({ + sessionId: 'session-claim', + turnId: 'turn-claim', + runId: 'run-claim', + messageId: 'message-claim', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'stop-claim', + ), + 'cancelled_by_claim', + ); + } finally { + store.close(); + } + + store = createSqliteSessionMetadataStore(path); + try { + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'stop-claim', + ), + 'same_claim', + ); + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'other-claim', + ), + 'already_cancelled', + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('retires an accepted follow-up under its successor root', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-followup-admission' })); + const admission = await store.commitMessageAdmission({ + sessionId: 'session-followup-admission', + turnId: 'turn-current', + runId: 'run-current', + messageId: 'message-followup', + content: { text: 'queued before the successor root' }, + submittedContentDigest: messageContentDigest({ + text: 'queued before the successor root', + }), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 11, + }); + assert.equal(admission.disposition, 'followup'); + assert.deepEqual(await store.readMessages('session-followup-admission'), []); + const handoff = { + sessionId: 'session-followup-admission', + messageIds: ['message-followup'], + turnId: 'turn-successor', + provenRootMessages: [ + { + messageId: 'message-followup', + content: { text: 'queued before the successor root' }, + admittedAt: 11, + }, + ], + }; + await markMessagesHandedOffWithProvenRoots(store, handoff); + await markMessagesHandedOffWithProvenRoots(store, handoff); + assert.deepEqual(await store.listMessageAdmissions('session-followup-admission'), []); + assert.deepEqual(await store.readMessages('session-followup-admission'), []); + } finally { + store.close(); + } + }); + + test('persists a follow-up reorder across SQLite restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-reorder-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-reorder' })); + for (const [index, messageId] of ['message-first', 'message-second'].entries()) { + await store.commitMessageAdmission({ + sessionId: 'session-reorder', + turnId: 'turn-current', + runId: 'run-current', + messageId, + content: { text: messageId }, + submittedContentDigest: messageContentDigest({ text: messageId }), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 20 + index, + }); + } + await store.reorderMessageAdmissions('session-reorder', [ + 'message-second', + 'message-first', + ]); + } finally { + store.close(); + } + + const reopened = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual( + (await reopened.listMessageAdmissions('session-reorder')).map( + (admission) => admission.messageId, + ), + ['message-second', 'message-first'], + ); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('migrates v24 legacy session statuses to active exactly once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-status-v24-')); + const path = join(root, 'state.sqlite'); + const sessionIds = ['legacy-review', 'legacy-done', 'legacy-both', 'legacy-unchanged']; + const migrationSnapshots = new Map< + string, + { + readonly committedAt: number; + readonly metadataVersion: number; + readonly statusUpdatedAt?: number; + } + >(); + const persistedRowsAfterMigration: Array<{ + readonly sessionId: string; + readonly payloadStatus: string; + readonly metadataVersion: number; + }> = []; + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); + for (const id of sessionIds) { + await setup.create( + fullHeader({ + id, + status: 'active', + blockedReason: undefined, + statusUpdatedAt: id === 'legacy-done' ? 404 : id === 'legacy-both' ? 505 : 303, + }), + ); + } + setup.close(); + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DROP INDEX session_metadata_one_workhub_coordination_session; + ALTER TABLE session_metadata ADD COLUMN status TEXT; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + UPDATE session_metadata + SET + status = json_extract(payload_json, '$.status'), + status_updated_at = json_extract(payload_json, '$.statusUpdatedAt'); + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + DROP INDEX session_metadata_by_external_origin; + ALTER TABLE session_metadata DROP COLUMN external_adapter_id; + ALTER TABLE session_metadata DROP COLUMN external_source_session_id; + `); + legacy + .prepare( + ` + UPDATE session_metadata + SET status = ?, metadata_version = ?, committed_at = ? + WHERE session_id = ? + `, + ) + .run('review', 7, 100, 'legacy-review'); + legacy + .prepare( + ` + UPDATE session_metadata + SET + payload_json = json_set(payload_json, '$.status', ?), + metadata_version = ?, + committed_at = ? + WHERE session_id = ? + `, + ) + .run('done', 11, 4_000_000_000_000, 'legacy-done'); + legacy + .prepare( + ` + UPDATE session_metadata + SET + status = ?, + payload_json = json_set(payload_json, '$.status', ?), + metadata_version = ?, + committed_at = ? + WHERE session_id = ? + `, + ) + .run('review', 'done', 17, 500, 'legacy-both'); + legacy + .prepare( + ` + UPDATE session_metadata + SET metadata_version = ?, committed_at = ? + WHERE session_id = ? + `, + ) + .run(13, 300, 'legacy-unchanged'); + legacy + .prepare( + `UPDATE session_metadata_schema SET version = 24 WHERE scope = 'session_metadata'`, + ) + .run(); + } finally { + legacy.close(); + } + + const migrationStartedAt = Date.now(); + const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.deepEqual((await migrated.read('legacy-review')).header.status, 'active'); + assert.deepEqual((await migrated.read('legacy-done')).header.status, 'active'); + assert.deepEqual((await migrated.read('legacy-both')).header.status, 'active'); + assert.equal((await migrated.read('legacy-review')).metadataVersion, 8); + assert.equal((await migrated.read('legacy-done')).metadataVersion, 12); + assert.equal((await migrated.read('legacy-both')).metadataVersion, 18); + assert.equal((await migrated.read('legacy-unchanged')).metadataVersion, 13); + assert.ok((await migrated.read('legacy-review')).committedAt >= migrationStartedAt); + assert.equal((await migrated.read('legacy-done')).committedAt, 4_000_000_000_000); + assert.ok((await migrated.read('legacy-both')).committedAt >= migrationStartedAt); + assert.equal((await migrated.read('legacy-unchanged')).committedAt, 300); + assert.equal((await migrated.read('legacy-review')).header.statusUpdatedAt, 303); + assert.equal((await migrated.read('legacy-done')).header.statusUpdatedAt, 404); + assert.equal((await migrated.read('legacy-both')).header.statusUpdatedAt, 505); + for (const sessionId of sessionIds) { + const record = await migrated.read(sessionId); + migrationSnapshots.set(sessionId, { + committedAt: record.committedAt, + metadataVersion: record.metadataVersion, + statusUpdatedAt: record.header.statusUpdatedAt, + }); + } + const page = await migrated.listCatalogPage({}, undefined, 10); + assert.deepEqual(page.records.map((record) => record.header.id).sort(), [ + 'legacy-both', + 'legacy-done', + 'legacy-review', + 'legacy-unchanged', + ]); + assert.equal(page.hasMore, false); + } finally { + migrated.close(); + } + + const persisted = new DatabaseSync(path); + try { + const rows = ( + persisted + .prepare( + ` + SELECT + session_id AS sessionId, + json_extract(payload_json, '$.status') AS payloadStatus, + metadata_version AS metadataVersion + FROM session_metadata + ORDER BY session_id + `, + ) + .all() as Array<{ + readonly sessionId: string; + readonly payloadStatus: string; + readonly metadataVersion: number; + }> + ).map((row) => ({ ...row })); + persistedRowsAfterMigration.push(...rows); + assert.deepEqual(rows, [ + { + sessionId: 'legacy-both', + payloadStatus: 'active', + metadataVersion: 18, + }, + { + sessionId: 'legacy-done', + payloadStatus: 'active', + metadataVersion: 12, + }, + { + sessionId: 'legacy-review', + payloadStatus: 'active', + metadataVersion: 8, + }, + { + sessionId: 'legacy-unchanged', + payloadStatus: 'active', + metadataVersion: 13, + }, + ]); + } finally { + persisted.close(); + } + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 30 }); + try { + for (const sessionId of sessionIds) { + const record = await reopened.read(sessionId); + assert.deepEqual( + { + committedAt: record.committedAt, + metadataVersion: record.metadataVersion, + statusUpdatedAt: record.header.statusUpdatedAt, + }, + migrationSnapshots.get(sessionId), + ); + } + } finally { + reopened.close(); + } + + const reopenedPersisted = new DatabaseSync(path); + try { + const rows = ( + reopenedPersisted + .prepare( + ` + SELECT + session_id AS sessionId, + json_extract(payload_json, '$.status') AS payloadStatus + FROM session_metadata + ORDER BY session_id + `, + ) + .all() as Array<{ + readonly sessionId: string; + readonly payloadStatus: string; + }> + ).map((row) => ({ ...row })); + assert.deepEqual( + rows, + persistedRowsAfterMigration.map(({ sessionId, payloadStatus }) => ({ + sessionId, + payloadStatus, + })), + ); + } finally { + reopenedPersisted.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('migrates v26 archive signals onto one canonical archive field exactly once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-archive-v26-')); + const path = join(root, 'state.sqlite'); + const changedSessionIds = [ + 'json-only', + 'sql-only', + 'sql-status-only', + 'json-status', + 'archived-at-only', + 'missing-json-false', + ] as const; + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); + await setup.create(fullHeader({ id: 'active-unchanged' })); + await setup.create(fullHeader({ id: 'missing-json-false' })); + await setup.create(fullHeader({ id: 'canonical-archived', isArchived: true })); + await setup.create( + fullHeader({ + id: 'json-only', + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 101, + }), + ); + await setup.create( + fullHeader({ + id: 'sql-only', + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 151, + }), + ); + await setup.create( + fullHeader({ + id: 'sql-status-only', + status: 'blocked', + blockedReason: 'permission_required', + statusUpdatedAt: 202, + }), + ); + await setup.create( + fullHeader({ + id: 'json-status', + status: 'blocked', + blockedReason: 'auth', + statusUpdatedAt: 303, + }), + ); + await setup.create( + fullHeader({ + id: 'archived-at-only', + status: 'blocked', + blockedReason: 'unknown', + statusUpdatedAt: 404, + }), + ); + setup.close(); + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DROP INDEX session_metadata_one_workhub_coordination_session; + ALTER TABLE session_metadata ADD COLUMN status TEXT; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + UPDATE session_metadata + SET + status = json_extract(payload_json, '$.status'), + status_updated_at = json_extract(payload_json, '$.statusUpdatedAt'); + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + DROP INDEX session_metadata_by_external_origin; + ALTER TABLE session_metadata DROP COLUMN external_adapter_id; + ALTER TABLE session_metadata DROP COLUMN external_source_session_id; + UPDATE session_metadata_schema SET version = 26 WHERE scope = 'session_metadata'; + `); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.isArchived', json('true')) + WHERE session_id = 'json-only'`, + ) + .run(); + legacy + .prepare(`UPDATE session_metadata SET is_archived = 1 WHERE session_id = 'sql-only'`) + .run(); + legacy + .prepare( + `UPDATE session_metadata SET status = 'archived' WHERE session_id = 'sql-status-only'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.status', 'archived') + WHERE session_id = 'json-status'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.archivedAt', 505) + WHERE session_id = 'archived-at-only'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_remove(payload_json, '$.isArchived') + WHERE session_id = 'missing-json-false'`, + ) + .run(); + } finally { + legacy.close(); + } + + const migrationStartedAt = Date.now(); + const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); + const snapshots = new Map(); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + for (const sessionId of ['active-unchanged', 'canonical-archived']) { + const record = await migrated.read(sessionId); + assert.equal(record.metadataVersion, 1); + assert.equal(record.committedAt, 10); + snapshots.set(sessionId, { + metadataVersion: record.metadataVersion, + committedAt: record.committedAt, + }); + } + assert.equal((await migrated.read('active-unchanged')).header.isArchived, false); + assert.equal((await migrated.read('canonical-archived')).header.isArchived, true); + + for (const sessionId of changedSessionIds) { + const record = await migrated.read(sessionId); + assert.equal(record.header.isArchived, sessionId !== 'missing-json-false'); + assert.equal(record.metadataVersion, 2); + assert.ok(record.committedAt >= migrationStartedAt); + assert.equal('archivedAt' in record.header, false); + snapshots.set(sessionId, { + metadataVersion: record.metadataVersion, + committedAt: record.committedAt, + }); + } + + const jsonStatus = await migrated.read('json-status'); + assert.equal(jsonStatus.header.status, 'active'); + assert.equal(jsonStatus.header.blockedReason, undefined); + assert.equal(jsonStatus.header.statusUpdatedAt, undefined); + + for (const [sessionId, blockedReason, statusUpdatedAt] of [ + ['json-only', 'tool_failed', 101], + ['sql-only', 'tool_failed', 151], + ['sql-status-only', 'permission_required', 202], + ['archived-at-only', 'unknown', 404], + ] as const) { + const record = await migrated.read(sessionId); + assert.equal(record.header.status, 'blocked'); + assert.equal(record.header.blockedReason, blockedReason); + assert.equal(record.header.statusUpdatedAt, statusUpdatedAt); + } + } finally { + migrated.close(); + } + + const persisted = new DatabaseSync(path); + try { + const columns = persisted + .prepare('PRAGMA table_info(session_metadata)') + .all() as unknown as Array<{ readonly name: string }>; + assert.equal( + columns.some(({ name }) => name === 'status'), + false, + ); + assert.equal( + columns.some(({ name }) => name === 'status_updated_at'), + false, + ); + assert.equal( + persisted + .prepare( + "SELECT 1 AS found FROM sqlite_schema WHERE type = 'index' AND name = 'session_metadata_by_status'", + ) + .get(), + undefined, + ); + const archiveRows = persisted + .prepare( + `SELECT + session_id AS sessionId, + is_archived AS sqlArchived, + json_type(payload_json, '$.isArchived') AS jsonArchivedType, + json_type(payload_json, '$.archivedAt') AS archivedAtType + FROM session_metadata + ORDER BY session_id`, + ) + .all() as unknown as Array<{ + readonly sessionId: string; + readonly sqlArchived: number; + readonly jsonArchivedType: string; + readonly archivedAtType: string | null; + }>; + for (const row of archiveRows) { + const expectedArchived = + row.sessionId !== 'active-unchanged' && row.sessionId !== 'missing-json-false'; + assert.equal(row.sqlArchived, expectedArchived ? 1 : 0); + assert.equal(row.jsonArchivedType, expectedArchived ? 'true' : 'false'); + assert.equal(row.archivedAtType, null); + } + } finally { + persisted.close(); + } + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 30 }); + try { + for (const [sessionId, snapshot] of snapshots) { + const record = await reopened.read(sessionId); + assert.deepEqual( + { metadataVersion: record.metadataVersion, committedAt: record.committedAt }, + snapshot, + ); + } + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects a session metadata schema newer than the supported version', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-schema-fence-')); + const path = join(root, 'state.sqlite'); + const newerSchemaVersion = SQLITE_SESSION_METADATA_SCHEMA_VERSION + 1; + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + const newer = new DatabaseSync(path); + try { + newer + .prepare( + `UPDATE session_metadata_schema SET version = ? WHERE scope = 'session_metadata'`, + ) + .run(newerSchemaVersion); + } finally { + newer.close(); + } + assert.throws( + () => createSqliteSessionMetadataStore(path), + new RegExp( + `schema ${newerSchemaVersion} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, + 'u', + ), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('changes archive state without overwriting execution status', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); + try { + const header = fullHeader({ + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 20, + }); + await store.create(header); + + const [archived] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 1 }], + true, + ); + + assert.equal(archived?.header.isArchived, true); + assert.equal(archived?.header.status, 'blocked'); + assert.equal(archived?.header.blockedReason, 'tool_failed'); + assert.equal(archived?.header.statusUpdatedAt, 20); + assert.equal('archivedAt' in (archived?.header ?? {}), false); + + const [restored] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 2 }], + false, + ); + + assert.equal(restored?.header.isArchived, false); + assert.equal(restored?.header.status, 'blocked'); + assert.equal(restored?.header.blockedReason, 'tool_failed'); + assert.equal(restored?.header.statusUpdatedAt, 20); + + const unchanged = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 3 }], + false, + ); + assert.equal(unchanged[0]?.metadataVersion, 3); + assert.equal(unchanged[0]?.committedAt, restored?.committedAt); + } finally { + store.close(); + } + }); + + test('rejects Session lifecycle fields through generic metadata writes', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + const header = fullHeader(); + await store.create(header); + + await assert.rejects( + store.update(header.id, { isArchived: true } as unknown as SessionHeaderPatch), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.update(header.id, { archivedAt: 123 } as unknown as SessionHeaderPatch), + /Invalid session header/u, + ); + await assert.rejects( + store.updateSessionConfiguration(header.id, { + expectedVersion: 1, + configuration: { + backend: header.backend, + llmConnectionSlug: header.llmConnectionSlug, + connectionLocked: header.connectionLocked, + model: header.model, + thinkingLevel: header.thinkingLevel, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + labels: header.labels, + isArchived: true, + } as unknown as SessionConfigurationMetadataUpdate['configuration'], + lifecycle: { kind: 'preserve' }, + }), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.create({ ...fullHeader({ id: 'polluted' }), archivedAt: 123 } as SessionHeader), + /Invalid session header/u, + ); + + const current = await store.read(header.id); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.isArchived, false); + assert.equal('archivedAt' in current.header, false); + } finally { + store.close(); + } + }); + + test('atomically retires a revision family with CAS and tombstone retries', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); + const root = fullHeader({ + id: 'family-root', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + isArchived: false, + status: 'active', + blockedReason: undefined, + }); + const revision = fullHeader({ + id: 'family-revision', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: root.id, + revisionParentSessionId: root.id, + revisionOfTurnId: 'turn-1', + revisionIndex: 2, + revisionState: 'committed', + isArchived: false, + status: 'active', + blockedReason: undefined, + }); + try { + await store.create(root); + await store.create(revision); + + await assert.rejects( + store.setArchivedVersioned( + [ + { sessionId: root.id, expectedVersion: 1 }, + { sessionId: revision.id, expectedVersion: 2 }, + ], + true, + ), + SessionMetadataVersionConflictError, + ); + for (const sessionId of [root.id, revision.id]) { + const current = await store.read(sessionId); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.isArchived, false); + } + + const archived = await store.setArchivedVersioned( + [ + { sessionId: root.id, expectedVersion: 1 }, + { sessionId: revision.id, expectedVersion: 1 }, + ], + true, + ); + assert.deepEqual( + archived.map((record) => ({ + id: record.header.id, + revision: record.metadataVersion, + isArchived: record.header.isArchived, + status: record.header.status, + })), + [ + { id: revision.id, revision: 2, isArchived: true, status: 'active' }, + { id: root.id, revision: 2, isArchived: true, status: 'active' }, + ], + ); + + await assert.rejects( + store.removeVersioned([ + { sessionId: root.id, expectedVersion: 2 }, + { sessionId: revision.id, expectedVersion: 3 }, + ]), + SessionMetadataVersionConflictError, + ); + assert.equal((await store.probeRemoval(root.id)).kind, 'present'); + assert.equal((await store.probeRemoval(revision.id)).kind, 'present'); + + const identities = [ + { sessionId: root.id, expectedVersion: 2 }, + { sessionId: revision.id, expectedVersion: 2 }, + ]; + assert.deepEqual(await store.removeVersioned(identities), [revision.id, root.id]); + assert.deepEqual(await store.probeRemoval(root.id), { kind: 'removed' }); + assert.deepEqual(await store.probeRemoval(revision.id), { kind: 'removed' }); + assert.deepEqual(await store.listPendingSessionRetirementCleanupIds(root.id), [ + revision.id, + root.id, + ]); + assert.deepEqual(await store.listPendingSessionRetirementCleanupIds(revision.id), [ + revision.id, + root.id, + ]); + await store.completeSessionRetirementCleanup(revision.id); + assert.deepEqual(await store.listPendingSessionRetirementCleanupIds(root.id), [root.id]); + await store.completeSessionRetirementCleanup(root.id); + assert.deepEqual(await store.listPendingSessionRetirementCleanupIds(), []); + assert.deepEqual(await store.removeVersioned(identities), [revision.id, root.id]); + } finally { + store.close(); + } + }); + + test('atomically archives linked Sessions while removing their parent', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); + const parent = fullHeader({ + id: 'parent-session', + isArchived: false, + status: 'active', + }); + const child = fullHeader({ + id: 'child-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + isArchived: false, + status: 'active', + blockedReason: undefined, + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'spawn-call', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'implementation', + agentName: 'Implementation', + profile: 'implementation', + systemPrompt: 'Implement the task.', + toolNames: ['Read', 'Write'], + categoryPolicy: {}, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + }); + try { + await store.create(parent); + await store.createSubagent(child); + + await assert.rejects( + store.removeVersioned( + [{ sessionId: parent.id, expectedVersion: 1 }], + [{ sessionId: child.id, expectedVersion: 2 }], + ), + SessionMetadataVersionConflictError, + ); + assert.equal((await store.probeRemoval(parent.id)).kind, 'present'); + assert.equal((await store.read(child.id)).header.isArchived, false); + + assert.deepEqual( + await store.removeVersioned( + [{ sessionId: parent.id, expectedVersion: 1 }], + [{ sessionId: child.id, expectedVersion: 1 }], + ), + [parent.id], + ); + assert.deepEqual(await store.probeRemoval(parent.id), { kind: 'removed' }); + const archivedChild = await store.read(child.id); + assert.equal(archivedChild.header.isArchived, true); + assert.equal(archivedChild.header.status, 'active'); + assert.equal(archivedChild.metadataVersion, 2); + } finally { + store.close(); + } + }); + + test('does not rewrite an already archived linked Session during parent removal', async () => { + let now = 100; + const store = createSqliteSessionMetadataStore(':memory:', { now: () => now }); + const parent = fullHeader({ + id: 'parent-session', + isArchived: false, + status: 'active', + }); + const child = fullHeader({ + id: 'child-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + isArchived: false, + status: 'active', + blockedReason: undefined, + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'spawn-call', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'implementation', + agentName: 'Implementation', + profile: 'implementation', + systemPrompt: 'Implement the task.', + toolNames: ['Read', 'Write'], + categoryPolicy: {}, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + }); + try { + await store.create(parent); + await store.createSubagent(child); + await store.setArchivedVersioned([{ sessionId: child.id, expectedVersion: 1 }], true); + const archivedBeforeRemoval = await store.read(child.id); + + now = 200; + assert.deepEqual( + await store.removeVersioned( + [{ sessionId: parent.id, expectedVersion: 1 }], + [{ sessionId: child.id, expectedVersion: archivedBeforeRemoval.metadataVersion }], + ), + [parent.id], + ); + + const archivedAfterRemoval = await store.read(child.id); + assert.equal(archivedAfterRemoval.metadataVersion, archivedBeforeRemoval.metadataVersion); + assert.equal(archivedAfterRemoval.committedAt, archivedBeforeRemoval.committedAt); + assert.deepEqual(archivedAfterRemoval.header, archivedBeforeRemoval.header); + } finally { + store.close(); + } + }); + + test('coexists with the RuntimeEvent schema in one workspace database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-runtime-database-')); + const path = join(root, 'runtime.sqlite'); + const runtime = createSqliteRuntimeStore(path); + const metadata = createSqliteSessionMetadataStore(path); + try { + assert.equal(runtime.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); + assert.equal(metadata.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + await metadata.create(fullHeader()); + await runtime.appendRuntimeEvent('session-1', 'run-1', { + id: 'event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + }); + assert.equal((await metadata.read('session-1')).header.name, 'Session'); + assert.equal((await runtime.readRuntimeEvents('session-1', 'run-1')).length, 1); + } finally { + metadata.close(); + runtime.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('persists an explicitly supplied external genesis boundary', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader(), { kind: 'external', revision: 17 }); + + assert.deepEqual(await store.readExecutionBoundary('session-1'), { + kind: 'external', + revision: 0, + }); + } finally { + store.close(); + } + }); + + test('persists one immutable normalized sandbox boundary request at the current revision', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 50 }); + try { + await store.create(fullHeader()); + const request = await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'boundary-request-1', + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [ + { path: '/outside/tree/file.txt', access: 'read', scope: 'exact' }, + { path: '/outside/tree', access: 'read', scope: 'subtree' }, + ], + }, + }, + justification: 'Read the requested source tree.', + }); + + assert.deepEqual(request, { + sessionId: 'session-1', + requestId: 'boundary-request-1', + status: 'pending', + baseRevision: 0, + expansion: { + filesystem: { + entries: [{ path: '/outside/tree', access: 'read', scope: 'subtree' }], + }, + }, + justification: 'Read the requested source tree.', + createdAt: 50, + turnId: 'turn-1', + }); + assert.equal((await store.readExecutionBoundary('session-1')).revision, 0); + } finally { + store.close(); + } + }); + + test('serializes stale approvals without lost authority and settles retries idempotently', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(100) }); + try { + await store.create(fullHeader()); + for (const [requestId, path] of [ + ['request-a', '/outside/a'], + ['request-b', '/outside/b'], + ] as const) { + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + turnId: 'turn-1', + expansion: { + filesystem: { entries: [{ path, access: 'read', scope: 'subtree' }] }, + }, + justification: `Read ${path}.`, + }); + } + + const first = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-a', + decision: 'allow', + }); + assert.equal(first.changed, true); + assert.equal(first.boundary.revision, 1); + + const second = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-b', + decision: 'allow', + }); + assert.equal(second.changed, true); + assert.equal(second.boundary.revision, 2); + assert.equal(second.boundary.kind, 'managed'); + if (second.boundary.kind === 'managed') { + assert.equal(canReadPath(second.boundary.profile, '/outside/a/file.txt'), true); + assert.equal(canReadPath(second.boundary.profile, '/outside/b/file.txt'), true); + } + + const retry = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-b', + decision: 'allow', + }); + assert.equal(retry.request.status, 'approved'); + assert.equal(retry.boundary.revision, 2); + assert.equal((await store.readExecutionBoundary('session-1')).revision, 2); + } finally { + store.close(); + } + }); + + test('rejects an expansion atomically before the complete boundary exceeds capacity', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(120) }); + let rejectedRequestId: string | undefined; + try { + await store.create(fullHeader()); + // Each request stays near MAX_SANDBOX_BOUNDARY_SERIALIZED_BYTES so the + // cumulative boundary crosses capacity in the fewest settles, using few + // near-MAX_SANDBOX_BOUNDARY_PATH_CHARS entries to keep the per-settle + // boundary scans cheap. + for (let request = 0; request < 30; request += 1) { + const requestId = `capacity-${request}`; + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + turnId: 'turn-1', + expansion: { + filesystem: { + entries: Array.from({ length: 15 }, (_, entry) => ({ + path: `/outside/${request}/${entry}-${'x'.repeat(4_000)}`, + access: 'read' as const, + scope: 'exact' as const, + })), + }, + }, + justification: 'Read generated inputs.', + }); + const before = await store.readExecutionBoundary('session-1'); + try { + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + decision: 'allow', + }); + } catch (error) { + assert.match(String(error), /execution boundary.*size limit/i); + rejectedRequestId = requestId; + assert.deepEqual(await store.readExecutionBoundary('session-1'), before); + assert.deepEqual( + (await store.listPendingSandboxBoundaryRequests('session-1')).map( + (pending) => pending.requestId, + ), + [requestId], + ); + break; + } + } + + assert.ok(rejectedRequestId, 'a cumulative boundary must reach the shared capacity'); + assert.ok( + Buffer.byteLength(JSON.stringify(await store.readExecutionBoundary('session-1')), 'utf8') <= + MAX_EXECUTION_BOUNDARY_SERIALIZED_BYTES, + ); + } finally { + store.close(); + } + }); + + test('settles an already-authorized temp path without inflating the boundary revision', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(90) }); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-tmp', + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [{ path: '/tmp/maka-output', access: 'write', scope: 'exact' }], + }, + }, + justification: 'Write a temporary output.', + }); + + const settlement = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-tmp', + decision: 'allow', + }); + + assert.equal(settlement.changed, false); + assert.equal(settlement.request.outcomeReason, 'already_applied'); + assert.equal(settlement.boundary.revision, 0); + } finally { + store.close(); + } + }); + + test('serializes competing approvals from independent SQLite connections', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-boundary-writer-race-')); + const path = join(root, 'sessions.sqlite'); + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader()); + for (const [requestId, outsidePath] of [ + ['request-a', '/outside/a'], + ['request-b', '/outside/b'], + ] as const) { + await setup.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [{ path: outsidePath, access: 'read', scope: 'subtree' }], + }, + }, + justification: `Read ${outsidePath}.`, + }); + } + } finally { + setup.close(); + } + + const release = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const first = boundarySettlementWorker(path, 'request-a', release); + let second: ReturnType | undefined; + try { + await first.ready; + second = boundarySettlementWorker(path, 'request-b'); + await second.ready; + first.start(); + await first.holding; + second.start(); + await second.attempting; + releaseWorker(release); + + const settlements = await Promise.all([first.settled, second.settled]); + assert.deepEqual( + settlements.map((settlement) => settlement.boundary.revision).sort((a, b) => a - b), + [1, 2], + ); + const verify = createSqliteSessionMetadataStore(path); + try { + const boundary = await verify.readExecutionBoundary('session-1'); + assert.equal(boundary.kind, 'managed'); + assert.equal(boundary.revision, 2); + if (boundary.kind === 'managed') { + assert.equal(canReadPath(boundary.profile, '/outside/a/file.txt'), true); + assert.equal(canReadPath(boundary.profile, '/outside/b/file.txt'), true); + } + } finally { + verify.close(); + } + } finally { + first.start(); + second?.start(); + releaseWorker(release); + await Promise.all([first.terminate(), second?.terminate()]); + await rm(root, { recursive: true, force: true }); + } + }); + + test('records Auto and Bypass changes in the same revision log and restores managed authority', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(200) }); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'approved-before-bypass', + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [{ path: '/outside/kept', access: 'write', scope: 'subtree' }], + }, + }, + justification: 'Write generated files.', + }); + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'approved-before-bypass', + decision: 'allow', + }); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'stale-after-bypass', + turnId: 'turn-1', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + + const bypass = await store.setExecutionBoundaryKind('session-1', 'bypass'); + assert.deepEqual(bypass, { kind: 'bypass', revision: 2 }); + assert.equal((await store.read('session-1')).header.permissionMode, 'bypass'); + const conflict = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'stale-after-bypass', + decision: 'allow', + }); + assert.equal(conflict.request.status, 'conflict'); + assert.equal(conflict.request.outcomeReason, 'boundary_kind_changed'); + assert.equal(conflict.boundary.revision, 2); + + const restored = await store.setExecutionBoundaryKind('session-1', 'managed'); + assert.equal(restored.kind, 'managed'); + assert.equal(restored.revision, 3); + assert.equal((await store.read('session-1')).header.permissionMode, 'ask'); + if (restored.kind === 'managed') { + assert.equal(canReadPath(restored.profile, '/outside/kept/file.txt'), true); + } + assert.equal((await store.setExecutionBoundaryKind('session-1', 'managed')).revision, 3); + } finally { + store.close(); + } + }); + + test('restores an unnamed managed profile after a temporary Bypass boundary', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(212) }); + const { name: _name, ...unnamedProfile } = createWorkspaceWritePermissionProfile(); + try { + await store.create(fullHeader(), { + kind: 'managed', + profile: unnamedProfile, + revision: 0, + }); + + await store.setExecutionBoundaryKind('session-1', 'bypass'); + const restored = await store.setExecutionBoundaryKind('session-1', 'managed'); + + assert.equal(restored.kind, 'managed'); + if (restored.kind === 'managed') assert.deepEqual(restored.profile, unnamedProfile); + } finally { + store.close(); + } + }); + + test('restores canonical Auto when an Explore-origin session has no Auto history', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(218) }); + try { + await store.create(fullHeader({ permissionMode: 'explore' })); + + await store.setExecutionBoundaryKind('session-1', 'bypass', { + permissionMode: 'bypass', + }); + const restored = await store.setExecutionBoundaryKind('session-1', 'managed', { + permissionMode: 'ask', + }); + + assert.equal(restored.kind, 'managed'); + if (restored.kind === 'managed') { + assert.deepEqual(restored.profile, createWorkspaceWritePermissionProfile()); + } + } finally { + store.close(); + } + }); + + test('classifies the internal read-only profile by policy instead of its display name', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(220) }); + const { name: _name, ...unnamedReadOnlyProfile } = createReadOnlyPermissionProfile(); + try { + await store.create(fullHeader({ permissionMode: 'explore' }), { + kind: 'managed', + profile: unnamedReadOnlyProfile, + revision: 0, + }); + + const restored = await store.setExecutionBoundaryKind('session-1', 'managed', { + permissionMode: 'ask', + }); + + assert.equal(restored.kind, 'managed'); + if (restored.kind === 'managed') { + assert.deepEqual(restored.profile, createWorkspaceWritePermissionProfile()); + } + } finally { + store.close(); + } + }); + + test('restores accumulated Auto authority after a temporary Explore boundary', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(225) }); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'approved-before-explore', + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [{ path: '/outside/kept', access: 'write', scope: 'subtree' }], + }, + }, + justification: 'Write generated files.', + }); + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'approved-before-explore', + decision: 'allow', + }); + + const explore = await store.setExecutionBoundaryKind('session-1', 'managed', { + permissionMode: 'explore', + }); + assert.equal(explore.kind, 'managed'); + if (explore.kind === 'managed') assert.equal(explore.profile.name, 'read-only'); + + const restored = await store.setExecutionBoundaryKind('session-1', 'managed', { + permissionMode: 'ask', + }); + assert.equal(restored.kind, 'managed'); + if (restored.kind === 'managed') { + assert.equal(canReadPath(restored.profile, '/outside/kept/file.txt'), true); + } + } finally { + store.close(); + } + }); + + test('reads the header projection and execution boundary from one authority snapshot', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(275) }); + try { + await store.create(fullHeader()); + await store.setExecutionBoundaryKind('session-1', 'bypass', { + permissionMode: 'bypass', + }); + + const snapshot = await store.readSessionAuthoritySnapshot('session-1'); + + assert.equal(snapshot.record.header.permissionMode, 'bypass'); + assert.equal(snapshot.boundary.kind, 'bypass'); + assert.equal(snapshot.boundary.revision, 1); + } finally { + store.close(); + } + }); + + test('rolls back a boundary kind and header projection as one transaction', async () => { + let armed = false; + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint: (point) => { + if (armed && point === 'after_sandbox_boundary_write') { + throw new Error('injected boundary projection failure'); + } + }, + }); + try { + await store.create(fullHeader()); + armed = true; + + await assert.rejects( + () => + store.setExecutionBoundaryKind('session-1', 'bypass', { + permissionMode: 'bypass', + }), + /injected boundary projection failure/, + ); + + assert.equal((await store.readExecutionBoundary('session-1')).kind, 'managed'); + assert.equal((await store.read('session-1')).header.permissionMode, 'ask'); + } finally { + store.close(); + } + }); + + test('rolls back request settlement and boundary application as one transaction', async () => { + let armed = false; + const store = createSqliteSessionMetadataStore(':memory:', { + now: nextNow(300), + failpoint: (point) => { + if (armed && point === 'after_sandbox_boundary_write') { + throw new Error('injected boundary commit failure'); + } + }, + }); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'atomic-request', + turnId: 'turn-1', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + + armed = true; + await assert.rejects( + () => + store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'atomic-request', + decision: 'allow', + }), + /injected boundary commit failure/, + ); + armed = false; + assert.equal((await store.readExecutionBoundary('session-1')).revision, 0); + + const recovered = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'atomic-request', + decision: 'allow', + }); + assert.equal(recovered.request.status, 'approved'); + assert.equal(recovered.boundary.revision, 1); + } finally { + store.close(); + } + }); + + test('projects only explicit denials from exact trusted continuation identities', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader()); + for (const reason of [ + 'client_denied', + 'turn_stopped', + 'turn_terminal', + 'host_restarted', + ] as const) { + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: reason, + turnId: 'turn-1', + runId: reason, + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + const settlement = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: reason, + decision: 'deny', + ...(reason === 'client_denied' ? {} : { closureReason: reason }), + }); + assert.equal(settlement.request.outcomeReason, reason); + assert.equal( + await store.hasExplicitSandboxBoundaryDenial([ + { sessionId: 'session-1', runId: reason, turnId: 'turn-1' }, + ]), + reason === 'client_denied', + ); + } + for (const identity of [ + { + sessionId: 'other-session', + runId: 'client_denied', + turnId: 'turn-1', + }, + { sessionId: 'session-1', runId: 'other-run', turnId: 'turn-1' }, + { + sessionId: 'session-1', + runId: 'client_denied', + turnId: 'other-turn', + }, + ]) + assert.equal(await store.hasExplicitSandboxBoundaryDenial([identity]), false); + assert.equal(await store.hasExplicitSandboxBoundaryDenial([]), false); + assert.equal( + await store.hasExplicitSandboxBoundaryDenial([ + { sessionId: 'session-1', runId: 'turn_stopped', turnId: 'turn-1' }, + { sessionId: 'session-1', runId: 'client_denied', turnId: 'turn-1' }, + ]), + true, + ); + } finally { + store.close(); + } + }); + + test('ambiguous legacy denial blocks only its trusted chain, even after an explicit denial', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-legacy-denial-')); + const path = join(directory, 'runtime.sqlite'); + const store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader()); + for (const runId of ['explicit', 'legacy', 'unknown']) { + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: runId, + turnId: 'turn-1', + runId, + expansion: { network: { enabled: true } }, + justification: 'Use the network.', + }); + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: runId, + decision: 'deny', + }); + } + const legacy = new DatabaseSync(path); + try { + legacy + .prepare("UPDATE sandbox_boundary_log SET outcome_reason = NULL WHERE run_id = 'legacy'") + .run(); + legacy + .prepare( + "UPDATE sandbox_boundary_log SET outcome_reason = 'unknown_reason' WHERE run_id = 'unknown'", + ) + .run(); + } finally { + legacy.close(); + } + const identity = (runId: string) => ({ sessionId: 'session-1', runId, turnId: 'turn-1' }); + assert.equal(await store.hasExplicitSandboxBoundaryDenial([identity('unrelated')]), false); + assert.equal(await store.hasExplicitSandboxBoundaryDenial([identity('explicit')]), true); + for (const runId of ['legacy', 'unknown']) { + for (const chain of [ + [identity('explicit'), identity(runId)], + [identity(runId), identity('explicit')], + ]) { + await assert.rejects( + store.hasExplicitSandboxBoundaryDenial(chain), + /cannot be attributed safely/, + ); + } + } + } finally { + store.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test('does not invent revisions for denial or an already-contained approval', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'denied-request', + turnId: 'turn-1', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + const denied = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'denied-request', + decision: 'deny', + }); + assert.equal(denied.request.status, 'denied'); + assert.equal(denied.boundary.revision, 0); + + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'already-contained', + turnId: 'turn-1', + expansion: { + filesystem: { + entries: [{ path: '/workspace/repo/file.txt', access: 'read', scope: 'exact' }], + }, + }, + justification: 'Read a workspace file.', + }); + const noop = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'already-contained', + decision: 'allow', + }); + assert.equal(noop.request.status, 'approved'); + assert.equal(noop.request.outcomeReason, 'already_applied'); + assert.equal(noop.changed, false); + assert.equal(noop.boundary.revision, 0); + } finally { + store.close(); + } + }); + + test('records host restart when recovery denies an ownerless boundary request', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(700) }); + try { + await store.create(fullHeader()); + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'restart-request', + turnId: 'turn-1', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + + const recovered = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'restart-request', + decision: 'deny', + closureReason: 'host_restarted', + }); + + assert.equal(recovered.request.status, 'denied'); + assert.equal(recovered.request.outcomeReason, 'host_restarted'); + assert.equal(recovered.boundary.revision, 0); + assert.deepEqual(await store.listPendingSandboxBoundaryRequests('session-1'), []); + // The closure stays re-readable after it stops being pending; that is + // what lets an interrupted recovery finish the job on its next attempt. + assert.deepEqual( + (await store.listSandboxBoundaryRestartClosures('session-1')).map((closure) => [ + closure.requestId, + closure.turnId, + ]), + [['restart-request', 'turn-1']], + ); + } finally { + store.close(); + } + }); + + test('lists only host-restart closures, never other settlements', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(720) }); + try { + await store.create(fullHeader()); + for (const requestId of ['restart-closed', 'plain-denied', 'approved', 'still-pending']) { + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + turnId: 'turn-1', + runId: 'run-1', + expansion: { network: { enabled: true } }, + justification: `Request ${requestId}.`, + }); + } + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'restart-closed', + decision: 'deny', + closureReason: 'host_restarted', + }); + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'plain-denied', + decision: 'deny', + }); + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'approved', + decision: 'allow', + }); + + const closures = await store.listSandboxBoundaryRestartClosures('session-1'); + assert.deepEqual( + closures.map((closure) => closure.requestId), + ['restart-closed'], + ); + assert.equal(closures[0]?.runId, 'run-1'); + } finally { + store.close(); + } + }); + + test('keeps request provenance durable and rejects a reuse that changes it', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(740) }); + try { + await store.create(fullHeader()); + const created = await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-1', + turnId: 'turn-7', + runId: 'run-9', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + assert.equal(created.turnId, 'turn-7'); + assert.equal(created.runId, 'run-9'); + + // Same id, same content: idempotent re-create returns the same row. + const again = await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-1', + turnId: 'turn-7', + runId: 'run-9', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }); + assert.deepEqual(again, created); + + await assert.rejects( + store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'request-1', + turnId: 'turn-8', + runId: 'run-9', + expansion: { network: { enabled: true } }, + justification: 'Fetch a dependency.', + }), + /identity was reused/, + ); + } finally { + store.close(); + } + }); + + test('lists only pending sandbox boundary requests for resume', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader()); + for (const requestId of ['keep-pending', 'settle-denied'] as const) { + await store.createSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId, + turnId: 'turn-1', + expansion: { network: { enabled: true } }, + justification: `Request ${requestId}.`, + }); + } + await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: 'settle-denied', + decision: 'deny', + }); + + assert.deepEqual( + (await store.listPendingSandboxBoundaryRequests('session-1')).map( + (request) => request.requestId, + ), + ['keep-pending'], + ); + } finally { + store.close(); + } + }); + + test('lists sessions in recency order with readable flags, archive state, and labels', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create( + fullHeader({ + id: 'older', + name: 'Older', + lastMessageAt: 20, + labels: ['alpha', 'shared'], + isFlagged: true, + }), + ); + await store.create( + fullHeader({ + id: 'newer', + name: 'Newer', + lastMessageAt: 40, + labels: ['shared'], + isFlagged: true, + }), + ); + await store.create( + fullHeader({ + id: 'archived', + name: 'Archived', + isArchived: true, + status: 'active', + blockedReason: undefined, + lastMessageAt: 50, + labels: ['shared'], + }), + ); + + const listed = await store.list(undefined, 'all'); + assert.deepEqual( + listed.map((record) => record.header.id), + ['archived', 'newer', 'older'], + ); + assert.deepEqual( + listed.map((record) => record.header.labels), + [['shared'], ['shared'], ['alpha', 'shared']], + ); + assert.deepEqual( + listed.map((record) => ({ + archived: record.header.isArchived, + flagged: record.header.isFlagged, + })), + [ + { archived: true, flagged: false }, + { archived: false, flagged: true }, + { archived: false, flagged: true }, + ], + ); + } finally { + store.close(); + } + }); + + test('queries typed subagent relations through the dedicated parent index', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + const subagentParent = { + kind: 'subagent' as const, + parentSessionId: 'parent-session', + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground' as const, + }; + const subagentRuntime = { + schemaVersion: 1 as const, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read', 'Glob', 'Grep'], + categoryPolicy: { read: 'allow' as const }, + }; + const subagentSpawn = { + schemaVersion: 1 as const, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }; + try { + const created = await store.createSubagent( + fullHeader({ + id: 'child-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent, + subagentRuntime, + subagentSpawn, + }), + ); + assert.equal(created.created, true); + await store.create( + fullHeader({ + id: 'ordinary-branch', + parentSessionId: 'parent-session', + branchOfTurnId: 'parent-turn', + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + }), + ); + await store.create( + fullHeader({ + id: 'other-child', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent: { ...subagentParent, parentSessionId: 'other-parent' }, + }), + ); + + const children = await store.list( + { subagentParentSessionId: subagentParent.parentSessionId }, + 'all', + ); + assert.deepEqual( + children.map((record) => record.header.id), + ['child-session'], + ); + assert.deepEqual(children[0]?.header.subagentParent, subagentParent); + assert.deepEqual(children[0]?.header.subagentRuntime, subagentRuntime); + assert.deepEqual(children[0]?.header.subagentSpawn, subagentSpawn); + await assert.rejects( + () => store.update('child-session', { subagentParent: undefined }), + /parent relation is immutable/, + ); + await assert.rejects( + () => store.update('child-session', { subagentRuntime: undefined }), + /runtime snapshot is immutable/, + ); + await assert.rejects( + () => store.update('child-session', { subagentSpawn: undefined }), + /spawn identity is immutable/, + ); + } finally { + store.close(); + } + }); + + test('atomically reuses one child per durable spawn identity and rejects request drift', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + const parent = { + kind: 'subagent' as const, + parentSessionId: 'parent-session', + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', + }, + lifecycle: 'foreground' as const, + }; + const runtime = { + schemaVersion: 1 as const, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Original durable prompt.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' as const }, + }; + const childHeader = (overrides: Partial): SessionHeader => + fullHeader({ + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + subagentParent: parent, + subagentRuntime: runtime, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + ...overrides, + }); + try { + const first = await store.createSubagent(childHeader({ id: 'child-original' })); + assert.equal(first.created, true); + + const retry = await store.createSubagent( + childHeader({ + id: 'child-retry-candidate', + subagentRuntime: { ...runtime, systemPrompt: 'A changed catalog prompt.' }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'different-proposed-turn', + initialRunId: 'different-proposed-run', + }, + }), + ); + assert.equal(retry.created, false); + assert.equal(retry.record.header.id, 'child-original'); + assert.equal(retry.record.header.subagentRuntime?.systemPrompt, 'Original durable prompt.'); + assert.equal(retry.record.header.subagentSpawn?.initialRunId, 'child-run'); + + await assert.rejects( + () => + store.createSubagent( + childHeader({ + id: 'drifted-child', + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'b'.repeat(64), + initialTurnId: 'drifted-turn', + initialRunId: 'drifted-run', + }, + }), + ), + /reused for different work/, + ); + + const swarmItem = await store.createSubagent( + childHeader({ + id: 'swarm-child', + subagentParent: { + ...parent, + swarm: { swarmId: 'swarm-1', itemId: 'item-1' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'c'.repeat(64), + initialTurnId: 'swarm-turn', + initialRunId: 'swarm-run', + }, + }), + ); + assert.equal(swarmItem.created, true); + + assert.equal(await store.remove('child-original'), true); + await assert.rejects( + () => + store.createSubagent( + childHeader({ + id: 'child-after-delete', + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'retry-after-delete-turn', + initialRunId: 'retry-after-delete-run', + }, + }), + ), + /belongs to deleted session: child-original/, + ); + } finally { + store.close(); + } + }); + + test('updates metadata and labels with a compare-and-set version', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(10) }); + try { + await store.create(fullHeader()); + const updated = await store.update( + 'session-1', + { + name: 'Renamed', + labels: ['replacement'], + hasUnread: false, + lastReadMessageId: 'message-2', + }, + { expectedVersion: 1 }, + ); + assert.equal(updated.metadataVersion, 2); + assert.equal(updated.header.name, 'Renamed'); + assert.deepEqual(updated.header.labels, ['replacement']); + assert.equal(updated.header.lastReadMessageId, 'message-2'); + assert.deepEqual((await store.read('session-1')).header.labels, ['replacement']); + + await assert.rejects( + () => store.update('session-1', { name: 'Stale' }, { expectedVersion: 1 }), + SessionMetadataConflictError, + ); + assert.equal((await store.read('session-1')).header.name, 'Renamed'); + } finally { + store.close(); + } + }); + + test('keeps stable create claims across exact retries, removal, and reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-stable-session-create-')); + const path = join(root, 'sessions.sqlite'); + const requestFingerprint = `sha256:${'a'.repeat(64)}`; + try { + const store = createSqliteSessionMetadataStore(path, { now: nextNow(10) }); + const header = fullHeader({ id: 'stable-session' }); + try { + const created = await store.createStableSession(header, requestFingerprint); + assert.equal(created.kind, 'created'); + assert.equal(created.record.metadataVersion, 1); + + const retry = await store.createStableSession( + fullHeader({ id: 'stable-session', name: 'Changed default' }), + requestFingerprint, + ); + assert.equal(retry.kind, 'existing'); + assert.equal(retry.record.header.name, 'Session'); + assert.deepEqual( + await store.probeStableSessionCreate('stable-session', `sha256:${'b'.repeat(64)}`), + { kind: 'conflict', reason: 'identity_mismatch' }, + ); + assert.equal(await store.remove('stable-session'), true); + } finally { + store.close(); + } + + const reopened = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual( + await reopened.probeStableSessionCreate('stable-session', requestFingerprint), + { kind: 'conflict', reason: 'removed' }, + ); + assert.deepEqual( + await reopened.createStableSession( + fullHeader({ id: 'stable-session' }), + requestFingerprint, + ), + { kind: 'conflict', reason: 'removed' }, + ); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('reserves stable create identity before metadata commit and across reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-stable-session-create-claim-')); + const path = join(root, 'sessions.sqlite'); + const requestFingerprint = `sha256:${'c'.repeat(64)}`; + try { + const store = createSqliteSessionMetadataStore(path, { now: () => 10 }); + try { + assert.deepEqual( + await store.claimStableSessionCreate('stable-session', requestFingerprint), + { kind: 'absent' }, + ); + } finally { + store.close(); + } + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 20 }); + try { + assert.deepEqual( + await reopened.probeStableSessionCreate('stable-session', requestFingerprint), + { kind: 'absent' }, + ); + assert.deepEqual( + await reopened.probeStableSessionCreate('stable-session', `sha256:${'d'.repeat(64)}`), + { kind: 'conflict', reason: 'identity_mismatch' }, + ); + const created = await reopened.createStableSession( + fullHeader({ id: 'stable-session' }), + requestFingerprint, + ); + assert.equal(created.kind, 'created'); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('commits configuration and sandbox boundary as one compare-and-set transaction', async () => { + let armed = false; + const store = createSqliteSessionMetadataStore(':memory:', { + now: nextNow(20), + failpoint: (point) => { + if (armed && point === 'after_sandbox_boundary_write') { + throw new Error('boundary failpoint'); + } + }, + }); + const configuration = { + expectedVersion: 1, + configuration: { + backend: 'ai-sdk' as const, + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'openrouter', + connectionLocked: true, + model: 'openrouter/free', + thinkingLevel: undefined, + permissionMode: 'bypass' as const, + collaborationMode: 'plan' as const, + orchestrationMode: 'graph' as const, + labels: ['configured'], + }, + lifecycle: { kind: 'preserve' as const }, + }; + try { + await store.create( + fullHeader({ + id: 'configured-session', + status: 'active', + blockedReason: undefined, + parentSessionId: undefined, + permissionMode: 'ask', + }), + ); + + armed = true; + await assert.rejects( + store.updateSessionConfiguration('configured-session', configuration), + /boundary failpoint/, + ); + armed = false; + assert.equal((await store.read('configured-session')).header.permissionMode, 'ask'); + assert.deepEqual(await store.readExecutionBoundary('configured-session'), { + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }); + + const updated = await store.updateSessionConfiguration('configured-session', configuration); + assert.equal(updated.metadataVersion, 2); + assert.equal(updated.header.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.equal(updated.header.model, 'openrouter/free'); + assert.equal(updated.header.collaborationMode, 'plan'); + assert.equal(updated.header.orchestrationMode, 'graph'); + assert.deepEqual(updated.header.labels, ['configured']); + assert.deepEqual(await store.readExecutionBoundary('configured-session'), { + kind: 'bypass', + revision: 1, + }); + await assert.rejects( + store.updateSessionConfiguration('configured-session', configuration), + (error: unknown) => { + assert.ok(error instanceof SessionMetadataVersionConflictError); + assert.equal(error.expectedVersion, 1); + assert.equal(error.actualVersion, 2); + return true; + }, + ); + await assert.rejects( + store.updateSessionConfiguration('configured-session', { + ...configuration, + expectedVersion: 2, + lifecycle: { kind: 'clear_connection_block', statusUpdatedAt: 30 }, + }), + /no longer has a connection block/, + ); + } finally { + store.close(); + } + }); + + test('clears only the explicit connection-block lifecycle transition', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 40 }); + try { + await store.create( + fullHeader({ + id: 'connection-blocked-session', + status: 'blocked', + blockedReason: 'NO_REAL_CONNECTION', + statusUpdatedAt: 10, + }), + ); + const updated = await store.updateSessionConfiguration('connection-blocked-session', { + expectedVersion: 1, + configuration: { + backend: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'openrouter', + connectionLocked: true, + model: 'openrouter/free', + thinkingLevel: undefined, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + labels: [], + }, + lifecycle: { kind: 'clear_connection_block', statusUpdatedAt: 30 }, + }); + assert.equal(updated.header.status, 'active'); + assert.equal(updated.header.blockedReason, undefined); + assert.equal(updated.header.statusUpdatedAt, 30); + } finally { + store.close(); + } + }); + + test('rolls back row changes at every injected transaction failure', async () => { + for (const failpoint of [ + 'after_session_row_write', + ] satisfies SqliteSessionMetadataStoreFailpoint[]) { + let armed = true; + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint: (point) => { + if (armed && point === failpoint) throw new Error(`failpoint: ${point}`); + }, + }); + try { + await assert.rejects(() => store.create(fullHeader()), /failpoint/); + await assert.rejects(() => store.read('session-1'), /not found/); + + armed = false; + await store.create(fullHeader()); + armed = true; + await assert.rejects( + () => store.update('session-1', { name: 'Not committed', labels: ['lost'] }), + /failpoint/, + ); + const current = await store.read('session-1'); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.name, 'Session'); + assert.deepEqual(current.header.labels, ['alpha', 'beta']); + } finally { + store.close(); + } + } + }); + + test('deletes metadata atomically', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader()); + assert.equal(await store.remove('session-1'), true); + assert.equal(await store.remove('session-1'), false); + assert.equal(await store.has('session-1'), false); + assert.equal(await store.isTombstoned('session-1'), true); + await assert.rejects(() => store.create(fullHeader()), /tombstoned/); + } finally { + store.close(); + } + }); +}); + +function boundarySettlementWorker( + path: string, + requestId: string, + holdAfterBoundaryWrite?: SharedArrayBuffer, +): { + ready: Promise; + attempting: Promise; + holding: Promise; + settled: Promise; + start(): void; + terminate(): Promise; +} { + const startSettlement = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const worker = new Worker( + new URL('./fixtures/settle-sandbox-boundary-worker.js', import.meta.url), + { + workerData: { + path, + requestId, + startSettlement, + ...(holdAfterBoundaryWrite ? { holdAfterBoundaryWrite } : {}), + }, + }, + ); + const ready = promiseWithResolvers(); + const attempting = promiseWithResolvers(); + const holding = promiseWithResolvers(); + const settled = promiseWithResolvers(); + worker.on( + 'message', + ( + message: + | { type: 'ready' } + | { type: 'attempting' } + | { type: 'holding' } + | { type: 'settled'; settlement: SandboxBoundarySettlement } + | { type: 'failed'; message: string }, + ) => { + if (message.type === 'ready') ready.resolve(); + else if (message.type === 'attempting') attempting.resolve(); + else if (message.type === 'holding') holding.resolve(); + else if (message.type === 'settled') settled.resolve(message.settlement); + else { + const error = new Error(message.message); + ready.reject(error); + attempting.reject(error); + holding.reject(error); + settled.reject(error); + } + }, + ); + worker.on('error', (error) => { + ready.reject(error); + attempting.reject(error); + holding.reject(error); + settled.reject(error); + }); + return { + ready: ready.promise, + attempting: attempting.promise, + holding: holding.promise, + settled: settled.promise, + start: () => releaseWorker(startSettlement), + terminate: () => worker.terminate(), + }; +} + +function releaseWorker(signal: SharedArrayBuffer): void { + const state = new Int32Array(signal); + Atomics.store(state, 0, 1); + Atomics.notify(state, 0); +} + +function promiseWithResolvers(): { + promise: Promise; + resolve(value: T | PromiseLike): void; + reject(reason?: unknown): void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +describe('SQLite agent graph operator provisions', () => { + test('atomically commits one child Session and monotonic topology row', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(700) }); + try { + await store.commitAgentGraphScheduleUpdate({ + schemaVersion: 1, + updateId: `graph_update_${'1'.repeat(32)}`, + updateFingerprint: `sha256:${'2'.repeat(64)}`, + graphId: 'graph-1', + source: { + sessionId: 'supervisor-session', + runId: 'supervisor-run', + turnId: 'supervisor-turn', + toolCallId: 'schedule-tool', + }, + addWork: [ + { + workId: `graph_work_${'3'.repeat(32)}`, + target: { kind: 'agent', agentId: 'local-read' }, + instruction: 'Inspect the input.', + inputIds: [], + }, + ], + stop: [], + }); + const request = graphProvisionRequest(); + const first = await store.createAgentGraphOperator(graphChildHeader(), request, 1); + assert.equal(first.created, true); + assert.equal(first.record.header.id, 'graph-child'); + assert.equal(first.provision.targetSessionId, 'graph-child'); + assert.deepEqual(await store.listAgentGraphOperatorProvisions('graph-1'), [first.provision]); + + const retryRequest = { + ...request, + initialTurnId: 'disposable-turn', + initialRunId: 'disposable-run', + }; + const retry = await store.createAgentGraphOperator( + graphChildHeader({ + id: 'disposable-child', + subagentSpawn: { + ...graphChildHeader().subagentSpawn!, + initialTurnId: retryRequest.initialTurnId, + initialRunId: retryRequest.initialRunId, + }, + }), + retryRequest, + 1, + ); + assert.equal(retry.created, false); + assert.equal(retry.record.header.id, 'graph-child'); + assert.equal(retry.provision.initialRunId, request.initialRunId); + + await assert.rejects( + store.createAgentGraphOperator( + graphChildHeader({ id: 'drift-child' }), + { ...request, provisionFingerprint: `sha256:${'9'.repeat(64)}` }, + 1, + ), + /reused for different work/, + ); + await assert.rejects( + store.remove('graph-child'), + /Cannot remove graph operator Session graph-child/, + ); + assert.equal(await store.has('graph-child'), true); + assert.equal(await store.isTombstoned('graph-child'), false); + assert.deepEqual(await store.listAgentGraphOperatorProvisions('graph-1'), [first.provision]); + } finally { + store.close(); + } + }); + + test('retires a graph root with its operator and purges only that graph control state', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(800) }); + try { + const root = await store.create( + fullHeader({ + id: 'supervisor-session', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + status: 'active', + blockedReason: undefined, + }), + ); + await store.commitAgentGraphScheduleUpdate({ + schemaVersion: 1, + updateId: `graph_update_${'1'.repeat(32)}`, + updateFingerprint: `sha256:${'2'.repeat(64)}`, + graphId: 'graph-1', + source: { + sessionId: root.header.id, + runId: 'supervisor-run', + turnId: 'supervisor-turn', + toolCallId: 'schedule-tool', + }, + addWork: [ + { + workId: `graph_work_${'3'.repeat(32)}`, + target: { kind: 'agent', agentId: 'local-read' }, + instruction: 'Inspect the input.', + inputIds: [], + }, + ], + stop: [], + }); + const child = await store.createAgentGraphOperator( + graphChildHeader(), + graphProvisionRequest(), + 1, + ); + await store.claimAgentGraphIntent({ + schemaVersion: 1, + claimId: `graph_claim_${'a'.repeat(32)}`, + graphId: 'graph-1', + intentId: `graph_intent_${'b'.repeat(32)}`, + intentFingerprint: `sha256:${'c'.repeat(64)}`, + readinessContextFingerprint: `sha256:${'d'.repeat(64)}`, + targetOperatorId: graphProvisionRequest().operatorId, + targetSessionId: child.record.header.id, + targetTurnId: 'graph-turn', + targetRunId: 'graph-run', + }); + await store.claimAgentGraphSupervisorWake({ + schemaVersion: 1, + graphId: 'graph-1', + wakeId: 'graph-wake', + snapshotVersion: 'snapshot-1', + rootSessionId: root.header.id, + }); + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'graph-wake', + attemptId: 'graph-attempt', + turnId: 'supervisor-wake-turn', + }); + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-1', + rootSessionId: root.header.id, + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-1', + snapshot: { status: 'running' }, + replaceOperators: true, + operators: [{ operatorId: graphProvisionRequest().operatorId, payload: {} }], + terminalActivities: [ + { recordId: 'terminal-record', eventTime: 1, payload: { status: 'completed' } }, + ], + activityRecords: [{ recordId: 'terminal-record', eventTime: 1 }], + }); + await store.commitAgentGraphScheduleUpdate({ + schemaVersion: 1, + updateId: `graph_update_${'7'.repeat(32)}`, + updateFingerprint: `sha256:${'8'.repeat(64)}`, + graphId: 'graph-2', + source: { + sessionId: 'other-supervisor', + runId: 'other-run', + turnId: 'other-turn', + toolCallId: 'other-tool', + }, + addWork: [], + stop: [{ targetId: 'other-target', reason: 'done' }], + }); + + await assert.rejects( + store.removeVersioned([ + { sessionId: child.record.header.id, expectedVersion: child.record.metadataVersion }, + ]), + /Cannot remove graph operator Session graph-child/, + ); + await assert.rejects( + store.removeVersioned([ + { sessionId: root.header.id, expectedVersion: root.metadataVersion }, + ]), + /graph operator graph-child is outside the retirement unit/, + ); + assert.deepEqual( + await store.removeVersioned([ + { sessionId: root.header.id, expectedVersion: root.metadataVersion }, + { sessionId: child.record.header.id, expectedVersion: child.record.metadataVersion }, + ]), + ['graph-child', 'supervisor-session'], + ); + assert.equal(await store.has(root.header.id), false); + assert.equal(await store.has(child.record.header.id), false); + assert.equal((await store.listAgentGraphOperatorProvisions('graph-1')).length, 1); + + assert.equal(await store.purgeAgentGraphControlState('graph-1'), 9); + assert.deepEqual(await store.listAgentGraphOperatorProvisions('graph-1'), []); + assert.deepEqual(await store.listAgentGraphScheduleUpdates('graph-1'), []); + assert.deepEqual(await store.listAgentGraphIntentClaims('graph-1'), []); + assert.equal(await store.readAgentGraphSupervisorWake('graph-1', 'graph-wake'), undefined); + assert.equal(await store.readAgentGraphClientProjection('graph-1'), undefined); + assert.deepEqual( + await store.listAgentGraphClientTerminalActivities('graph-1', { limit: 1 }), + { records: [], hasMore: false }, + ); + assert.equal(await store.purgeAgentGraphControlState('graph-1'), 0); + assert.equal((await store.listAgentGraphScheduleUpdates('graph-2')).length, 1); + } finally { + store.close(); + } + }); + + test('rolls back child and topology together on a provision failure', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint(point) { + if (point === 'after_agent_graph_operator_provision_write') throw new Error('crash'); + }, + }); + try { + await store.commitAgentGraphScheduleUpdate({ + schemaVersion: 1, + updateId: `graph_update_${'1'.repeat(32)}`, + updateFingerprint: `sha256:${'2'.repeat(64)}`, + graphId: 'graph-1', + source: { + sessionId: 'supervisor-session', + runId: 'supervisor-run', + turnId: 'supervisor-turn', + toolCallId: 'schedule-tool', + }, + addWork: [ + { + workId: `graph_work_${'3'.repeat(32)}`, + target: { kind: 'agent', agentId: 'local-read' }, + instruction: 'Inspect the input.', + inputIds: [], + }, + ], + stop: [], + }); + await assert.rejects( + store.createAgentGraphOperator(graphChildHeader(), graphProvisionRequest(), 1), + /crash/, + ); + assert.deepEqual(await store.listAgentGraphOperatorProvisions('graph-1'), []); + await assert.rejects(store.read('graph-child'), /not found/); + } finally { + store.close(); + } + }); +}); + +describe('SQLite agent graph client projections', () => { + test('atomically reads a graph with an independently versioned operator row', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + now: nextNow(400), + }); + try { + await store.create(graphRootHeader('root-session')); + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-atomic-read', + rootSessionId: 'root-session', + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-1', + snapshot: { version: 1 }, + replaceOperators: true, + operators: [ + { operatorId: 'operator-1', payload: { status: 'running' } }, + { operatorId: 'operator-2', payload: { status: 'waiting' } }, + ], + terminalActivities: [], + activityRecords: [], + }); + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-atomic-read', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'snapshot-1', + snapshotVersion: 'snapshot-2', + snapshot: { version: 2 }, + replaceOperators: false, + operators: [{ operatorId: 'operator-1', payload: { status: 'completed' } }], + terminalActivities: [], + activityRecords: [{ recordId: 'record-1', eventTime: 10 }], + incrementalRecordId: 'record-1', + }); + + const materialized = await store.readAgentGraphClientProjectionWithOperator( + 'graph-atomic-read', + 'operator-2', + ); + assert.equal(materialized?.projection.snapshotVersion, 'snapshot-2'); + assert.equal(materialized?.operator?.snapshotVersion, 'snapshot-1'); + assert.deepEqual(materialized?.operator?.payload, { status: 'waiting' }); + assert.deepEqual( + await store.readAgentGraphClientProjectionWithOperator( + 'graph-atomic-read', + 'missing-operator', + ), + { + projection: materialized?.projection, + }, + ); + } finally { + store.close(); + } + }); + + test('CAS-fences stale writers and deduplicates incremental durable records', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + now: nextNow(500), + }); + try { + await store.create(graphRootHeader('root-session')); + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-cas', + rootSessionId: 'root-session', + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-1', + snapshot: { version: 1 }, + replaceOperators: true, + operators: [{ operatorId: 'operator-1', payload: { status: 'running' } }], + terminalActivities: [], + activityRecords: [], + }); + await assert.rejects( + store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-cas', + rootSessionId: 'root-session', + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-create-race', + snapshot: { version: 99 }, + replaceOperators: true, + operators: [], + terminalActivities: [], + activityRecords: [], + }), + /version conflict/, + ); + await assert.rejects( + store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-cas', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'stale-snapshot', + snapshotVersion: 'snapshot-stale-write', + snapshot: { version: 99 }, + replaceOperators: false, + operators: [], + terminalActivities: [ + { + recordId: 'stale-terminal', + eventTime: 9, + payload: { recordId: 'stale-terminal' }, + }, + ], + activityRecords: [{ recordId: 'stale-terminal', eventTime: 9 }], + }), + /version conflict/, + ); + assert.deepEqual( + await store.listAgentGraphClientTerminalActivities('graph-cas', { + limit: 8, + }), + { records: [], hasMore: false }, + ); + + const applied = await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-cas', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'snapshot-1', + snapshotVersion: 'snapshot-2', + snapshot: { version: 2 }, + replaceOperators: false, + operators: [{ operatorId: 'operator-1', payload: { status: 'completed' } }], + terminalActivities: [], + activityRecords: [{ recordId: 'record-1', eventTime: 10 }], + incrementalRecordId: 'record-1', + }); + assert.equal(applied.snapshotVersion, 'snapshot-2'); + + const duplicate = await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-cas', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'snapshot-2', + snapshotVersion: 'snapshot-3', + snapshot: { version: 3 }, + replaceOperators: false, + operators: [{ operatorId: 'operator-1', payload: { status: 'failed' } }], + terminalActivities: [], + activityRecords: [{ recordId: 'record-1', eventTime: 10 }], + incrementalRecordId: 'record-1', + }); + assert.equal(duplicate.snapshotVersion, 'snapshot-2'); + assert.deepEqual((await store.readAgentGraphClientProjection('graph-cas'))?.payload, { + version: 2, + }); + assert.deepEqual( + (await store.readAgentGraphClientOperatorProjection('graph-cas', 'operator-1'))?.payload, + { status: 'completed' }, + ); + } finally { + store.close(); + } + }); + + test('materializes bounded current state and keyset-pages terminal activity', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + now: nextNow(1_000), + }); + try { + await store.create(graphRootHeader('root-session')); + const first = await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-1', + rootSessionId: 'root-session', + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-1', + snapshot: { version: 1 }, + replaceOperators: true, + operators: [ + { operatorId: 'operator-1', payload: { status: 'running' } }, + { operatorId: 'operator-2', payload: { status: 'completed' } }, + ], + terminalActivities: [ + { recordId: 'record-1', eventTime: 1, payload: { recordId: 'record-1' } }, + { recordId: 'record-2', eventTime: 2, payload: { recordId: 'record-2' } }, + { recordId: 'record-3', eventTime: 3, payload: { recordId: 'record-3' } }, + ], + activityRecords: [ + { recordId: 'record-1', eventTime: 1 }, + { recordId: 'record-2', eventTime: 2 }, + { recordId: 'record-3', eventTime: 3 }, + ], + }); + assert.equal(first.snapshotVersion, 'snapshot-1'); + assert.deepEqual((await store.readAgentGraphClientProjection('graph-1'))?.payload, { + version: 1, + }); + assert.deepEqual( + (await store.readAgentGraphClientOperatorProjection('graph-1', 'operator-1'))?.payload, + { status: 'running' }, + ); + const firstPage = await store.listAgentGraphClientTerminalActivities('graph-1', { limit: 2 }); + assert.equal(firstPage.hasMore, true); + assert.deepEqual( + firstPage.records.map((record) => record.recordId), + ['record-3', 'record-2'], + ); + const secondPage = await store.listAgentGraphClientTerminalActivities('graph-1', { + limit: 2, + before: { eventTime: 2, recordId: 'record-2' }, + }); + assert.equal(secondPage.hasMore, false); + assert.deepEqual( + secondPage.records.map((record) => record.recordId), + ['record-1'], + ); + await assert.rejects( + store.listAgentGraphClientTerminalActivities('graph-1', { + limit: 2, + before: { eventTime: 99, recordId: 'record-2' }, + }), + (error: unknown) => error instanceof AgentGraphClientTerminalCursorError, + ); + + await store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-1', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'snapshot-1', + snapshotVersion: 'snapshot-2', + snapshot: { version: 2 }, + replaceOperators: true, + operators: [{ operatorId: 'operator-1', payload: { status: 'completed' } }], + terminalActivities: [ + { recordId: 'record-3', eventTime: 3, payload: { recordId: 'record-3' } }, + ], + activityRecords: [{ recordId: 'record-3', eventTime: 3 }], + }); + assert.equal( + await store.readAgentGraphClientOperatorProjection('graph-1', 'operator-2'), + undefined, + ); + assert.equal( + (await store.readAgentGraphClientOperatorProjection('graph-1', 'operator-1')) + ?.snapshotVersion, + 'snapshot-2', + ); + await assert.rejects( + store.commitAgentGraphClientProjection({ + schemaVersion: 1, + graphId: 'graph-1', + rootSessionId: 'root-session', + expectedSnapshotVersion: 'snapshot-2', + snapshotVersion: 'snapshot-3', + snapshot: { version: 3 }, + replaceOperators: true, + operators: [], + terminalActivities: [ + { recordId: 'record-3', eventTime: 4, payload: { recordId: 'record-3' } }, + ], + activityRecords: [{ recordId: 'record-3', eventTime: 4 }], + }), + /changed after materialization/, + ); + } finally { + store.close(); + } + }); + + test('rejects a projection commit after its root retirement cleanup completes', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + const root = await store.create(graphRootHeader('root-session')); + const request = { + schemaVersion: 1 as const, + graphId: 'graph-retired-root', + rootSessionId: root.header.id, + expectedSnapshotVersion: null, + snapshotVersion: 'snapshot-1', + snapshot: { status: 'idle' }, + replaceOperators: true, + operators: [], + terminalActivities: [], + activityRecords: [], + }; + + await store.removeVersioned([ + { sessionId: root.header.id, expectedVersion: root.metadataVersion }, + ]); + await store.purgeAgentGraphControlState(request.graphId); + await store.completeSessionRetirementCleanup(root.header.id); + + await assert.rejects(store.commitAgentGraphClientProjection(request), /not found/); + assert.equal(await store.readAgentGraphClientProjection(request.graphId), undefined); + assert.deepEqual(await store.listPendingSessionRetirementCleanupIds(), []); + } finally { + store.close(); + } + }); +}); + +function fullHeader(overrides: Partial = {}): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/workspace', + cwd: '/workspace/repo', + createdAt: 1, + lastMessageAt: 3, + name: 'Session', + titleIsManual: true, + isFlagged: false, + labels: ['alpha', 'beta'], + isArchived: false, + status: 'blocked', + blockedReason: 'permission_required', + statusUpdatedAt: 4, + parentSessionId: 'parent-session', + branchOfTurnId: 'branch-turn', + revisionRootSessionId: 'root-session', + revisionParentSessionId: 'previous-session', + revisionOfTurnId: 'revised-turn', + revisionIndex: 2, + revisionState: 'committed', + lastReadMessageId: 'message-1', + hasUnread: true, + backend: 'ai-sdk', + llmConnectionSlug: 'openai', + connectionLocked: true, + model: 'gpt-5', + toolProfile: 'headless-coding-v1', + thinkingLevel: 'high', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'swarm', + schemaVersion: 1, + ...overrides, + }; +} + +type ProvenRootHandoffInput = MarkMessagesHandedOffInput & { + readonly provenRootMessages: readonly ProvenRootMessageHandoff[]; +}; + +async function markMessagesHandedOffWithProvenRoots( + store: ReturnType, + input: ProvenRootHandoffInput, +): Promise { + return store.markMessagesHandedOff(input); +} + +function graphRootHeader(id: string): SessionHeader { + return fullHeader({ + id, + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + status: 'active', + blockedReason: undefined, + }); +} + +function graphProvisionRequest(): AgentGraphOperatorProvisionRequest { + return { + schemaVersion: 1, + provisionId: `graph_provision_${'4'.repeat(32)}`, + provisionFingerprint: `sha256:${'5'.repeat(64)}`, + graphId: 'graph-1', + workId: `graph_work_${'3'.repeat(32)}`, + agentId: 'local-read', + operatorId: `graph_operator_${'6'.repeat(32)}`, + initialTurnId: 'graph-turn', + initialRunId: 'graph-run', + edges: [], + }; +} + +function graphChildHeader(overrides: Partial = {}): SessionHeader { + const request = graphProvisionRequest(); + return fullHeader({ + id: 'graph-child', + parentSessionId: undefined, + branchOfTurnId: undefined, + revisionRootSessionId: undefined, + revisionParentSessionId: undefined, + revisionOfTurnId: undefined, + revisionIndex: undefined, + revisionState: undefined, + status: 'active', + blockedReason: undefined, + orchestrationMode: 'default', + subagentParent: { + kind: 'subagent', + parentSessionId: 'supervisor-session', + spawnedBy: { + parentRunId: 'supervisor-run', + parentTurnId: 'supervisor-turn', + toolCallId: 'schedule-tool', + }, + graph: { + graphId: request.graphId, + workId: request.workId, + operatorId: request.operatorId, + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: request.agentId, + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read only.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: '5'.repeat(64), + initialTurnId: request.initialTurnId, + initialRunId: request.initialRunId, + }, + ...overrides, + }); +} + +function nextNow(start: number): () => number { + let current = start; + return () => current++; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76b4ea43c37a7b07d80ac638de5b6839c74a8fe0f7aef8b02989222bb0b65c6e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76b4ea43c37a7b07d80ac638de5b6839c74a8fe0f7aef8b02989222bb0b65c6e.source new file mode 100644 index 0000000000..1998bba41c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/76b4ea43c37a7b07d80ac638de5b6839c74a8fe0f7aef8b02989222bb0b65c6e.source @@ -0,0 +1,328 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { writeAtomicFile } from './atomic-file-write.js'; +import { withFileUpdateLock } from './file-update-lock.js'; +import { hardenDirectory } from './stable-storage.js'; + +/** + * Pure-Node credential store. Shared by the desktop app and any + * non-desktop consumer (CLI / third party) that runs the + * runtime outside Electron. + * + * At rest this is plaintext JSON behind 0600 file perms (file-first; + * see issue #32). The OS user account is the security boundary + * (SECURITY.md). At-rest encryption (an OS keychain via a pure-Node + * binding, or a passphrase) is a later addition — deliberately deferred + * until there is a real backend, so its sync/async shape is designed + * against that backend instead of guessed now. + * + * Writes are serialized across processes by an atomic-mkdir lockfile that is + * never stolen (see withCredentialFileLock), so two store instances (or + * processes) sharing one file can't lose each other's update through a + * read-modify-write race. + * + * Secret VALUES are never logged. Callers expose the typed + * `CredentialStore` API to third parties — never the raw file format, + * which stays an internal implementation detail. + */ + +type StoredCredentialKind = + | 'apiKey' + | 'oauthToken' + | 'requestHeaders' + | 'botToken' + | 'botAppSecret' + | 'proxyPassword' + | 'tavilyApiKey' + | 'runtimeHostAccess' + | 'runtimeHostCapabilityProvider'; +export type CredentialKind = + | 'api_key' + | 'oauth_token' + | 'request_headers' + | 'bot_token' + | 'app_secret' + | 'proxy_password' + | 'tavily_api_key' + | 'runtime_host_access' + | 'runtime_host_capability_provider'; + +/** Current on-disk schema version. Unknown versions fail closed on read. */ +export const CREDENTIAL_SCHEMA_VERSION = 1; + +interface CredentialFile { + version: number; + values: Record; +} + +/** + * Outcome of a compare-and-set write. + * + * `committed: true` — the basis was still the stored authority, so the new + * value was persisted (this caller is the winner). + * + * `committed: false` — the basis was stale; someone committed first. `current` + * is what the store holds instead, and it distinguishes the two loser cases the + * refresh lifecycle must tell apart: + * - `current === null`: the entry is gone. A terminal delete (e.g. a logout) + * happened after the caller read its basis; the caller must NOT resurrect it. + * - `current` is a string: the entry was changed by a concurrent winner. The + * caller adopts `current` instead of overwriting it. + */ +export type CredentialCasResult = + | { committed: true } + | { committed: false; current: string | null }; + +export interface CredentialStore { + getSecret(slug: string, kind: CredentialKind): Promise; + setSecret(slug: string, kind: CredentialKind, value: string): Promise; + /** Delete one kind, or — with no kind — every kind for the slug (e.g. a + * connection being removed). */ + deleteSecret(slug: string, kind?: CredentialKind): Promise; + /** + * Optional compare-and-set write. Persist `value` for `(slug, kind)` only + * while the stored entry still equals `expected` — the basis the caller read + * before deciding to write. `expected: null` asserts the entry is absent. + * + * The basis check and the write run together under the same cross-process + * lock as `setSecret`, so no concurrent writer can slip in between them; the + * check is a specialization of the current read-modify-write, not a lease held + * across any external I/O. + * + * Optional capability: third-party `CredentialStore` implementations and the + * future `credential_provider` backends stay source-compatible without it. + * When it is absent, callers fall back to an unconditional `setSecret` + * (today's behavior). + */ + compareAndSetSecret?( + slug: string, + kind: CredentialKind, + expected: string | null, + value: string, + ): Promise; +} + +export function createFileCredentialStore(workspaceRoot: string): CredentialStore { + return new FileCredentialStore(join(workspaceRoot, 'credentials.json')); +} + +class FileCredentialStore implements CredentialStore { + constructor(private readonly path: string) {} + + getSecret(slug: string, kind: CredentialKind): Promise { + return this.get(slug, toStoredKind(kind)); + } + + setSecret(slug: string, kind: CredentialKind, value: string): Promise { + return this.set(slug, toStoredKind(kind), value); + } + + async deleteSecret(slug: string, kind?: CredentialKind): Promise { + await this.mutate((values) => { + if (kind) { + delete values[this.key(slug, toStoredKind(kind))]; + return; + } + // No kind: clear every kind for the slug in one read-modify-write. + for (const storedKind of STORED_CREDENTIAL_KINDS) { + delete values[this.key(slug, storedKind)]; + } + }); + } + + private async get(slug: string, kind: StoredCredentialKind): Promise { + const value = (await this.readUnlocked()).values[this.key(slug, kind)]; + return value === undefined ? null : value; + } + + private set(slug: string, kind: StoredCredentialKind, value: string): Promise { + return this.mutate((values) => { + values[this.key(slug, kind)] = value; + }); + } + + /** + * Compare-and-set specialization of the read-modify-write: read under the + * lock, verify the stored entry still equals the caller's basis, and only then + * write. A mismatch commits nothing and reports what the store holds so the + * loser can distinguish a terminal delete (`current === null`) from a + * concurrent winner it must adopt (`current` is a string). + */ + compareAndSetSecret( + slug: string, + kind: CredentialKind, + expected: string | null, + value: string, + ): Promise { + const key = this.key(slug, toStoredKind(kind)); + return withCredentialFileLock(this.path, async () => { + const file = await this.readUnlocked(); + const stored = file.values[key]; + const current = stored === undefined ? null : stored; + if (current !== expected) { + return { committed: false, current }; + } + file.values[key] = value; + await this.write(file); + return { committed: true }; + }); + } + + /** + * Read-modify-write the whole file under the cross-process lockfile. The lock + * serializes concurrent calls on this instance and a second store instance / + * process alike, so one mechanism covers both — no separate in-instance queue. + */ + private mutate(apply: (values: Record) => void): Promise { + return withCredentialFileLock(this.path, async () => { + const file = await this.readUnlocked(); + apply(file.values); + await this.write(file); + }); + } + + private key(slug: string, kind: StoredCredentialKind): string { + return `${slug}:${kind}`; + } + + private async readUnlocked(): Promise { + let raw: string; + try { + raw = await readFile(this.path, 'utf8'); + } catch (error) { + if ((error as { code?: string }).code === 'ENOENT') { + return { version: CREDENTIAL_SCHEMA_VERSION, values: {} }; + } + throw error; + } + const parsed = JSON.parse(raw) as Partial; + // Fail closed on an unknown schema rather than inventing defaults. + if (parsed.version !== CREDENTIAL_SCHEMA_VERSION) { + throw new Error( + `Unsupported credentials.json schema version: ${String(parsed.version)} ` + + `(expected ${CREDENTIAL_SCHEMA_VERSION}). Remove the file and re-authenticate.`, + ); + } + // A v1 file must carry a well-formed `values` map. Treat a missing or + // malformed `values` as corruption and fail closed rather than silently + // serving an empty store (which would read as "no credentials"). + const values = parsed.values; + if (values === null || typeof values !== 'object' || Array.isArray(values)) { + throw new Error('Corrupt credentials.json: `values` is missing or not an object.'); + } + for (const [k, v] of Object.entries(values)) { + if (typeof v !== 'string') { + throw new Error(`Corrupt credentials.json: value for "${k}" is not a string.`); + } + } + return { version: CREDENTIAL_SCHEMA_VERSION, values: values as Record }; + } + + private write(file: CredentialFile): Promise { + return writeSecretFileAtomic(this.path, JSON.stringify(file, null, 2) + '\n'); + } +} + +/** + * Owner-only atomic write for a credentials file. Directory hardening is an + * explicit credential-store policy; file publication uses the shared legacy + * JSON writer so its mode, synchronization, and cleanup behavior remains + * aligned with settings and MCP config. + */ +async function writeSecretFileAtomic(path: string, contents: string): Promise { + await hardenDirectory(dirname(path)); + await writeAtomicFile(path, contents, { fileMode: 0o600 }); +} + +const LOCK_TIMEOUT_MS = 10_000; + +/** + * Serialize a read-modify-write across processes / store instances that share + * one credentials.json, so two writers can't lose each other's update through a + * read, read, write, write race. + * + * Acquire is an atomic `mkdir` of `${targetPath}.lock` (POSIX mkdir is atomic + * and fails EEXIST if it already exists); release deletes it. The lock is NEVER + * stolen — a held or leftover lock is waited on, then we fail loud. That is the + * whole design, and the reason it is correct. Every "detect a crashed holder's + * stale lock, then remove it and re-acquire" scheme — the earlier hand-rolled + * ones AND proper-lockfile — is a TOCTOU race: between judging a lock stale and + * deleting it, another contender can reclaim it, so the delete drops a live + * lock and both writers enter the critical section. There is no safe userspace + * compare-and-steal, so we do not steal at all. + * + * The cost: a hard crash (SIGKILL / power loss) mid-write leaves the lock + * directory behind, and the next writer fails loud until it is removed — an + * explicit, one-command recovery, never a silent lost update. A clean exit or a + * completed write releases it via the finally. credentials.json is written + * rarely and is local, so this is the right trade for credential data. + * + * `timeoutMs` defaults to LOCK_TIMEOUT_MS; it is a parameter only so a test can + * drive the fail-loud path with a small value. Exported for that test — it is + * deliberately NOT re-exported from index.ts, so the package's public surface + * stays the typed store and callers can't drive the lock directly. + */ +export async function withCredentialFileLock( + targetPath: string, + fn: () => Promise, + timeoutMs: number = LOCK_TIMEOUT_MS, +): Promise { + // Same owner-only hardening the writer applies, so the lock directory can + // never sit looser than the secret it guards. + await hardenDirectory(dirname(targetPath)); + return withFileUpdateLock(targetPath, fn, timeoutMs); +} + +const STORED_CREDENTIAL_KINDS = [ + 'apiKey', + 'oauthToken', + 'requestHeaders', + 'botToken', + 'botAppSecret', + 'proxyPassword', + 'tavilyApiKey', + 'runtimeHostAccess', + 'runtimeHostCapabilityProvider', +] as const satisfies readonly StoredCredentialKind[]; + +function toStoredKind(kind: CredentialKind): StoredCredentialKind { + switch (kind) { + case 'api_key': + return 'apiKey'; + case 'oauth_token': + return 'oauthToken'; + case 'request_headers': + return 'requestHeaders'; + case 'bot_token': + return 'botToken'; + case 'app_secret': + return 'botAppSecret'; + case 'proxy_password': + return 'proxyPassword'; + case 'tavily_api_key': + return 'tavilyApiKey'; + case 'runtime_host_access': + return 'runtimeHostAccess'; + case 'runtime_host_capability_provider': + return 'runtimeHostCapabilityProvider'; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/79665fa282288bb4d0d74979ba991a179ba27c54a855f5ca8e052df61248b17f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/79665fa282288bb4d0d74979ba991a179ba27c54a855f5ca8e052df61248b17f.source new file mode 100644 index 0000000000..3eb88bba2e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/79665fa282288bb4d0d74979ba991a179ba27c54a855f5ca8e052df61248b17f.source @@ -0,0 +1,771 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION } from '@maka/core/model-call-attempt'; +import { + PricingCommitUnknownError, + PricingRevisionConflictError, + PricingStoreClosedError, + PricingStoreNotLoadedError, + PricingStorePublicationError, + PricingValidationError, +} from '../pricing-store.js'; +import { + resolveStorageRoot, + STORAGE_ROOT_MARKER_FILE, + StorageRootAuthorityError, + tryAcquireInteractiveRootReader, + tryAcquireInteractiveRootOwner, + type StorageRootAuthorityErrorCode, +} from '../root-authority.js'; +import { + TelemetryQueryValidationError, + TelemetryRepoClosedError, + TelemetryRepoNotLoadedError, + TelemetryRepoPublicationError, +} from '../telemetry-repo.js'; +import { + classifyInteractiveUsageStoresFailure, + InteractiveUsageStoresClosedError, + openInteractiveUsageStoresForRead, + openInteractiveUsageStoresForWrite, +} from '../usage-stores.js'; +import { acquireOperationalStateDatabase } from '../operational-state-store.js'; +import { removeControlDirectory } from './fixtures/control-directory-hygiene.js'; + +describe('InteractiveUsageStores', () => { + test('classifies facade failures without exposing concrete errors to callers', () => { + assert.deepEqual( + classifyInteractiveUsageStoresFailure(new PricingRevisionConflictError(3, 4)), + { kind: 'revision_conflict', expectedRevision: 3, actualRevision: 4 }, + ); + for (const error of [ + new PricingValidationError('bad mutation'), + new TelemetryQueryValidationError('bad query'), + ]) { + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'invalid_request', + }); + } + for (const error of [ + new InteractiveUsageStoresClosedError(), + new PricingStoreClosedError(), + new TelemetryRepoClosedError(), + new StorageRootAuthorityError('invalid_lease', 'revoked'), + new StorageRootAuthorityError('invalid_owner', 'inauthentic'), + ]) { + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'lifecycle', + }); + } + for (const error of [ + new PricingCommitUnknownError({ cause: new Error('directory sync') }), + new TelemetryRepoPublicationError(true, { cause: new Error('directory sync') }), + ]) { + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'commit_outcome_unknown', + needsDrain: true, + }); + } + for (const error of [ + new PricingStorePublicationError({ cause: new Error('rename') }), + new TelemetryRepoPublicationError(false, { cause: new Error('rename') }), + ]) { + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'persistence_failed', + needsDrain: true, + }); + } + for (const error of [new PricingStoreNotLoadedError(), new TelemetryRepoNotLoadedError()]) { + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'persistence_failed', + needsDrain: false, + }); + } + const rootAuthorityNeedsDrain = { + invalid_root: false, + invalid_root_kind: false, + root_not_found: false, + root_unmarked: true, + invalid_marker: true, + root_identity_collision: true, + root_identity_changed: true, + invalid_repair: false, + invalid_capability: false, + invalid_lock_artifact: false, + insecure_control_directory: false, + root_io_failed: false, + control_io_failed: false, + lock_failed: false, + } as const satisfies Record< + Exclude, + boolean + >; + for (const [code, needsDrain] of Object.entries(rootAuthorityNeedsDrain)) { + assert.deepEqual( + classifyInteractiveUsageStoresFailure( + new StorageRootAuthorityError(code as StorageRootAuthorityErrorCode, code), + ), + { kind: 'persistence_failed', needsDrain }, + ); + } + + const unknown = new Error('unknown'); + assert.deepEqual(classifyInteractiveUsageStoresFailure(unknown), { + kind: 'unknown', + error: unknown, + }); + }); + + test('seeds an empty pricing authority for a fresh workspace', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + try { + assert.deepEqual(await stores.pricing.snapshot(), { revision: 0, overrides: [] }); + } finally { + await stores.close(); + await owner.close(); + } + }); + }); + + test('publishes the owning Session after each durable model-usage write', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + const changed: string[] = []; + const unsubscribe = stores.subscribeSessionUsageChanges((sessionId) => + changed.push(sessionId), + ); + try { + await stores.telemetry.recordLlmCall(llmRecord({ sessionId: 'session-legacy' })); + appendModelCallAuthorityEvent(root, modelCallAttempt('session-canonical')); + await stores.modelCalls.catchUpModelCallProjection({ sessionId: 'session-canonical' }); + assert.deepEqual(changed, ['session-legacy', 'session-canonical']); + } finally { + unsubscribe(); + await stores.close(); + await owner.close(); + } + }); + }); + + test('publishes the owning Session after a durable tool-usage write', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + const changed: string[] = []; + const unsubscribe = stores.subscribeSessionUsageChanges((sessionId) => + changed.push(sessionId), + ); + try { + // A session whose last activity is a tool invocation must still see the + // usage summary refresh — the trace panel's time ring reads the + // summary on exactly this signal. + await stores.telemetry.recordToolInvocation(toolRecord({ sessionId: 'session-tool' })); + assert.deepEqual(changed, ['session-tool']); + } finally { + unsubscribe(); + await stores.close(); + await owner.close(); + } + }); + }); + + test('does not republish idempotent model-usage mutations', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + const changed: string[] = []; + const unsubscribe = stores.subscribeSessionUsageChanges((sessionId) => + changed.push(sessionId), + ); + try { + const record = modelCallAttempt('session-idempotent'); + appendModelCallAuthorityEvent(root, record); + await stores.modelCalls.catchUpModelCallProjection({ sessionId: 'session-idempotent' }); + await stores.modelCalls.catchUpModelCallProjection({ sessionId: 'session-idempotent' }); + + assert.deepEqual(changed, ['session-idempotent']); + } finally { + unsubscribe(); + await stores.close(); + await owner.close(); + } + }); + }); + + test('classifies a renamed or replaced live root as a draining persistence failure', { + skip: + process.platform === 'win32' + ? 'Windows does not permit renaming a directory with an open SQLite database' + : false, + }, async () => { + for (const replacement of [false, true]) { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + try { + await rename(root, `${root}-moved`); + if (replacement) await mkdir(root); + await assert.rejects( + () => stores.pricing.snapshot(), + (error: unknown) => { + assert.ok(error instanceof StorageRootAuthorityError); + assert.equal(error.code, 'root_identity_changed'); + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'persistence_failed', + needsDrain: true, + }); + return true; + }, + ); + } finally { + await stores.close(); + await owner.close(); + } + }); + } + }); + + test('classifies poisoned live root markers as draining persistence failures', async () => { + const scenarios: ReadonlyArray<{ + code: 'root_unmarked' | 'invalid_marker'; + poison(markerPath: string): Promise; + }> = [ + { + code: 'root_unmarked', + poison: (markerPath) => rm(markerPath), + }, + { + code: 'invalid_marker', + poison: (markerPath) => writeFile(markerPath, '{'), + }, + { + code: 'invalid_marker', + poison: async (markerPath) => { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { kind: string }; + marker.kind = 'retired_kind'; + await writeFile(markerPath, `${JSON.stringify(marker)}\n`); + }, + }, + ]; + + for (const scenario of scenarios) { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await scenario.poison(join(root, STORAGE_ROOT_MARKER_FILE)); + try { + await assert.rejects( + () => stores.pricing.snapshot(), + (error: unknown) => { + assert.ok(error instanceof StorageRootAuthorityError); + assert.equal(error.code, scenario.code); + assert.deepEqual(classifyInteractiveUsageStoresFailure(error), { + kind: 'persistence_failed', + needsDrain: true, + }); + return true; + }, + ); + } finally { + await stores.close(); + await owner.close(); + } + }); + } + }); + + test('drain waits accepted writes and rejects new admission', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + const accepted = stores.telemetry.recordLlmCall(llmRecord()); + const drained = stores.beginDrain(); + + assert.throws( + () => stores.telemetry.recordToolInvocation(toolRecord()), + InteractiveUsageStoresClosedError, + ); + await Promise.all([accepted, drained]); + await stores.close(); + await assert.rejects( + () => readFile(join(root, 'telemetry.json'), 'utf8'), + (error: NodeJS.ErrnoException) => error.code === 'ENOENT', + ); + await owner.close(); + const successor = await tryAcquireInteractiveRootOwner(capability); + assert(successor); + const reopened = await openInteractiveUsageStoresForWrite(successor.lease); + assert.equal((await reopened.telemetry.logs({ range: 'all' })).rows[0]?.id, 'usage_1'); + await reopened.close(); + await successor.close(); + }); + }); + + test('lease-bound facade exposes separate LLM and filtered tool logs', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordLlmCall(llmRecord()); + await stores.telemetry.recordToolInvocation(toolRecord()); + + assert.equal((await stores.telemetry.logs({ range: 'all' })).total, 1); + const tools = await stores.telemetry.toolLogs({ + range: 'all', + toolName: 'Bash', + status: 'success', + }); + assert.equal(tools.total, 1); + assert.equal(tools.rows[0]?.toolName, 'Bash'); + await assert.rejects( + () => stores.telemetry.logs({ range: 'all', toolName: 'Bash' }), + /toolName is not applicable to LLM logs/, + ); + await stores.close(); + await owner.close(); + }); + }); + + test('legacy summary clamps each cache reading to its own input', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordLlmCall( + llmRecord({ + id: 'malformed-cache', + inputTokens: 100, + cacheHitInputTokens: 200, + cachedInputTokens: 200, + }), + ); + await stores.telemetry.recordLlmCall( + llmRecord({ id: 'cache-miss', inputTokens: 100, cacheHitInputTokens: 0 }), + ); + await stores.telemetry.recordLlmCall( + llmRecord({ + id: 'impossible-cache-hit', + inputTokens: 0, + cacheHitInputTokens: 1, + cachedInputTokens: 1, + cacheMissInputTokens: 0, + }), + ); + + const summary = await stores.telemetry.summary({ range: 'all' }); + assert.equal(summary.totalTokens.input, 200); + assert.equal(summary.totalTokens.cacheRead, 100); + assert.equal(summary.cacheHitRequests, 1); + + await stores.close(); + await owner.close(); + }); + }); + + test('legacy usage buckets clamp each cache reading to its own input', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordLlmCall( + llmRecord({ + inputTokens: 100, + cacheHitInputTokens: 200, + cachedInputTokens: 200, + }), + ); + + const buckets = await stores.telemetry.buckets({ range: 'all' }, 'model'); + assert.equal(buckets[0]?.cacheReadTokens, 100); + + await stores.close(); + await owner.close(); + }); + }); + + test('legacy summary reads only the requested Session', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordLlmCall( + llmRecord({ id: 'session-a-call', sessionId: 'session-a', costUsd: 1 }), + ); + await stores.telemetry.recordLlmCall( + llmRecord({ id: 'session-b-call', sessionId: 'session-b', costUsd: 9 }), + ); + + const summary = await stores.telemetry.summary({ + range: 'all', + sessionId: 'session-a', + }); + assert.equal(summary.totalRequests, 1); + assert.equal(summary.totalCostUsd, 1); + + await stores.close(); + await owner.close(); + }); + }); + + test('legacy summary sums recorded call time over the same rows as its tokens', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordLlmCall(llmRecord({ id: 'call-a', latencyMs: 1_200 })); + await stores.telemetry.recordLlmCall(llmRecord({ id: 'call-b', latencyMs: 300 })); + + const summary = await stores.telemetry.summary({ range: 'all' }); + assert.equal(summary.totalDurationMs, 1_500); + + await stores.close(); + await owner.close(); + }); + }); + + test('tool summary scopes to the requested Session and range', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordToolInvocation( + toolRecord({ id: 'tool-a', sessionId: 'session-a', durationMs: 120 }), + ); + await stores.telemetry.recordToolInvocation( + toolRecord({ id: 'tool-b', sessionId: 'session-b', durationMs: 80 }), + ); + + const sessionA = await stores.telemetry.toolSummary({ + range: 'all', + sessionId: 'session-a', + }); + assert.deepEqual(sessionA, { requests: 1, durationMs: 120 }); + + // Without a session filter the ledger answers with everything in range — + // the same contract the tool buckets follow. + const everySession = await stores.telemetry.toolSummary({ range: 'all' }); + assert.deepEqual(everySession, { requests: 2, durationMs: 200 }); + + const empty = await stores.telemetry.toolSummary({ + range: { from: 0, to: 1 }, + sessionId: 'session-a', + }); + assert.deepEqual(empty, { requests: 0, durationMs: 0 }); + + await stores.close(); + await owner.close(); + }); + }); + + test('tool summary applies the full summary query to the tool rows', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await stores.telemetry.recordToolInvocation( + toolRecord({ + id: 'tool-openai-ok', + sessionId: 'session-a', + toolName: 'Bash', + providerId: 'openai', + modelId: 'gpt-5', + status: 'success', + durationMs: 100, + }), + ); + await stores.telemetry.recordToolInvocation( + toolRecord({ + id: 'tool-anthropic-err', + sessionId: 'session-a', + toolName: 'Read', + providerId: 'anthropic', + modelId: 'claude-opus-5', + status: 'error', + durationMs: 300, + }), + ); + + // The tool ring sits beside the model totals under one query, so a + // filter the rows can answer must narrow both sides the same way. + const provider = await stores.telemetry.toolSummary({ + range: 'all', + sessionId: 'session-a', + providerId: 'openai', + }); + assert.deepEqual(provider, { requests: 1, durationMs: 100 }); + + const status = await stores.telemetry.toolSummary({ + range: 'all', + sessionId: 'session-a', + status: 'error', + }); + assert.deepEqual(status, { requests: 1, durationMs: 300 }); + + const model = await stores.telemetry.toolSummary({ + range: 'all', + sessionId: 'session-a', + modelId: 'gpt-5', + }); + assert.deepEqual(model, { requests: 1, durationMs: 100 }); + + const tool = await stores.telemetry.toolSummary({ + range: 'all', + sessionId: 'session-a', + toolName: 'Read', + }); + assert.deepEqual(tool, { requests: 1, durationMs: 300 }); + + await stores.close(); + await owner.close(); + }); + }); + + test('tool buckets answer the full summary query, including Session and provider', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + // Two rows share provider and model, so only the session (and the tool + // name) can tell them apart — the bucket view must apply the same + // filters the summary beside it applies. + await stores.telemetry.recordToolInvocation( + toolRecord({ + id: 'bucket-session-a', + sessionId: 'session-a', + toolName: 'Bash', + providerId: 'openai', + modelId: 'gpt-5', + durationMs: 100, + }), + ); + await stores.telemetry.recordToolInvocation( + toolRecord({ + id: 'bucket-session-b', + sessionId: 'session-b', + toolName: 'Bash', + providerId: 'openai', + modelId: 'gpt-5', + durationMs: 400, + }), + ); + + const scoped = await stores.telemetry.buckets( + { range: 'all', sessionId: 'session-a', providerId: 'openai' }, + 'tool', + ); + assert.deepEqual( + scoped.map((bucket) => [bucket.key, bucket.requests, bucket.avgLatencyMs]), + [['Bash', 1, 100]], + ); + + await stores.close(); + await owner.close(); + }); + }); + + test('every facade read observes lease revocation', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const stores = await openInteractiveUsageStoresForWrite(owner.lease); + await owner.close(); + + await assert.rejects( + () => stores.telemetry.summary({ range: 'all' }), + (error) => error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + await assert.rejects( + () => stores.pricing.snapshot(), + (error) => error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + await stores.close(); + }); + }); + + test('reader close releases local resources after lease revocation', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert(owner); + const writer = await openInteractiveUsageStoresForWrite(owner.lease); + await writer.close(); + await owner.close(); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert(readerOwner); + const reader = await openInteractiveUsageStoresForRead(readerOwner.lease); + await readerOwner.close(); + + await assert.rejects( + () => reader.pricing.snapshot(), + (error) => error instanceof StorageRootAuthorityError && error.code === 'invalid_lease', + ); + await reader.close(); + }); + }); +}); + +async function withInteractiveRoot( + run: (input: { + root: string; + capability: Awaited>>; + }) => Promise, +): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-usage-stores-')); + try { + const root = join(base, 'interactive'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + try { + await run({ root, capability }); + } finally { + await removeControlDirectory(capability.rootId); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +} + +function llmRecord(overrides: Record = {}) { + return { + id: 'usage_1', + providerId: 'openai', + modelId: 'gpt-5', + inputTokens: 10, + outputTokens: 20, + cacheHitInputTokens: 0, + cacheMissInputTokens: 10, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + reasoningTokens: 0, + totalTokens: 30, + costUsd: 0.001, + latencyMs: 100, + status: 'success', + date: '2026-01-01', + ts: Date.UTC(2026, 0, 1), + startedAt: Date.UTC(2026, 0, 1) - 100, + ...overrides, + } as Parameters< + Awaited>['telemetry']['recordLlmCall'] + >[0]; +} + +function toolRecord(overrides: Record = {}) { + return { + id: 'tool_1', + toolName: 'Bash', + durationMs: 30, + status: 'success', + bytesIn: 1, + bytesOut: 2, + date: '2026-01-01', + ts: Date.UTC(2026, 0, 1), + startedAt: Date.UTC(2026, 0, 1), + ...overrides, + } as Parameters< + Awaited< + ReturnType + >['telemetry']['recordToolInvocation'] + >[0]; +} + +function modelCallAttempt(sessionId: string) { + return { + schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + logicalCallId: 'call-1', + attemptId: 'attempt-1', + traceId: 'trace-1', + sessionId, + runId: 'run-1', + turnId: 'turn-1', + step: 0, + attempt: 0, + callKind: 'main' as const, + providerId: 'openai', + modelId: 'gpt-5', + startedAt: 1, + completedAt: 2, + latencyMs: 1, + status: 'completed' as const, + usageBasis: 'reported' as const, + inputTokens: 1, + outputTokens: 1, + costBasis: 'priced' as const, + costUsd: 0.001, + }; +} + +function appendModelCallAuthorityEvent( + root: string, + value: ReturnType, +): void { + const lease = acquireOperationalStateDatabase(root); + try { + lease.transaction('write', () => { + lease.database + .prepare(` + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES (?, ?, 0) + `) + .run(value.sessionId, value.runId); + lease.database + .prepare(` + INSERT INTO core_agent_run_events( + session_id, run_id, sequence, event_id, event_type, event_ts, record_json + ) VALUES (?, ?, 0, 'model-call-1', 'model_call_attempt_recorded', 0, ?) + `) + .run( + value.sessionId, + value.runId, + JSON.stringify({ + id: 'model-call-1', + type: 'model_call_attempt_recorded', + ts: 0, + sessionId: value.sessionId, + runId: value.runId, + turnId: value.turnId, + data: value, + }), + ); + lease.database + .prepare(` + UPDATE core_agent_runs SET latest_model_call_sequence = 0 + WHERE session_id = ? AND run_id = ? + `) + .run(value.sessionId, value.runId); + }); + } finally { + lease.close(); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/797f2fa9be3663837c4844a5136910f100c9038fbbf5b3ba4da529163d0c373a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/797f2fa9be3663837c4844a5136910f100c9038fbbf5b3ba4da529163d0c373a.source new file mode 100644 index 0000000000..7a99587d7e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/797f2fa9be3663837c4844a5136910f100c9038fbbf5b3ba4da529163d0c373a.source @@ -0,0 +1,384 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Tests for SettingsStore.upsertOnboardingMilestone (PR110b). + * + * Verifies the write path: + * - timestamp is generated by the store (never accepted from caller) + * - invalid id surfaces an error (via the sanitizer's closed enum) + * - last-valid-entry-wins dedup with stable first-seen position + * - settings.json round-trips through the sanitizer on read + * - upserting one milestone never disturbs other milestones + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createSettingsStore } from '../settings-store.js'; + +describe('SettingsStore.upsertOnboardingMilestone (PR110b)', () => { + it('stamps a fresh Date.now() timestamp (renderer cannot tamper)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-')); + try { + const store = createSettingsStore(workspaceRoot); + const before = Date.now(); + const result = await store.upsertOnboardingMilestone('first_chat_sent', 'completed'); + const after = Date.now(); + assert.equal(result.length, 1); + const entry = result[0]; + assert.ok(entry); + assert.equal(entry.id, 'first_chat_sent'); + assert.ok(typeof entry.completedAt === 'number'); + assert.ok( + entry.completedAt! >= before && entry.completedAt! <= after, + 'timestamp must come from main process clock', + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('skipped status writes skippedAt (mutually exclusive with completedAt)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-skipped-')); + try { + const store = createSettingsStore(workspaceRoot); + const result = await store.upsertOnboardingMilestone('first_personalization', 'skipped'); + const entry = result[0]; + assert.ok(entry); + assert.equal(entry.id, 'first_personalization'); + assert.ok(typeof entry.skippedAt === 'number'); + assert.equal(entry.completedAt, undefined); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid status (TypeScript-bypassing call)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-invalid-')); + try { + const store = createSettingsStore(workspaceRoot); + // Cast to bypass TS so we can prove the runtime guard works. + // Keep the receiver via arrow fn so `this` isn't lost. + await assert.rejects( + () => + ( + store.upsertOnboardingMilestone as unknown as ( + id: string, + status: string, + ) => Promise + ).call(store, 'first_chat_sent', 'unknown_status'), + /invalid onboarding milestone status/, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid milestone id (sanitizer drops, then store re-throws)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-bad-id-')); + try { + const store = createSettingsStore(workspaceRoot); + await assert.rejects( + () => + ( + store.upsertOnboardingMilestone as unknown as ( + id: string, + status: 'completed' | 'skipped', + ) => Promise + ).call(store, 'not_a_milestone', 'completed'), + /invalid onboarding milestone id/, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('upserting the same id last-valid-entry wins (skipped overrides completed)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-upsert-')); + try { + const store = createSettingsStore(workspaceRoot); + await store.upsertOnboardingMilestone('first_chat_sent', 'completed'); + const after = await store.upsertOnboardingMilestone('first_chat_sent', 'skipped'); + assert.equal(after.length, 1); + const entry = after[0]; + assert.equal(entry?.id, 'first_chat_sent'); + assert.ok(typeof entry?.skippedAt === 'number'); + // Once skipped is set, completed must NOT survive. + assert.equal(entry?.completedAt, undefined); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('does not disturb other milestones when upserting one', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-others-')); + try { + const store = createSettingsStore(workspaceRoot); + await store.upsertOnboardingMilestone('first_chat_sent', 'completed'); + const before = (await store.get()).onboarding.milestones[0]?.completedAt; + await store.upsertOnboardingMilestone('first_personalization', 'completed'); + const after = (await store.get()).onboarding.milestones; + assert.equal(after.length, 2); + const chat = after.find((m) => m.id === 'first_chat_sent'); + const pers = after.find((m) => m.id === 'first_personalization'); + assert.equal(chat?.completedAt, before, 'first_chat_sent timestamp preserved'); + assert.ok(pers?.completedAt); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('settings.json on disk contains only sanitized milestones', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-disk-')); + try { + const store = createSettingsStore(workspaceRoot); + await store.upsertOnboardingMilestone('first_chat_sent', 'completed'); + const raw = await readFile(join(workspaceRoot, 'settings.json'), 'utf8'); + const parsed = JSON.parse(raw) as { + onboarding: { milestones: Array> }; + }; + assert.equal(parsed.onboarding.milestones.length, 1); + const entry = parsed.onboarding.milestones[0]!; + // Must contain only the schema fields — nothing else. + assert.deepEqual(Object.keys(entry).sort(), ['completedAt', 'id']); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('reading settings.json with garbage milestones drops them via sanitizer', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-garbage-')); + try { + const store = createSettingsStore(workspaceRoot); + // Seed a bad settings.json by hand and reload. + const { writeFile, mkdir } = await import('node:fs/promises'); + await mkdir(workspaceRoot, { recursive: true }); + await writeFile( + join(workspaceRoot, 'settings.json'), + JSON.stringify({ + schemaVersion: 1, + onboarding: { + milestones: [ + { id: 'first_chat_sent', completedAt: 1 }, + { id: 'first_personalization', completedAt: 'bad' }, // invalid + { id: 'first_chat_sent', skippedAt: 2 }, // last-wins + { id: 'unknown_id', completedAt: 3 }, // invalid id + { id: 'first_chat_sent', completedAt: 4, prompt: 'leak' }, // extra field + ], + }, + }), + ); + const settings = await store.get(); + // After sanitize: first_chat_sent has skippedAt: 2 (last valid; + // the 4-with-extra is rejected before it could overwrite). + assert.equal(settings.onboarding.milestones.length, 1); + assert.equal(settings.onboarding.milestones[0]?.id, 'first_chat_sent'); + assert.equal(settings.onboarding.milestones[0]?.skippedAt, 2); + assert.equal(settings.onboarding.milestones[0]?.completedAt, undefined); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +describe('SettingsStore.clearOnboardingMilestone', () => { + it('removes one milestone and preserves the rest', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-clear-')); + try { + const store = createSettingsStore(workspaceRoot); + await store.upsertOnboardingMilestone('first_chat_sent', 'completed'); + await store.upsertOnboardingMilestone('first_model_swap', 'skipped'); + + const after = await store.clearOnboardingMilestone('first_model_swap'); + + assert.equal( + after.some((entry) => entry.id === 'first_model_swap'), + false, + ); + assert.equal( + after.some((entry) => entry.id === 'first_chat_sent'), + true, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid milestone id (TypeScript-bypassing call)', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-milestone-clear-invalid-')); + try { + const store = createSettingsStore(workspaceRoot); + await assert.rejects( + () => + (store.clearOnboardingMilestone as unknown as (id: string) => Promise).call( + store, + 'not_a_milestone', + ), + /invalid onboarding milestone id/, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +describe('SettingsStore.updateIf', () => { + it('checks and writes atomically against the latest queued settings', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-conditional-')); + try { + const store = createSettingsStore(workspaceRoot); + await store.update({ + botChat: { channels: { dingtalk: { appId: 'installed-id' } } }, + }); + + const applied = await store.updateIf( + (current) => current.botChat.channels.dingtalk.appId === 'installed-id', + { botChat: { channels: { dingtalk: { appId: 'rolled-back-id' } } } }, + ); + assert.equal(applied.applied, true); + assert.equal(applied.settings.botChat.channels.dingtalk.appId, 'rolled-back-id'); + + const skipped = await store.updateIf( + (current) => current.botChat.channels.dingtalk.appId === 'installed-id', + { botChat: { channels: { dingtalk: { appId: 'must-not-win' } } } }, + ); + assert.equal(skipped.applied, false); + assert.equal((await store.get()).botChat.channels.dingtalk.appId, 'rolled-back-id'); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +describe('SettingsStore.get file recovery', () => { + it('atomically removes legacy proxy credentials from settings.json on read', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-proxy-migration-')); + try { + const store = createSettingsStore(workspaceRoot); + const settingsPath = join(workspaceRoot, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + schemaVersion: 1, + network: { + proxy: { + enabled: true, + host: '127.0.0.1', + port: 7897, + password: 'legacy-plaintext-secret', + passwordConfigured: true, + }, + }, + }), + 'utf8', + ); + + const settings = await store.get(); + const migrated = JSON.parse(await readFile(settingsPath, 'utf8')) as { + network: { proxy: Record }; + }; + + assert.equal(settings.network.proxy.host, '127.0.0.1'); + assert.equal('password' in migrated.network.proxy, false); + assert.equal('passwordConfigured' in migrated.network.proxy, false); + assert.equal( + (await readFile(settingsPath, 'utf8')).includes('legacy-plaintext-secret'), + false, + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('serializes concurrent first reads while creating default settings', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-concurrent-defaults-')); + const originalNow = Date.now; + try { + Date.now = () => 1_000; + const store = createSettingsStore(workspaceRoot); + + const settings = await Promise.all(Array.from({ length: 16 }, () => store.get())); + + assert.equal( + settings.every((value) => value.schemaVersion === 1), + true, + ); + const raw = await readFile(join(workspaceRoot, 'settings.json'), 'utf8'); + assert.equal(JSON.parse(raw).schemaVersion, 1); + } finally { + Date.now = originalNow; + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('creates defaults only when settings.json is missing', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-defaults-')); + try { + const store = createSettingsStore(workspaceRoot); + + const settings = await store.get(); + const raw = await readFile(join(workspaceRoot, 'settings.json'), 'utf8'); + + assert.equal(settings.schemaVersion, 1); + assert.match(raw, /"schemaVersion": 1/); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('rejects corrupt settings.json without overwriting user settings bytes', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-corrupt-')); + try { + const store = createSettingsStore(workspaceRoot); + const settingsPath = join(workspaceRoot, 'settings.json'); + const corrupt = '{"appearance":{"theme":"dark"}'; + await writeFile(settingsPath, corrupt, 'utf8'); + + await assert.rejects(() => store.get(), SyntaxError); + assert.equal(await readFile(settingsPath, 'utf8'), corrupt); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it('preserves a restrictive umask-derived settings.json mode and leaves no temp file behind', { + skip: process.platform === 'win32', + }, async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-mode-')); + const previousUmask = process.umask(0o027); + try { + const store = createSettingsStore(workspaceRoot); + + await store.get(); // first run writes the defaults + + assert.deepEqual(await readdir(workspaceRoot), ['settings.json']); + assert.equal( + (await stat(join(workspaceRoot, 'settings.json'))).mode & 0o777, + 0o640, + 'settings.json retains the mode produced by the legacy default and current umask', + ); + } finally { + process.umask(previousUmask); + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source new file mode 100644 index 0000000000..202f195ecf --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source @@ -0,0 +1,76 @@ +{ + "name": "@maka/storage", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "type": "module", + "exports": { + "./activation-secret-injector": "./dist/activation-secret-injector.js", + "./agent-graph-control-store": "./dist/agent-graph-control-store.js", + "./agent-run-store": "./dist/agent-run-store.js", + "./artifact-stores": "./dist/artifact-stores.js", + "./config-transfer": "./dist/config-transfer.js", + "./context-offload-store": "./dist/context-offload-store.js", + "./tool-result-archive-evidence": "./dist/tool-result-archive-evidence.js", + "./credential-store": "./dist/credential-store.js", + "./daily-review-authority": "./dist/daily-review-authority.js", + "./deep-research-authority": "./dist/deep-research-authority.js", + "./deep-research-store": "./dist/deep-research-store.js", + "./encrypted-file-managed-secret-store": "./dist/encrypted-file-managed-secret-store.js", + "./execution-stores": "./dist/execution-stores.js", + "./external-sessions": "./dist/external-sessions.js", + "./file-lifetime-owner": "./dist/file-lifetime-owner.js", + "./file-update-lock": "./dist/file-update-lock.js", + "./foreign-session-store": "./dist/foreign-session-store.js", + "./git-worktree-child-executor": "./dist/git-worktree-child-executor.js", + "./goal-authority": "./dist/goal-authority.js", + "./interaction-store": "./dist/interaction-store-public.js", + "./long-term-memory-store": "./dist/long-term-memory-store.js", + "./managed-secret-store": "./dist/managed-secret-store.js", + "./mcp-config-store": "./dist/mcp-config-store.js", + "./memory-bundle-store": "./dist/memory-bundle-store.js", + "./model-call-ledger": "./dist/model-call-ledger.js", + "./operational-state-store": "./dist/operational-state-store-public.js", + "./pet-pack-store": "./dist/pet-pack-store.js", + "./plan-authority": "./dist/plan-authority.js", + "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js", + "./process-lifetime-owner": "./dist/process-lifetime-owner.js", + "./project-catalog": "./dist/project-catalog.js", + "./project-catalog-authority": "./dist/project-catalog-authority.js", + "./root-authority": "./dist/root-authority.js", + "./read-image-snapshot-store": "./dist/read-image-snapshot-store.js", + "./runtime-event-persistence": "./dist/runtime-event-persistence.js", + "./runtime-policy-stores": "./dist/runtime-policy-stores.js", + "./scheduled-task-store": "./dist/scheduled-task-store.js", + "./quiescent-session-snapshot": "./dist/quiescent-session-snapshot.js", + "./production-session-snapshot": "./dist/production-session-snapshot.js", + "./session-bundle-policy": "./dist/session-bundle-policy.js", + "./session-copy-cleanup": "./dist/session-copy-cleanup.js", + "./session-todo-authority": "./dist/session-todo-authority.js", + "./session-message-projection": "./dist/session-message-projection.js", + "./session-store": "./dist/session-store.js", + "./settings-store": "./dist/settings-store.js", + "./shell-run-authority": "./dist/shell-run-authority.js", + "./shell-run-store": "./dist/shell-run-store.js", + "./sqlite-runtime-store": "./dist/sqlite-runtime-store.js", + "./sqlite-session-metadata-store": "./dist/sqlite-session-metadata-store.js", + "./stable-storage": "./dist/stable-storage.js", + "./state-root-composition": "./dist/state-root-composition.js", + "./storage-writer-composition": "./dist/storage-writer-composition.js", + "./usage-stores": "./dist/usage-stores.js", + "./work-board-store": "./dist/work-board-store.js", + "./workspace-identity": "./dist/workspace-identity.js", + "./workspace-root": "./dist/workspace-root.js", + "./write-queue": "./dist/write-queue.js" + }, + "scripts": { + "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:dist": "node --test \"dist/**/*.test.js\"" + }, + "dependencies": { + "@maka/core": "0.1.0", + "fs-native-extensions": "^1.5.1" + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b17d4a01d1b5e4b5047fd09f5b9cd7a34edef934e81251c843eedcc5a5c2b80.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b17d4a01d1b5e4b5047fd09f5b9cd7a34edef934e81251c843eedcc5a5c2b80.source new file mode 100644 index 0000000000..f13c684f59 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b17d4a01d1b5e4b5047fd09f5b9cd7a34edef934e81251c843eedcc5a5c2b80.source @@ -0,0 +1,1723 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { copyOpaqueStateIdentityDescriptor } from './session-bundle-contract.js'; +import type { + OpaqueStateIdentityDescriptor, + PreparedSessionBundleSnapshot, +} from './session-bundle-contract.js'; +import { isSessionBundleUstarPathV1 } from './session-bundle-ustar.js'; +import { createSessionCopyCleanupAuthority } from './session-copy-cleanup.js'; +import { isSafeSessionId } from './session-store.js'; +import type { ProcessLifetimeOwner } from './process-lifetime-owner.js'; + +export const SESSION_SNAPSHOT_POLICY_VERSION = 1 as const; +export const SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION = 1 as const; + +const SNAPSHOT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const SNAPSHOT_CLEANUP_ID_PATTERN = + /^snapshot-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/u; +const CONFIRMATION_GRANT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; +const MAX_OWNER_RECORD_BYTES = 1_024; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const NO_FOLLOW_OPEN_FLAG = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + +export type SessionSnapshotWorkspaceEntryKind = 'file' | 'directory'; + +export type SessionSnapshotWorkspaceExclusionCategory = + | 'dependency_tree' + | 'source_control' + | 'cache' + | 'log' + | 'runtime_scratch' + | 'confirmed_secret_path'; + +export type SessionSnapshotWorkspaceConfirmationCategory = 'suspected_secret_path'; + +export type SessionSnapshotWorkspaceRejectionCategory = + | 'known_secret_file' + | 'unsafe_path' + | 'unsupported_portable_path' + | 'unsupported_entry'; + +export type SessionSnapshotWorkspacePolicyDecision = + | { readonly kind: 'include' } + | { + readonly kind: 'exclude'; + readonly category: SessionSnapshotWorkspaceExclusionCategory; + } + | { + readonly kind: 'confirm'; + readonly category: SessionSnapshotWorkspaceConfirmationCategory; + /** Normalized topmost directory whose complete subtree the decision covers. */ + readonly confirmationPath: string; + } + | { + readonly kind: 'reject'; + readonly category: SessionSnapshotWorkspaceRejectionCategory; + }; + +export interface SessionSnapshotWorkspaceEntry { + /** Slash-separated relative path supplied without normalization aliases. */ + readonly relativePath: string; + readonly kind: SessionSnapshotWorkspaceEntryKind; +} + +export interface SessionSnapshotWorkspacePolicy { + readonly version: typeof SESSION_SNAPSHOT_POLICY_VERSION; + /** + * Receives normalized relative paths. A preparer must stop descending as + * soon as a directory is excluded, including by a confirmed exclusion; + * descendant entries are not counted. A confirmed include permits descent + * but does not override later high-confidence secret rejections. + */ + classify(entry: SessionSnapshotWorkspaceEntry): SessionSnapshotWorkspacePolicyDecision; +} + +const INCLUDE = Object.freeze({ kind: 'include' } as const); + +// This fail-closed rejection set is deliberately narrower than the workspace +// measurement rules introduced by #1353. Snapshot rejection is reserved for +// names that identify known secret material; public certificate encodings and +// other ambiguous formats are not rejected by extension alone. +const PUBLIC_ENV_TEMPLATE_PATTERN = /^\.env\.(?:example|sample|template)$/i; +const KNOWN_SECRET_WORKSPACE_DIRECTORY_NAMES = new Set(['.ssh']); +const SUSPECTED_SECRET_WORKSPACE_DIRECTORY_NAMES = new Set(['credentials', 'private', 'secrets']); +const KNOWN_SECRET_WORKSPACE_FILE_PATTERNS = [ + /^\.env(?:\..*)?$/i, + /^\.(?:npmrc|netrc|pypirc|terraformrc)$/i, + /^\.git-credentials(?:\.lock)?$/i, + /^(?:credentials?|secrets?)(?:\.(?:cfg|conf|ini|json|log|properties|toml|ya?ml))?$/i, + /(?:^|[-_.])(?:id_(?:rsa|dsa|ecdsa|ed25519)|private[-_.]?key)(?:$|[-_.])/i, + /^(?:private|privkey)\.pem$/i, + /\.(?:key|p12|pfx)$/i, +] as const; + +/** + * V1 portable-workspace policy. The coordinator pins this exact policy, while + * the trusted filesystem preparer is its enforcement point: the coordinator + * does not re-traverse or attest the prepared destination. The preparer remains + * responsible for applying every decision and rejecting symlinks, hard links, + * special files, path races, case conflicts, and quota violations. + */ +export const SESSION_SNAPSHOT_WORKSPACE_POLICY_V1: SessionSnapshotWorkspacePolicy = Object.freeze({ + version: SESSION_SNAPSHOT_POLICY_VERSION, + classify(entry: SessionSnapshotWorkspaceEntry): SessionSnapshotWorkspacePolicyDecision { + const decoded = decodeWorkspaceEntry(entry); + if (decoded.kind === 'reject') return decoded; + const { segments, basename: name } = decoded; + const lowerSegments = segments.map((segment) => segment.toLowerCase()); + const lowerName = name.toLowerCase(); + + if (lowerSegments.includes('.git')) { + return { kind: 'exclude', category: 'source_control' }; + } + if (lowerSegments.includes('node_modules')) { + return { kind: 'exclude', category: 'dependency_tree' }; + } + if ( + lowerSegments.includes('.cache') || + lowerSegments.includes('.maka-cache') || + lowerSegments.includes('.turbo') + ) { + return { kind: 'exclude', category: 'cache' }; + } + if (lowerSegments.includes('.maka-runtime') || lowerSegments.includes('.maka-activation')) { + return { kind: 'exclude', category: 'runtime_scratch' }; + } + if (isKnownSecretEntry(entry.kind, lowerSegments, lowerName)) { + return { kind: 'reject', category: 'known_secret_file' }; + } + const confirmationPath = findSuspectedSecretDirectoryPath(entry.kind, segments, lowerSegments); + if (confirmationPath !== undefined) { + return { kind: 'confirm', category: 'suspected_secret_path', confirmationPath }; + } + if ( + lowerSegments.includes('logs') || + lowerSegments.includes('.logs') || + (entry.kind === 'file' && lowerName.endsWith('.log')) + ) { + return { kind: 'exclude', category: 'log' }; + } + return INCLUDE; + }, +}); + +export interface SessionSnapshotCancellation { + readonly signal: AbortSignal; + /** Absolute Unix time in milliseconds. */ + readonly deadlineAt?: number; +} + +/** + * Trusted host/owner authority for one complete Session mutation boundary. + * + * Before invoking `operation`, an implementation must stop admitting new + * mutations for this Maka Session, drain already-admitted state, Artifact and + * workspace mutations, and reject non-terminal Activations, background + * processes, pending approvals, and externally resumable actions. It must keep + * that boundary until `operation` settles, serialize preparations for the same + * Session, and honor cancellation/deadline while waiting. This interface does + * not make a process-local mutex authoritative by itself: every real writer + * must already be governed by the supplied Host/Owner implementation. + */ +export interface SessionSnapshotQuiescenceAuthority { + runQuiescent( + input: { + readonly makaSessionId: string; + readonly cancellation: SessionSnapshotCancellation; + }, + operation: () => Promise, + ): Promise; +} + +export interface SessionSnapshotStatePreparer { + /** Creates the exact, previously absent root and closes every source/destination handle. */ + prepareState(input: { + readonly makaSessionId: string; + readonly destinationRoot: string; + readonly cancellation: SessionSnapshotCancellation; + }): Promise; +} + +export interface SessionSnapshotWorkspacePreparation { + /** Number of included files and directories, including empty directories. */ + readonly includedEntries: number; + /** Number of topmost excluded entries; descendants of an excluded directory are not counted. */ + readonly excludedEntries: number; + /** Bounded audit diagnostics; paths and file contents are deliberately absent. */ + readonly excludedEntriesByCategory: Readonly< + Record + >; + readonly payloadBytes: number; +} + +export type SessionSnapshotWorkspaceConfirmationAction = 'include' | 'exclude'; + +/** + * Trusted control-plane lookup for a previously recorded explicit user choice. + * + * Implementations must authenticate the principal, verify ownership of the + * Maka Session, and bind the grant to the exact policy version, normalized + * confirmation path and current Workspace source revision/digest. Decisions + * live outside Session state, workspaces, staging roots and Session Bundles. + * This lookup must not wait for interactive user input while the Session is + * quiescent; an absent or stale decision returns `undefined` and fails snapshot + * preparation closed. + */ +export interface SessionSnapshotWorkspaceConfirmationAuthority { + resolveConfirmation(input: { + readonly makaSessionId: string; + readonly confirmationGrantId: string; + readonly policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION; + readonly category: SessionSnapshotWorkspaceConfirmationCategory; + readonly confirmationPath: string; + readonly cancellation: SessionSnapshotCancellation; + }): Promise<{ readonly action: SessionSnapshotWorkspaceConfirmationAction } | undefined>; +} + +export interface SessionSnapshotWorkspaceConfirmationResolver { + /** + * Resolves the policy's confirmation decision into a final include/exclude + * decision. Repeated descendants of one confirmed directory reuse the same + * control-plane lookup for this preparation. + */ + resolve( + entry: SessionSnapshotWorkspaceEntry, + ): Promise< + | { readonly kind: 'include' } + | { readonly kind: 'exclude'; readonly category: 'confirmed_secret_path' } + >; +} + +export interface SessionSnapshotWorkspacePreparer { + /** + * Trusted enforcement point for the supplied workspace policy. Creates the + * exact, previously absent root, applies every policy decision without + * downgrading it, resolves every `confirm` decision before copying or + * descending, and closes every source/destination handle. The coordinator + * validates the returned root and bounded counters, but does not independently + * traverse the result to prove that the policy was applied. + */ + prepareWorkspace(input: { + readonly makaSessionId: string; + readonly destinationRoot: string; + readonly policy: SessionSnapshotWorkspacePolicy; + readonly confirmation: SessionSnapshotWorkspaceConfirmationResolver; + readonly cancellation: SessionSnapshotCancellation; + }): Promise; +} + +/** + * Trusted platform adapter that verifies a staging root is private to the + * current principal. On Windows this must inspect the effective ACL of both + * the parent and each newly created snapshot directory; POSIX mode bits are + * neither available nor an adequate substitute there. + */ +export interface SessionSnapshotPrivateStagingRootAuthority { + verifyPrivateStagingRoot(input: { + readonly canonicalPath: string; + }): Promise<{ readonly canonicalPath: string }>; +} + +export interface PrepareQuiescentSessionSnapshotInput { + readonly makaSessionId: string; + /** Opaque, control-plane-issued grant; never persisted in the Session Bundle. */ + readonly confirmationGrantId?: string; + readonly signal?: AbortSignal; + /** Absolute Unix time in milliseconds. */ + readonly deadlineAt?: number; +} + +export interface PreparedSessionBundleHandle { + readonly snapshot: PreparedSessionBundleSnapshot; + readonly policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION; + readonly workspace: SessionSnapshotWorkspacePreparation; + /** + * Idempotent after successful cleanup; failures remain retryable. Path and + * identity checks fail closed on replacements observable before deletion, but + * do not defend against an adversarial same-principal replacement in the final + * path-based filesystem-operation window. + */ + release(): Promise; +} + +export interface QuiescentSessionSnapshotCoordinator { + prepare(input: PrepareQuiescentSessionSnapshotInput): Promise; +} + +export type SessionSnapshotErrorCode = + | 'invalid_input' + | 'snapshot_busy' + | 'snapshot_cancelled' + | 'session_not_quiescent' + | 'source_changed' + | 'unsafe_source' + | 'policy_rejected' + | 'quota_exceeded' + | 'cleanup_failed' + | 'io_failure'; + +export type SessionSnapshotPhase = + | 'admission' + | 'staging' + | 'state' + | 'workspace' + | 'publication' + | 'cleanup'; + +export interface SessionSnapshotErrorDetails { + readonly phase?: SessionSnapshotPhase; + /** Cleanup also failed; the top-level code still classifies the primary failure. */ + readonly cleanupFailed?: true; + readonly policyCategory?: + | SessionSnapshotWorkspaceRejectionCategory + | SessionSnapshotWorkspaceConfirmationCategory; + readonly limit?: number; + readonly observed?: number; +} + +export interface SessionSnapshotErrorOptions extends ErrorOptions { + readonly details?: SessionSnapshotErrorDetails; +} + +export class SessionSnapshotError extends Error { + readonly details?: Readonly; + + constructor( + readonly code: SessionSnapshotErrorCode, + message: string, + options: SessionSnapshotErrorOptions = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'SessionSnapshotError'; + if (options.details !== undefined) this.details = Object.freeze({ ...options.details }); + } +} + +export interface FileQuiescentSessionSnapshotCoordinatorOptions { + /** + * Private control-plane directory outside live state and workspace roots. + * Code running as the same OS principal and able to mutate this parent is in + * the trusted computing boundary; Node's path-based recursive removal cannot + * make an adversarial final-check-to-delete race impossible. + */ + readonly stagingParent: string; + readonly quiescence: SessionSnapshotQuiescenceAuthority; + readonly state: SessionSnapshotStatePreparer; + readonly workspace: SessionSnapshotWorkspacePreparer; + /** Persistent owner/recovery authority for every private staging root. */ + readonly stagingCleanup: SessionSnapshotStagingCleanupAuthority; + /** Optional control-plane lookup; suspected paths fail closed when absent. */ + readonly confirmationAuthority?: SessionSnapshotWorkspaceConfirmationAuthority; + /** Required on Windows; optional additional verification on POSIX platforms. */ + readonly privateStagingRootAuthority?: SessionSnapshotPrivateStagingRootAuthority; + readonly now?: () => number; + readonly newSnapshotId?: () => string; +} + +export interface SessionSnapshotStagingLease { + readonly snapshotId: string; + readonly ownerToken: string; + readonly makaSessionId: string; +} + +export interface SessionSnapshotStagingCleanupRecovery { + readonly removed: string[]; + readonly failed: Array<{ readonly snapshotId: string; readonly error: unknown }>; +} + +/** + * Persistent lifetime authority for snapshot staging. Production startup must + * call `recover()` after acquiring its ProcessLifetimeOwner and before serving + * snapshot requests. + */ +export interface SessionSnapshotStagingCleanupAuthority { + /** Absolute staging parent this authority's persisted leases are bound to. */ + readonly stagingParent: string; + ownCreation(lease: SessionSnapshotStagingLease, operation: () => Promise): Promise; + cleanup(lease: SessionSnapshotStagingLease): Promise; + recover(): Promise; +} + +/** + * Reuses the Session-copy persisted cleanup lease engine in a separate + * operational-state root. The separate root prevents either recovery domain + * from interpreting and deleting the other domain's resources. + */ +export function createFileSessionSnapshotStagingCleanupAuthority(input: { + readonly cleanupStateRoot: string; + readonly stagingParent: string; + readonly processLifetimeOwner: ProcessLifetimeOwner; + readonly privateStagingRootAuthority?: SessionSnapshotPrivateStagingRootAuthority; +}): SessionSnapshotStagingCleanupAuthority { + if (!isAbsolute(input.cleanupStateRoot)) { + throw new TypeError('Session snapshot cleanupStateRoot must be absolute'); + } + if (!isAbsolute(input.stagingParent)) { + throw new TypeError('Session snapshot stagingParent must be absolute'); + } + return new FileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: resolve(input.cleanupStateRoot), + stagingParent: resolve(input.stagingParent), + processLifetimeOwner: input.processLifetimeOwner, + privateStagingRootAuthority: input.privateStagingRootAuthority, + }); +} + +class FileSessionSnapshotStagingCleanupAuthority implements SessionSnapshotStagingCleanupAuthority { + readonly stagingParent: string; + readonly #cleanup: ReturnType; + + constructor(input: { + cleanupStateRoot: string; + stagingParent: string; + processLifetimeOwner: ProcessLifetimeOwner; + privateStagingRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined; + }) { + this.stagingParent = input.stagingParent; + this.#cleanup = createSessionCopyCleanupAuthority({ + workspaceRoot: input.cleanupStateRoot, + processLifetimeOwner: input.processLifetimeOwner, + // A creating snapshot already has enough durable identity to be removed; + // unlike a Session copy, it has no remote creation protocol to resume. + resumeSessionCopy: async () => {}, + removeSession: async (cleanupId) => { + const lease = decodeSnapshotCleanupId(cleanupId); + await removePersistedSnapshotStaging({ + parent: input.stagingParent, + snapshotId: lease.snapshotId, + ownerToken: lease.ownerToken, + privateRootAuthority: input.privateStagingRootAuthority, + }); + }, + }); + } + + ownCreation(lease: SessionSnapshotStagingLease, operation: () => Promise): Promise { + const normalized = normalizeSnapshotStagingLease(lease); + return this.#cleanup.ownCreation( + { + sessionId: encodeSnapshotCleanupId(normalized), + kind: 'revision', + sourceSessionId: normalized.makaSessionId, + sourceTurnId: normalized.snapshotId, + ownerId: `snapshot:${normalized.ownerToken}`, + }, + operation, + ); + } + + cleanup(lease: SessionSnapshotStagingLease): Promise { + return this.#cleanup.cleanup(encodeSnapshotCleanupId(normalizeSnapshotStagingLease(lease))); + } + + async recover(): Promise { + const recovery = await this.#cleanup.recover(); + return { + removed: recovery.removed.map((cleanupId) => decodeSnapshotCleanupId(cleanupId).snapshotId), + failed: recovery.failed.map(({ sessionId, error }) => ({ + snapshotId: decodeSnapshotCleanupId(sessionId).snapshotId, + error, + })), + }; + } +} + +export function createFileQuiescentSessionSnapshotCoordinator( + options: FileQuiescentSessionSnapshotCoordinatorOptions, +): QuiescentSessionSnapshotCoordinator { + return new FileQuiescentSessionSnapshotCoordinator(options); +} + +class FileQuiescentSessionSnapshotCoordinator implements QuiescentSessionSnapshotCoordinator { + readonly #stagingParent: string; + readonly #stagingCleanup: SessionSnapshotStagingCleanupAuthority; + readonly #quiescence: SessionSnapshotQuiescenceAuthority; + readonly #state: SessionSnapshotStatePreparer; + readonly #workspace: SessionSnapshotWorkspacePreparer; + readonly #confirmationAuthority: SessionSnapshotWorkspaceConfirmationAuthority | undefined; + readonly #privateStagingRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined; + readonly #now: () => number; + readonly #newSnapshotId: () => string; + + constructor(options: FileQuiescentSessionSnapshotCoordinatorOptions) { + if (!isAbsolute(options.stagingParent)) { + throw new TypeError('Session snapshot stagingParent must be absolute'); + } + if ('policy' in options) { + throw new TypeError('Session snapshot V1 safety policy cannot be overridden'); + } + this.#stagingParent = resolve(options.stagingParent); + if (resolve(options.stagingCleanup.stagingParent) !== this.#stagingParent) { + throw new TypeError('Session snapshot staging cleanup authority is bound to another parent'); + } + this.#stagingCleanup = options.stagingCleanup; + this.#quiescence = options.quiescence; + this.#state = options.state; + this.#workspace = options.workspace; + this.#confirmationAuthority = options.confirmationAuthority; + this.#privateStagingRootAuthority = options.privateStagingRootAuthority; + this.#now = options.now ?? Date.now; + this.#newSnapshotId = options.newSnapshotId ?? randomUUID; + } + + async prepare(input: PrepareQuiescentSessionSnapshotInput): Promise { + const makaSessionId = requireMakaSessionId(input.makaSessionId); + const confirmationGrantId = requireOptionalConfirmationGrantId(input.confirmationGrantId); + const cancellation = createCancellation(input, this.#now); + let prepared: OwnedPreparedSessionBundleHandle | undefined; + let stagingLease: SessionSnapshotStagingLease | undefined; + let stagingLeaseOwned = false; + let operationStarted = false; + try { + cancellation.assertActive(); + const result = await this.#quiescence.runQuiescent( + { makaSessionId, cancellation: cancellation.value }, + async () => { + if (operationStarted) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot quiescence operation was invoked more than once', + { details: { phase: 'admission' } }, + ); + } + operationStarted = true; + cancellation.assertActive(); + stagingLease = { + snapshotId: requireSnapshotId(this.#newSnapshotId()), + ownerToken: randomUUID(), + makaSessionId, + }; + return this.#stagingCleanup.ownCreation(stagingLease, async () => { + stagingLeaseOwned = true; + cancellation.assertActive(); + const staging = await OwnedSnapshotStaging.create( + this.#stagingParent, + stagingLease!.snapshotId, + stagingLease!.ownerToken, + this.#privateStagingRootAuthority, + ); + const stateIdentity = copyOpaqueStateIdentityDescriptor( + await this.#state.prepareState({ + makaSessionId, + destinationRoot: staging.stateRoot, + cancellation: cancellation.value, + }), + ); + cancellation.assertActive(); + await assertPreparedRoot(staging.stateRoot, 'state'); + + const workspace = normalizeWorkspacePreparation( + await this.#workspace.prepareWorkspace({ + makaSessionId, + destinationRoot: staging.workspaceRoot, + policy: SESSION_SNAPSHOT_WORKSPACE_POLICY_V1, + confirmation: createWorkspaceConfirmationResolver({ + makaSessionId, + confirmationGrantId, + authority: this.#confirmationAuthority, + cancellation: cancellation.value, + }), + cancellation: cancellation.value, + }), + ); + cancellation.assertActive(); + await assertPreparedRoot(staging.workspaceRoot, 'workspace'); + cancellation.assertActive(); + + const published = await staging.publish(); + cancellation.assertActive(); + const handle = new OwnedPreparedSessionBundleHandle( + published, + stateIdentity, + workspace, + SESSION_SNAPSHOT_POLICY_VERSION, + this.#stagingCleanup, + stagingLease!, + ); + prepared = handle; + return handle; + }); + }, + ); + cancellation.assertActive(); + if (!prepared || result !== prepared) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot quiescence operation did not return its prepared handle', + { details: { phase: 'admission' } }, + ); + } + return result; + } catch (error) { + const primaryError = normalizePreparationError(error); + if (prepared || (stagingLease && stagingLeaseOwned)) { + try { + if (prepared) await prepared.release(); + else await this.#stagingCleanup.cleanup(stagingLease!); + } catch (cleanupError) { + throw primaryErrorWithCleanupFailure(primaryError, cleanupError); + } + } + throw primaryError; + } finally { + cancellation.close(); + } + } +} + +interface SnapshotOwnerRecord { + readonly schemaVersion: typeof SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION; + readonly snapshotId: string; + readonly ownerToken: string; + readonly rootDev: string; + readonly rootIno: string; +} + +interface FilesystemIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +interface SnapshotOwnerBinding { + readonly path: string; + readonly cleanupPath: string; + readonly record: SnapshotOwnerRecord; + readonly identity: FilesystemIdentity; +} + +interface PublishedSnapshotStaging { + readonly parent: string; + readonly root: string; + readonly cleanupRoot: string; + readonly stateRoot: string; + readonly workspaceRoot: string; + readonly owner: SnapshotOwnerBinding; + readonly identity: FilesystemIdentity; +} + +class OwnedSnapshotStaging { + readonly stateRoot: string; + readonly workspaceRoot: string; + readonly #preparingRoot: string; + readonly #publishedRoot: string; + readonly #cleanupRoot: string; + readonly #ownerFile: string; + readonly #ownerCleanupFile: string; + readonly #snapshotId: string; + readonly #ownerToken: string; + #owner: SnapshotOwnerBinding | undefined; + #identity: FilesystemIdentity | undefined; + #published = false; + + private constructor(parent: string, snapshotId: string, ownerToken: string) { + this.#preparingRoot = join(parent, `.snapshot-${snapshotId}.preparing`); + this.#publishedRoot = join(parent, `snapshot-${snapshotId}`); + this.#cleanupRoot = join(parent, `.snapshot-${snapshotId}.${ownerToken}.cleanup`); + this.#ownerFile = join(parent, `.snapshot-${snapshotId}.owner.json`); + this.#ownerCleanupFile = join(parent, `.snapshot-${snapshotId}.${ownerToken}.owner-cleanup`); + this.#snapshotId = snapshotId; + this.#ownerToken = ownerToken; + this.stateRoot = join(this.#preparingRoot, 'state'); + this.workspaceRoot = join(this.#preparingRoot, 'workspace'); + } + + static async create( + parent: string, + snapshotId: string, + ownerToken: string, + privateRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined, + ): Promise { + const canonicalParent = await preparePrivateStagingParent(parent, privateRootAuthority); + const staging = new OwnedSnapshotStaging(canonicalParent, snapshotId, ownerToken); + let rootCreated = false; + try { + await assertMissing(staging.#preparingRoot); + await assertMissing(staging.#publishedRoot); + await assertMissing(staging.#cleanupRoot); + await assertMissing(staging.#ownerCleanupFile); + await mkdir(staging.#preparingRoot, { mode: 0o700 }); + rootCreated = true; + staging.#identity = await readDirectoryIdentity(staging.#preparingRoot); + try { + await verifyPrivateStagingDirectory(staging.#preparingRoot, privateRootAuthority); + } catch (verificationError) { + throw new SessionSnapshotError('unsafe_source', 'Session snapshot staging root is unsafe', { + cause: verificationError, + details: { phase: 'staging' }, + }); + } + const record: SnapshotOwnerRecord = { + schemaVersion: SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION, + snapshotId: staging.#snapshotId, + ownerToken: staging.#ownerToken, + rootDev: staging.#identity.dev.toString(), + rootIno: staging.#identity.ino.toString(), + }; + staging.#owner = await writeOwnerRecord( + staging.#ownerFile, + staging.#ownerCleanupFile, + record, + ); + return staging; + } catch (error) { + const primaryError = normalizeStagingCreationError(error); + if (rootCreated && staging.#identity) { + try { + await removeDirectoryBoundToIdentity({ + parent: canonicalParent, + root: staging.#preparingRoot, + cleanupRoot: staging.#cleanupRoot, + identity: staging.#identity, + }); + } catch (cleanupError) { + throw primaryErrorWithCleanupFailure(primaryError, cleanupError); + } + } else if (rootCreated) { + throw primaryErrorWithCleanupFailure( + primaryError, + new Error('Snapshot root identity is unavailable'), + ); + } + throw primaryError; + } + } + + async publish(): Promise { + if (this.#published) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot staging is already published', + { + details: { phase: 'publication' }, + }, + ); + } + try { + if (!this.#identity || !this.#owner) { + throw new Error('Snapshot ownership is unavailable'); + } + await assertOwnedRoot(this.#preparingRoot, this.#owner, this.#identity); + await rename(this.#preparingRoot, this.#publishedRoot); + this.#published = true; + const identity = await readDirectoryIdentity(this.#publishedRoot); + if (!sameFilesystemIdentity(identity, this.#identity)) { + throw new Error('Published root identity changed'); + } + await assertOwnerRecord(this.#owner); + return { + parent: dirname(this.#publishedRoot), + root: this.#publishedRoot, + cleanupRoot: this.#cleanupRoot, + stateRoot: join(this.#publishedRoot, 'state'), + workspaceRoot: join(this.#publishedRoot, 'workspace'), + owner: this.#owner, + identity, + }; + } catch (error) { + throw new SessionSnapshotError('io_failure', 'Unable to publish Session snapshot staging', { + cause: error, + details: { phase: 'publication' }, + }); + } + } +} + +class OwnedPreparedSessionBundleHandle implements PreparedSessionBundleHandle { + readonly snapshot: PreparedSessionBundleSnapshot; + readonly workspace: SessionSnapshotWorkspacePreparation; + readonly policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION; + readonly #stagingCleanup: SessionSnapshotStagingCleanupAuthority; + readonly #stagingLease: SessionSnapshotStagingLease; + #releaseTask: Promise | undefined; + #released = false; + + constructor( + staging: PublishedSnapshotStaging, + stateIdentity: OpaqueStateIdentityDescriptor, + workspace: SessionSnapshotWorkspacePreparation, + policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION, + stagingCleanup: SessionSnapshotStagingCleanupAuthority, + stagingLease: SessionSnapshotStagingLease, + ) { + this.#stagingCleanup = stagingCleanup; + this.#stagingLease = stagingLease; + this.snapshot = Object.freeze({ + stateRoot: staging.stateRoot, + workspaceRoot: staging.workspaceRoot, + stateIdentity: Object.freeze(copyOpaqueStateIdentityDescriptor(stateIdentity)), + }); + this.workspace = Object.freeze({ ...workspace }); + this.policyVersion = policyVersion; + } + + async release(): Promise { + if (this.#released) return; + if (this.#releaseTask) return this.#releaseTask; + const task = this.#releaseOnce(); + this.#releaseTask = task; + try { + await task; + this.#released = true; + } finally { + if (!this.#released) this.#releaseTask = undefined; + } + } + + async #releaseOnce(): Promise { + try { + await this.#stagingCleanup.cleanup(this.#stagingLease); + } catch (error) { + throw cleanupFailure(error); + } + } +} + +function decodeWorkspaceEntry(entry: SessionSnapshotWorkspaceEntry): + | { readonly kind: 'valid'; readonly segments: readonly string[]; readonly basename: string } + | { + readonly kind: 'reject'; + readonly category: 'unsafe_path' | 'unsupported_portable_path' | 'unsupported_entry'; + } { + if (entry.kind !== 'file' && entry.kind !== 'directory') { + return { kind: 'reject', category: 'unsupported_entry' }; + } + if ( + typeof entry.relativePath !== 'string' || + entry.relativePath.length === 0 || + entry.relativePath.includes('\\') || + entry.relativePath.includes('\0') || + entry.relativePath.startsWith('/') || + entry.relativePath.endsWith('/') + ) { + return { kind: 'reject', category: 'unsafe_path' }; + } + const segments = entry.relativePath.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + return { kind: 'reject', category: 'unsafe_path' }; + } + const bundlePath = `workspace/${entry.relativePath}${entry.kind === 'directory' ? '/' : ''}`; + if (!isSessionBundleUstarPathV1(bundlePath)) { + return { kind: 'reject', category: 'unsupported_portable_path' }; + } + return { kind: 'valid', segments, basename: segments.at(-1)! }; +} + +function isKnownSecretEntry( + kind: SessionSnapshotWorkspaceEntryKind, + lowerSegments: readonly string[], + lowerName: string, +): boolean { + if (lowerSegments.some((segment) => KNOWN_SECRET_WORKSPACE_DIRECTORY_NAMES.has(segment))) { + return true; + } + if (kind === 'directory') return false; + if (PUBLIC_ENV_TEMPLATE_PATTERN.test(lowerName) || lowerName.endsWith('.pub')) return false; + if (KNOWN_SECRET_WORKSPACE_FILE_PATTERNS.some((pattern) => pattern.test(lowerName))) return true; + if (lowerName === 'service-account.json' || lowerName === 'service-account-key.json') { + return true; + } + return ( + (lowerSegments.at(-2) === '.docker' && lowerName === 'config.json') || + (lowerSegments.at(-2) === '.kube' && lowerName === 'config') || + (lowerSegments.at(-2) === 'gcloud' && + lowerSegments.at(-3) === '.config' && + lowerName === 'application_default_credentials.json') + ); +} + +function findSuspectedSecretDirectoryPath( + kind: SessionSnapshotWorkspaceEntryKind, + segments: readonly string[], + lowerSegments: readonly string[], +): string | undefined { + const directorySegmentCount = + kind === 'directory' ? lowerSegments.length : lowerSegments.length - 1; + const index = lowerSegments + .slice(0, directorySegmentCount) + .findIndex((segment) => SUSPECTED_SECRET_WORKSPACE_DIRECTORY_NAMES.has(segment)); + return index < 0 ? undefined : segments.slice(0, index + 1).join('/'); +} + +function createWorkspaceConfirmationResolver(input: { + readonly makaSessionId: string; + readonly confirmationGrantId: string | undefined; + readonly authority: SessionSnapshotWorkspaceConfirmationAuthority | undefined; + readonly cancellation: SessionSnapshotCancellation; +}): SessionSnapshotWorkspaceConfirmationResolver { + const resolutions = new Map< + string, + Promise< + | { readonly kind: 'include' } + | { readonly kind: 'exclude'; readonly category: 'confirmed_secret_path' } + > + >(); + + return Object.freeze({ + resolve(entry: SessionSnapshotWorkspaceEntry) { + const decision = SESSION_SNAPSHOT_WORKSPACE_POLICY_V1.classify(entry); + if (decision.kind !== 'confirm') { + throw new TypeError('Workspace entry does not require control-plane confirmation'); + } + const key = `${decision.category}\0${decision.confirmationPath}`; + const existing = resolutions.get(key); + if (existing !== undefined) return existing; + const resolution = resolveWorkspaceConfirmation({ + ...input, + category: decision.category, + confirmationPath: decision.confirmationPath, + }); + resolutions.set(key, resolution); + return resolution; + }, + }); +} + +async function resolveWorkspaceConfirmation(input: { + readonly makaSessionId: string; + readonly confirmationGrantId: string | undefined; + readonly authority: SessionSnapshotWorkspaceConfirmationAuthority | undefined; + readonly category: SessionSnapshotWorkspaceConfirmationCategory; + readonly confirmationPath: string; + readonly cancellation: SessionSnapshotCancellation; +}): Promise< + | { readonly kind: 'include' } + | { readonly kind: 'exclude'; readonly category: 'confirmed_secret_path' } +> { + input.cancellation.signal.throwIfAborted(); + if (input.confirmationGrantId === undefined) throw confirmationRequired(input.category); + if (input.authority === undefined) throw confirmationRequired(input.category); + const resolution = await input.authority.resolveConfirmation({ + makaSessionId: input.makaSessionId, + confirmationGrantId: input.confirmationGrantId, + policyVersion: SESSION_SNAPSHOT_POLICY_VERSION, + category: input.category, + confirmationPath: input.confirmationPath, + cancellation: input.cancellation, + }); + input.cancellation.signal.throwIfAborted(); + if (resolution === undefined) throw confirmationRequired(input.category); + if (resolution.action === 'include') return INCLUDE; + if (resolution.action === 'exclude') { + return { kind: 'exclude', category: 'confirmed_secret_path' }; + } + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot control-plane confirmation is invalid', + { details: { phase: 'workspace' } }, + ); +} + +function confirmationRequired( + category: SessionSnapshotWorkspaceConfirmationCategory, +): SessionSnapshotError { + return new SessionSnapshotError( + 'policy_rejected', + 'Session snapshot requires an explicit control-plane confirmation', + { details: { phase: 'workspace', policyCategory: category } }, + ); +} + +function requireMakaSessionId(value: unknown): string { + if (typeof value !== 'string' || !isSafeSessionId(value)) { + throw new SessionSnapshotError('invalid_input', 'Maka Session identity is invalid', { + details: { phase: 'admission' }, + }); + } + return value; +} + +function requireOptionalConfirmationGrantId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !CONFIRMATION_GRANT_ID_PATTERN.test(value)) { + throw new SessionSnapshotError( + 'invalid_input', + 'Session snapshot confirmation grant identity is invalid', + { details: { phase: 'admission' } }, + ); + } + return value; +} + +function requireSnapshotId(value: unknown): string { + if (typeof value !== 'string' || !SNAPSHOT_ID_PATTERN.test(value)) { + throw new SessionSnapshotError('io_failure', 'Session snapshot identity allocation failed', { + details: { phase: 'staging' }, + }); + } + return value; +} + +function normalizeSnapshotStagingLease( + value: SessionSnapshotStagingLease, +): SessionSnapshotStagingLease { + return Object.freeze({ + snapshotId: requireSnapshotId(value.snapshotId), + ownerToken: requireSnapshotOwnerToken(value.ownerToken), + makaSessionId: requireMakaSessionId(value.makaSessionId), + }); +} + +function requireSnapshotOwnerToken(value: unknown): string { + if (typeof value !== 'string' || !SNAPSHOT_ID_PATTERN.test(value)) { + throw new SessionSnapshotError('io_failure', 'Session snapshot owner identity is invalid', { + details: { phase: 'staging' }, + }); + } + return value; +} + +function encodeSnapshotCleanupId(lease: SessionSnapshotStagingLease): string { + return `snapshot-${lease.snapshotId}-${lease.ownerToken}`; +} + +function decodeSnapshotCleanupId(cleanupId: string): { + readonly snapshotId: string; + readonly ownerToken: string; +} { + const match = SNAPSHOT_CLEANUP_ID_PATTERN.exec(cleanupId); + if (!match) throw new Error('Invalid Session snapshot cleanup identity'); + return { snapshotId: match[1]!, ownerToken: match[2]! }; +} + +function normalizeWorkspacePreparation( + value: SessionSnapshotWorkspacePreparation, +): SessionSnapshotWorkspacePreparation { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + for (const count of [value.includedEntries, value.excludedEntries, value.payloadBytes]) { + if (!Number.isSafeInteger(count) || count < 0) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + } + const excludedEntriesByCategory = normalizeExclusionCounts(value.excludedEntriesByCategory); + const categorizedExclusions = Object.values(excludedEntriesByCategory).reduce( + (total, count) => total + count, + 0, + ); + if (categorizedExclusions !== value.excludedEntries) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + return { + includedEntries: value.includedEntries, + excludedEntries: value.excludedEntries, + excludedEntriesByCategory, + payloadBytes: value.payloadBytes, + }; +} + +const WORKSPACE_EXCLUSION_CATEGORIES = [ + 'dependency_tree', + 'source_control', + 'cache', + 'log', + 'runtime_scratch', + 'confirmed_secret_path', +] as const satisfies readonly SessionSnapshotWorkspaceExclusionCategory[]; + +function normalizeExclusionCounts( + value: Readonly>, +): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + const record = value as Record; + const keys = Object.keys(record); + if ( + keys.length !== WORKSPACE_EXCLUSION_CATEGORIES.length || + keys.some( + (key) => + !WORKSPACE_EXCLUSION_CATEGORIES.includes(key as SessionSnapshotWorkspaceExclusionCategory), + ) + ) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + const normalized = Object.fromEntries( + WORKSPACE_EXCLUSION_CATEGORIES.map((category) => { + const count = record[category]; + if (!Number.isSafeInteger(count) || (count as number) < 0) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + return [category, count]; + }), + ) as Record; + return Object.freeze(normalized); +} + +function createCancellation( + input: PrepareQuiescentSessionSnapshotInput, + now: () => number, +): { + readonly value: SessionSnapshotCancellation; + assertActive(): void; + close(): void; +} { + if ( + input.deadlineAt !== undefined && + (!Number.isSafeInteger(input.deadlineAt) || input.deadlineAt < 0) + ) { + throw new SessionSnapshotError('invalid_input', 'Session snapshot deadline is invalid', { + details: { phase: 'admission' }, + }); + } + const controller = new AbortController(); + const abort = () => controller.abort(); + input.signal?.addEventListener('abort', abort, { once: true }); + let timeout: NodeJS.Timeout | undefined; + const scheduleDeadline = () => { + if (input.deadlineAt === undefined) return; + const remaining = input.deadlineAt - now(); + if (remaining <= 0) { + abort(); + return; + } + timeout = setTimeout(scheduleDeadline, Math.min(remaining, MAX_TIMER_DELAY_MS)); + timeout.unref(); + }; + const remaining = input.deadlineAt === undefined ? undefined : input.deadlineAt - now(); + if (remaining !== undefined && remaining > 0) scheduleDeadline(); + if (input.signal?.aborted || (remaining !== undefined && remaining <= 0)) abort(); + const value = Object.freeze({ + signal: controller.signal, + ...(input.deadlineAt === undefined ? {} : { deadlineAt: input.deadlineAt }), + }); + return { + value, + assertActive: () => { + if (controller.signal.aborted) { + throw new SessionSnapshotError( + 'snapshot_cancelled', + 'Session snapshot preparation was cancelled', + { details: { phase: 'admission' } }, + ); + } + }, + close: () => { + if (timeout) clearTimeout(timeout); + input.signal?.removeEventListener('abort', abort); + }, + }; +} + +async function preparePrivateStagingParent( + path: string, + authority: SessionSnapshotPrivateStagingRootAuthority | undefined, +): Promise { + try { + await mkdir(path, { recursive: true, mode: 0o700 }); + const canonical = await realpath(path); + await verifyPrivateStagingDirectory(canonical, authority); + return canonical; + } catch (error) { + throw new SessionSnapshotError('unsafe_source', 'Session snapshot staging root is unsafe', { + cause: error, + details: { phase: 'staging' }, + }); + } +} + +async function verifyPrivateStagingDirectory( + path: string, + authority: SessionSnapshotPrivateStagingRootAuthority | undefined, +): Promise { + const canonical = await realpath(path); + if (canonical !== path) throw new Error('Staging directory is not canonical'); + const info = await lstat(canonical, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Staging parent is invalid'); + if (process.platform === 'win32' && !authority) { + throw new Error('A Windows ACL verifier is required for the staging parent'); + } + if (process.platform !== 'win32') { + const permissions = Number(info.mode & 0o777n); + if ((permissions & 0o077) !== 0) { + throw new Error('Staging parent is accessible outside its owner'); + } + const currentUserId = process.getuid?.(); + if (currentUserId !== undefined && info.uid !== BigInt(currentUserId)) { + throw new Error('Staging parent has a different filesystem owner'); + } + } + if (authority) { + const verification = await authority.verifyPrivateStagingRoot({ canonicalPath: canonical }); + if ( + !verification || + typeof verification.canonicalPath !== 'string' || + verification.canonicalPath !== canonical + ) { + throw new Error('Staging parent privacy verification was bound to a different path'); + } + } +} + +async function assertMissing(path: string): Promise { + try { + await lstat(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw error; + } + throw new Error('Snapshot staging identity already exists'); +} + +async function writeOwnerRecord( + path: string, + cleanupPath: string, + record: SnapshotOwnerRecord, +): Promise { + let handle: Awaited> | undefined; + let identity: FilesystemIdentity | undefined; + let failure: unknown; + try { + handle = await open( + path, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW_OPEN_FLAG, + 0o600, + ); + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) { + throw new Error('Snapshot ownership record is not a private file'); + } + identity = { dev: info.dev, ino: info.ino }; + await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); + if (process.platform !== 'win32') await handle.chmod(0o600); + await handle.sync(); + } catch (error) { + failure = error; + } + if (handle) { + try { + await handle.close(); + } catch (error) { + failure = failure === undefined ? error : new AggregateError([failure, error]); + } + } + if (!identity) { + if (failure !== undefined) throw failure; + throw new Error('Snapshot ownership record identity is unavailable'); + } + const binding = { path, cleanupPath, record, identity }; + if (failure === undefined) { + try { + await assertOwnerRecord(binding); + return binding; + } catch (error) { + failure = error; + } + } + try { + await removeFileBoundToIdentity({ + parent: dirname(path), + root: path, + cleanupRoot: cleanupPath, + identity, + }); + } catch (cleanupError) { + const primaryError = new SessionSnapshotError( + 'io_failure', + 'Unable to write Session snapshot ownership record', + { cause: failure, details: { phase: 'staging' } }, + ); + throw primaryErrorWithCleanupFailure(primaryError, cleanupError); + } + throw failure; +} + +async function assertPreparedRoot(path: string, label: 'state' | 'workspace'): Promise { + let info; + try { + info = await lstat(path, { bigint: true }); + } catch (error) { + throw new SessionSnapshotError('io_failure', `Prepared ${label} snapshot is unavailable`, { + cause: error, + details: { phase: label }, + }); + } + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', `Prepared ${label} snapshot is unsafe`, { + details: { phase: label }, + }); + } +} + +async function assertOwnerRecord( + binding: SnapshotOwnerBinding, + path = binding.path, +): Promise { + const handle = await open(path, fsConstants.O_RDONLY | NO_FOLLOW_OPEN_FLAG); + try { + const info = await handle.stat({ bigint: true }); + if ( + !info.isFile() || + info.isSymbolicLink() || + info.nlink !== 1n || + info.size > BigInt(MAX_OWNER_RECORD_BYTES) || + !sameFilesystemIdentity({ dev: info.dev, ino: info.ino }, binding.identity) + ) { + throw new Error('Snapshot ownership record is invalid'); + } + const value = JSON.parse(await handle.readFile('utf8')) as unknown; + if (!sameOwnerRecord(value, binding.record)) { + throw new Error('Snapshot ownership record changed'); + } + } finally { + await handle.close(); + } + const pathIdentity = await readFileIdentity(path); + if (!sameFilesystemIdentity(pathIdentity, binding.identity)) { + throw new Error('Snapshot ownership record path changed'); + } +} + +async function assertOwnedRoot( + root: string, + owner: SnapshotOwnerBinding, + identity: FilesystemIdentity, +): Promise { + const actual = await readDirectoryIdentity(root); + if (!sameFilesystemIdentity(actual, identity)) { + throw new Error('Published Session snapshot root identity changed'); + } + if ( + owner.record.rootDev !== identity.dev.toString() || + owner.record.rootIno !== identity.ino.toString() + ) { + throw new Error('Snapshot ownership record is bound to another root'); + } + await assertOwnerRecord(owner); +} + +async function removeOwnedSnapshotDirectory(input: { + parent: string; + root: string; + cleanupRoot: string; + owner: SnapshotOwnerBinding; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + dirname(input.owner.path) !== input.parent || + dirname(input.owner.cleanupPath) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalDirectoryIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalDirectoryIdentity(input.root); + if (!cleanupIdentity && !rootIdentity) { + await removeOwnerRecord(input.owner); + return; + } + await assertOwnerRecord(input.owner); + if (cleanupIdentity) { + if (rootIdentity) { + throw new Error('Session snapshot cleanup paths conflict'); + } + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await assertOwnedRoot(input.cleanupRoot, input.owner, input.identity); + await rm(input.cleanupRoot, { recursive: true, force: false }); + await removeOwnerRecord(input.owner); + return; + } + await assertOwnedRoot(input.root, input.owner, input.identity); + await rename(input.root, input.cleanupRoot); + await assertOwnedRoot(input.cleanupRoot, input.owner, input.identity); + await rm(input.cleanupRoot, { recursive: true, force: false }); + await removeOwnerRecord(input.owner); +} + +async function removePersistedSnapshotStaging(input: { + parent: string; + snapshotId: string; + ownerToken: string; + privateRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined; +}): Promise { + const parent = await preparePrivateStagingParent(input.parent, input.privateRootAuthority); + const preparingRoot = join(parent, `.snapshot-${input.snapshotId}.preparing`); + const publishedRoot = join(parent, `snapshot-${input.snapshotId}`); + const cleanupRoot = join(parent, `.snapshot-${input.snapshotId}.${input.ownerToken}.cleanup`); + const owner = await readPersistedSnapshotOwnerBinding({ + parent, + snapshotId: input.snapshotId, + ownerToken: input.ownerToken, + }); + const [preparingIdentity, publishedIdentity, cleanupIdentity] = await Promise.all([ + readOptionalDirectoryIdentity(preparingRoot), + readOptionalDirectoryIdentity(publishedRoot), + readOptionalDirectoryIdentity(cleanupRoot), + ]); + + if (preparingIdentity && publishedIdentity) { + throw new Error('Session snapshot staging has conflicting preparation and publication roots'); + } + if (!owner) { + if (publishedIdentity) { + throw new Error('Published Session snapshot has no ownership record'); + } + if (preparingIdentity && cleanupIdentity) { + throw new Error('Unbound Session snapshot cleanup paths conflict'); + } + const identity = preparingIdentity ?? cleanupIdentity; + if (!identity) return; + // The persisted ProcessLifetimeOwner lease authenticates this exact UUID + // during the small create-to-owner-record crash window. + await removeDirectoryBoundToIdentity({ + parent, + root: preparingRoot, + cleanupRoot, + identity, + }); + return; + } + + const identity = snapshotRootIdentity(owner.record); + await removeOwnedSnapshotDirectory({ + parent, + root: preparingIdentity ? preparingRoot : publishedIdentity ? publishedRoot : preparingRoot, + cleanupRoot, + owner, + identity, + }); +} + +async function readPersistedSnapshotOwnerBinding(input: { + parent: string; + snapshotId: string; + ownerToken: string; +}): Promise { + const path = join(input.parent, `.snapshot-${input.snapshotId}.owner.json`); + const cleanupPath = join( + input.parent, + `.snapshot-${input.snapshotId}.${input.ownerToken}.owner-cleanup`, + ); + const [pathIdentity, cleanupIdentity] = await Promise.all([ + readOptionalFileIdentity(path), + readOptionalFileIdentity(cleanupPath), + ]); + if (pathIdentity && cleanupIdentity) { + throw new Error('Snapshot ownership cleanup paths conflict'); + } + const identity = pathIdentity ?? cleanupIdentity; + if (!identity) return undefined; + const actualPath = pathIdentity ? path : cleanupPath; + const handle = await open(actualPath, fsConstants.O_RDONLY | NO_FOLLOW_OPEN_FLAG); + let record: SnapshotOwnerRecord; + try { + const info = await handle.stat({ bigint: true }); + if ( + !info.isFile() || + info.isSymbolicLink() || + info.nlink !== 1n || + info.size > BigInt(MAX_OWNER_RECORD_BYTES) || + !sameFilesystemIdentity({ dev: info.dev, ino: info.ino }, identity) + ) { + throw new Error('Snapshot ownership record is invalid'); + } + const value = JSON.parse(await handle.readFile('utf8')) as unknown; + if (!isSnapshotOwnerRecord(value, input.snapshotId, input.ownerToken)) { + throw new Error('Snapshot ownership record changed'); + } + record = value; + } finally { + await handle.close(); + } + const currentIdentity = await readFileIdentity(actualPath); + if (!sameFilesystemIdentity(currentIdentity, identity)) { + throw new Error('Snapshot ownership record path changed'); + } + return { path, cleanupPath, record, identity }; +} + +function isSnapshotOwnerRecord( + value: unknown, + snapshotId: string, + ownerToken: string, +): value is SnapshotOwnerRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + Object.keys(record).length === 5 && + record.schemaVersion === SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION && + record.snapshotId === snapshotId && + record.ownerToken === ownerToken && + typeof record.rootDev === 'string' && + /^(?:0|[1-9][0-9]*)$/u.test(record.rootDev) && + typeof record.rootIno === 'string' && + /^(?:0|[1-9][0-9]*)$/u.test(record.rootIno) + ); +} + +function snapshotRootIdentity(record: SnapshotOwnerRecord): FilesystemIdentity { + return { dev: BigInt(record.rootDev), ino: BigInt(record.rootIno) }; +} + +async function removeOwnerRecord(owner: SnapshotOwnerBinding): Promise { + const cleanupIdentity = await readOptionalFileIdentity(owner.cleanupPath); + const ownerIdentity = await readOptionalFileIdentity(owner.path); + if (cleanupIdentity) { + if (ownerIdentity) throw new Error('Snapshot ownership cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, owner.identity)) { + throw new Error('Snapshot ownership cleanup file changed'); + } + await assertOwnerRecord(owner, owner.cleanupPath); + await rm(owner.cleanupPath, { force: false }); + return; + } + if (!ownerIdentity) return; + if (!sameFilesystemIdentity(ownerIdentity, owner.identity)) { + throw new Error('Snapshot ownership record path changed'); + } + await assertOwnerRecord(owner); + await rename(owner.path, owner.cleanupPath); + await assertOwnerRecord(owner, owner.cleanupPath); + await rm(owner.cleanupPath, { force: false }); +} + +async function removeDirectoryBoundToIdentity(input: { + parent: string; + root: string; + cleanupRoot: string; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalDirectoryIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalDirectoryIdentity(input.root); + if (cleanupIdentity) { + if (rootIdentity) throw new Error('Session snapshot cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await rm(input.cleanupRoot, { recursive: true, force: false }); + return; + } + if (!rootIdentity) return; + if (!sameFilesystemIdentity(rootIdentity, input.identity)) { + throw new Error('Session snapshot root identity changed'); + } + await rename(input.root, input.cleanupRoot); + const renamedIdentity = await readDirectoryIdentity(input.cleanupRoot); + if (!sameFilesystemIdentity(renamedIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await rm(input.cleanupRoot, { recursive: true, force: false }); +} + +async function removeFileBoundToIdentity(input: { + parent: string; + root: string; + cleanupRoot: string; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot file cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalFileIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalFileIdentity(input.root); + if (cleanupIdentity) { + if (rootIdentity) throw new Error('Session snapshot file cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot file cleanup identity changed'); + } + await rm(input.cleanupRoot, { force: false }); + return; + } + if (!rootIdentity) return; + if (!sameFilesystemIdentity(rootIdentity, input.identity)) { + throw new Error('Session snapshot file identity changed'); + } + await rename(input.root, input.cleanupRoot); + const renamedIdentity = await readFileIdentity(input.cleanupRoot); + if (!sameFilesystemIdentity(renamedIdentity, input.identity)) { + throw new Error('Session snapshot file cleanup identity changed'); + } + await rm(input.cleanupRoot, { force: false }); +} + +async function readOptionalDirectoryIdentity( + path: string, +): Promise { + try { + return await readDirectoryIdentity(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function readOptionalFileIdentity(path: string): Promise { + try { + return await readFileIdentity(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function readDirectoryIdentity(path: string): Promise { + const info = await lstat(path, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Session snapshot root is not a directory'); + } + return { dev: info.dev, ino: info.ino }; +} + +async function readFileIdentity(path: string): Promise { + const info = await lstat(path, { bigint: true }); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) { + throw new Error('Session snapshot ownership record is not a private file'); + } + return { dev: info.dev, ino: info.ino }; +} + +function sameFilesystemIdentity(left: FilesystemIdentity, right: FilesystemIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function sameOwnerRecord(value: unknown, expected: SnapshotOwnerRecord): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + Object.keys(record).length === 5 && + record.schemaVersion === expected.schemaVersion && + record.snapshotId === expected.snapshotId && + record.ownerToken === expected.ownerToken && + record.rootDev === expected.rootDev && + record.rootIno === expected.rootIno + ); +} + +function normalizePreparationError(error: unknown): SessionSnapshotError { + if (error instanceof SessionSnapshotError) return error; + if (isAbortError(error)) { + return new SessionSnapshotError( + 'snapshot_cancelled', + 'Session snapshot preparation was cancelled', + { details: { phase: 'admission' } }, + ); + } + return new SessionSnapshotError('io_failure', 'Session snapshot preparation failed', { + cause: error, + }); +} + +function normalizeStagingCreationError(error: unknown): SessionSnapshotError { + if (error instanceof SessionSnapshotError) return error; + return new SessionSnapshotError('io_failure', 'Unable to create Session snapshot staging', { + cause: error, + details: { phase: 'staging' }, + }); +} + +function primaryErrorWithCleanupFailure( + primaryError: SessionSnapshotError, + cleanupError: unknown, +): SessionSnapshotError { + return new SessionSnapshotError(primaryError.code, primaryError.message, { + cause: new AggregateError([primaryError, cleanupError]), + details: { ...primaryError.details, cleanupFailed: true }, + }); +} + +function cleanupFailure(cause: unknown): SessionSnapshotError { + return new SessionSnapshotError('cleanup_failed', 'Session snapshot cleanup failed', { + cause, + details: { phase: 'cleanup' }, + }); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b495c5b6637efac2e6b6d291181dedf4e9d3407e23bfdcacf6d4d0017ac612c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b495c5b6637efac2e6b6d291181dedf4e9d3407e23bfdcacf6d4d0017ac612c.source new file mode 100644 index 0000000000..07f80adaed --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7b495c5b6637efac2e6b6d291181dedf4e9d3407e23bfdcacf6d4d0017ac612c.source @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { resolveStorageRoot, tryAcquireStateRootOwner } from '../root-authority.js'; +import { + bindStateRootComposition, + STATE_ROOT_COMPOSITION_FILE, + StateRootCompositionError, +} from '../state-root-composition.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +test('State Root composition binding is durable, idempotent, and exclusive', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-state-root-composition-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireStateRootOwner(capability); + assert.ok(owner); + try { + await bindStateRootComposition(owner.lease, 'maka.interactive'); + const first = await readFile(join(root, STATE_ROOT_COMPOSITION_FILE), 'utf8'); + await bindStateRootComposition(owner.lease, 'maka.interactive'); + assert.equal(await readFile(join(root, STATE_ROOT_COMPOSITION_FILE), 'utf8'), first); + await assert.rejects( + () => bindStateRootComposition(owner.lease, 'maka.batch-test'), + (error: unknown) => + error instanceof StateRootCompositionError && error.code === 'composition_mismatch', + ); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('State Root composition binding rejects a corrupt existing authority record', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-state-root-composition-corrupt-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireStateRootOwner(capability); + assert.ok(owner); + try { + await writeFile( + join(root, STATE_ROOT_COMPOSITION_FILE), + '{"schemaVersion":1,"compositionId":"maka.interactive","extra":true}\n', + ); + await assert.rejects( + () => bindStateRootComposition(owner.lease, 'maka.interactive'), + (error: unknown) => + error instanceof StateRootCompositionError && error.code === 'invalid_composition', + ); + } finally { + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7bbbf4ea0188ff2e1977dcf6667bc8bfadf6bc82e837086cc3756eac41e6ac41.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7bbbf4ea0188ff2e1977dcf6667bc8bfadf6bc82e837086cc3756eac41e6ac41.source new file mode 100644 index 0000000000..d82a0a7f24 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7bbbf4ea0188ff2e1977dcf6667bc8bfadf6bc82e837086cc3756eac41e6ac41.source @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import type { ContextOffloadLimits } from '@maka/core/context-offload'; +import { + authenticateInteractiveContextOffloadReader, + authenticateInteractiveContextOffloadWriter, + createInteractiveContextOffloadReader, + openInteractiveContextOffloadStoreForWrite, + type InteractiveContextOffloadReader, + type InteractiveContextOffloadWriter, +} from '../context-offload-store.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + type InteractiveRootOwner, + type StorageRootLease, +} from '../root-authority.js'; +import { CONTEXT_OFFLOAD_DATABASE_NAME } from '../sqlite-context-offload-store.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('requires authentic Storage Root leases and writer facades', async () => { + await assert.rejects( + () => + openInteractiveContextOffloadStoreForWrite({} as StorageRootLease<'interactive', 'write'>, { + limits: testLimits(), + }), + invalidLease, + ); + assert.throws( + () => authenticateInteractiveContextOffloadWriter({} as InteractiveContextOffloadWriter), + invalidLease, + ); + assert.throws( + () => authenticateInteractiveContextOffloadReader({} as InteractiveContextOffloadReader), + invalidLease, + ); +}); + +test('single-flights one limit-bound writer and snapshots admitted inputs', async () => { + await withInteractiveOwner(async (owner, root) => { + const mutableLimits = testLimits(); + const opening = openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: mutableLimits, + }); + const concurrentOpening = openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: testLimits(), + }); + const conflictingOpening = assert.rejects( + openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: { ...testLimits(), workspacePhysicalBytes: 63 }, + }), + /different limits/u, + ); + (mutableLimits.ownerMaxBytes as { tool_result_archive: number }).tool_result_archive = 0; + (mutableLimits as { sessionLogicalBytes: number }).sessionLogicalBytes = 0; + const [first, second] = await Promise.all([opening, concurrentOpening]); + try { + await conflictingOpening; + assert.strictEqual(second, first); + assert.strictEqual(authenticateInteractiveContextOffloadWriter(first), first); + const reader = createInteractiveContextOffloadReader(first); + assert.strictEqual(createInteractiveContextOffloadReader(first), reader); + assert.strictEqual(authenticateInteractiveContextOffloadReader(reader), reader); + assert.deepEqual(Object.keys(reader).sort(), ['access', 'kind', 'read']); + assert.equal((await stat(join(root, CONTEXT_OFFLOAD_DATABASE_NAME))).isFile(), true); + + const bytes = new TextEncoder().encode('safe'); + const input = { + sessionId: 'source', + owner: { kind: 'tool_result_archive' as const, ownerId: 'source-owner' }, + bytes, + mediaType: 'application/json', + }; + const putting = first.put(input); + input.sessionId = 'mutated'; + input.owner.ownerId = 'mutated-owner'; + input.mediaType = 'text/plain'; + bytes.fill(0x78); + const stored = await putting; + assert.equal(stored.ok, true); + if (!stored.ok) return; + assert.equal(stored.record.sessionId, 'source'); + assert.equal(stored.record.owner.ownerId, 'source-owner'); + assert.equal(stored.record.mediaType, 'application/json'); + assert.deepEqual( + await reader.read({ sessionId: 'source', refId: stored.record.refId, maxBytes: 64 }), + { ok: true, record: stored.record, bytes: new TextEncoder().encode('safe') }, + ); + + const copyInput = { + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: stored.record.refId, + targetOwner: { kind: 'tool_result_archive' as const, ownerId: 'target-owner' }, + }, + ], + }; + const copying = first.copyReferences(copyInput); + copyInput.targetSessionId = 'mutated-target'; + copyInput.references[0]!.targetOwner.ownerId = 'mutated-target-owner'; + const copied = await copying; + assert.equal(copied.ok, true); + if (!copied.ok) return; + assert.deepEqual( + await first.copyReferences({ + sourceSessionId: 'source', + targetSessionId: 'target', + references: [ + { + sourceRefId: stored.record.refId, + targetOwner: { kind: 'tool_result_archive', ownerId: 'target-owner' }, + }, + ], + }), + copied, + ); + } finally { + await first.close(); + } + }); +}); + +test('close drains admitted work, revokes the facade, and permits a clean reopen', async () => { + await withInteractiveOwner(async (owner) => { + const writer = await openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: testLimits(), + }); + const admitted = writer.put({ + sessionId: 'session-1', + owner: { kind: 'read_image_snapshot', ownerId: 'read-1' }, + bytes: new TextEncoder().encode('image'), + mediaType: 'image/png', + }); + const reader = createInteractiveContextOffloadReader(writer); + const closing = writer.close(); + const reopening = openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: testLimits(), + }); + assert.equal((await admitted).ok, true); + await closing; + await assert.rejects(writer.usage(), invalidLease); + assert.throws(() => authenticateInteractiveContextOffloadWriter(writer), invalidLease); + assert.throws(() => authenticateInteractiveContextOffloadReader(reader), invalidLease); + await assert.rejects( + reader.read({ sessionId: 'session-1', refId: 'ref-1', maxBytes: 64 }), + invalidLease, + ); + + const reopened = await reopening; + try { + assert.notStrictEqual(reopened, writer); + assert.deepEqual(await reopened.usage('session-1'), { + references: 1, + logicalBytes: 5, + physicalBytes: 5, + }); + } finally { + await reopened.close(); + } + }); +}); + +test('root-owner close revokes new context-offload operations', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-authority-revoke-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveContextOffloadStoreForWrite(owner.lease, { + limits: testLimits(), + }); + try { + await owner.close(); + await assert.rejects(writer.usage(), invalidLease); + } finally { + await writer.close(); + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +function testLimits(): ContextOffloadLimits { + return { + ownerMaxBytes: { + read_image_snapshot: 64, + tool_result_archive: 64, + }, + sessionLogicalBytes: 64, + workspacePhysicalBytes: 64, + }; +} + +function invalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} + +async function withInteractiveOwner( + run: (owner: InteractiveRootOwner, root: string) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-context-offload-authority-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await run(owner, root); + } finally { + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7cd049534cc371d4d3a5e300353f241ca8b0c8fab6ed8c39dd028d80e42bb954.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7cd049534cc371d4d3a5e300353f241ca8b0c8fab6ed8c39dd028d80e42bb954.source new file mode 100644 index 0000000000..41d537b324 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7cd049534cc371d4d3a5e300353f241ca8b0c8fab6ed8c39dd028d80e42bb954.source @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION, + AgentGraphScheduleClosedError, + AgentGraphScheduleRevisionConflictError, + type AgentGraphScheduleUpdateRequest, +} from '@maka/core/agent-graph-schedule'; +import { + AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, + type AgentGraphIntentClaimRequest, +} from '@maka/core/agent-graph-control'; +import { + AgentGraphScheduleUpdateConflictError, + createSqliteSessionMetadataStore, +} from '../sqlite-session-metadata-store.js'; + +describe('SQLite agent graph schedule updates', () => { + test('commits ordered idempotent updates and closes the schedule atomically', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNumber(40) }); + try { + const first = await store.commitAgentGraphScheduleUpdate(request()); + const retry = await store.commitAgentGraphScheduleUpdate(request()); + const finish = await store.commitAgentGraphScheduleUpdate( + request({ + updateId: `graph_update_${'d'.repeat(32)}`, + updateFingerprint: `sha256:${'e'.repeat(64)}`, + source: { + sessionId: 'session-main', + runId: 'run-main', + turnId: 'turn-main', + toolCallId: 'tool-finish', + }, + addWork: [], + stop: [{ targetId: first.update.addWork[0]!.workId, reason: 'evidence is sufficient' }], + finish: { resultIds: ['result-1'], reason: 'accept the verified result' }, + }), + ); + + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.deepEqual(retry.update, first.update); + assert.equal(first.update.revision, 1); + assert.equal(finish.update.revision, 2); + assert.deepEqual(await store.listAgentGraphScheduleUpdates('graph-1'), [ + first.update, + finish.update, + ]); + await assert.rejects( + store.commitAgentGraphScheduleUpdate( + request({ + updateId: `graph_update_${'f'.repeat(32)}`, + updateFingerprint: `sha256:${'1'.repeat(64)}`, + source: { + sessionId: 'session-main', + runId: 'run-main', + turnId: 'turn-later', + toolCallId: 'tool-later', + }, + }), + ), + /already finished/, + ); + } finally { + store.close(); + } + }); + + test('rejects tool-call and update identities reused for different work', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.commitAgentGraphScheduleUpdate(request()); + await assert.rejects( + store.commitAgentGraphScheduleUpdate( + request({ + updateFingerprint: `sha256:${'9'.repeat(64)}`, + addWork: [ + { + ...request().addWork[0]!, + instruction: 'Perform different work.', + }, + ], + }), + ), + AgentGraphScheduleUpdateConflictError, + ); + await assert.rejects( + store.commitAgentGraphScheduleUpdate( + request({ + updateId: `graph_update_${'7'.repeat(32)}`, + updateFingerprint: `sha256:${'8'.repeat(64)}`, + }), + ), + AgentGraphScheduleUpdateConflictError, + ); + assert.equal((await store.listAgentGraphScheduleUpdates('graph-1')).length, 1); + } finally { + store.close(); + } + }); + + test('rolls back an update when the transaction fails before commit', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint(point) { + if (point === 'after_agent_graph_schedule_update_write') throw new Error('crash'); + }, + }); + try { + await assert.rejects(store.commitAgentGraphScheduleUpdate(request()), /crash/); + assert.deepEqual(await store.listAgentGraphScheduleUpdates('graph-1'), []); + } finally { + store.close(); + } + }); + + test('replays the same schedule after reopening the workspace database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-agent-graph-schedule-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path, { now: () => 77 }); + const committed = await store.commitAgentGraphScheduleUpdate(request()); + store.close(); + + const reopened = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual(await reopened.listAgentGraphScheduleUpdates('graph-1'), [ + committed.update, + ]); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('linearizes scheduled admission against revision changes and closure', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNumber(90) }); + try { + await store.commitAgentGraphScheduleUpdate(request()); + const first = await store.claimAgentGraphIntentAtScheduleRevision(claimRequest(), 1); + assert.equal(first.created, true); + + await store.commitAgentGraphScheduleUpdate( + request({ + updateId: `graph_update_${'d'.repeat(32)}`, + updateFingerprint: `sha256:${'e'.repeat(64)}`, + source: { + sessionId: 'session-main', + runId: 'run-main', + turnId: 'turn-finish', + toolCallId: 'tool-finish', + }, + addWork: [], + stop: [], + finish: { resultIds: ['result-1'], reason: 'accept the result' }, + }), + ); + + await assert.rejects( + store.claimAgentGraphIntentAtScheduleRevision( + { + ...claimRequest(), + claimId: `graph_claim_${'d'.repeat(32)}`, + intentId: `graph_intent_${'e'.repeat(32)}`, + targetTurnId: 'turn-2', + targetRunId: 'run-2', + }, + 1, + ), + (error: unknown) => + error instanceof AgentGraphScheduleRevisionConflictError && error.currentRevision === 2, + ); + assert.equal( + ( + await store.claimAgentGraphIntentAtScheduleRevision( + { ...claimRequest(), targetTurnId: 'discarded', targetRunId: 'discarded' }, + 2, + ) + ).created, + false, + ); + await assert.rejects( + store.claimAgentGraphIntentAtScheduleRevision( + { + ...claimRequest(), + claimId: `graph_claim_${'d'.repeat(32)}`, + intentId: `graph_intent_${'e'.repeat(32)}`, + targetTurnId: 'turn-2', + targetRunId: 'run-2', + }, + 2, + ), + AgentGraphScheduleClosedError, + ); + assert.equal((await store.listAgentGraphIntentClaims('graph-1')).length, 1); + } finally { + store.close(); + } + }); +}); + +function request( + overrides: Partial = {}, +): AgentGraphScheduleUpdateRequest { + return { + schemaVersion: AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION, + updateId: `graph_update_${'a'.repeat(32)}`, + updateFingerprint: `sha256:${'b'.repeat(64)}`, + graphId: 'graph-1', + source: { + sessionId: 'session-main', + runId: 'run-main', + turnId: 'turn-main', + toolCallId: 'tool-main', + }, + addWork: [ + { + workId: `graph_work_${'c'.repeat(32)}`, + target: { kind: 'agent', agentId: 'fact-checker' }, + instruction: 'Verify the selected evidence.', + inputIds: ['result-1'], + }, + ], + stop: [], + ...overrides, + }; +} + +function nextNumber(start: number): () => number { + let value = start; + return () => value++; +} + +function claimRequest(): AgentGraphIntentClaimRequest { + return { + schemaVersion: AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, + claimId: `graph_claim_${'a'.repeat(32)}`, + graphId: 'graph-1', + intentId: `graph_intent_${'b'.repeat(32)}`, + intentFingerprint: `sha256:${'c'.repeat(64)}`, + readinessContextFingerprint: `sha256:${'d'.repeat(64)}`, + targetOperatorId: 'writer', + targetSessionId: 'session-writer', + targetTurnId: 'turn-1', + targetRunId: 'run-1', + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7d25e909912fd4eda8f6d9cd79441a46b02ef360bd1e34b3b075cf6d75275010.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7d25e909912fd4eda8f6d9cd79441a46b02ef360bd1e34b3b075cf6d75275010.source new file mode 100644 index 0000000000..afb600aa36 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7d25e909912fd4eda8f6d9cd79441a46b02ef360bd1e34b3b075cf6d75275010.source @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { ModelFactsDocumentOwner } from '../model-facts-store.js'; +import { cleanupRuntimePolicyDocumentTemps } from '../runtime-policy/document-io.js'; + +test('model facts persist and malformed documents fail closed with a bounded diagnostic', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-')); + try { + const owner = new ModelFactsDocumentOwner(); + assert.deepEqual((await owner.readWithDiagnostics(root)).document.overrides, {}); + await writeFile(join(root, 'model-facts.json'), '{not-json}', 'utf8'); + const result = await owner.readWithDiagnostics(root); + assert.equal(result.diagnostic, 'malformed'); + assert.deepEqual(result.document.overrides, {}); + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ schemaVersion: 1, overrides: { 'openai:o4-mini': { unknown: true } } }), + 'utf8', + ); + assert.equal((await owner.readWithDiagnostics(root)).diagnostic, 'malformed'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('model facts temporary writes are removed by runtime policy recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-recovery-')); + try { + await writeFile( + join(root, 'model-facts.json.00000000-0000-4000-8000-000000000000.tmp'), + '{}', + 'utf8', + ); + await cleanupRuntimePolicyDocumentTemps(root); + assert.deepEqual(await readdir(root), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('future model facts schemas fail closed without rewriting the document', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-future-')); + try { + const owner = new ModelFactsDocumentOwner(); + const future = JSON.stringify({ + schemaVersion: 2, + overrides: { 'openai:o4-mini': { contextWindow: 1 } }, + }); + await writeFile(join(root, 'model-facts.json'), future, 'utf8'); + const read = await owner.readWithDiagnostics(root); + assert.equal(read.diagnostic, 'unsupported_schema'); + assert.equal(await readFile(join(root, 'model-facts.json'), 'utf8'), future); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7e4483722c7538035f9d729f2cdb37cd276b7661c722ea1392378f5eb6ef9bb1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7e4483722c7538035f9d729f2cdb37cd276b7661c722ea1392378f5eb6ef9bb1.source new file mode 100644 index 0000000000..8409ea104e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7e4483722c7538035f9d729f2cdb37cd276b7661c722ea1392378f5eb6ef9bb1.source @@ -0,0 +1,689 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { + compareScheduledTasksForList, + computeNextFireAt, + decodePersistedScheduledTask, + isScheduledTaskDue, + nextScheduledTaskStateAfterFire, + normalizeCreateScheduledTaskInput, + normalizeUpdateScheduledTaskInput, + pauseScheduledTask, + resumeScheduledTask, + SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS, + type ScheduledTask, + type ScheduledTaskRun, + type ScheduledTaskSchedule, +} from '@maka/core/scheduled-task'; +import { markPersisted } from '@maka/core/persisted-value'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; + +const writerBrand: unique symbol = Symbol('InteractiveScheduledTaskStoreWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export type ScheduledTaskStoreErrorCode = 'invalid_input' | 'not_found' | 'operation_conflict'; + +export class ScheduledTaskStoreError extends Error { + constructor( + readonly code: ScheduledTaskStoreErrorCode, + message: string, + ) { + super(message); + this.name = 'ScheduledTaskStoreError'; + } +} + +interface ScheduledTaskStore { + list(): Promise; + get(id: string): Promise; + create(input: unknown, now?: number): Promise; + update(id: string, patch: unknown, now?: number): Promise; + pause(id: string, now?: number): Promise; + resume(id: string, now?: number): Promise; + snooze(id: string, delayMs: number, now?: number): Promise; + clearRunHistory(id: string, now?: number): Promise; + remove(id: string): Promise; + claimNextDue(now?: number): Promise; + claimNow(id: string, now?: number): Promise; + listPendingFires(): Promise; + bindFireExecution( + claimId: string, + execution: ScheduledTaskFireExecution, + ): Promise; + setFireNativeState( + claimId: string, + state: ScheduledTaskNativeFireState, + ): Promise; + cancelWaitingNativeFire(taskId: string): Promise; + settleFire( + claimId: string, + run: Omit & { id?: string }, + ): Promise; + ready(): Promise; + close(): void; +} + +export interface ScheduledTaskFireClaim { + id: string; + taskId: string; + scheduledFor: number; + claimedAt: number; + task: ScheduledTask; + execution?: ScheduledTaskFireExecution; + nativeState?: ScheduledTaskNativeFireState; +} + +export interface ScheduledTaskDueScan { + readonly claim: ScheduledTaskFireClaim | null; + readonly expired: readonly ScheduledTask[]; +} + +export type ScheduledTaskNativeFireState = 'waiting_for_provider' | 'invoking'; + +export interface ScheduledTaskFireExecution { + sessionId: string; + turnId: string; + runId: string; + userMessageId: string; +} + +export interface InteractiveScheduledTaskStoreWriter extends ScheduledTaskStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; +} + +export function authenticateInteractiveScheduledTaskStoreWriter( + writer: InteractiveScheduledTaskStoreWriter, +): InteractiveScheduledTaskStoreWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive ScheduledTask Store writer', + ); + } + return writer; +} + +export async function openInteractiveScheduledTaskStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + const pending = Promise.resolve().then(async () => { + let store: ScheduledTaskStore | undefined; + try { + store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const opened = new SqliteScheduledTaskStore(root); + await opened.ready(); + return opened; + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const raced = writerByLease.get(lease); + if (raced) { + store.close(); + return raced; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: ScheduledTaskStore, +): InteractiveScheduledTaskStoreWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'ScheduledTask Store writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractiveScheduledTaskStoreWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + list: () => run(() => store.list()), + get: (id) => run(() => store.get(id)), + create: (input, now) => run(() => store.create(input, now)), + update: (id, patch, now) => run(() => store.update(id, patch, now)), + pause: (id, now) => run(() => store.pause(id, now)), + resume: (id, now) => run(() => store.resume(id, now)), + snooze: (id, delayMs, now) => run(() => store.snooze(id, delayMs, now)), + clearRunHistory: (id, now) => run(() => store.clearRunHistory(id, now)), + remove: (id) => run(() => store.remove(id)), + claimNextDue: (now) => run(() => store.claimNextDue(now)), + claimNow: (id, now) => run(() => store.claimNow(id, now)), + listPendingFires: () => run(() => store.listPendingFires()), + bindFireExecution: (claimId, execution) => + run(() => store.bindFireExecution(claimId, execution)), + setFireNativeState: (claimId, state) => run(() => store.setFireNativeState(claimId, state)), + cancelWaitingNativeFire: (taskId) => run(() => store.cancelWaitingNativeFire(taskId)), + settleFire: (claimId, record) => run(() => store.settleFire(claimId, record)), + ready: () => run(() => store.ready()), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + return Object.freeze(writer); +} + +class SqliteScheduledTaskStore implements ScheduledTaskStore { + readonly #lease: OperationalStateDatabaseLease; + private queue: Promise = Promise.resolve(); + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + } + + ready(): Promise { + return Promise.resolve(); + } + + close(): void { + this.#lease.close(); + } + + async list(): Promise { + return this.readTasks().sort(compareScheduledTasksForList); + } + + async get(id: string): Promise { + return this.readTask(id); + } + + async create(input: unknown, now = Date.now()): Promise { + const normalized = normalizeCreateScheduledTaskInput(input, now); + if (!normalized.ok) throw storeError('invalid_input', normalized.message); + const value = normalized.value; + const task: ScheduledTask = { + id: randomUUID(), + title: value.title, + intent: { kind: 'text', body: value.intentBody }, + schedule: value.schedule, + effect: value.effect, + status: 'active', + nextFireAt: value.nextFireAt, + lastFireAt: null, + fireCount: 0, + maxFires: value.maxFires ?? null, + expiresAt: value.expiresAt ?? null, + createdBy: value.createdBy, + createdAt: now, + updatedAt: now, + runs: [], + lastError: null, + }; + await this.enqueueWrite(() => { + this.#lease.database + .prepare(` + INSERT INTO workflow_scheduled_tasks(task_id, created_at, updated_at, record_json) + VALUES (?, ?, ?, ?) + `) + .run(task.id, task.createdAt, task.updatedAt, JSON.stringify(task)); + }); + return task; + } + + async update(id: string, patch: unknown, now = Date.now()): Promise { + const normalized = normalizeUpdateScheduledTaskInput(patch, now); + if (!normalized.ok) throw storeError('invalid_input', normalized.message); + return this.updateTask(id, (task) => { + if (task.status === 'completed' || task.status === 'expired') { + throw storeError('operation_conflict', 'Cannot update a terminal scheduled task'); + } + const schedule = normalized.value.schedule ?? task.schedule; + const nextFireAt = task.status === 'active' ? computeRequiredNext(schedule, now) : null; + const effect = normalized.value.effect ?? task.effect; + const intentBody = normalized.value.intentBody ?? task.intent.body; + const expiresAt = Object.prototype.hasOwnProperty.call(normalized.value, 'expiresAt') + ? (normalized.value.expiresAt ?? null) + : task.expiresAt; + const maxFires = Object.prototype.hasOwnProperty.call(normalized.value, 'maxFires') + ? (normalized.value.maxFires ?? null) + : task.maxFires; + if (effect.kind !== 'notify' && !intentBody.trim()) { + throw storeError('invalid_input', 'Agent intent body is required'); + } + if (maxFires !== null && maxFires <= task.fireCount) { + throw storeError( + 'operation_conflict', + 'maxFires must be greater than the current fireCount', + ); + } + if (nextFireAt !== null && expiresAt !== null && nextFireAt >= expiresAt) { + throw storeError('invalid_input', 'Schedule must fire before expiresAt'); + } + return { + ...task, + ...(normalized.value.title !== undefined ? { title: normalized.value.title } : {}), + ...(normalized.value.intentBody !== undefined + ? { intent: { kind: 'text', body: normalized.value.intentBody } } + : {}), + schedule, + effect, + ...(Object.prototype.hasOwnProperty.call(normalized.value, 'maxFires') ? { maxFires } : {}), + expiresAt, + nextFireAt, + updatedAt: now, + }; + }); + } + + async pause(id: string, now = Date.now()): Promise { + return this.updateTask(id, (task) => pauseScheduledTask(task, now)); + } + + async resume(id: string, now = Date.now()): Promise { + return this.updateTask(id, (task) => { + const result = resumeScheduledTask(task, now); + if ('error' in result) throw storeError('operation_conflict', result.error); + if ( + result.nextFireAt !== null && + result.expiresAt !== null && + result.nextFireAt >= result.expiresAt + ) { + throw storeError('invalid_input', 'Schedule must fire before expiresAt'); + } + return result; + }); + } + + async snooze(id: string, delayMs: number, now = Date.now()): Promise { + if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > 7 * 24 * 60 * 60 * 1000) { + throw storeError( + 'invalid_input', + 'Scheduled task snooze delay must be between 1 ms and 7 days', + ); + } + return this.updateTask(id, (task) => { + if (task.status !== 'active' || task.nextFireAt === null) { + throw storeError('operation_conflict', 'Only active scheduled tasks can be snoozed'); + } + const nextFireAt = Math.max(now, task.nextFireAt) + Math.floor(delayMs); + if (task.expiresAt !== null && nextFireAt >= task.expiresAt) { + throw storeError('invalid_input', 'Snooze would move the task beyond expiresAt'); + } + return { ...task, nextFireAt, updatedAt: now }; + }); + } + + async clearRunHistory(id: string, now = Date.now()): Promise { + return this.updateTask(id, (task) => ({ + ...task, + runs: [], + lastError: null, + updatedAt: now, + })); + } + + async remove(id: string): Promise { + await this.enqueueWrite(() => { + this.assertNoPendingClaim(id); + this.requireTask(id); + this.#lease.database + .prepare('DELETE FROM workflow_scheduled_tasks WHERE task_id = ?') + .run(id); + }); + } + + async claimNextDue(now = Date.now()): Promise { + return this.enqueueWrite(() => { + // Due discovery still traverses the catalog to return every newly expired + // task. Only claim keys are needed, and only changed rows are written. + const claimRows = this.#lease.database + .prepare('SELECT task_id FROM workflow_scheduled_task_fires') + .all() as Array<{ task_id: string }>; + const claimedTaskIds = new Set(claimRows.map((row) => row.task_id)); + const expired: ScheduledTask[] = []; + const tasks = this.readTasks().map((task) => { + if (task.status === 'active' && task.expiresAt !== null && now >= task.expiresAt) { + const next = { ...task, status: 'expired' as const, nextFireAt: null, updatedAt: now }; + expired.push(next); + this.writeTask(next); + return next; + } + return task; + }); + const task = tasks + .filter((entry) => isScheduledTaskDue(entry, now) && !claimedTaskIds.has(entry.id)) + .sort( + (left, right) => left.nextFireAt! - right.nextFireAt! || left.id.localeCompare(right.id), + )[0]; + const claim = task ? createClaim(task, task.nextFireAt!, now) : null; + if (claim) this.insertClaim(claim); + return { claim, expired }; + }); + } + + async claimNow(id: string, now = Date.now()): Promise { + return this.enqueueWrite(() => { + const task = this.requireTask(id); + this.assertNoPendingClaim(id); + if (task.status !== 'active') { + throw storeError('operation_conflict', 'Only active tasks can be triggered now'); + } + if (task.expiresAt !== null && now >= task.expiresAt) { + throw storeError('operation_conflict', 'Scheduled task has expired'); + } + const claim = createClaim(task, now, now); + this.insertClaim(claim); + return claim; + }); + } + + async listPendingFires(): Promise { + const rows = this.#lease.database + .prepare(` + SELECT record_json + FROM workflow_scheduled_task_fires + ORDER BY claimed_at, claim_id + `) + .all() as Array<{ record_json?: unknown }>; + return rows.map((row, index) => decodeClaimRow(row, `row ${index + 1}`)); + } + + async bindFireExecution( + claimId: string, + execution: ScheduledTaskFireExecution, + ): Promise { + return this.updateClaim(claimId, (claim) => { + if (claim.task.effect.kind === 'notify') { + throw storeError( + 'operation_conflict', + `Scheduled task fire ${claimId} is not an Agent execution`, + ); + } + if (claim.execution) { + if (!sameExecution(claim.execution, execution)) { + throw storeError( + 'operation_conflict', + `Scheduled task fire ${claimId} already has another execution`, + ); + } + return claim; + } + return { ...claim, execution: { ...execution } }; + }); + } + + async setFireNativeState( + claimId: string, + nativeState: ScheduledTaskNativeFireState, + ): Promise { + return this.updateClaim(claimId, (claim) => { + if (claim.task.effect.kind !== 'notify') { + throw storeError( + 'operation_conflict', + `Scheduled task fire ${claimId} is not a native effect`, + ); + } + if (claim.nativeState === 'invoking' && nativeState !== 'invoking') { + throw storeError( + 'operation_conflict', + `Scheduled task fire ${claimId} already crossed delivery admission`, + ); + } + return claim.nativeState === nativeState ? claim : { ...claim, nativeState }; + }); + } + + async cancelWaitingNativeFire(taskId: string): Promise { + return this.enqueueWrite(() => { + const row = this.#lease.database + .prepare('SELECT record_json FROM workflow_scheduled_task_fires WHERE task_id = ?') + .get(taskId) as { record_json?: unknown } | undefined; + if (!row) return false; + const claim = decodeClaimRow(row, `task ${taskId}`); + if (claim.nativeState !== 'waiting_for_provider') { + throw storeError('operation_conflict', 'Scheduled task has a fire in progress'); + } + this.#lease.database + .prepare('DELETE FROM workflow_scheduled_task_fires WHERE task_id = ?') + .run(taskId); + return true; + }); + } + + async settleFire( + claimId: string, + run: Omit & { id?: string }, + ): Promise { + return this.enqueueWrite(() => { + const claim = this.readClaim(claimId); + if (!claim) throw new Error(`No such scheduled task fire claim: ${claimId}`); + const task = this.readTask(claim.taskId); + if (!task) throw new Error(`No such scheduled task: claim ${claimId}`); + const record: ScheduledTaskRun = { + id: run.id ?? randomUUID(), + at: run.at, + outcome: run.outcome, + message: [...run.message].slice(0, SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS).join(''), + ...(run.sessionId ? { sessionId: run.sessionId } : {}), + ...(run.runId ? { runId: run.runId } : {}), + }; + const updated = nextScheduledTaskStateAfterFire(task, record); + this.writeTask(updated); + this.#lease.database + .prepare('DELETE FROM workflow_scheduled_task_fires WHERE claim_id = ?') + .run(claimId); + return updated; + }); + } + + private readTasks(): ScheduledTask[] { + const rows = this.#lease.database + .prepare(` + SELECT task_id, record_json + FROM workflow_scheduled_tasks + ORDER BY created_at, task_id + `) + .all() as Array<{ task_id: string; record_json?: unknown }>; + return rows.map((row, index) => decodeTaskRow(row, `row ${index + 1}`)); + } + + private readTask(id: string): ScheduledTask | undefined { + const row = this.#lease.database + .prepare('SELECT task_id, record_json FROM workflow_scheduled_tasks WHERE task_id = ?') + .get(id) as { task_id: string; record_json?: unknown } | undefined; + if (!row) return undefined; + return decodeTaskRow(row, `task ${id}`); + } + + private requireTask(id: string): ScheduledTask { + const task = this.readTask(id); + if (!task) throw storeError('not_found', `No such scheduled task: ${id}`); + return task; + } + + private readClaim(id: string): ScheduledTaskFireClaim | undefined { + const row = this.#lease.database + .prepare('SELECT record_json FROM workflow_scheduled_task_fires WHERE claim_id = ?') + .get(id) as { record_json?: unknown } | undefined; + if (!row) return undefined; + const claim = decodeClaimRow(row, `claim ${id}`); + if (claim.id !== id) throw new Error(`Invalid scheduled task fire claim identity: ${id}`); + return claim; + } + + private assertNoPendingClaim(taskId: string): void { + const pending = this.#lease.database + .prepare('SELECT 1 FROM workflow_scheduled_task_fires WHERE task_id = ?') + .get(taskId); + if (pending) { + throw storeError('operation_conflict', 'Scheduled task has a fire in progress'); + } + } + + private updateTask( + id: string, + update: (task: ScheduledTask) => ScheduledTask, + ): Promise { + return this.enqueueWrite(() => { + const task = this.requireTask(id); + this.assertNoPendingClaim(id); + const updated = update(task); + if (updated !== task) this.writeTask(updated); + return updated; + }); + } + + private updateClaim( + id: string, + update: (claim: ScheduledTaskFireClaim) => ScheduledTaskFireClaim, + ): Promise { + return this.enqueueWrite(() => { + const claim = this.readClaim(id); + if (!claim) throw storeError('not_found', `No such scheduled task fire claim: ${id}`); + const updated = update(claim); + if (updated !== claim) { + this.#lease.database + .prepare('UPDATE workflow_scheduled_task_fires SET record_json = ? WHERE claim_id = ?') + .run(JSON.stringify(updated), id); + } + return structuredClone(updated); + }); + } + + private writeTask(task: ScheduledTask): void { + this.#lease.database + .prepare( + 'UPDATE workflow_scheduled_tasks SET updated_at = ?, record_json = ? WHERE task_id = ?', + ) + .run(task.updatedAt, JSON.stringify(task), task.id); + } + + private insertClaim(claim: ScheduledTaskFireClaim): void { + this.#lease.database + .prepare(` + INSERT INTO workflow_scheduled_task_fires(claim_id, task_id, claimed_at, record_json) + VALUES (?, ?, ?, ?) + `) + .run(claim.id, claim.taskId, claim.claimedAt, JSON.stringify(claim)); + } + + private enqueueWrite(operation: () => T): Promise { + const run = () => this.#lease.transaction('write', operation); + const next = this.queue.then(run, run); + this.queue = next.then( + () => {}, + () => {}, + ); + return next; + } +} + +function decodeTaskRow( + row: { task_id: string; record_json?: unknown }, + location: string, +): ScheduledTask { + if (typeof row.record_json !== 'string') { + throw new Error(`Invalid scheduled task at ${location}`); + } + const task = decodePersistedScheduledTask( + markPersisted(JSON.parse(row.record_json)), + ); + if (task.id !== row.task_id) throw new Error(`Invalid scheduled task identity: ${row.task_id}`); + return task; +} + +function decodeClaimRow(row: { record_json?: unknown }, location: string): ScheduledTaskFireClaim { + if (typeof row.record_json !== 'string') { + throw new Error(`Invalid scheduled task fire claim at ${location}`); + } + const claim = JSON.parse(row.record_json) as ScheduledTaskFireClaim; + return { + ...claim, + task: decodePersistedScheduledTask(markPersisted(claim.task)), + }; +} + +function computeRequiredNext(schedule: ScheduledTaskSchedule, now: number): number { + const next = computeNextFireAt(schedule, now); + if (next === null) { + throw storeError('invalid_input', 'Schedule has no fire within one year'); + } + return next; +} + +function createClaim( + task: ScheduledTask, + scheduledFor: number, + claimedAt: number, +): ScheduledTaskFireClaim { + return { + id: randomUUID(), + taskId: task.id, + scheduledFor, + claimedAt, + task: structuredClone(task), + }; +} + +function storeError(code: ScheduledTaskStoreErrorCode, message: string): ScheduledTaskStoreError { + return new ScheduledTaskStoreError(code, message); +} + +function sameExecution( + left: ScheduledTaskFireExecution, + right: ScheduledTaskFireExecution, +): boolean { + return ( + left.sessionId === right.sessionId && + left.turnId === right.turnId && + left.runId === right.runId && + left.userMessageId === right.userMessageId + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7f8e5fa6a8beec10da43463031b54aaee6b8bac65d26b4bad5de325b87ab3e83.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7f8e5fa6a8beec10da43463031b54aaee6b8bac65d26b4bad5de325b87ab3e83.source new file mode 100644 index 0000000000..939cb70706 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/7f8e5fa6a8beec10da43463031b54aaee6b8bac65d26b4bad5de325b87ab3e83.source @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Revision } from '@maka/core/runtime-policy'; +import { codecError, type CodecSource } from './errors.js'; + +export function record( + value: unknown, + context: string, + source: CodecSource, + allowed: readonly string[], + required: readonly string[] = allowed, +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw codecError(source, `${context} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw codecError(source, `${context} must be a plain object`); + } + const candidate = value as Record; + const allowedSet = new Set(allowed); + for (const key of Object.keys(candidate)) { + if (!allowedSet.has(key)) + throw codecError(source, `${context} contains unknown field '${key}'`); + } + for (const key of required) { + if (!Object.hasOwn(candidate, key)) throw codecError(source, `${context} is missing '${key}'`); + } + return candidate; +} + +export function revision(value: unknown, context: string, source: CodecSource): Revision { + return integer(value, context, 0, Number.MAX_SAFE_INTEGER, source); +} + +export function integer( + value: unknown, + context: string, + min: number, + max: number, + source: CodecSource, +): number { + if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max) { + throw codecError(source, `${context} must be an integer between ${min} and ${max}`); + } + return value as number; +} + +export function unique(values: readonly string[], context: string, source: CodecSource): void { + if (new Set(values).size !== values.length) { + throw codecError(source, `${context} values must be unique`); + } +} + +export function nextRevision(value: Revision): Revision { + if (value >= Number.MAX_SAFE_INTEGER) { + throw codecError('invalid_document', 'Revision space is exhausted'); + } + return value + 1; +} + +export function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const item of Object.values(value)) deepFreeze(item); + return value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/80034974180b5d088d1173eb2edfb8bbdbd8aa807a63ff8aa2367e88e88c5859.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/80034974180b5d088d1173eb2edfb8bbdbd8aa807a63ff8aa2367e88e88c5859.source new file mode 100644 index 0000000000..c0f4158d68 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/80034974180b5d088d1173eb2edfb8bbdbd8aa807a63ff8aa2367e88e88c5859.source @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { mkdtemp, open, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { + publishMarkerFile, + type MarkerFileDependencies, + type MarkerFileHandle, +} from '../marker-file.js'; + +test('keeps the open primitive captured at module initialization', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-marker-file-captured-open-')); + const markerFile = '.marker.json'; + const originalOpen = fs.promises.open; + let intercepted = false; + fs.promises.open = (async (path, flags, mode) => { + if (typeof path === 'string' && path.startsWith(join(root, `${markerFile}.`))) { + intercepted = true; + } + return originalOpen(path, flags, mode); + }) as typeof fs.promises.open; + try { + await publishMarkerFile({ + root, + markerFile, + contents: '{"schemaVersion":1}\n', + maxBytes: 1_024, + publication: 'create', + invalidFile: () => new Error('invalid marker'), + }); + assert.equal(intercepted, false); + } finally { + fs.promises.open = originalOpen; + await rm(root, { recursive: true, force: true }); + } +}); + +for (const publication of ['create', 'replace'] as const) { + for (const failurePhase of ['write', 'sync', 'close'] as const) { + test(`${publication} removes its temporary marker after a ${failurePhase} failure`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-marker-file-fault-')); + const markerFile = '.marker.json'; + const temporaryPath = join(root, `${markerFile}.${process.pid}.fault.tmp`); + const fault = new Error(`${failurePhase} failed`); + try { + await assert.rejects( + () => + publishMarkerFile( + { + root, + markerFile, + contents: '{"schemaVersion":1}\n', + maxBytes: 1_024, + publication, + invalidFile: () => new Error('invalid marker'), + }, + { + randomUUID: () => 'fault', + open: faultingOpen(temporaryPath, failurePhase, fault), + }, + ), + fault, + ); + assert.deepEqual(await readdir(root), []); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } +} + +function faultingOpen( + temporaryPath: string, + failurePhase: 'write' | 'sync' | 'close', + fault: Error, +): MarkerFileDependencies['open'] { + return async (path, flags, mode) => { + const handle = await open(path, flags, mode); + if (path !== temporaryPath) return handle; + + let closeFailed = false; + const wrapped: MarkerFileHandle = { + stat: (options) => handle.stat(options), + read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), + writeFile: async (data, encoding) => { + if (failurePhase === 'write') { + await handle.writeFile(data.slice(0, 1), encoding); + throw fault; + } + await handle.writeFile(data, encoding); + }, + sync: async () => { + if (failurePhase === 'sync') throw fault; + await handle.sync(); + }, + close: async () => { + if (failurePhase === 'close' && !closeFailed) { + closeFailed = true; + await handle.close(); + throw fault; + } + await handle.close(); + }, + }; + return wrapped; + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/810960299e37df778b8b38738d94bdb9a1b6ddec29ad53107e0aca445525044f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/810960299e37df778b8b38738d94bdb9a1b6ddec29ad53107e0aca445525044f.source new file mode 100644 index 0000000000..6348662534 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/810960299e37df778b8b38738d94bdb9a1b6ddec29ad53107e0aca445525044f.source @@ -0,0 +1,708 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm as remove, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, parse, relative, resolve } from 'node:path'; +import { test } from 'node:test'; +import { promisify } from 'node:util'; + +import { + createProjectCatalog as createProjectCatalogBase, + type ProjectCatalog, + ProjectPathBoundaryError, + ProjectUnavailableError, + ProjectPathMismatchError, + type ResolvedProjectLocation, + resolveProjectLocation, +} from '../project-catalog.js'; +import { createSessionStore } from '../session-store.js'; +import { createGitRepositoryWithWorktree } from './fixtures/git-repository.js'; + +const execFileAsync = promisify(execFile); +const trackedCatalogs = new Map(); + +// Every catalog owns a lease on runtime.sqlite. POSIX can unlink that database +// while it is open, but Windows cannot, so test cleanup must release every +// catalog under the temporary root before removing the root itself. +function createProjectCatalog( + storageRoot: string, + deps?: Parameters[1], +): ProjectCatalog { + const catalog = createProjectCatalogBase(storageRoot, deps); + const close = catalog.close.bind(catalog); + catalog.close = () => { + if (!trackedCatalogs.delete(catalog)) return; + close(); + }; + trackedCatalogs.set(catalog, storageRoot); + return catalog; +} + +async function rm(path: string, options?: Parameters[1]): Promise { + const removedRoot = resolve(path); + for (const [catalog, storageRoot] of [...trackedCatalogs].reverse()) { + const storagePath = resolve(storageRoot); + const fromRemovedRoot = relative(removedRoot, storagePath); + if ( + fromRemovedRoot === '' || + (!fromRemovedRoot.startsWith('..') && !isAbsolute(fromRemovedRoot)) + ) { + catalog.close(); + } + } + await remove(path, options); +} + +function sessionInput(cwd: string, projectId: string) { + return { + cwd, + projectId, + backend: 'fake' as const, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask' as const, + }; +} + +test('a plain folder resolves without requiring the Git executable', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-folder-no-git-')); + try { + const folder = join(base, 'folder'); + await mkdir(folder); + + assert.deepEqual(await resolveProjectLocationWithoutGit(folder), { + canonicalPath: await realpath(folder), + identity: `folder:${await realpath(folder)}`, + kind: 'folder', + }); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('a Git probe failure cannot persistently downgrade a repository to a folder', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-repository-no-git-')); + try { + const repository = join(base, 'repository'); + const storage = join(base, 'storage'); + await mkdir(repository); + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); + + await assert.rejects(() => registerProjectWithoutGit(repository, storage)); + // Nothing may be recorded: a folder identity written here would outlive the + // probe failure and permanently split the repository from its worktrees. + assert.deepEqual(await createProjectCatalog(storage).list(), []); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('a repository and its linked worktree resolve to one project identity', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-location-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'project-catalog-test'); + + const main = await resolveProjectLocation({ path: repository }); + const linked = await resolveProjectLocation({ path: linkedWorktree }); + + assert.equal(main.kind, 'git'); + assert.equal(linked.kind, 'git'); + assert.equal(main.identity, linked.identity); + assert.notEqual(main.canonicalPath, linked.canonicalPath); + assert.equal(main.git?.isWorktree, false); + assert.equal(linked.git?.isWorktree, true); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('registering a nested folder keeps that folder instead of the enclosing repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-folder-')); + try { + const parent = join(base, 'parent-project'); + const child = join(parent, 'child-project'); + await mkdir(child, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: (() => { + let id = 0; + return () => `project-${++id}`; + })(), + }); + + const parentProject = await catalog.register(parent); + const childProject = await catalog.register(child); + const parentPath = await realpath(parent); + const childPath = await realpath(child); + + assert.notEqual(childProject.id, parentProject.id); + assert.equal(parentProject.preferredPath, parentPath); + assert.equal(childProject.preferredPath, childPath); + assert.equal(childProject.name, 'child-project'); + + // session.create → HostWorkspaceResolver.touch(projectId, preferredPath) + const touched = await catalog.touch(childProject.id, childProject.preferredPath); + assert.equal(touched.id, childProject.id); + assert.equal(touched.preferredPath, childPath); + await assert.rejects( + () => catalog.touch(childProject.id, parentPath), + (error) => error instanceof ProjectPathMismatchError && error.projectId === childProject.id, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('registration validates the final canonical path against its boundary', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-boundary-')); + try { + const publishedRoot = join(base, 'published'); + const outside = join(base, 'outside'); + await Promise.all([mkdir(publishedRoot), mkdir(outside)]); + const catalog = createProjectCatalog(join(base, 'storage')); + + await assert.rejects( + () => catalog.register(outside, { withinRoot: publishedRoot }), + (error) => error instanceof ProjectPathBoundaryError, + ); + assert.deepEqual(await catalog.list(), []); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('relink and relinkWithSessions keep a nested repository directory', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-relink-')); + const storage = join(base, 'storage'); + const sessions = createSessionStore(storage); + try { + const parent = join(base, 'parent-project'); + const child = join(parent, 'child-project'); + const childTwo = join(parent, 'child-two'); + const elsewhere = join(base, 'elsewhere'); + const elsewhereTwo = join(base, 'elsewhere-two'); + await mkdir(child, { recursive: true }); + await mkdir(childTwo, { recursive: true }); + await mkdir(elsewhere, { recursive: true }); + await mkdir(elsewhereTwo, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); + const catalog = createProjectCatalog(storage, { + now: () => 1_000, + createId: (() => { + let id = 0; + return () => `project-${++id}`; + })(), + }); + + const parentProject = await catalog.register(parent); + const original = await catalog.register(elsewhere); + const originalSessions = await catalog.register(elsewhereTwo); + const childPath = await realpath(child); + const childTwoPath = await realpath(childTwo); + const assigned = await sessions.create(sessionInput(elsewhereTwo, originalSessions.id)); + + const relinked = await catalog.relink(original.id, child); + assert.equal(relinked.id, original.id); + assert.notEqual(relinked.id, parentProject.id); + assert.equal(relinked.preferredPath, childPath); + + const { project: relinkedSessions, updatedSessionIds } = await catalog.relinkWithSessions( + originalSessions.id, + childTwo, + ); + assert.equal(relinkedSessions.id, originalSessions.id); + assert.notEqual(relinkedSessions.id, parentProject.id); + assert.equal(relinkedSessions.preferredPath, childTwoPath); + assert.deepEqual(updatedSessionIds, [assigned.id]); + const header = await sessions.readHeaderSnapshot(assigned.id); + assert.equal(header.projectId, relinkedSessions.id); + assert.equal(header.cwd, childTwoPath); + } finally { + await sessions.close?.(); + await rm(base, { recursive: true, force: true }); + } +}); + +async function resolveProjectLocationWithoutGit(path: string): Promise { + const stdout = await runProjectCatalogWithoutGit( + 'const [moduleUrl, path] = process.argv.slice(1); const { resolveProjectLocation } = await import(moduleUrl); console.log(JSON.stringify(await resolveProjectLocation({ path })));', + path, + ); + return JSON.parse(stdout) as ResolvedProjectLocation; +} + +async function registerProjectWithoutGit(path: string, storage: string): Promise { + await runProjectCatalogWithoutGit( + 'const [moduleUrl, path, storage] = process.argv.slice(1); const { createProjectCatalog } = await import(moduleUrl); await createProjectCatalog(storage).register(path);', + path, + storage, + ); +} + +async function runProjectCatalogWithoutGit(source: string, ...args: string[]): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, PATH: '' }; + delete env.Path; + const moduleUrl = new URL('../project-catalog.js', import.meta.url).href; + const { stdout } = await execFileAsync( + process.execPath, + ['--input-type=module', '-e', source, moduleUrl, ...args], + { env, encoding: 'utf8' }, + ); + return stdout; +} + +test('registering a repository and its linked worktree creates one project with two locations', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-catalog-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'catalog-linked'); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + + const first = await catalog.register(repository); + now = 2_000; + const second = await catalog.register(linkedWorktree); + const repositoryPath = await realpath(repository); + const linkedWorktreePath = await realpath(linkedWorktree); + const expectedPaths = [linkedWorktreePath, repositoryPath].sort(); + + assert.equal(first.id, 'project-1'); + assert.equal(first.preferredPath, repositoryPath); + assert.equal(second.id, first.id); + assert.equal(second.preferredPath, linkedWorktreePath); + assert.deepEqual( + (await catalog.list()).map((project) => ({ + id: project.id, + name: project.name, + paths: project.locations.map((location) => location.path).sort(), + worktrees: project.locations.map((location) => location.isWorktree).sort(), + })), + [ + { + id: 'project-1', + name: 'repository', + paths: expectedPaths, + worktrees: [false, true], + }, + ], + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('registering without preference preserves the preferred location until it is touched', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-catalog-not-preferred-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'catalog-not-preferred'); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const doNotPrefer = { prefer: false } as const; + const repositoryPath = await realpath(repository); + const linkedWorktreePath = await realpath(linkedWorktree); + + const first = await catalog.register(repository, doNotPrefer); + now = 2_000; + const added = await catalog.register(linkedWorktree, doNotPrefer); + assert.equal(added.id, first.id); + assert.equal(added.locations.length, 2); + assert.equal(added.preferredPath, repositoryPath); + + now = 3_000; + const registeredAgain = await catalog.register(linkedWorktree, doNotPrefer); + assert.equal(registeredAgain.preferredPath, repositoryPath); + + now = 4_000; + const touched = await catalog.touch(first.id, linkedWorktreePath); + assert.equal(touched.preferredPath, linkedWorktreePath); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('archiving a project preserves it with an archive timestamp', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-archive-')); + try { + const workspace = join(base, 'workspace'); + await mkdir(workspace); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const project = await catalog.register(workspace); + + now = 2_000; + const archived = await catalog.archive(project.id); + + assert.equal(archived.archivedAt, 2_000); + assert.equal((await catalog.list())[0]?.id, project.id); + assert.equal((await catalog.list())[0]?.archivedAt, 2_000); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('restoring an archived project makes the same project active again', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-restore-')); + try { + const workspace = join(base, 'workspace'); + await mkdir(workspace); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const project = await catalog.register(workspace); + now = 2_000; + await catalog.archive(project.id); + + now = 3_000; + const restored = await catalog.restore(project.id); + + assert.equal(restored.id, project.id); + assert.equal(restored.archivedAt, undefined); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('renaming a project stores the trimmed display name without changing its identity', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-rename-')); + try { + const workspace = join(base, 'workspace'); + await mkdir(workspace); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const project = await catalog.register(workspace); + + now = 2_000; + const renamed = await catalog.rename(project.id, ' Design System '); + + assert.equal(renamed.id, project.id); + assert.equal(renamed.name, 'Design System'); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('a missing project directory remains in the catalog as unavailable', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-unavailable-')); + try { + const workspace = join(base, 'workspace'); + const storage = join(base, 'storage'); + await mkdir(workspace); + const catalog = createProjectCatalog(storage, { + now: () => 1_000, + createId: () => 'project-1', + }); + const project = await catalog.register(workspace); + await rm(workspace, { recursive: true, force: true }); + + const restoredCatalog = createProjectCatalog(storage); + const [unavailable] = await restoredCatalog.list(); + + assert.equal(unavailable?.id, project.id); + assert.equal(unavailable?.available, false); + assert.equal(unavailable?.preferredPath, undefined); + assert.equal(unavailable?.locations.length, 1); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('two catalogs changing one project at the same time keep both changes', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-concurrent-')); + try { + const workspace = join(base, 'workspace'); + const storage = join(base, 'storage'); + await mkdir(workspace); + const first = createProjectCatalog(storage, { now: () => 1_000 }); + const second = createProjectCatalog(storage, { now: () => 2_000 }); + const project = await first.register(workspace); + // Each catalog rewrites the whole table; without holding the write lock + // across its own read, the later writer replays a stale copy and the other + // window's edit disappears with no error anywhere. + await Promise.all([second.archive(project.id), first.rename(project.id, 'Renamed')]); + + const [merged] = await first.list(); + assert.equal(merged?.name, 'Renamed', 'the rename must survive the concurrent archive'); + assert.equal(merged?.archivedAt, 2_000, 'the archive must survive the concurrent rename'); + first.close(); + second.close(); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('relinking an unavailable project preserves its id and adopts the new directory', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-')); + try { + const workspace = join(base, 'workspace'); + const relocated = join(base, 'relocated'); + await mkdir(workspace); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const project = await catalog.register(workspace); + await rm(workspace, { recursive: true, force: true }); + await mkdir(relocated); + + now = 2_000; + const relinked = await catalog.relink(project.id, relocated); + + assert.equal(relinked.id, project.id); + assert.equal(relinked.available, true); + assert.equal(relinked.preferredPath, await realpath(relocated)); + assert.deepEqual( + relinked.locations.map((location) => location.path), + [await realpath(relocated)], + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('Host relink rolls Project and Session membership back in one transaction', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-session-relink-')); + const storage = join(base, 'storage'); + const originalPath = join(base, 'original'); + const destinationPath = join(base, 'destination'); + await Promise.all([mkdir(originalPath), mkdir(destinationPath)]); + const injected = new Error('injected atomic relink failure'); + const catalog = createProjectCatalog(storage, { + createId: (() => { + let id = 0; + return () => `project-${++id}`; + })(), + relinkFailpoint: () => { + throw injected; + }, + }); + const sessions = createSessionStore(storage); + try { + const original = await catalog.register(originalPath); + const duplicate = await catalog.register(destinationPath); + const originalSession = await sessions.create(sessionInput(originalPath, original.id)); + const duplicateSession = await sessions.create(sessionInput(destinationPath, duplicate.id)); + + await assert.rejects( + () => catalog.relinkWithSessions(original.id, destinationPath), + (error) => error === injected, + ); + + assert.deepEqual( + (await catalog.list()).map(({ id }) => id).sort(), + [original.id, duplicate.id].sort(), + ); + assert.equal((await sessions.readHeaderSnapshot(originalSession.id)).projectId, original.id); + assert.equal((await sessions.readHeaderSnapshot(originalSession.id)).cwd, originalPath); + assert.equal((await sessions.readHeaderSnapshot(duplicateSession.id)).projectId, duplicate.id); + assert.equal((await sessions.readHeaderSnapshot(duplicateSession.id)).cwd, destinationPath); + } finally { + await sessions.close?.(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('conflicting relink preserves every available worktree location from the merged project', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-relink-worktrees-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'relink-linked'); + let id = 0; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: () => `project-${++id}`, + }); + const originalPath = join(base, 'original'); + await mkdir(originalPath); + const original = await catalog.register(originalPath); + await rm(originalPath, { recursive: true, force: true }); + await catalog.register(repository); + await catalog.register(linkedWorktree); + + const { project: relinked } = await catalog.relinkWithSessions(original.id, repository); + + assert.deepEqual( + relinked.locations.map((location) => location.path).sort(), + [await realpath(repository), await realpath(linkedWorktree)].sort(), + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('projects are listed by most recent use', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-recency-')); + try { + const firstPath = join(base, 'first'); + const secondPath = join(base, 'second'); + await mkdir(firstPath); + await mkdir(secondPath); + let now = 1_000; + let id = 0; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => `project-${++id}`, + }); + await catalog.register(firstPath); + now = 2_000; + await catalog.register(secondPath); + + assert.deepEqual( + (await catalog.list()).map((project) => project.name), + ['second', 'first'], + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('touching a project moves it to the front of the recent list', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-touch-')); + try { + const firstPath = join(base, 'first'); + const secondPath = join(base, 'second'); + await mkdir(firstPath); + await mkdir(secondPath); + let now = 1_000; + let id = 0; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => `project-${++id}`, + }); + const first = await catalog.register(firstPath); + now = 2_000; + await catalog.register(secondPath); + + now = 3_000; + await catalog.touch(first.id); + + assert.deepEqual( + (await catalog.list()).map((project) => project.id), + [first.id, 'project-2'], + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('touch reports a Project that disappears before path resolution as unavailable', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-touch-missing-')); + try { + const path = join(base, 'project'); + await mkdir(path); + const catalog = createProjectCatalog(join(base, 'storage')); + const project = await catalog.register(path); + await remove(path, { recursive: true }); + + await assert.rejects( + () => catalog.touch(project.id, path), + (error) => error instanceof ProjectUnavailableError && error.projectId === project.id, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('selecting a project returns its most recent available location and rejects inactive projects', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-select-')); + try { + const availablePath = join(base, 'available'); + const missingPath = join(base, 'missing'); + await mkdir(availablePath); + let now = 1_000; + let id = 0; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => `project-${++id}`, + }); + const available = await catalog.register(availablePath); + await mkdir(missingPath); + const missing = await catalog.register(missingPath); + await rm(missingPath, { recursive: true, force: true }); + + now = 2_000; + const selected = await catalog.select(available.id); + assert.equal(selected.path, await realpath(availablePath)); + assert.equal(selected.project.id, available.id); + + await assert.rejects(() => catalog.select(missing.id), /unavailable/i); + await catalog.archive(available.id); + await assert.rejects(() => catalog.select(available.id), /archived/i); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('registering a filesystem root writes a project that a fresh catalog can read', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-root-')); + const storage = join(base, 'storage'); + try { + const root = parse(base).root; + const catalog = createProjectCatalog(storage, { + createId: () => 'project-root', + }); + + const project = await catalog.register(root); + const reopened = createProjectCatalog(storage); + + assert.ok(project.name.length > 0); + assert.equal((await reopened.list())[0]?.id, project.id); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('catalog validates generated state before publishing it', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-write-validation-')); + const workspace = join(base, 'workspace'); + await mkdir(workspace); + try { + const catalog = createProjectCatalog(join(base, 'storage'), { + createId: () => '', + }); + + await assert.rejects(() => catalog.register(workspace), /Invalid project catalog/); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81601024e587ad4ccbb092622616a7449b0220737d33a5ed6cc7612f93c55c47.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81601024e587ad4ccbb092622616a7449b0220737d33a5ed6cc7612f93c55c47.source new file mode 100644 index 0000000000..1298b4a095 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81601024e587ad4ccbb092622616a7449b0220737d33a5ed6cc7612f93c55c47.source @@ -0,0 +1,410 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { + decodeCredentialVersionBasis, + normalizeCredentialSecret, + normalizeDeleteCredentialInput, + normalizeSetCredentialInput, + type CredentialLocator, + type CredentialMutationResult, + type CredentialStatus, + type CredentialVaultSnapshot, + type CredentialVersionBasis, + type DeleteCredentialInput, + type SetCredentialInput, +} from '@maka/core/runtime-policy'; +import { deepFreeze, integer, nextRevision, record, revision, unique } from './codec.js'; +import { + codecError, + decodeCredentialInput, + decodePersistedDomain, + RuntimePolicyStoreError, +} from './errors.js'; +import { + readBoundedJsonDocument, + serializeJsonDocument, + VAULT_DOCUMENT_MAX_BYTES, + writeJsonDocument, +} from './document-io.js'; +import type { RuntimePolicyCredentialMaterial } from './operations.js'; + +const FILE = 'credential-vault.json'; +const SCHEMA_VERSION = 1 as const; +const MAX_SECRET_LENGTH = 64 * 1024; +const MAX_VAULT_ENTRIES = 2_048; + +export interface CredentialVaultEntry extends CredentialVersionBasis { + readonly secret: string; + readonly updatedAt: number; +} + +export interface CredentialVaultDocument { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly revision: number; + readonly entries: readonly CredentialVaultEntry[]; +} + +interface PreparedCredentialSet { + readonly kind: 'ready'; + readonly document: CredentialVaultDocument; + readonly entry: CredentialVaultEntry; +} + +interface PreparedCredentialDelete { + readonly kind: 'ready'; + readonly document: CredentialVaultDocument; +} + +export class CredentialVaultDocumentOwner { + async read(root: string): Promise { + const value = await readBoundedJsonDocument(root, FILE, VAULT_DOCUMENT_MAX_BYTES); + if (value === undefined) return { schemaVersion: SCHEMA_VERSION, revision: 0, entries: [] }; + const raw = record(value, FILE, 'invalid_document', ['schemaVersion', 'revision', 'entries']); + if (raw.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + if (!Array.isArray(raw.entries) || raw.entries.length > MAX_VAULT_ENTRIES) { + throw codecError('invalid_document', `${FILE}.entries must be a bounded array`); + } + const entries = raw.entries.map((item, index) => parseEntry(item, `${FILE}.entries[${index}]`)); + unique( + entries.map((entry) => locatorKey(entry.locator)), + `${FILE} locators`, + 'invalid_document', + ); + unique( + entries.map((entry) => entry.credentialId), + `${FILE} credential ids`, + 'invalid_document', + ); + return { + schemaVersion: SCHEMA_VERSION, + revision: revision(raw.revision, `${FILE}.revision`, 'invalid_document'), + entries, + }; + } + + async set(root: string, rawInput: SetCredentialInput): Promise { + const prepared = this.prepareSet(await this.read(root), rawInput); + if (prepared.kind !== 'ready') return prepared; + await this.commitSet(root, prepared); + return committed(prepared.document); + } + + prepareSet( + current: CredentialVaultDocument, + rawInput: SetCredentialInput, + ): PreparedCredentialSet | CredentialMutationResult { + const input = decodeCredentialInput(() => normalizeSetCredentialInput(rawInput)); + assertCredentialInputSecretLimit(input.secret, 'set credential secret'); + const index = findCredentialIndex(current, input.locator); + const previous = index < 0 ? undefined : current.entries[index]; + if (!matchesExpectation(previous, input.expected)) { + return credentialStale( + input.expected ? { locator: input.locator, ...input.expected } : null, + previous ? credentialBasis(previous) : null, + ); + } + if (index < 0 && current.entries.length >= MAX_VAULT_ENTRIES) { + throw codecError('invalid_credential_input', 'Credential vault entry limit has been reached'); + } + const entry: CredentialVaultEntry = previous + ? { + ...previous, + revision: nextRevision(previous.revision), + secret: input.secret, + updatedAt: Date.now(), + } + : { + locator: input.locator, + credentialId: randomUUID(), + revision: 1, + secret: input.secret, + updatedAt: Date.now(), + }; + const entries = [...current.entries]; + if (index < 0) entries.push(entry); + else entries[index] = entry; + const next = { + schemaVersion: SCHEMA_VERSION, + revision: nextRevision(current.revision), + entries, + }; + this.assertDocumentSize(next); + return { kind: 'ready', document: next, entry }; + } + + async commitSet(root: string, prepared: PreparedCredentialSet): Promise { + await this.write(root, prepared.document); + } + + async delete(root: string, rawInput: DeleteCredentialInput): Promise { + const prepared = this.prepareDelete(await this.read(root), rawInput); + if (prepared.kind !== 'ready') return prepared; + return this.commitDelete(root, prepared); + } + + prepareDelete( + current: CredentialVaultDocument, + rawInput: DeleteCredentialInput, + ): PreparedCredentialDelete | CredentialMutationResult { + const input = decodeCredentialInput(() => normalizeDeleteCredentialInput(rawInput)); + const index = findCredentialIndex(current, input.expected.locator); + const previous = index < 0 ? undefined : current.entries[index]; + if (!sameCredentialBasis(previous, input.expected)) { + return credentialStale(input.expected, previous ? credentialBasis(previous) : null); + } + const next = { + schemaVersion: SCHEMA_VERSION, + revision: nextRevision(current.revision), + entries: current.entries.filter((_entry, candidate) => candidate !== index), + }; + this.assertDocumentSize(next); + return { kind: 'ready', document: next }; + } + + async commitDelete( + root: string, + prepared: PreparedCredentialDelete, + ): Promise { + await this.write(root, prepared.document); + return committed(prepared.document); + } + + async deleteConnectionCredentials( + root: string, + current: CredentialVaultDocument, + connectionId: string, + ): Promise { + const entries = current.entries.filter( + (entry) => + entry.locator.scope !== 'connection' || entry.locator.connectionId !== connectionId, + ); + return this.replaceEntries(root, current, entries); + } + + async deleteOrphanedConnectionCredentials( + root: string, + current: CredentialVaultDocument, + liveConnectionIds: ReadonlySet, + ): Promise { + const entries = current.entries.filter( + (entry) => + entry.locator.scope !== 'connection' || liveConnectionIds.has(entry.locator.connectionId), + ); + return this.replaceEntries(root, current, entries); + } + + private async replaceEntries( + root: string, + current: CredentialVaultDocument, + entries: CredentialVaultDocument['entries'], + ): Promise { + if (entries.length === current.entries.length) return vaultSnapshot(current); + const next = { + schemaVersion: SCHEMA_VERSION, + revision: nextRevision(current.revision), + entries, + }; + await this.write(root, next); + return vaultSnapshot(next); + } + + private async write(root: string, document: CredentialVaultDocument): Promise { + this.assertDocumentSize(document); + await writeJsonDocument(root, FILE, document, VAULT_DOCUMENT_MAX_BYTES); + } + + private assertDocumentSize(document: CredentialVaultDocument): void { + if (serializeJsonDocument(document).length > VAULT_DOCUMENT_MAX_BYTES) { + throw new RuntimePolicyStoreError( + 'invalid_credential_input', + `credential vault exceeds its ${VAULT_DOCUMENT_MAX_BYTES} byte limit`, + ); + } + } +} + +export function vaultSnapshot(document: CredentialVaultDocument): CredentialVaultSnapshot { + return deepFreeze({ + revision: document.revision, + entries: document.entries.map((entry) => credentialStatusFromEntry(entry)), + }); +} + +export function credentialStatus( + document: CredentialVaultDocument, + locator: CredentialLocator, +): CredentialStatus { + const entry = findCredential(document, locator); + return deepFreeze( + entry + ? credentialStatusFromEntry(entry) + : { + locator: structuredClone(locator), + configured: false, + credentialId: null, + revision: null, + updatedAt: null, + }, + ); +} + +export function credentialMaterial(entry: CredentialVaultEntry): RuntimePolicyCredentialMaterial { + return deepFreeze({ ...credentialBasis(entry), secret: entry.secret }); +} + +export function credentialBasis(entry: CredentialVaultEntry): CredentialVersionBasis { + return { + locator: structuredClone(entry.locator), + credentialId: entry.credentialId, + revision: entry.revision, + }; +} + +export function findCredential( + document: CredentialVaultDocument, + locator: CredentialLocator, +): CredentialVaultEntry | undefined { + return document.entries.find((entry) => sameLocator(entry.locator, locator)); +} + +export function sameCredentialBasis( + actual: CredentialVaultEntry | undefined, + expected: CredentialVersionBasis, +): boolean { + return ( + actual !== undefined && + sameLocator(actual.locator, expected.locator) && + actual.credentialId === expected.credentialId && + actual.revision === expected.revision + ); +} + +function parseEntry(value: unknown, context: string): CredentialVaultEntry { + const item = record(value, context, 'invalid_document', [ + 'locator', + 'credentialId', + 'revision', + 'secret', + 'updatedAt', + ]); + const basis = decodePersistedDomain(() => + decodeCredentialVersionBasis({ + locator: item.locator, + credentialId: item.credentialId, + revision: item.revision, + }), + ); + const secret = decodePersistedDomain(() => normalizeCredentialSecret(item.secret)); + assertPersistedSecretLimit(secret, `${context}.secret`); + return { + ...basis, + secret, + updatedAt: integer( + item.updatedAt, + `${context}.updatedAt`, + 0, + Number.MAX_SAFE_INTEGER, + 'invalid_document', + ), + }; +} + +function assertCredentialInputSecretLimit(value: string, context: string): void { + if (value.length > MAX_SECRET_LENGTH) { + throw codecError( + 'invalid_credential_input', + `${context} must be no longer than ${MAX_SECRET_LENGTH} characters`, + ); + } +} + +function assertPersistedSecretLimit(value: string, context: string): void { + if (value.length > MAX_SECRET_LENGTH) { + throw codecError( + 'invalid_document', + `${context} must be no longer than ${MAX_SECRET_LENGTH} characters`, + ); + } +} + +function credentialStatusFromEntry(entry: CredentialVaultEntry): CredentialStatus { + return { + locator: structuredClone(entry.locator), + configured: true, + credentialId: entry.credentialId, + revision: entry.revision, + updatedAt: entry.updatedAt, + }; +} + +function findCredentialIndex( + document: CredentialVaultDocument, + locator: CredentialLocator, +): number { + return document.entries.findIndex((entry) => sameLocator(entry.locator, locator)); +} + +function sameLocator(left: CredentialLocator, right: CredentialLocator): boolean { + if (left.scope !== right.scope || left.kind !== right.kind) return false; + if (left.scope === 'connection' && right.scope === 'connection') { + return left.connectionId === right.connectionId; + } + if (left.scope === 'web_search' && right.scope === 'web_search') { + return left.provider === right.provider; + } + return left.scope === 'network_proxy' && right.scope === 'network_proxy'; +} + +function locatorKey(locator: CredentialLocator): string { + switch (locator.scope) { + case 'connection': + return `connection:${locator.connectionId}:${locator.kind}`; + case 'web_search': + return `web_search:${locator.provider}:api_key`; + case 'network_proxy': + return 'network_proxy:password'; + } +} + +function matchesExpectation( + actual: CredentialVaultEntry | undefined, + expected: SetCredentialInput['expected'], +): boolean { + if (expected === null) return actual === undefined; + return ( + actual !== undefined && + actual.credentialId === expected.credentialId && + actual.revision === expected.revision + ); +} + +function credentialStale( + expected: CredentialVersionBasis | null, + actual: CredentialVersionBasis | null, +): CredentialMutationResult { + return deepFreeze({ kind: 'credential_stale', expected, actual }); +} + +function committed(document: CredentialVaultDocument): CredentialMutationResult { + return deepFreeze({ kind: 'committed', snapshot: vaultSnapshot(document) }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81ffd3c1921b95e0ab6a3ea648204bed015675f8f85c8de68e8769bd8d7e79c6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81ffd3c1921b95e0ab6a3ea648204bed015675f8f85c8de68e8769bd8d7e79c6.source new file mode 100644 index 0000000000..1983457980 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/81ffd3c1921b95e0ab6a3ea648204bed015675f8f85c8de68e8769bd8d7e79c6.source @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { + isSafeWorkBoardId, + type WorkBoardItem, + type WorkBoardListQuery, + type WorkBoardScope, +} from '@maka/core/work-board'; +import { WorkBoardStoreError } from './work-board-store-error.js'; + +export interface WorkBoardListStatement { + sql: string; + params: Array; +} + +/** + * Builds the exact list SQL executed by WorkBoardStore.list(). Internal module + * used by the store and by the query-plan regression test so the test cannot + * drift from the production statement. + */ +export function buildWorkBoardListStatement( + value: WorkBoardListQuery, + limit: number, +): WorkBoardListStatement { + const params: Array = []; + let sql = ` + SELECT item_id, revision, created_at, updated_at, scope_kind, project_id, archived, record_json + FROM workflow_work_board_items + WHERE 1 = 1 + `; + if (!value.includeArchived) { + sql += ' AND archived = 0'; + } + if (value.scope) { + sql = appendScopePredicate(sql, params, value.scope, value.projectIds); + } + if (value.cursor) { + const cursor = decodeWorkBoardCursor(value.cursor); + if (!cursor || cursor.filterFingerprint !== workBoardFilterFingerprint(value)) { + throw new WorkBoardStoreError('invalid_input', 'cursor does not match the list filters'); + } + sql += ' AND (updated_at < ? OR (updated_at = ? AND item_id < ?))'; + params.push(cursor.updatedAt, cursor.updatedAt, cursor.itemId); + } + sql += ' ORDER BY updated_at DESC, item_id DESC LIMIT ?'; + params.push(limit + 1); + return { sql, params }; +} + +export function workBoardFilterFingerprint(value: WorkBoardListQuery): string { + const scope = + value.scope === undefined + ? 'any' + : value.scope.kind === 'project' + ? `project:${[...(value.projectIds ?? [value.scope.projectId])].sort().join('|')}` + : 'inbox'; + // Cursors bind to the complete normalized filter without copying an unbounded + // project alias set into the public cursor payload. + return createHash('sha256') + .update(`${value.includeArchived ? 'archived-included' : 'active-only'}:${scope}`) + .digest('base64url'); +} + +export function encodeWorkBoardCursor(item: WorkBoardItem, filterFingerprint: string): string { + return Buffer.from( + JSON.stringify({ updatedAt: item.updatedAt, itemId: item.id, filterFingerprint }), + 'utf8', + ).toString('base64url'); +} + +function decodeWorkBoardCursor( + cursor: string, +): { updatedAt: number; itemId: string; filterFingerprint: string } | null { + try { + const parsed: unknown = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + const { updatedAt, itemId, filterFingerprint } = record; + if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt) || updatedAt < 0) { + return null; + } + if (!isSafeWorkBoardId(itemId)) return null; + if ( + typeof filterFingerprint !== 'string' || + filterFingerprint.length === 0 || + filterFingerprint.length > 512 + ) { + return null; + } + return { updatedAt, itemId, filterFingerprint }; + } catch { + return null; + } +} + +function appendScopePredicate( + sql: string, + params: Array, + scope: WorkBoardScope, + projectIds?: readonly string[], +): string { + if (scope.kind === 'inbox') { + sql += ' AND scope_kind = ? AND project_id IS NULL'; + params.push('inbox'); + return sql; + } + if (projectIds && projectIds.length > 0) { + sql += ` AND scope_kind = ? AND project_id IN (${projectIds.map(() => '?').join(', ')})`; + params.push('project', ...projectIds); + return sql; + } + sql += ' AND scope_kind = ? AND project_id = ?'; + params.push('project', scope.projectId); + return sql; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/843ed7973606ff6618c2911320e422d5509729712a1f7a112c1ed452889eb8a6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/843ed7973606ff6618c2911320e422d5509729712a1f7a112c1ed452889eb8a6.source new file mode 100644 index 0000000000..ba09ef3abf --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/843ed7973606ff6618c2911320e422d5509729712a1f7a112c1ed452889eb8a6.source @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ShellRunPatch, ShellRunRecord, ShellRunStore } from '@maka/core/shell-run'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { createSqliteShellRunStore, type ClosableShellRunStore } from './shell-run-store.js'; + +const writerBrand: unique symbol = Symbol('InteractiveShellRunWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveShellRunWriter extends ShellRunStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + close(): void; +} + +export function authenticateInteractiveShellRunWriter( + writer: InteractiveShellRunWriter, +): InteractiveShellRunWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive ShellRun writer', + ); + } + return writer; +} + +export async function openInteractiveShellRunStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let store: ClosableShellRunStore | undefined; + try { + store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const opened = createSqliteShellRunStore(root); + try { + await opened.ready(); + return opened; + } catch (error) { + opened.close(); + throw error; + } + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: ClosableShellRunStore, +): InteractiveShellRunWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'ShellRun writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractiveShellRunWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + createShellRun: (record) => { + const accepted = cloneRecord(record); + return run(() => store.createShellRun(accepted)); + }, + updateShellRun: (sessionId, shellRunId, patch) => { + const accepted = clonePatch(patch); + return run(() => store.updateShellRun(sessionId, shellRunId, accepted)); + }, + readShellRun: (sessionId, shellRunId) => run(() => store.readShellRun(sessionId, shellRunId)), + listSessionShellRuns: (sessionId) => run(() => store.listSessionShellRuns(sessionId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + return Object.freeze(writer); +} + +function cloneRecord(record: ShellRunRecord): ShellRunRecord { + return structuredClone(record); +} + +function clonePatch(patch: ShellRunPatch): ShellRunPatch { + return structuredClone(patch); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/849fff466825150f84f0d75515fa9befa55492c0ff2a836576bb4eed28cbb42f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/849fff466825150f84f0d75515fa9befa55492c0ff2a836576bb4eed28cbb42f.source new file mode 100644 index 0000000000..e5ecb7352c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/849fff466825150f84f0d75515fa9befa55492c0ff2a836576bb4eed28cbb42f.source @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const [stateRoot, workspaceRoot, destination, resultPath, limitsJson, identityHex] = + process.argv.slice(2); +if ( + stateRoot === undefined || + workspaceRoot === undefined || + destination === undefined || + resultPath === undefined || + limitsJson === undefined || + identityHex === undefined +) { + process.exit(2); +} + +const originalLink = fs.promises.link.bind(fs.promises); +let capturedPath: string | undefined; +let temporaryPath: string | undefined; +fs.promises.link = async (existingPath, newPath) => { + temporaryPath = existingPath.toString(); + capturedPath = `${temporaryPath}.captured`; + await fs.promises.rename(temporaryPath, capturedPath); + await fs.promises.writeFile(temporaryPath, 'EVIL'); + await originalLink(existingPath, newPath); +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); +try { + await createSessionBundleFileService().pack({ + snapshot: { + stateRoot, + workspaceRoot, + stateIdentity: { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from(identityHex, 'hex'), + }, + }, + envelope: { + sessionId: 'cloud-session-1', + lastCommittedActivationId: 'activation-9', + }, + destination, + limits: JSON.parse(limitsJson), + }); + process.exit(3); +} catch (error) { + await fs.promises.writeFile( + resultPath, + JSON.stringify({ + code: error instanceof SessionBundleFileError ? error.code : 'unexpected', + capturedPath, + temporaryPath, + }), + ); + process.exit(error instanceof SessionBundleFileError && error.code === 'source_changed' ? 0 : 4); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84ad48a9cab2c64fc6da9687e90318eeeefa657980771d847cb872cdfb801f51.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84ad48a9cab2c64fc6da9687e90318eeeefa657980771d847cb872cdfb801f51.source new file mode 100644 index 0000000000..c2792e84e2 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84ad48a9cab2c64fc6da9687e90318eeeefa657980771d847cb872cdfb801f51.source @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { chmod, lstat, mkdir, type FileHandle } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { + openStableNativeLockFile, + releaseNativeFileLock, + tryAcquireNativeFileLock, +} from './native-file-lock.js'; + +export interface FileLifetimeOwner { + close(): Promise; +} + +export async function acquireFileLifetimeOwner(path: string): Promise { + const owner = await tryAcquireOpenedFileLifetimeOwner(path); + if (!owner) throw new Error(`Another process owns ${path}`); + return owner; +} + +/** Try once to own one named file for the lifetime of this process handle. */ +export async function tryAcquireFileLifetimeOwner( + path: string, +): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error(`File lifetime owner root is not a directory: ${directory}`); + } + if (process.platform !== 'win32') await chmod(directory, 0o700); + + return tryAcquireOpenedFileLifetimeOwner(path); +} + +async function tryAcquireOpenedFileLifetimeOwner( + path: string, +): Promise { + const handle = await openStableNativeLockFile(path); + let acquired: boolean; + try { + acquired = tryAcquireNativeFileLock(handle); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + if (!acquired) { + await handle.close(); + return undefined; + } + return new FileLifetimeOwnerImpl(handle); +} + +class FileLifetimeOwnerImpl implements FileLifetimeOwner { + #closeTask: Promise | undefined; + + constructor(private readonly handle: FileHandle) {} + + close(): Promise { + this.#closeTask ??= this.#close(); + return this.#closeTask; + } + + async #close(): Promise { + releaseNativeFileLock(this.handle); + await this.handle.close(); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84efe92201ff6e0228d10104ca2bf549bed29b6512e3208b4c19fbf59e2d97c3.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84efe92201ff6e0228d10104ca2bf549bed29b6512e3208b4c19fbf59e2d97c3.source new file mode 100644 index 0000000000..22b4d45b85 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/84efe92201ff6e0228d10104ca2bf549bed29b6512e3208b4c19fbf59e2d97c3.source @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import type { SessionHeader } from '@maka/core/session'; +import type { ExternalAgentId, ExternalSessionAdapterRegistry } from '@maka/core/external-session'; +import type { SessionAuthorityStore } from './session-store.js'; + +export type ExternalSessionImportTarget = Omit & { + cwd?: string; + name?: string; +}; + +export interface ExternalSessionImportRequest { + adapterId: ExternalAgentId; + sourceSessionId: string; + target: ExternalSessionImportTarget; +} + +/** + * Converts no formats itself. The selected adapter returns Maka StoredMessages; + * the importer only chooses target Session settings and commits them atomically. + */ +export class ExternalSessionImporter { + constructor( + private readonly adapters: ExternalSessionAdapterRegistry, + private readonly sessions: Pick, + ) {} + + async import(request: ExternalSessionImportRequest): Promise { + const adapter = this.adapters.require(request.adapterId); + const external = await adapter.readSession(request.sourceSessionId); + + return this.sessions.createImportedSession( + { + ...request.target, + cwd: request.target.cwd ?? external.metadata.cwd, + name: request.target.name ?? external.metadata.name, + }, + external.messages, + { + adapterId: request.adapterId, + sourceSessionId: request.sourceSessionId, + }, + ); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85bd0cc26fbca8a854c73db12d5425d4f8aa89ac42405e5af202a04f68e3a7b4.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85bd0cc26fbca8a854c73db12d5425d4f8aa89ac42405e5af202a04f68e3a7b4.source new file mode 100644 index 0000000000..5a2dd03cd7 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85bd0cc26fbca8a854c73db12d5425d4f8aa89ac42405e5af202a04f68e3a7b4.source @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { compileCronExpression } from '@maka/core/cron-expression'; +import { SCHEDULED_TASK_CRON_MAX_CHARS } from '@maka/core/scheduled-task'; + +/** Converts the one released Plan Reminder grammar difference into current cron syntax. */ +export function canonicalizeLegacyPlanReminderCronExpression(expression: string): string { + if ([...expression].length > SCHEDULED_TASK_CRON_MAX_CHARS) throw invalidCron(expression); + const parts = expression.split(' '); + if (parts.length !== 5 || !compileCronExpression(expression).ok) throw invalidCron(expression); + const canonical = parts + .map((field) => + field + .split(',') + .map((token) => token.match(/^(\d+)\/\d+$/)?.[1] ?? token) + .join(','), + ) + .join(' '); + if (!compileCronExpression(canonical).ok) throw invalidCron(expression); + return canonical; +} + +function invalidCron(expression: string): Error { + return new Error(`Invalid legacy Plan Reminder cron expression: ${expression}`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85c52c63fe0656944c59fb26d46702cfffbc0350b9d4c2138084e39099dffbaf.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85c52c63fe0656944c59fb26d46702cfffbc0350b9d4c2138084e39099dffbaf.source new file mode 100644 index 0000000000..4ab8ddd4e1 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/85c52c63fe0656944c59fb26d46702cfffbc0350b9d4c2138084e39099dffbaf.source @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import type { + ApplyMemoryMutationsRequest, + CommitMemoryExtractionRequest, + MemoryItemStore, + MemoryItemWrite, + SearchMemoryItemsByKeyRequest, +} from '@maka/core/long-term-memory'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { SqliteMemoryItemStore } from './sqlite-long-term-memory-store.js'; + +export { SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION } from './sqlite-long-term-memory-schema.js'; + +export const LONG_TERM_MEMORY_DATABASE_NAME = 'memory.sqlite'; + +const writerBrand: unique symbol = Symbol('InteractiveLongTermMemoryWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveLongTermMemoryWriter extends MemoryItemStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + close(): void; +} + +export function authenticateInteractiveLongTermMemoryWriter( + writer: InteractiveLongTermMemoryWriter, +): InteractiveLongTermMemoryWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive long-term memory writer', + ); + } + return writer; +} + +/** + * Open the dedicated memory.sqlite through an authenticated Storage Root lease. + * Production code must use this facade rather than opening the low-level Store. + */ +export function openInteractiveLongTermMemoryStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + return openLongTermMemoryStoreForWrite(lease); +} + +async function openLongTermMemoryStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let store: SqliteMemoryItemStore | undefined; + try { + store = await runWithStorageRootLease( + lease, + 'interactive', + 'write', + async (root) => new SqliteMemoryItemStore(join(root, LONG_TERM_MEMORY_DATABASE_NAME)), + ); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: SqliteMemoryItemStore, +): InteractiveLongTermMemoryWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Long-term memory writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractiveLongTermMemoryWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + applyMutations: (request) => { + const snapshot = snapshotApplyRequest(request); + return run(() => store.applyMutations(snapshot)); + }, + commitExtraction: (request) => { + const snapshot = snapshotCommitExtractionRequest(request); + return run(() => store.commitExtraction(snapshot)); + }, + initializeExtractionCursor: (sessionId, processedOrdinal) => + run(() => store.initializeExtractionCursor(sessionId, processedOrdinal)), + readExtractionCursor: (sessionId) => run(() => store.readExtractionCursor(sessionId)), + readPendingExtractionFailure: (sessionId) => + run(() => store.readPendingExtractionFailure(sessionId)), + recordCompactionPolicyDenial: (denial) => { + const snapshot = Object.freeze({ ...denial }); + return run(() => store.recordCompactionPolicyDenial(snapshot)); + }, + readCompactionPolicyDenials: (sessionId) => + run(() => store.readCompactionPolicyDenials(sessionId)), + settleExtractionFailure: (request) => { + const snapshot = Object.freeze({ ...request }); + return run(() => store.settleExtractionFailure(snapshot)); + }, + readExtractionReceipt: (operationId) => run(() => store.readExtractionReceipt(operationId)), + readItem: (itemId) => run(() => store.readItem(itemId)), + searchByKeys: (request) => { + const snapshot = snapshotSearchRequest(request); + return run(() => store.searchByKeys(snapshot)); + }, + readOperation: (operationId) => run(() => store.readOperation(operationId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + return Object.freeze(writer); +} + +function snapshotCommitExtractionRequest( + request: CommitMemoryExtractionRequest, +): CommitMemoryExtractionRequest { + return Object.freeze({ + operationId: request.operationId, + sessionId: request.sessionId, + expectedCursorOrdinal: request.expectedCursorOrdinal, + nextCursorOrdinal: request.nextCursorOrdinal, + coverageHash: request.coverageHash, + items: Object.freeze(request.items.map(snapshotItemWrite)), + requestedItemIndexes: Object.freeze([...request.requestedItemIndexes]), + ...(request.noOpReason ? { noOpReason: request.noOpReason } : {}), + ...(request.skipReason ? { skipReason: request.skipReason } : {}), + trigger: request.trigger, + ...(request.compactionCheckpointId + ? { compactionCheckpointId: request.compactionCheckpointId } + : {}), + }); +} + +function snapshotApplyRequest(request: ApplyMemoryMutationsRequest): ApplyMemoryMutationsRequest { + return Object.freeze({ + operationId: request.operationId, + mutations: Object.freeze( + request.mutations.map((mutation) => { + if (mutation.type === 'create') { + return Object.freeze({ type: mutation.type, item: snapshotItemWrite(mutation.item) }); + } + if (mutation.type === 'update') { + return Object.freeze({ + type: mutation.type, + itemId: mutation.itemId, + expectedVersion: mutation.expectedVersion, + item: snapshotItemWrite(mutation.item), + }); + } + return Object.freeze({ + type: mutation.type, + itemId: mutation.itemId, + expectedVersion: mutation.expectedVersion, + }); + }), + ), + }); +} + +function snapshotItemWrite(item: MemoryItemWrite): MemoryItemWrite { + return Object.freeze({ + ...item, + keys: Object.freeze(item.keys.map((key) => Object.freeze({ ...key }))), + sources: Object.freeze(item.sources.map((source) => Object.freeze({ ...source }))), + }); +} + +function snapshotSearchRequest( + request: SearchMemoryItemsByKeyRequest, +): SearchMemoryItemsByKeyRequest { + return Object.freeze({ ...request, terms: Object.freeze([...request.terms]) }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8881d69156be2045a7d4bf371839f7590e7cb385f19340747f041663e6d119c5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8881d69156be2045a7d4bf371839f7590e7cb385f19340747f041663e6d119c5.source new file mode 100644 index 0000000000..9091932e61 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8881d69156be2045a7d4bf371839f7590e7cb385f19340747f041663e6d119c5.source @@ -0,0 +1,421 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MODEL_CALL_KINDS, + type LlmCallRecord, + type ToolInvocationRecord, +} from '@maka/core/usage-stats/types'; +import { isContextBudgetDiagnostic, isPromptSegmentEstimate } from '@maka/core/usage-record-schema'; + +export type PersistedLlmCallRecord = LlmCallRecord & { + id: string; + cacheHitInputTokens: number; + cacheMissInputTokens: number; + cachedInputTokens: number; + cacheWriteInputTokens: number; + reasoningTokens: number; + totalTokens: number; + costUsd: number; + date: string; + ts: number; +}; + +export type PersistedToolInvocationRecord = ToolInvocationRecord & { + id: string; + argsSummary?: string; + bytesIn: number; + bytesOut: number; + date: string; + ts: number; +}; + +type ExactKeyShape = { + readonly [Key in keyof Value]-?: true; +}; + +function exactKeys(shape: ExactKeyShape): ReadonlySet { + return new Set(Object.keys(shape)); +} + +const LLM_KEYS = exactKeys({ + sessionId: true, + turnId: true, + callKind: true, + callId: true, + connectionSlug: true, + providerId: true, + modelId: true, + inputTokens: true, + outputTokens: true, + cacheHitInputTokens: true, + cacheMissInputTokens: true, + cachedInputTokens: true, + cacheWriteInputTokens: true, + reasoningTokens: true, + totalTokens: true, + rawFinishReason: true, + rawUsage: true, + latencyMs: true, + status: true, + errorClass: true, + costUsd: true, + startedAt: true, + systemPromptHash: true, + prefixHash: true, + prefixChangeReason: true, + requestShapeHash: true, + requestShapeChangeReason: true, + toolSchemaChangeReason: true, + toolAvailability: true, + cacheMissInputSource: true, + promptSegments: true, + contextBudget: true, + id: true, + date: true, + ts: true, +}); +const TOOL_KEYS = exactKeys({ + sessionId: true, + turnId: true, + toolCallId: true, + toolName: true, + providerId: true, + modelId: true, + durationMs: true, + status: true, + errorClass: true, + argsSummary: true, + resultSummary: true, + bytesIn: true, + bytesOut: true, + startedAt: true, + id: true, + date: true, + ts: true, +}); +const PREFIX_CHANGE_REASONS = new Set([ + 'first_turn', + 'system_prompt_changed', + 'tool_schema_changed', + 'provider_options_changed', + 'model_or_provider_changed', + 'history_projection_changed', + 'stable', + 'unknown', +]); +const TOOL_SCHEMA_CHANGE_REASONS = new Set([ + 'tool_schema_changed', + 'tool_source_enabled', + 'tool_source_state_changed', +]); + +export function decodePersistedLlmCallRecord(input: unknown): PersistedLlmCallRecord { + if (!isRecord(input) || !hasOnlyKeys(input, LLM_KEYS)) throw invalid('invalid LLM row keys'); + if (!strings(input, ['id', 'providerId', 'modelId', 'date'])) { + throw invalid('invalid required LLM string'); + } + if ( + !nonNegativeNumbers(input, [ + 'inputTokens', + 'outputTokens', + 'cacheHitInputTokens', + 'cacheMissInputTokens', + 'cachedInputTokens', + 'cacheWriteInputTokens', + 'reasoningTokens', + 'totalTokens', + 'latencyMs', + 'costUsd', + 'startedAt', + 'ts', + ]) + ) { + throw invalid('invalid required LLM number'); + } + if (input.cachedInputTokens !== input.cacheHitInputTokens) { + throw invalid('cachedInputTokens must equal cacheHitInputTokens'); + } + if (!['success', 'error', 'aborted'].includes(input.status as string)) { + throw invalid('invalid LLM status'); + } + if ( + !optionalStrings(input, [ + 'sessionId', + 'turnId', + 'callId', + 'connectionSlug', + 'rawFinishReason', + 'errorClass', + 'systemPromptHash', + 'prefixHash', + 'requestShapeHash', + ]) + ) { + throw invalid('invalid optional LLM string'); + } + if (!optionalEnum(input.callKind, new Set(MODEL_CALL_KINDS))) { + throw invalid('invalid callKind'); + } + if (!optionalEnum(input.prefixChangeReason, PREFIX_CHANGE_REASONS)) { + throw invalid('invalid prefixChangeReason'); + } + if (!optionalEnum(input.requestShapeChangeReason, PREFIX_CHANGE_REASONS)) { + throw invalid('invalid requestShapeChangeReason'); + } + if (!optionalEnum(input.toolSchemaChangeReason, TOOL_SCHEMA_CHANGE_REASONS)) { + throw invalid('invalid toolSchemaChangeReason'); + } + if (!optionalEnum(input.cacheMissInputSource, new Set(['explicit', 'derived']))) { + throw invalid('invalid cacheMissInputSource'); + } + if (input.rawUsage !== undefined && !isRawUsage(input.rawUsage)) { + throw invalid('invalid rawUsage'); + } + if (input.toolAvailability !== undefined && !isToolAvailability(input.toolAvailability)) { + throw invalid('invalid toolAvailability'); + } + if ( + input.promptSegments !== undefined && + (!Array.isArray(input.promptSegments) || + !input.promptSegments.every( + (segment) => isPromptSegmentEstimate(segment) && hasNoNegativeNumbers(segment), + )) + ) { + throw invalid('invalid promptSegments'); + } + if ( + input.contextBudget !== undefined && + (!isContextBudgetDiagnostic(input.contextBudget) || + !contextBudgetCountsAreNonNegative(input.contextBudget)) + ) { + throw invalid('invalid contextBudget'); + } + return cloneAndFreeze(input) as unknown as PersistedLlmCallRecord; +} + +export function decodePersistedToolInvocationRecord(input: unknown): PersistedToolInvocationRecord { + if (!isRecord(input) || !hasOnlyKeys(input, TOOL_KEYS)) throw invalid('invalid tool row keys'); + if (!strings(input, ['id', 'toolName', 'date'])) { + throw invalid('invalid required tool string'); + } + if (!nonNegativeNumbers(input, ['durationMs', 'bytesIn', 'bytesOut', 'startedAt', 'ts'])) { + throw invalid('invalid required tool number'); + } + if (!['success', 'error', 'aborted'].includes(input.status as string)) { + throw invalid('invalid tool status'); + } + if ( + !optionalStrings(input, [ + 'sessionId', + 'turnId', + 'toolCallId', + 'providerId', + 'modelId', + 'errorClass', + 'argsSummary', + ]) + ) { + throw invalid('invalid optional tool string'); + } + if (input.resultSummary !== undefined && !isToolResultSummary(input.resultSummary)) { + throw invalid('invalid tool resultSummary'); + } + return cloneAndFreeze(input) as unknown as PersistedToolInvocationRecord; +} + +const RAW_USAGE_KEYS = new Set([ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'prompt_cache_hit_tokens', + 'prompt_cache_miss_tokens', + 'prompt_tokens_details', + 'completion_tokens_details', +]); +const RAW_USAGE_NUMBERS = [ + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'prompt_cache_hit_tokens', + 'prompt_cache_miss_tokens', +] as const; + +function isRawUsage(input: unknown): boolean { + return ( + isRecord(input) && + hasOnlyKeys(input, RAW_USAGE_KEYS) && + optionalNonNegativeNumbers(input, RAW_USAGE_NUMBERS) && + isTokenDetails(input.prompt_tokens_details, 'cached_tokens') && + isTokenDetails(input.completion_tokens_details, 'reasoning_tokens') + ); +} + +function isTokenDetails(input: unknown, key: string): boolean { + return ( + input === undefined || + (isRecord(input) && hasOnlyKeys(input, new Set([key])) && optionalNonNegative(input[key])) + ); +} + +const TOOL_AVAILABILITY_NUMBERS = [ + 'visibleToolCount', + 'fullToolCount', + 'hiddenToolCount', + 'visibleToolSchemaChars', + 'fullToolSchemaChars', + 'toolSchemaCharReduction', + 'estimatedToolSchemaTokenReduction', +] as const; +const TOOL_AVAILABILITY_KEYS = new Set([ + 'mode', + 'enabledSourceIds', + 'availableSourceIds', + 'connectorToolName', + 'visibleToolNamesBySource', + ...TOOL_AVAILABILITY_NUMBERS, +]); + +function isToolAvailability(input: unknown): boolean { + return ( + isRecord(input) && + hasOnlyKeys(input, TOOL_AVAILABILITY_KEYS) && + (input.mode === 'economy' || input.mode === 'search') && + isStringArray(input.enabledSourceIds) && + optionalStringArray(input.availableSourceIds) && + optionalString(input.connectorToolName) && + (input.visibleToolNamesBySource === undefined || + (isRecord(input.visibleToolNamesBySource) && + Object.values(input.visibleToolNamesBySource).every(isStringArray))) && + optionalNonNegativeNumbers(input, TOOL_AVAILABILITY_NUMBERS) + ); +} + +function isToolResultSummary(input: unknown): boolean { + const numberKeys = [ + 'itemCount', + 'startedItemCount', + 'completedItemCount', + 'failedItemCount', + 'cancelledItemCount', + 'artifactCount', + ] as const; + return ( + isRecord(input) && + hasOnlyKeys(input, new Set(['kind', 'status', ...numberKeys])) && + typeof input.kind === 'string' && + optionalString(input.status) && + optionalNonNegativeNumbers(input, numberKeys) + ); +} + +function hasOnlyKeys(value: Record, allowed: ReadonlySet): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function strings(value: Record, keys: readonly string[]): boolean { + return keys.every((key) => typeof value[key] === 'string'); +} + +function optionalStrings(value: Record, keys: readonly string[]): boolean { + return keys.every((key) => optionalString(value[key])); +} + +function nonNegativeNumbers(value: Record, keys: readonly string[]): boolean { + return keys.every((key) => isNonNegativeFinite(value[key])); +} + +function optionalNonNegativeNumbers( + value: Record, + keys: readonly string[], +): boolean { + return keys.every((key) => optionalNonNegative(value[key])); +} + +function optionalNonNegative(value: unknown): boolean { + return value === undefined || isNonNegativeFinite(value); +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function optionalEnum(value: unknown, allowed: ReadonlySet): boolean { + return value === undefined || (typeof value === 'string' && allowed.has(value)); +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function optionalStringArray(value: unknown): boolean { + return value === undefined || isStringArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function hasNoNegativeNumbers(value: unknown): boolean { + if (typeof value === 'number') return value >= 0; + if (Array.isArray(value)) return value.every(hasNoNegativeNumbers); + if (isRecord(value)) return Object.values(value).every(hasNoNegativeNumbers); + return true; +} + +function contextBudgetCountsAreNonNegative(value: unknown): boolean { + if (!isRecord(value)) return hasNoNegativeNumbers(value); + return Object.entries(value).every(([key, entry]) => + key === 'compactionDecisions' + ? entry === undefined || + (Array.isArray(entry) && entry.every(compactionDecisionCountsAreNonNegative)) + : hasNoNegativeNumbers(entry), + ); +} + +function compactionDecisionCountsAreNonNegative(value: unknown): boolean { + if (!isRecord(value)) return false; + return Object.entries(value).every( + ([key, entry]) => key === 'estimatedTokensSaved' || hasNoNegativeNumbers(entry), + ); +} + +function cloneAndFreeze(value: T): T { + let clone: T; + try { + clone = structuredClone(value); + } catch (error) { + throw invalid(`record must be structured-cloneable: ${String(error)}`); + } + return deepFreeze(clone); +} + +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value)) deepFreeze(nested); + return Object.freeze(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function invalid(message: string): Error { + return new Error(`Invalid telemetry record: ${message}`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8939b0f4e3e262644694d0637555d14d3c97f218a7bea77a635c306d73cac369.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8939b0f4e3e262644694d0637555d14d3c97f218a7bea77a635c306d73cac369.source new file mode 100644 index 0000000000..e6c61a7650 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8939b0f4e3e262644694d0637555d14d3c97f218a7bea77a635c306d73cac369.source @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION } from '@maka/core/agent-graph-control'; +import { AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION } from '@maka/core/agent-graph-schedule'; +import { AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION } from '@maka/core/agent-graph-supervisor-wake'; +import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; + +describe('SQLite agent graph timeline metadata', () => { + test('reads one graph control-plane snapshot with current admission and wake attempts', async () => { + let now = 100; + const store = createSqliteSessionMetadataStore(':memory:', { now: () => now++ }); + try { + await store.commitAgentGraphScheduleUpdate({ + schemaVersion: AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION, + updateId: `graph_update_${'a'.repeat(32)}`, + updateFingerprint: `sha256:${'b'.repeat(64)}`, + graphId: 'graph-1', + source: { + sessionId: 'root-session', + runId: 'root-run', + turnId: 'root-turn', + toolCallId: 'schedule-call', + }, + addWork: [ + { + workId: `graph_work_${'c'.repeat(32)}`, + target: { kind: 'operator', operatorId: 'operator-1' }, + instruction: 'Sensitive schedule instruction.', + inputIds: [], + }, + ], + stop: [], + }); + await store.claimAgentGraphIntentAtScheduleRevision( + { + schemaVersion: AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, + claimId: `graph_claim_${'d'.repeat(32)}`, + graphId: 'graph-1', + intentId: `graph_intent_${'e'.repeat(32)}`, + intentFingerprint: `sha256:${'f'.repeat(64)}`, + readinessContextFingerprint: `sha256:${'1'.repeat(64)}`, + targetOperatorId: 'operator-1', + targetSessionId: 'child-session', + targetTurnId: 'child-turn', + targetRunId: 'child-run', + }, + 1, + ); + await store.beginAgentGraphIntentExecutionAtScheduleRevision( + 'graph-1', + `graph_intent_${'e'.repeat(32)}`, + 1, + ); + await store.claimAgentGraphSupervisorWake({ + schemaVersion: AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + graphId: 'graph-1', + wakeId: 'wake-1', + snapshotVersion: 'snapshot-1', + rootSessionId: 'root-session', + }); + await store.beginAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-1', + turnId: 'wake-turn', + }); + await store.completeAgentGraphSupervisorWakeAttempt({ + graphId: 'graph-1', + wakeId: 'wake-1', + attemptId: 'attempt-1', + status: 'delivered', + }); + + const snapshot = await store.readAgentGraphTimelineMetadata('graph-1'); + assert.equal(snapshot.graphId, 'graph-1'); + assert.equal(snapshot.scheduleUpdates.length, 1); + assert.equal(snapshot.operatorProvisions.length, 0); + assert.equal(snapshot.intentClaims.length, 1); + assert.deepEqual(snapshot.intentAdmissions, [ + { + graphId: 'graph-1', + intentId: `graph_intent_${'e'.repeat(32)}`, + state: 'executing', + updatedAt: 102, + }, + ]); + assert.equal(snapshot.supervisorWakes.length, 1); + assert.equal(snapshot.supervisorWakes[0]?.wake.status, 'delivered'); + assert.deepEqual( + snapshot.supervisorWakes[0]?.attempts.map((attempt) => ({ + attemptId: attempt.attemptId, + status: attempt.status, + startedAt: attempt.startedAt, + completedAt: attempt.completedAt, + })), + [ + { + attemptId: 'attempt-1', + status: 'delivered', + startedAt: 104, + completedAt: 105, + }, + ], + ); + assert.deepEqual(await store.readAgentGraphTimelineMetadata('another-graph'), { + graphId: 'another-graph', + scheduleUpdates: [], + operatorProvisions: [], + intentClaims: [], + intentAdmissions: [], + supervisorWakes: [], + }); + } finally { + store.close(); + } + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8985912bc57fdddd12a6a0629d8271dd0f510370378aa45dd5894cf2e3cac104.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8985912bc57fdddd12a6a0629d8271dd0f510370378aa45dd5894cf2e3cac104.source new file mode 100644 index 0000000000..4617f90f40 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8985912bc57fdddd12a6a0629d8271dd0f510370378aa45dd5894cf2e3cac104.source @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + decodePersistedLegacyRunHeader, + invocationOpeningFromLegacyRunHeader, + type LegacyRunHeader, +} from '../legacy-run-header.js'; + +describe('legacy Run header decoding', () => { + test('rejects a header with multiple hosted root authorities', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader({ + ...runHeader(), + scheduledTaskId: 'scheduled-task-1', + goalId: 'goal-1', + }), + /Invalid AgentRun header schema/, + ); + }); + + test('folds every retired persisted value', () => { + const decoded = decodePersistedLegacyRunHeader({ + ...runHeader(), + status: 'waiting_permission', + permissionMode: 'execute', + automationId: 'automation-1', + }); + assert.equal(decoded.status, 'waiting_for_user'); + assert.equal(decoded.permissionMode, 'ask'); + assert.equal(decoded.legacyAutomationId, 'automation-1'); + assert.equal(Object.hasOwn(decoded, 'automationId'), false); + }); + + test('accepts both bound and legacy connection identity', () => { + assert.equal(decodePersistedLegacyRunHeader(runHeader()).llmConnectionId, undefined); + const bound = decodePersistedLegacyRunHeader({ + ...runHeader(), + llmConnectionId: '11111111-1111-4111-8111-111111111111', + }); + assert.equal(bound.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.throws( + () => decodePersistedLegacyRunHeader({ ...runHeader(), llmConnectionId: '' }), + /Invalid AgentRun header schema/, + ); + }); + + test('projects an unbound connection as an unauthenticated route', () => { + const opening = invocationOpeningFromLegacyRunHeader( + decodePersistedLegacyRunHeader(runHeader()), + ); + assert.equal(opening.route.provenance, 'unknown'); + assert.equal(opening.source.kind, 'fresh'); + }); +}); + +describe('legacy continuation source decoding', () => { + test('rejects a V2 replay manifest that does not identify its boundary', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader( + headerWithContinuation({ + ...validV2ContinuationSource(), + replayManifestDigest: `sha256:${'c'.repeat(64)}`, + }), + ), + /Invalid AgentRun header schema/, + ); + }); + + test('projects a V2 source onto the opening fact', () => { + const header = decodePersistedLegacyRunHeader( + headerWithContinuation(validV2ContinuationSource()), + ); + const source = invocationOpeningFromLegacyRunHeader(header).source; + assert.equal(source.kind, 'continuation'); + if (source.kind !== 'continuation') throw new Error('unreachable'); + assert.equal(source.claimId, 'claim-1'); + assert.equal(source.sourceRunId, 'source-run'); + }); +}); + +function runHeader(): Record { + return { + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'created', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/workspace', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 1, + }; +} + +function headerWithContinuation( + continuationSource: LegacyRunHeader['continuationSource'], +): Record { + return { + ...runHeader(), + runId: 'target-run', + invocationId: 'target-invocation', + turnId: 'target-turn', + continuationSource, + }; +} + +function validV2ContinuationSource(): Extract< + NonNullable, + { protocol: 'continuation_source_v2' } +> { + return { + protocol: 'continuation_source_v2', + claimId: 'claim-1', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 1, + sourcePrefixDigest: `sha256:${'b'.repeat(64)}`, + replayManifestDigest: `sha256:${'a'.repeat(64)}`, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/89df39ff4c27acff065c1d58502c3e3f8c060a1ea2f3d6422204e71e2eb61687.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/89df39ff4c27acff065c1d58502c3e3f8c060a1ea2f3d6422204e71e2eb61687.source new file mode 100644 index 0000000000..0a5ff08694 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/89df39ff4c27acff065c1d58502c3e3f8c060a1ea2f3d6422204e71e2eb61687.source @@ -0,0 +1,1177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { + createFileSessionSnapshotStagingCleanupAuthority, + createFileQuiescentSessionSnapshotCoordinator, + type SessionSnapshotCancellation, + SessionSnapshotError, + type SessionSnapshotQuiescenceAuthority, + type SessionSnapshotStatePreparer, + type SessionSnapshotStagingCleanupAuthority, + type SessionSnapshotWorkspacePreparation, + type SessionSnapshotWorkspacePreparer, + SESSION_SNAPSHOT_WORKSPACE_POLICY_V1, +} from '../quiescent-session-snapshot.js'; +import { + acquireProcessLifetimeOwner, + type ProcessLifetimeOwner, +} from '../process-lifetime-owner.js'; + +const roots: string[] = []; +const processLifetimeOwners: ProcessLifetimeOwner[] = []; + +afterEach(async () => { + await Promise.all(processLifetimeOwners.splice(0).map((owner) => owner.close())); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test('prepares state and workspace under one quiescence boundary, then releases live writers', async () => { + const fixture = await createFixture(); + await writeFile(join(fixture.liveStateRoot, 'runtime.sqlite'), 'state-at-boundary', 'utf8'); + await writeFile(join(fixture.liveWorkspaceRoot, 'main.ts'), 'workspace-at-boundary', 'utf8'); + const events: string[] = []; + let quiescent = false; + + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + assert.equal(quiescent, false); + quiescent = true; + events.push('enter'); + try { + return await operation(); + } finally { + quiescent = false; + events.push('exit'); + } + }, + }, + state: { + async prepareState(input) { + assert.equal(quiescent, true); + events.push('state'); + await mkdir(input.destinationRoot); + await copyFile( + join(fixture.liveStateRoot, 'runtime.sqlite'), + join(input.destinationRoot, 'runtime.sqlite'), + ); + return { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from('{"makaSessionId":"session-1"}', 'utf8'), + }; + }, + }, + workspace: { + async prepareWorkspace(input) { + assert.equal(quiescent, true); + assert.equal(input.policy, SESSION_SNAPSHOT_WORKSPACE_POLICY_V1); + events.push('workspace'); + await mkdir(input.destinationRoot); + await copyFile( + join(fixture.liveWorkspaceRoot, 'main.ts'), + join(input.destinationRoot, 'main.ts'), + ); + return workspaceResult({ includedEntries: 1 }); + }, + }, + }).prepare({ makaSessionId: 'session-1' }); + + assert.deepEqual(events, ['enter', 'state', 'workspace', 'exit']); + assert.equal(quiescent, false); + assert.notEqual(handle.snapshot.stateRoot, fixture.liveStateRoot); + assert.notEqual(handle.snapshot.workspaceRoot, fixture.liveWorkspaceRoot); + assert.equal( + await readFile(join(handle.snapshot.stateRoot, 'runtime.sqlite'), 'utf8'), + 'state-at-boundary', + ); + assert.equal( + await readFile(join(handle.snapshot.workspaceRoot, 'main.ts'), 'utf8'), + 'workspace-at-boundary', + ); + + await writeFile(join(fixture.liveStateRoot, 'runtime.sqlite'), 'later-state', 'utf8'); + await writeFile(join(fixture.liveWorkspaceRoot, 'main.ts'), 'later-workspace', 'utf8'); + assert.equal( + await readFile(join(handle.snapshot.stateRoot, 'runtime.sqlite'), 'utf8'), + 'state-at-boundary', + ); + assert.equal( + await readFile(join(handle.snapshot.workspaceRoot, 'main.ts'), 'utf8'), + 'workspace-at-boundary', + ); + assert.deepEqual(handle.workspace, workspaceResult({ includedEntries: 1 })); + + const publishedRoot = dirname(handle.snapshot.stateRoot); + const cleanupRename = interceptSnapshotCleanupRename(publishedRoot, async () => { + await mkdir(publishedRoot, { mode: 0o700 }); + await writeFile(join(publishedRoot, 'replacement.txt'), 'keep', 'utf8'); + }); + await Promise.all([handle.release(), handle.release()]); + await cleanupRename.completed; + assert.equal(await readFile(join(publishedRoot, 'replacement.txt'), 'utf8'), 'keep'); + await rm(publishedRoot, { recursive: true }); + await handle.release(); +}); + +test('serializes concurrent preparations for the same Session through the authority contract', async () => { + const fixture = await createFixture(); + const authority = new SerialQuiescenceAuthority(); + const firstWorkspaceEntered = deferred(); + const allowFirstWorkspace = deferred(); + let statePreparations = 0; + + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: authority, + state: { + async prepareState(input) { + statePreparations += 1; + await mkdir(input.destinationRoot); + return stateIdentity(input.makaSessionId); + }, + }, + workspace: { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + if (statePreparations === 1) { + firstWorkspaceEntered.resolve(); + await allowFirstWorkspace.promise; + } + return workspaceResult(); + }, + }, + }); + + const first = coordinator.prepare({ makaSessionId: 'same-session' }); + await firstWorkspaceEntered.promise; + const second = coordinator.prepare({ makaSessionId: 'same-session' }); + await Promise.resolve(); + assert.equal(statePreparations, 1); + assert.deepEqual(authority.activeSessions, ['same-session']); + + allowFirstWorkspace.resolve(); + const firstHandle = await first; + const secondHandle = await second; + assert.equal(statePreparations, 2); + assert.equal(authority.maximumConcurrentBySession.get('same-session'), 1); + await Promise.all([firstHandle.release(), secondHandle.release()]); +}); + +test('does not globally serialize preparations for different Sessions', async () => { + const fixture = await createFixture(); + const authority = new SerialQuiescenceAuthority(); + const firstWorkspaceEntered = deferred(); + const allowFirstWorkspace = deferred(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: authority, + state: directoryStatePreparer, + workspace: { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + if (input.makaSessionId === 'session-a') { + firstWorkspaceEntered.resolve(); + await allowFirstWorkspace.promise; + } + return workspaceResult(); + }, + }, + }); + + const first = coordinator.prepare({ makaSessionId: 'session-a' }); + await firstWorkspaceEntered.promise; + const secondHandle = await coordinator.prepare({ makaSessionId: 'session-b' }); + assert.deepEqual(authority.activeSessions, ['session-a']); + assert.equal(authority.maximumConcurrentBySession.get('session-a'), 1); + assert.equal(authority.maximumConcurrentBySession.get('session-b'), 1); + + allowFirstWorkspace.resolve(); + const firstHandle = await first; + await Promise.all([firstHandle.release(), secondHandle.release()]); +}); + +test('removes partial staging and preserves a stable policy rejection', async () => { + const fixture = await createFixture(); + const rejection = new SessionSnapshotError( + 'policy_rejected', + 'Workspace snapshot policy rejected an entry', + { details: { phase: 'workspace', policyCategory: 'known_secret_file' } }, + ); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: { + async prepareState(input) { + await mkdir(input.destinationRoot); + await writeFile(join(input.destinationRoot, 'runtime.sqlite'), 'partial', 'utf8'); + return stateIdentity(input.makaSessionId); + }, + }, + workspace: { + async prepareWorkspace() { + throw rejection; + }, + }, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'session-secret' }), + (error: unknown) => error === rejection, + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); + assert.equal(rejection.message.includes('.env'), false); +}); + +test('failure cleanup refuses a staging root replaced by an unrelated directory', async () => { + const fixture = await createFixture(); + let unrelatedFile = ''; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: { + async prepareWorkspace(input) { + const preparingRoot = dirname(input.destinationRoot); + await rename(preparingRoot, `${preparingRoot}.displaced`); + await mkdir(preparingRoot, { mode: 0o700 }); + unrelatedFile = join(preparingRoot, 'unrelated.txt'); + await writeFile(unrelatedFile, 'keep', 'utf8'); + throw new Error('workspace preparation failed'); + }, + }, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'failure-cleanup-owner' }), + (error: unknown) => { + assert.equal(error instanceof SessionSnapshotError && error.code, 'io_failure'); + assert.deepEqual(error instanceof SessionSnapshotError && error.details, { + cleanupFailed: true, + }); + return true; + }, + ); + assert.equal(await readFile(unrelatedFile, 'utf8'), 'keep'); +}); + +test('cleans a published snapshot when the authority fails while releasing quiescence', async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + await operation(); + throw new Error('authority release failed'); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'session-release-failure' }), + (error: unknown) => + error instanceof SessionSnapshotError && + error.code === 'io_failure' && + error.message === 'Session snapshot preparation failed', + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('cancellation and an expired deadline stop before staging begins', async () => { + const fixture = await createFixture(); + let authorityCalls = 0; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + now: () => 10_000, + quiescence: { + async runQuiescent(_input, operation) { + authorityCalls += 1; + return operation(); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'cancelled', signal: controller.signal }), + isSnapshotError('snapshot_cancelled'), + ); + await assert.rejects( + coordinator.prepare({ makaSessionId: 'expired', deadlineAt: 10_000 }), + isSnapshotError('snapshot_cancelled'), + ); + assert.equal(authorityCalls, 0); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('a deadline beyond the Node timer limit is rescheduled until the absolute time', async (t) => { + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }); + const fixture = await createFixture(); + const authorityEntered = deferred(); + let cancellationSignal: AbortSignal | undefined; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + now: Date.now, + quiescence: { + async runQuiescent(input) { + cancellationSignal = input.cancellation.signal; + authorityEntered.resolve(); + await waitForAbort(input.cancellation); + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + const timerLimit = 2_147_483_647; + const preparation = coordinator.prepare({ + makaSessionId: 'long-deadline', + deadlineAt: timerLimit + 1_000, + }); + await authorityEntered.promise; + + t.mock.timers.tick(timerLimit); + assert.equal(cancellationSignal?.aborted, false); + t.mock.timers.tick(999); + assert.equal(cancellationSignal?.aborted, false); + t.mock.timers.tick(1); + await assert.rejects(preparation, isSnapshotError('snapshot_cancelled')); +}); + +test('cancellation while waiting for quiescence is propagated as snapshot_cancelled', async () => { + const fixture = await createFixture(); + const authorityEntered = deferred(); + const controller = new AbortController(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(input) { + authorityEntered.resolve(); + await waitForAbort(input.cancellation); + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + const preparation = coordinator.prepare({ makaSessionId: 'waiting', signal: controller.signal }); + await authorityEntered.promise; + controller.abort(); + await assert.rejects(preparation, isSnapshotError('snapshot_cancelled')); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('cancellation while leaving quiescence cleans the published snapshot', async () => { + const fixture = await createFixture(); + const controller = new AbortController(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + const result = await operation(); + controller.abort(); + return result; + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'cancel-after-publish', signal: controller.signal }), + isSnapshotError('snapshot_cancelled'), + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('an I/O failure racing cancellation remains an I/O failure', async () => { + const fixture = await createFixture(); + const controller = new AbortController(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + controller.abort(); + throw Object.assign(new Error('workspace disk failed'), { code: 'EIO' }); + }, + }, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'io-racing-cancel', signal: controller.signal }), + (error: unknown) => + error instanceof SessionSnapshotError && + error.code === 'io_failure' && + error.cause instanceof Error && + error.cause.message === 'workspace disk failed', + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('release refuses a replacement directory and remains retryable for its owned root', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }).prepare({ makaSessionId: 'release-owner' }); + const publishedRoot = dirname(handle.snapshot.stateRoot); + const displacedRoot = `${publishedRoot}.displaced`; + await rename(publishedRoot, displacedRoot); + await mkdir(publishedRoot, { mode: 0o700 }); + const unrelated = join(publishedRoot, 'unrelated.txt'); + await writeFile(unrelated, 'keep', 'utf8'); + + await assert.rejects(handle.release(), isSnapshotError('cleanup_failed')); + assert.equal(await readFile(unrelated, 'utf8'), 'keep'); + + await rm(publishedRoot, { recursive: true }); + await rename(displacedRoot, publishedRoot); + await handle.release(); + await assert.rejects(readdir(publishedRoot), isCode('ENOENT')); +}); + +test('release resumes an interrupted partial cleanup using the external ownership record', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }).prepare({ makaSessionId: 'partial-release-retry' }); + const publishedRoot = dirname(handle.snapshot.stateRoot); + const snapshotId = basename(publishedRoot).slice('snapshot-'.length); + const ownerFile = join(fixture.stagingParent, `.snapshot-${snapshotId}.owner.json`); + const owner = JSON.parse(await readFile(ownerFile, 'utf8')) as { ownerToken: string }; + const cleanupRoot = join( + fixture.stagingParent, + `.snapshot-${snapshotId}.${owner.ownerToken}.cleanup`, + ); + + await rename(publishedRoot, cleanupRoot); + await rm(join(cleanupRoot, 'state'), { recursive: true }); + await handle.release(); + + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('a successor process recovers staging owned by a released process lifetime', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }).prepare({ makaSessionId: 'orphan-recovery' }); + const publishedRoot = dirname(handle.snapshot.stateRoot); + const snapshotId = basename(publishedRoot).slice('snapshot-'.length); + + await fixture.processLifetimeOwner.close(); + const successorOwner = await acquireProcessLifetimeOwner(join(fixture.root, 'cleanup-owners')); + processLifetimeOwners.push(successorOwner); + const successor = createFileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: join(fixture.root, 'snapshot-cleanup-state'), + stagingParent: fixture.stagingParent, + processLifetimeOwner: successorOwner, + privateStagingRootAuthority, + }); + + assert.deepEqual(await successor.recover(), { removed: [snapshotId], failed: [] }); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('recovery removes an exact preparing root left before its owner record was written', async () => { + const fixture = await createFixture(); + const lease = { + snapshotId: '00000000-0000-4000-8000-000000000002', + ownerToken: '00000000-0000-4000-8000-000000000003', + makaSessionId: 'orphaned-preparing-root', + }; + const preparingRoot = join(fixture.stagingParent, `.snapshot-${lease.snapshotId}.preparing`); + await assert.rejects( + fixture.stagingCleanup.ownCreation(lease, async () => { + await mkdir(preparingRoot, { mode: 0o700 }); + throw new Error('simulated process exit before owner record'); + }), + /simulated process exit/u, + ); + + await fixture.processLifetimeOwner.close(); + const successorOwner = await acquireProcessLifetimeOwner(join(fixture.root, 'cleanup-owners')); + processLifetimeOwners.push(successorOwner); + const successor = createFileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: join(fixture.root, 'snapshot-cleanup-state'), + stagingParent: fixture.stagingParent, + processLifetimeOwner: successorOwner, + privateStagingRootAuthority, + }); + + assert.deepEqual(await successor.recover(), { + removed: [lease.snapshotId], + failed: [], + }); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('creation failure cleans only the inode it created and preserves a colliding owner file', async () => { + const fixture = await createFixture(); + const snapshotId = '00000000-0000-4000-8000-000000000001'; + const ownerFile = join(fixture.stagingParent, `.snapshot-${snapshotId}.owner.json`); + await writeFile(ownerFile, 'unrelated', 'utf8'); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + newSnapshotId: () => snapshotId, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'create-failure-cleanup' }), + isSnapshotError('io_failure'), + ); + assert.equal(await readFile(ownerFile, 'utf8'), 'unrelated'); + assert.deepEqual(await readdir(fixture.stagingParent), [basename(ownerFile)]); +}); + +test('rejects a caller-supplied workspace policy override instead of downgrading V1 safety', async () => { + const fixture = await createFixture(); + assert.throws( + () => + createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + policy: { + version: 1, + classify: () => ({ kind: 'include' }), + }, + } as Parameters[0]), + /cannot be overridden/u, + ); +}); + +test('binds private-root verification to the exact canonical staging path', async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority: { + async verifyPrivateStagingRoot() { + return { canonicalPath: fixture.root }; + }, + }, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'wrong-private-root-attestation' }), + isSnapshotError('unsafe_source'), + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('verifies and cleans each newly created snapshot directory when platform privacy fails', async () => { + const fixture = await createFixture(); + let verificationCalls = 0; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority: { + async verifyPrivateStagingRoot(input) { + verificationCalls += 1; + return { + canonicalPath: verificationCalls === 1 ? input.canonicalPath : fixture.stagingParent, + }; + }, + }, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'unsafe-created-staging-root' }), + isSnapshotError('unsafe_source'), + ); + assert.equal(verificationCalls, 2); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('requires a caller-provided Windows ACL verifier', { + skip: process.platform !== 'win32', +}, async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'missing-windows-acl-verifier' }), + isSnapshotError('unsafe_source'), + ); +}); + +test('requires a private staging parent on POSIX', { + skip: process.platform === 'win32', +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-snapshot-public-')); + roots.push(root); + const stagingParent = join(root, 'staging'); + await mkdir(stagingParent, { mode: 0o755 }); + const processLifetimeOwner = await acquireProcessLifetimeOwner(join(root, 'cleanup-owners')); + processLifetimeOwners.push(processLifetimeOwner); + const stagingCleanup = createFileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: join(root, 'snapshot-cleanup-state'), + stagingParent, + processLifetimeOwner, + }); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent, + stagingCleanup, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'unsafe-parent' }), + isSnapshotError('unsafe_source'), + ); + assert.deepEqual(await readdir(stagingParent), []); +}); + +test('V1 workspace policy includes portable inputs, excludes rebuildable data, and rejects secrets', () => { + const cases = [ + ['package.json', 'file', { kind: 'include' }], + ['pnpm-lock.yaml', 'file', { kind: 'include' }], + ['.maka-workspace.json', 'file', { kind: 'include' }], + ['.git/config', 'file', { kind: 'exclude', category: 'source_control' }], + [ + 'packages/app/node_modules/pkg/index.js', + 'file', + { kind: 'exclude', category: 'dependency_tree' }, + ], + ['.turbo/cache.bin', 'file', { kind: 'exclude', category: 'cache' }], + ['logs/agent.txt', 'file', { kind: 'exclude', category: 'log' }], + ['debug.log', 'file', { kind: 'exclude', category: 'log' }], + ['secrets.log', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.env.log', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/private-key.log', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.maka-runtime/input.json', 'file', { kind: 'exclude', category: 'runtime_scratch' }], + ['.env.local', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.env.example', 'file', { kind: 'include' }], + ['.env.template', 'file', { kind: 'include' }], + ['.env.example.local', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/id_ed25519', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/id_ed25519.pub', 'file', { kind: 'include' }], + ['keys/id_rsa.pub', 'file', { kind: 'include' }], + ['credentials.yaml', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['secrets.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['src/secrets.ts', 'file', { kind: 'include' }], + ['docs/secrets.md', 'file', { kind: 'include' }], + ['private', 'file', { kind: 'include' }], + ['.terraformrc', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.git-credentials.lock', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/client-private-key.pem', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['certs/client.p12', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['certs/client.crt', 'file', { kind: 'include' }], + ['certs/client.cer', 'file', { kind: 'include' }], + ['certs/client.csr', 'file', { kind: 'include' }], + ['certs/client.pem', 'file', { kind: 'include' }], + ['certs/client.der', 'file', { kind: 'include' }], + ['privkey.pem', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['private.pem', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/service-account.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + [ + 'secrets', + 'directory', + { kind: 'confirm', category: 'suspected_secret_path', confirmationPath: 'secrets' }, + ], + [ + 'secrets/token', + 'file', + { kind: 'confirm', category: 'suspected_secret_path', confirmationPath: 'secrets' }, + ], + [ + 'credentials/oauth.json', + 'file', + { kind: 'confirm', category: 'suspected_secret_path', confirmationPath: 'credentials' }, + ], + [ + 'private/token', + 'file', + { kind: 'confirm', category: 'suspected_secret_path', confirmationPath: 'private' }, + ], + [ + 'config/credentials/production.yml.enc', + 'file', + { + kind: 'confirm', + category: 'suspected_secret_path', + confirmationPath: 'config/credentials', + }, + ], + [ + 'include/private/header.h', + 'file', + { + kind: 'confirm', + category: 'suspected_secret_path', + confirmationPath: 'include/private', + }, + ], + ['include/private/id_ed25519', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.ssh/config', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.aws/credentials', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.cargo/credentials', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.docker/config.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.kube/config', 'file', { kind: 'reject', category: 'known_secret_file' }], + [ + '.config/gcloud/application_default_credentials.json', + 'file', + { kind: 'reject', category: 'known_secret_file' }, + ], + ['../escape', 'file', { kind: 'reject', category: 'unsafe_path' }], + ['a\\b', 'file', { kind: 'reject', category: 'unsafe_path' }], + ['CON', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['nested/LPT1.txt', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['foo:bar', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['name.', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['name ', 'directory', { kind: 'reject', category: 'unsupported_portable_path' }], + ] as const; + + for (const [relativePath, kind, expected] of cases) { + assert.deepEqual( + SESSION_SNAPSHOT_WORKSPACE_POLICY_V1.classify({ relativePath, kind }), + expected, + ); + } +}); + +test('binds explicit control-plane confirmation to the Session, policy and subtree', async () => { + const fixture = await createFixture(); + const requests: Array<{ + makaSessionId: string; + confirmationGrantId: string; + policyVersion: number; + category: string; + confirmationPath: string; + }> = []; + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + confirmationAuthority: { + async resolveConfirmation(input) { + requests.push({ + makaSessionId: input.makaSessionId, + confirmationGrantId: input.confirmationGrantId, + policyVersion: input.policyVersion, + category: input.category, + confirmationPath: input.confirmationPath, + }); + return { action: 'include' }; + }, + }, + workspace: { + async prepareWorkspace(input) { + assert.deepEqual( + await input.confirmation.resolve({ + relativePath: 'config/credentials', + kind: 'directory', + }), + { kind: 'include' }, + ); + assert.deepEqual( + await input.confirmation.resolve({ + relativePath: 'config/credentials/production.yml.enc', + kind: 'file', + }), + { kind: 'include' }, + ); + await mkdir(input.destinationRoot); + return workspaceResult({ includedEntries: 2 }); + }, + }, + }).prepare({ makaSessionId: 'confirmed-session', confirmationGrantId: 'grant-1' }); + + assert.deepEqual(requests, [ + { + makaSessionId: 'confirmed-session', + confirmationGrantId: 'grant-1', + policyVersion: 1, + category: 'suspected_secret_path', + confirmationPath: 'config/credentials', + }, + ]); + await handle.release(); +}); + +test('rejects a malformed control-plane confirmation grant before quiescence', async () => { + const fixture = await createFixture(); + let enteredQuiescence = false; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + enteredQuiescence = true; + return operation(); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'confirmed-session', confirmationGrantId: '../grant' }), + isSnapshotError('invalid_input'), + ); + assert.equal(enteredQuiescence, false); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('fails closed and cleans staging when a suspected path has no explicit confirmation', async () => { + const fixture = await createFixture(); + let authorityCalls = 0; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + confirmationAuthority: { + async resolveConfirmation() { + authorityCalls += 1; + return { action: 'include' }; + }, + }, + workspace: { + async prepareWorkspace(input) { + await input.confirmation.resolve({ relativePath: 'include/private', kind: 'directory' }); + throw new Error('unreachable'); + }, + }, + }); + + await assert.rejects(coordinator.prepare({ makaSessionId: 'unconfirmed-session' }), (error) => { + assert.equal(error instanceof SessionSnapshotError && error.code, 'policy_rejected'); + assert.deepEqual(error instanceof SessionSnapshotError && error.details, { + phase: 'workspace', + policyCategory: 'suspected_secret_path', + }); + return true; + }); + assert.equal(authorityCalls, 0); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('applies an explicit control-plane exclusion with bounded diagnostics', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + stagingCleanup: fixture.stagingCleanup, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + confirmationAuthority: { + async resolveConfirmation() { + return { action: 'exclude' }; + }, + }, + workspace: { + async prepareWorkspace(input) { + assert.deepEqual( + await input.confirmation.resolve({ relativePath: 'secrets', kind: 'directory' }), + { kind: 'exclude', category: 'confirmed_secret_path' }, + ); + await mkdir(input.destinationRoot); + return workspaceResult({ + excludedEntries: 1, + excludedEntriesByCategory: { + ...workspaceResult().excludedEntriesByCategory, + confirmed_secret_path: 1, + }, + }); + }, + }, + }).prepare({ makaSessionId: 'excluded-session', confirmationGrantId: 'grant-2' }); + + assert.deepEqual( + handle.workspace.excludedEntriesByCategory, + workspaceResult({ + excludedEntries: 1, + excludedEntriesByCategory: { + ...workspaceResult().excludedEntriesByCategory, + confirmed_secret_path: 1, + }, + }).excludedEntriesByCategory, + ); + await handle.release(); +}); + +const immediateAuthority: SessionSnapshotQuiescenceAuthority = { + async runQuiescent(_input, operation) { + return operation(); + }, +}; + +const privateStagingRootAuthority = { + async verifyPrivateStagingRoot(input: { canonicalPath: string }) { + return { canonicalPath: await realpath(input.canonicalPath) }; + }, +}; + +const directoryStatePreparer: SessionSnapshotStatePreparer = { + async prepareState(input) { + await mkdir(input.destinationRoot); + return stateIdentity(input.makaSessionId); + }, +}; + +const directoryWorkspacePreparer: SessionSnapshotWorkspacePreparer = { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + return workspaceResult(); + }, +}; + +function stateIdentity(makaSessionId: string) { + return { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from(JSON.stringify({ makaSessionId }), 'utf8'), + }; +} + +function workspaceResult( + overrides: Partial = {}, +): SessionSnapshotWorkspacePreparation { + return { + includedEntries: 0, + excludedEntries: 0, + excludedEntriesByCategory: { + dependency_tree: 0, + source_control: 0, + cache: 0, + log: 0, + runtime_scratch: 0, + confirmed_secret_path: 0, + }, + payloadBytes: 0, + ...overrides, + }; +} + +async function createFixture(): Promise<{ + root: string; + stagingParent: string; + stagingCleanup: SessionSnapshotStagingCleanupAuthority; + processLifetimeOwner: ProcessLifetimeOwner; + liveStateRoot: string; + liveWorkspaceRoot: string; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-session-snapshot-')); + roots.push(root); + const stagingParent = join(root, 'staging'); + const liveStateRoot = join(root, 'live-state'); + const liveWorkspaceRoot = join(root, 'live-workspace'); + await Promise.all([ + mkdir(stagingParent, { mode: 0o700 }), + mkdir(liveStateRoot), + mkdir(liveWorkspaceRoot), + ]); + const processLifetimeOwner = await acquireProcessLifetimeOwner(join(root, 'cleanup-owners')); + processLifetimeOwners.push(processLifetimeOwner); + const stagingCleanup = createFileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: join(root, 'snapshot-cleanup-state'), + stagingParent, + processLifetimeOwner, + privateStagingRootAuthority, + }); + return { + root, + stagingParent, + stagingCleanup, + processLifetimeOwner, + liveStateRoot, + liveWorkspaceRoot, + }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +class SerialQuiescenceAuthority implements SessionSnapshotQuiescenceAuthority { + readonly activeSessions: string[] = []; + readonly maximumConcurrentBySession = new Map(); + readonly #tails = new Map>(); + readonly #activeBySession = new Map(); + + async runQuiescent( + input: { makaSessionId: string; cancellation: SessionSnapshotCancellation }, + operation: () => Promise, + ): Promise { + const predecessor = this.#tails.get(input.makaSessionId) ?? Promise.resolve(); + const release = deferred(); + const tail = predecessor.catch(() => {}).then(() => release.promise); + this.#tails.set(input.makaSessionId, tail); + await predecessor; + if (input.cancellation.signal.aborted) throw Object.assign(new Error(), { name: 'AbortError' }); + + const active = (this.#activeBySession.get(input.makaSessionId) ?? 0) + 1; + this.#activeBySession.set(input.makaSessionId, active); + this.maximumConcurrentBySession.set( + input.makaSessionId, + Math.max(this.maximumConcurrentBySession.get(input.makaSessionId) ?? 0, active), + ); + this.activeSessions.push(input.makaSessionId); + try { + return await operation(); + } finally { + this.activeSessions.splice(this.activeSessions.indexOf(input.makaSessionId), 1); + this.#activeBySession.set(input.makaSessionId, active - 1); + release.resolve(); + if (this.#tails.get(input.makaSessionId) === tail) this.#tails.delete(input.makaSessionId); + } + } +} + +async function waitForAbort(cancellation: SessionSnapshotCancellation): Promise { + if (cancellation.signal.aborted) return; + await new Promise((resolve) => { + cancellation.signal.addEventListener('abort', () => resolve(), { once: true }); + }); +} + +function isSnapshotError(code: SessionSnapshotError['code']): (error: unknown) => boolean { + return (error) => error instanceof SessionSnapshotError && error.code === code; +} + +function isCode(code: string): (error: unknown) => boolean { + return (error) => + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code; +} + +function interceptSnapshotCleanupRename( + publishedRoot: string, + afterRename: () => Promise, +): { completed: Promise } { + const stagingParent = dirname(publishedRoot); + const expectedName = basename(publishedRoot); + let resolveCompleted!: () => void; + let rejectCompleted!: (error: unknown) => void; + const completed = new Promise((resolve, reject) => { + resolveCompleted = resolve; + rejectCompleted = reject; + }); + const observer = async () => { + try { + while (true) { + const names = await readdir(stagingParent); + if (!names.includes(expectedName)) { + await afterRename(); + resolveCompleted(); + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + } catch (error) { + rejectCompleted(error); + } + }; + void observer(); + return { completed }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8d49d5a7bb9e0e8b369230f063c819f023a830324fe924d443ae3d509bb48c55.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8d49d5a7bb9e0e8b369230f063c819f023a830324fe924d443ae3d509bb48c55.source new file mode 100644 index 0000000000..f6a6320fb8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8d49d5a7bb9e0e8b369230f063c819f023a830324fe924d443ae3d509bb48c55.source @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { open, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { syncDirectory } from './stable-storage.js'; + +/** + * Atomic writer shared by the legacy settings, MCP, and credentials JSON + * stores. It publishes an exclusive hidden temp file with rename and removes + * that temp on failures before publication. + * + * Durability support depends on the platform and storage stack: + * - Linux and POSIX systems other than macOS: sync the temp file before + * rename and fsync the parent directory afterwards. Persistence still + * depends on the filesystem, mount, device, and hardware honoring them. + * - macOS: use Node's ordinary fsync operations; Node does not expose + * F_FULLFSYNC here, so this is not a uniform sudden-power-loss guarantee. + * - Windows: sync the temp file before rename, but parent-directory sync is a + * no-op because Node does not provide an equivalent directory fence. + * + * The caller owns the final file-mode policy. The writer applies that mode + * through the open handle before synchronization, fail-loud on POSIX and + * skipped on Windows (no POSIX mode). Callers may supply an exact private mode + * or one already derived from the process umask. + * Directory creation and permission policy belong to each caller. + */ + +export interface AtomicFileWriteOptions { + /** Effective mode to apply to the temporary file before it is synchronized + * and published. */ + fileMode: number; +} + +/** The fs surface `writeAtomicFile` needs; injectable for fault-injection + * tests (same pattern as marker-file.ts). */ +export interface AtomicFileWriteHandle { + writeFile(data: string, encoding: 'utf8'): Promise; + chmod(mode: number): Promise; + sync(): Promise; + close(): Promise; +} + +export interface AtomicFileWriteDependencies { + open(path: string, flags: string, mode?: number): Promise; + randomUUID(): string; + syncDirectory(path: string): Promise; +} + +const defaultDependencies: AtomicFileWriteDependencies = { + open, + randomUUID, + syncDirectory, +}; + +export class AtomicFileWriteCommitUnknownError extends Error { + readonly published = true; + + constructor(options: { cause: unknown }) { + super('Atomic file commit outcome is unknown; reload before retrying', options); + this.name = 'AtomicFileWriteCommitUnknownError'; + } +} + +export async function writeAtomicFile( + path: string, + contents: string, + options: AtomicFileWriteOptions, + dependencies: Partial = {}, +): Promise { + const deps = { ...defaultDependencies, ...dependencies }; + const { fileMode } = options; + const tempPath = join(dirname(path), `.${basename(path)}.${deps.randomUUID()}.tmp`); + // Only the entry this call created — and hasn't renamed away — may be + // cleaned up. A pre-existing file or planted symlink the 'wx' open refused + // must not be deleted as "our" temp. + let tempCreated = false; + let published = false; + try { + const handle = await deps.open(tempPath, 'wx', fileMode); + tempCreated = true; + try { + await handle.writeFile(contents, 'utf8'); + if (process.platform !== 'win32') await handle.chmod(fileMode); + await handle.sync(); + await handle.close(); + } catch (error) { + // Release the descriptor best-effort; never let a close failure mask + // the error that actually aborted the write. + await handle.close().catch(() => {}); + throw error; + } + await rename(tempPath, path); + published = true; + tempCreated = false; + await deps.syncDirectory(dirname(path)); + } catch (error) { + if (tempCreated) { + // Cleanup must never mask the original failure. + await rm(tempPath, { force: true }).catch(() => {}); + } + if (published) throw new AtomicFileWriteCommitUnknownError({ cause: error }); + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da1ad9f45c64980e5ebc1a73f3c778df4cb2312070093227bc598d44b7035d2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da1ad9f45c64980e5ebc1a73f3c778df4cb2312070093227bc598d44b7035d2.source new file mode 100644 index 0000000000..7629661275 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da1ad9f45c64980e5ebc1a73f3c778df4cb2312070093227bc598d44b7035d2.source @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const [archivePath, archiveDigest, limitsJson, destinationRoot] = process.argv.slice(2); +if ( + archivePath === undefined || + archiveDigest === undefined || + limitsJson === undefined || + destinationRoot === undefined +) { + process.exit(2); +} + +const originalOpen = fs.promises.open.bind(fs.promises); +let targeted = false; +fs.promises.open = async (...args) => { + const handle = await originalOpen(...args); + if (targeted || !args[0].toString().endsWith('.owner.json')) return handle; + targeted = true; + const originalWrite = handle.write.bind(handle); + let wroteOneByte = false; + handle.write = (async ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => { + if (wroteOneByte) { + throw Object.assign(new Error('injected ownership write failure'), { + code: 'EIO', + }); + } + wroteOneByte = true; + return originalWrite(buffer, offset, Math.min(length, 1), position); + }) as typeof handle.write; + return handle; +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); +try { + await createSessionBundleFileService().hydrate({ + source: { + path: archivePath, + expectedArchiveDigest: archiveDigest as `sha256:${string}`, + }, + limits: JSON.parse(limitsJson), + expectedSessionId: 'cloud-session-1', + destinationRoot, + }); + process.exit(3); +} catch (error) { + process.stdout.write( + JSON.stringify({ + code: error instanceof SessionBundleFileError ? error.code : 'unexpected', + }), + ); + process.exit(error instanceof SessionBundleFileError && error.code === 'io_failure' ? 0 : 4); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da468a681539c9f3b2f2e336c35930205bbe4220918aaa20bb638deb3e87d28.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da468a681539c9f3b2f2e336c35930205bbe4220918aaa20bb638deb3e87d28.source new file mode 100644 index 0000000000..9d91e3bd3c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8da468a681539c9f3b2f2e336c35930205bbe4220918aaa20bb638deb3e87d28.source @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { + appendFile, + chmod, + lstat, + mkdir, + mkdtemp, + open, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { hardenDirectory, readStableBoundedFile } from '../stable-storage.js'; + +async function fixture(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), 'maka-stable-file-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'record.json'); + await writeFile(path, 'data'); + return { directory, path }; +} + +function invalidFile(): Error { + return new Error('invalid stable file'); +} + +test('reads one bounded regular-file snapshot and rejects in-place growth', async (t) => { + const { path } = await fixture(t); + assert.equal( + (await readStableBoundedFile({ path, maxBytes: 4, invalidFile })).toString('utf8'), + 'data', + ); + await assert.rejects( + readStableBoundedFile( + { path, maxBytes: 4, invalidFile }, + { + open: async (openedPath, flags) => { + const handle = await open(openedPath, flags); + let firstRead = true; + return { + stat: (options) => handle.stat(options), + read: async (buffer, offset, length, position) => { + if (firstRead) { + firstRead = false; + await appendFile(path, '!'); + } + return handle.read(buffer, offset, length, position); + }, + close: () => handle.close(), + }; + }, + }, + ), + /invalid stable file/u, + ); +}); + +test('rejects a symlink instead of following it', { + skip: process.platform === 'win32' ? 'POSIX no-follow semantics are required' : false, +}, async (t) => { + const { directory, path } = await fixture(t); + const link = join(directory, 'record-link.json'); + await symlink(path, link); + await assert.rejects( + readStableBoundedFile({ path: link, maxBytes: 4, invalidFile }), + /invalid stable file/u, + ); +}); + +test('rejects a pathname replaced after opening the file', async (t) => { + const { directory, path } = await fixture(t); + const replacement = join(directory, 'replacement.json'); + await writeFile(replacement, 'next'); + let observations = 0; + + await assert.rejects( + readStableBoundedFile( + { path, maxBytes: 4, invalidFile }, + { + lstat: async (observedPath, options) => { + observations += 1; + if (observations === 2) await rename(replacement, path); + return lstat(observedPath, options); + }, + }, + ), + /invalid stable file/u, + ); +}); + +test('hardenDirectory creates a 0700 directory chain', { + skip: process.platform === 'win32', +}, async (t) => { + const { directory } = await fixture(t); + const targetDir = join(directory, 'secrets', 'sub'); + await hardenDirectory(targetDir); + assert.equal((await stat(join(directory, 'secrets'))).mode & 0o777, 0o700); + assert.equal((await stat(targetDir)).mode & 0o777, 0o700); +}); + +test('hardenDirectory re-chmods a pre-existing world-accessible directory to 0700', { + skip: process.platform === 'win32', +}, async (t) => { + const { directory } = await fixture(t); + const loose = join(directory, 'loose'); + await mkdir(loose, { recursive: true, mode: 0o777 }); + await chmod(loose, 0o777); // mkdir's mode only applies on creation + await hardenDirectory(loose); + assert.equal((await stat(loose)).mode & 0o777, 0o700); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8dc7647ec230a67f2a3be117dcf54882b7ebc217ac63e0f4fd7eb9a947790b20.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8dc7647ec230a67f2a3be117dcf54882b7ebc217ac63e0f4fd7eb9a947790b20.source new file mode 100644 index 0000000000..e323386010 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8dc7647ec230a67f2a3be117dcf54882b7ebc217ac63e0f4fd7eb9a947790b20.source @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { WORKSPACE_AUTHORITY_SESSION_ID } from '@maka/core/workspace-version-authority'; + +import { type RuntimeEvent } from '@maka/core/runtime-event'; + +/** + * Generic and non-workspace writers must never create workspace authority + * facts or occupy the store-owned authority stream. SQLite's dedicated + * baseline writer is the only caller that may cross this boundary. + */ +export function assertNoReservedWorkspaceAuthorityAppend(event: RuntimeEvent): void { + if (event.actions?.workspaceFact !== undefined) { + throw new Error('Workspace facts require the atomic workspace version authority writer'); + } + if (event.actions?.managedMutationTerminal !== undefined) { + throw new Error('Managed mutation terminals require the atomic terminal authority writer'); + } + if (event.sessionId === WORKSPACE_AUTHORITY_SESSION_ID) { + throw new Error('RuntimeEvent targets the reserved workspace authority stream'); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8fa6b5e5061a75611ab69b71a7963514cecdccb7fced0b9fdd376dee38e97452.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8fa6b5e5061a75611ab69b71a7963514cecdccb7fced0b9fdd376dee38e97452.source new file mode 100644 index 0000000000..bff1517a2d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/8fa6b5e5061a75611ab69b71a7963514cecdccb7fced0b9fdd376dee38e97452.source @@ -0,0 +1,1229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { open, readFile, rm, type FileHandle } from 'node:fs/promises'; +import { + isNonEmptyUnicodeString, + isSha256Digest, + type SessionBundleArtifact, + type SessionBundleSource, + type Sha256Digest, +} from './session-bundle-contract.js'; + +const MAX_IDENTIFIER_LENGTH = 512; +const MAX_OBJECT_REF_LENGTH = 2_048; + +export const SESSION_BUNDLE_OBJECT_MEDIA_TYPE = + 'application/vnd.maka.session-bundle+tar;version=1;compression=zstd' as const; +export const SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE = + 'application/vnd.maka.session-checkpoint-manifest+json;version=1' as const; + +/** + * A Repository revision is opaque to callers. Revisions are scoped to one + * Cloud Session and must never be reused as object digests or across Sessions. + */ +export type SessionRepositoryRevision = string; + +export interface SessionRevisionRef { + readonly sessionId: string; + readonly revision: SessionRepositoryRevision; +} + +/** Trusted metadata for one immutable object. */ +export interface ImmutableObjectRef { + readonly objectRef: string; + readonly digest: Sha256Digest; + readonly bytes: number; + readonly mediaType: string; +} + +export type ImmutableObjectSource = + | { + readonly kind: 'file'; + readonly path: string; + } + | { + readonly kind: 'bytes'; + readonly value: Uint8Array; + }; + +export interface ImmutableObjectInput { + readonly digest: Sha256Digest; + readonly bytes: number; + readonly mediaType: string; + readonly source: ImmutableObjectSource; +} + +/** Caller-owned, bounded materialization target for one immutable object. */ +export interface ImmutableObjectMaterializationInput { + readonly ref: ImmutableObjectRef; + /** A new private file path. Materialization must never overwrite it. */ + readonly destination: string; + /** The caller's maximum acceptable byte count for this materialization. */ + readonly maxBytes: number; +} + +/** + * Large immutable bytes live behind this port. Its publication semantics are + * deliberately distinct from the Repository's head-CAS semantics. + */ +export interface ImmutableObjectStore { + /** + * Publishes bytes under a non-overwritable reference. Returning an existing + * reference for identical input is allowed, but the exact bytes and declared + * metadata must already be durably readable before this method returns. + */ + publish(input: ImmutableObjectInput): Promise; + /** Verifies the exact reference, byte count, media type, and digest. */ + assertReadable(ref: ImmutableObjectRef): Promise; + /** + * Materializes verified bytes at a caller-owned new file path without + * overwriting it. The operation must reject objects over maxBytes and verify + * the exact reference, byte count, and digest before it returns. + */ + materialize(input: ImmutableObjectMaterializationInput): Promise; +} + +export interface SessionCheckpointManifestV1 { + readonly schemaVersion: 1; + readonly compatibilityBundle: ImmutableObjectRef; +} + +/** + * The value is carried with its immutable reference so a Repository can + * validate the canonical Manifest digest without acquiring a general read API. + */ +export interface StoredSessionCheckpoint { + readonly manifest: ImmutableObjectRef; + readonly value: SessionCheckpointManifestV1; +} + +export interface CommittedSessionRevision { + readonly ref: SessionRevisionRef; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; +} + +export interface CommitSessionRevisionInput { + readonly sessionId: string; + readonly expectedRevision: SessionRepositoryRevision; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + /** + * Optional caller operation identity. Retrying the same identity and input + * returns the original committed revision rather than allocating another. + */ + readonly commitId?: string; +} + +export interface CreateSessionInput { + readonly sessionId: string; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; + /** Required for a Fork-created target Session. */ + readonly createdByForkId?: string; +} + +export interface ClaimForkInput { + readonly forkId: string; + readonly source: SessionRevisionRef; + readonly targetSessionId: string; +} + +export interface CompleteForkInput { + readonly forkId: string; +} + +export interface PendingForkOperation { + readonly state: 'pending'; + readonly forkId: string; + readonly source: SessionRevisionRef; + /** Captured from the verified source Session when the Fork is first claimed. */ + readonly sourceAgentId: string; + /** + * The exact source checkpoint admitted by the claim. V1 retains only the + * current head, so a later source advance must not make a pending Fork + * unable to recover its source bytes and metadata. + */ + readonly sourceCheckpoint: StoredSessionCheckpoint; + readonly targetSessionId: string; +} + +export interface CompletedForkOperation extends Omit { + readonly state: 'completed'; + readonly target: SessionRevisionRef; +} + +export type ForkOperation = PendingForkOperation | CompletedForkOperation; + +/** + * V1 never expires completed Fork identities. A production Repository must + * retain this mapping durably; the in-memory conformance implementation does + * so for its lifetime. + */ +export type ForkIdempotencyRetention = 'indefinite'; + +/** + * Strongly consistent Session metadata and operation records live behind this + * port. Immutable object publication remains a separate prerequisite. + */ +export interface SessionRepository { + readonly forkIdempotencyRetention: ForkIdempotencyRetention; + /** + * Resolves the current head and verifies its checkpoint as one Repository + * read. Callers that do not already hold a revision must not maintain an + * independent current-head record. + */ + checkoutCurrent(sessionId: string): Promise; + checkoutExact(ref: SessionRevisionRef): Promise; + createSession(input: CreateSessionInput): Promise; + commit(input: CommitSessionRevisionInput): Promise; + claimFork(input: ClaimForkInput): Promise; + completeFork(input: CompleteForkInput): Promise; +} + +export type SessionRepositoryErrorCode = + | 'session_not_found' + | 'source_revision_not_available' + | 'revision_not_available' + | 'revision_conflict' + | 'session_already_exists' + | 'idempotency_conflict' + | 'invalid_fork_target' + | 'fork_agent_mismatch' + | 'object_not_found' + | 'integrity_mismatch' + | 'quota_exceeded' + | 'io_failure'; + +export class SessionRepositoryError extends Error { + constructor( + readonly code: SessionRepositoryErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'SessionRepositoryError'; + } +} + +export interface PublishSessionCheckpointV1Input { + readonly objectStore: ImmutableObjectStore; + readonly compatibilityBundle: SessionBundleArtifact; +} + +/** + * Publishes and verifies the compatibility Bundle before publishing and + * verifying the immutable Manifest that names it. The returned checkpoint is + * suitable for a later Repository create or head-CAS operation. + */ +export async function publishSessionCheckpointV1( + input: PublishSessionCheckpointV1Input, +): Promise { + if (!isRecord(input)) + throw new TypeError('Session checkpoint publication input must be an object'); + const objectStore = requireImmutableObjectStore(input.objectStore); + const artifact = admitSessionBundleArtifact(input.compatibilityBundle); + const compatibilityBundle = await publishVerifiedObject(objectStore, { + digest: artifact.archiveDigest, + bytes: artifact.compressedBytes, + mediaType: SESSION_BUNDLE_OBJECT_MEDIA_TYPE, + source: { kind: 'file', path: artifact.path }, + }); + const value = createSessionCheckpointManifestV1(compatibilityBundle); + const manifestBytes = encodeSessionCheckpointManifestV1(value); + const manifest = await publishVerifiedObject(objectStore, { + digest: digestBytes(manifestBytes), + bytes: manifestBytes.byteLength, + mediaType: SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE, + source: { kind: 'bytes', value: manifestBytes }, + }); + return storedSessionCheckpoint({ manifest, value }); +} + +/** + * Converts a retained V1 compatibility Bundle reference into the exact + * filesystem source expected by the Bundle inspect/hydrate boundary. + * + * This is deliberately a materialization seam, not a general Repository read: + * callers own the bounded destination and clean it up after hydration/repack. + */ +export async function materializeSessionCheckpointV1(input: { + readonly objectStore: ImmutableObjectStore; + readonly checkpoint: StoredSessionCheckpoint; + readonly destination: string; + readonly maxBytes: number; +}): Promise { + if (!isRecord(input)) + throw new TypeError('Session checkpoint materialization input must be an object'); + const objectStore = requireImmutableObjectStore(input.objectStore); + const checkpoint = admitStoredSessionCheckpoint(input.checkpoint); + const request = admitImmutableObjectMaterializationInput({ + ref: checkpoint.value.compatibilityBundle, + destination: input.destination, + maxBytes: input.maxBytes, + }); + try { + await objectStore.assertReadable(checkpoint.manifest); + await objectStore.assertReadable(request.ref); + await objectStore.materialize(request); + return Object.freeze({ + path: request.destination, + expectedArchiveDigest: request.ref.digest, + }); + } catch (error) { + throw normalizeObjectStoreError(error); + } +} + +export function createSessionCheckpointManifestV1( + compatibilityBundle: ImmutableObjectRef, +): SessionCheckpointManifestV1 { + const admitted = admitImmutableObjectRef(compatibilityBundle); + if (admitted.mediaType !== SESSION_BUNDLE_OBJECT_MEDIA_TYPE) { + throw new TypeError('V1 compatibility Bundle has an unsupported media type'); + } + return Object.freeze({ + schemaVersion: 1, + compatibilityBundle: copyImmutableObjectRef(admitted), + }); +} + +/** RFC 8785/JCS V1 encoding used to bind a Manifest value to its object digest. */ +export function encodeSessionCheckpointManifestV1(input: SessionCheckpointManifestV1): Uint8Array { + const value = admitSessionCheckpointManifestV1(input); + return new TextEncoder().encode( + JSON.stringify({ + compatibilityBundle: { + bytes: value.compatibilityBundle.bytes, + digest: value.compatibilityBundle.digest, + mediaType: value.compatibilityBundle.mediaType, + objectRef: value.compatibilityBundle.objectRef, + }, + schemaVersion: value.schemaVersion, + }), + ); +} + +export function createInMemoryImmutableObjectStore(): ImmutableObjectStore { + return new InMemoryImmutableObjectStore(); +} + +export interface CreateInMemorySessionRepositoryOptions { + readonly objectStore: ImmutableObjectStore; +} + +/** + * Deterministic conformance implementation for coordinators and Fork tests. + * It models the V1 single-current-head retention policy, but it is not a + * durable control-plane backend. + */ +export function createInMemorySessionRepository( + options: CreateInMemorySessionRepositoryOptions, +): SessionRepository { + if (!isRecord(options)) throw new TypeError('In-memory Repository options must be an object'); + return new InMemorySessionRepository(requireImmutableObjectStore(options.objectStore)); +} + +class InMemorySessionRepository implements SessionRepository { + readonly forkIdempotencyRetention = 'indefinite' as const; + + private readonly sessions = new Map(); + private readonly commitsBySession = new Map>(); + private readonly forks = new Map(); + + constructor(private readonly objectStore: ImmutableObjectStore) {} + + async checkoutCurrent(sessionId: string): Promise { + const admittedSessionId = requireIdentifier(sessionId, 'Session identity'); + const session = this.sessions.get(admittedSessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + + // Capture exactly the current head selected by this read before awaiting + // object verification. A subsequent writer cannot substitute its head. + const committed = copyCommittedSessionRevision(session.head); + await this.assertCheckpointReadable(committed.checkpoint); + return committed; + } + + async checkoutExact(ref: SessionRevisionRef): Promise { + const requested = admitRevisionRef(ref, 'Session revision reference'); + const session = this.sessions.get(requested.sessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (session.head.ref.revision !== requested.revision) { + throw repositoryError( + 'revision_not_available', + 'Requested Session revision is not available', + ); + } + + // Capture the exact record before asynchronous object reads. A later head + // advance may make this revision non-current, but cannot substitute bytes. + const committed = copyCommittedSessionRevision(session.head); + await this.assertCheckpointReadable(committed.checkpoint); + return committed; + } + + async commit(input: CommitSessionRevisionInput): Promise { + const admitted = admitCommitSessionRevisionInput(input); + const prior = this.reconcilePriorCommit(admitted); + if (prior) { + await this.assertCheckpointReadable(prior.checkpoint); + return prior; + } + + const initial = this.sessions.get(admitted.sessionId); + if (!initial) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (initial.head.ref.revision !== admitted.expectedRevision) { + throw repositoryError('revision_conflict', 'Cloud Session head changed before commit'); + } + + await this.assertCheckpointReadable(admitted.checkpoint); + + // Object verification may yield. Reconcile an identical concurrent retry, + // then recheck CAS so another commit cannot be overwritten. + const admittedWhileReading = this.reconcilePriorCommit(admitted); + if (admittedWhileReading) return admittedWhileReading; + const session = this.sessions.get(admitted.sessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (session.head.ref.revision !== admitted.expectedRevision) { + throw repositoryError('revision_conflict', 'Cloud Session head changed before commit'); + } + + const result = committedRevision({ + sessionId: admitted.sessionId, + revision: nextRevision(session), + agentId: session.agentId, + checkpoint: admitted.checkpoint, + lastCommittedActivationId: admitted.lastCommittedActivationId, + forkedFrom: session.forkedFrom, + }); + session.head = result; + if (admitted.commitId !== undefined) { + let records = this.commitsBySession.get(admitted.sessionId); + if (!records) { + records = new Map(); + this.commitsBySession.set(admitted.sessionId, records); + } + records.set(admitted.commitId, { input: admitted, result }); + } + return copyCommittedSessionRevision(result); + } + + async createSession(input: CreateSessionInput): Promise { + const admitted = admitCreateSessionInput(input); + const existing = this.sessions.get(admitted.sessionId); + if (existing) { + const reconciled = this.reconcileExistingSessionCreate(existing, admitted); + await this.assertCheckpointReadable(reconciled.checkpoint); + return reconciled; + } + + await this.assertCheckpointReadable(admitted.checkpoint); + + // Object verification may yield. Create-if-absent is decided only after it + // returns, at this method's synchronous linearization point. + const afterVerification = this.sessions.get(admitted.sessionId); + if (afterVerification) { + const reconciled = this.reconcileExistingSessionCreate(afterVerification, admitted); + await this.assertCheckpointReadable(reconciled.checkpoint); + return reconciled; + } + + if (admitted.createdByForkId !== undefined) this.assertPendingForkCreate(admitted); + const initial = committedRevision({ + sessionId: admitted.sessionId, + revision: 'r1', + agentId: admitted.agentId, + checkpoint: admitted.checkpoint, + lastCommittedActivationId: admitted.lastCommittedActivationId, + forkedFrom: admitted.forkedFrom, + }); + this.sessions.set(admitted.sessionId, { + agentId: admitted.agentId, + head: initial, + nextRevisionNumber: 2, + forkedFrom: admitted.forkedFrom, + createdByForkId: admitted.createdByForkId, + createdRevision: initial, + }); + return copyCommittedSessionRevision(initial); + } + + async claimFork(input: ClaimForkInput): Promise { + const admitted = admitClaimForkInput(input); + const existing = this.forks.get(admitted.forkId); + if (existing) { + if (!sameForkClaim(existing, admitted)) { + throw repositoryError( + 'idempotency_conflict', + 'Fork identity was reused with different input', + ); + } + return copyForkOperation(existing); + } + if (admitted.targetSessionId === admitted.source.sessionId) { + throw repositoryError( + 'invalid_fork_target', + 'Fork target Session must differ from its source Session', + ); + } + + // The first claim is the linearization point for the source binding. It + // must prove the named revision is still current and readable before a + // retry can rely on this durable Fork record. + const initial = this.sessions.get(admitted.source.sessionId); + if (!initial || initial.head.ref.revision !== admitted.source.revision) { + throw repositoryError( + 'source_revision_not_available', + 'Fork source Session revision is not available', + ); + } + const source = copyCommittedSessionRevision(initial.head); + await this.assertCheckpointReadable(source.checkpoint); + + // Source verification may yield. Reconcile another claimant that won while + // it was in progress rather than overwriting its durable idempotency fact. + const claimedWhileReading = this.forks.get(admitted.forkId); + if (claimedWhileReading) { + if (!sameForkClaim(claimedWhileReading, admitted)) { + throw repositoryError( + 'idempotency_conflict', + 'Fork identity was reused with different input', + ); + } + return copyForkOperation(claimedWhileReading); + } + + // Only a new claim needs a current source. Keep this final check and the + // insertion synchronous, after reconciling any already admitted operation. + const afterVerification = this.sessions.get(admitted.source.sessionId); + if ( + !afterVerification || + afterVerification.head.ref.revision !== admitted.source.revision || + afterVerification.agentId !== source.agentId + ) { + throw repositoryError( + 'source_revision_not_available', + 'Fork source Session revision is no longer current', + ); + } + const pending: InternalPendingForkOperation = { + state: 'pending', + forkId: admitted.forkId, + source: admitted.source, + sourceAgentId: source.agentId, + sourceCheckpoint: source.checkpoint, + targetSessionId: admitted.targetSessionId, + }; + this.forks.set(admitted.forkId, pending); + return copyForkOperation(pending); + } + + async completeFork(input: CompleteForkInput): Promise { + const forkId = requireIdentifier(input?.forkId, 'Fork identity'); + const operation = this.forks.get(forkId); + if (!operation) throw repositoryError('idempotency_conflict', 'Fork identity was not claimed'); + if (operation.state === 'completed') return copyCompletedForkOperation(operation); + + const target = this.sessions.get(operation.targetSessionId); + if (!target) throw repositoryError('session_not_found', 'Fork target Session was not found'); + if (target.agentId !== operation.sourceAgentId) { + throw repositoryError( + 'fork_agent_mismatch', + 'Fork target Agent does not match its verified source Agent', + ); + } + if ( + target.createdByForkId !== operation.forkId || + !target.forkedFrom || + !sameRevisionRef(target.forkedFrom, operation.source) + ) { + throw repositoryError('idempotency_conflict', 'Fork target was created by another operation'); + } + await this.assertCheckpointReadable(target.createdRevision.checkpoint); + + const completed: InternalCompletedForkOperation = { + ...operation, + state: 'completed', + target: copyRevisionRef(target.createdRevision.ref), + }; + this.forks.set(forkId, completed); + return copyCompletedForkOperation(completed); + } + + private reconcilePriorCommit( + input: InternalCommitSessionRevisionInput, + ): CommittedSessionRevision | undefined { + if (input.commitId === undefined) return undefined; + const prior = this.commitsBySession.get(input.sessionId)?.get(input.commitId); + if (!prior) return undefined; + if (!sameCommitInput(prior.input, input)) { + throw repositoryError( + 'idempotency_conflict', + 'Commit identity was reused with different input', + ); + } + return copyCommittedSessionRevision(prior.result); + } + + private reconcileExistingSessionCreate( + existing: SessionState, + input: InternalCreateSessionInput, + ): CommittedSessionRevision { + if ( + input.createdByForkId !== undefined && + existing.createdByForkId === input.createdByForkId && + sameCreateInput(existing.createdRevision, existing.agentId, existing.forkedFrom, input) + ) { + return copyCommittedSessionRevision(existing.createdRevision); + } + throw repositoryError('session_already_exists', 'Cloud Session already exists'); + } + + private assertPendingForkCreate(input: InternalCreateSessionInput): void { + const operation = this.forks.get(input.createdByForkId!); + if ( + !operation || + operation.state !== 'pending' || + operation.targetSessionId !== input.sessionId || + !input.forkedFrom || + !sameRevisionRef(operation.source, input.forkedFrom) + ) { + throw repositoryError( + 'idempotency_conflict', + 'Fork target does not match its claimed operation', + ); + } + if (operation.sourceAgentId !== input.agentId) { + throw repositoryError( + 'fork_agent_mismatch', + 'Fork target Agent must match its verified source Agent', + ); + } + } + + private async assertCheckpointReadable( + input: StoredSessionCheckpoint, + ): Promise { + const checkpoint = admitStoredSessionCheckpoint(input); + try { + await this.objectStore.assertReadable(checkpoint.manifest); + await this.objectStore.assertReadable(checkpoint.value.compatibilityBundle); + return checkpoint; + } catch (error) { + throw normalizeObjectStoreError(error); + } + } +} + +class InMemoryImmutableObjectStore implements ImmutableObjectStore { + private readonly objects = new Map(); + + async publish(input: ImmutableObjectInput): Promise { + const admitted = admitImmutableObjectInput(input); + let bytes: Uint8Array; + try { + bytes = + admitted.source.kind === 'file' + ? await readFile(admitted.source.path) + : Uint8Array.from(admitted.source.value); + } catch (error) { + throw repositoryError( + 'io_failure', + 'Immutable object publication could not read bytes', + error, + ); + } + if (bytes.byteLength !== admitted.bytes || digestBytes(bytes) !== admitted.digest) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes do not match declared metadata', + ); + } + + const mediaTypeKey = digestBytes(new TextEncoder().encode(admitted.mediaType)).slice( + 'sha256:'.length, + 'sha256:'.length + 16, + ); + const ref = immutableObjectRef({ + objectRef: `memory://immutable-objects/${admitted.digest.slice('sha256:'.length)}/${mediaTypeKey}`, + digest: admitted.digest, + bytes: admitted.bytes, + mediaType: admitted.mediaType, + }); + const existing = this.objects.get(ref.objectRef); + if (existing) { + if (!sameImmutableObjectRef(existing.ref, ref) || !sameBytes(existing.bytes, bytes)) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object reference already contains different bytes or metadata', + ); + } + return copyImmutableObjectRef(existing.ref); + } + this.objects.set(ref.objectRef, { ref, bytes: Uint8Array.from(bytes) }); + return copyImmutableObjectRef(ref); + } + + async assertReadable(input: ImmutableObjectRef): Promise { + const ref = admitImmutableObjectRef(input); + const stored = this.objects.get(ref.objectRef); + if (!stored) throw repositoryError('object_not_found', 'Immutable object was not found'); + if ( + !sameImmutableObjectRef(stored.ref, ref) || + stored.bytes.byteLength !== ref.bytes || + digestBytes(stored.bytes) !== ref.digest + ) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes no longer match their trusted metadata', + ); + } + } + + async materialize(input: ImmutableObjectMaterializationInput): Promise { + const request = admitImmutableObjectMaterializationInput(input); + const stored = this.objects.get(request.ref.objectRef); + if (!stored) throw repositoryError('object_not_found', 'Immutable object was not found'); + if ( + !sameImmutableObjectRef(stored.ref, request.ref) || + stored.bytes.byteLength !== request.ref.bytes || + digestBytes(stored.bytes) !== request.ref.digest + ) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes no longer match their trusted metadata', + ); + } + if (request.ref.bytes > request.maxBytes) { + throw repositoryError( + 'quota_exceeded', + 'Immutable object exceeds materialization byte limit', + ); + } + let handle: FileHandle | undefined; + try { + handle = await open(request.destination, 'wx', 0o600); + await handle.writeFile(stored.bytes); + await handle.close(); + handle = undefined; + } catch (error) { + // An exclusive-open failure gives us no ownership of the existing path. + if (handle) { + await handle.close().catch(() => {}); + await rm(request.destination, { force: true }).catch(() => {}); + } + throw repositoryError('io_failure', 'Immutable object could not be materialized', error); + } + } +} + +interface SessionState { + readonly agentId: string; + head: CommittedSessionRevision; + nextRevisionNumber: number; + readonly forkedFrom?: SessionRevisionRef; + readonly createdByForkId?: string; + readonly createdRevision: CommittedSessionRevision; +} + +interface InMemoryObject { + readonly ref: ImmutableObjectRef; + readonly bytes: Uint8Array; +} + +interface InternalCommitSessionRevisionInput { + readonly sessionId: string; + readonly expectedRevision: SessionRepositoryRevision; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly commitId?: string; +} + +interface InternalCreateSessionInput { + readonly sessionId: string; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; + readonly createdByForkId?: string; +} + +interface InternalClaimForkInput { + readonly forkId: string; + readonly source: SessionRevisionRef; + readonly targetSessionId: string; +} + +interface CommitRecord { + readonly input: InternalCommitSessionRevisionInput; + readonly result: CommittedSessionRevision; +} + +type InternalForkOperation = InternalPendingForkOperation | InternalCompletedForkOperation; + +interface InternalPendingForkOperation extends PendingForkOperation {} + +interface InternalCompletedForkOperation extends CompletedForkOperation {} + +async function publishVerifiedObject( + objectStore: ImmutableObjectStore, + input: ImmutableObjectInput, +): Promise { + const expected = admitImmutableObjectInput(input); + try { + const published = admitImmutableObjectRef(await objectStore.publish(expected)); + if ( + published.digest !== expected.digest || + published.bytes !== expected.bytes || + published.mediaType !== expected.mediaType + ) { + throw repositoryError( + 'integrity_mismatch', + 'Published immutable object metadata does not match its input', + ); + } + await objectStore.assertReadable(published); + return copyImmutableObjectRef(published); + } catch (error) { + throw normalizeObjectStoreError(error); + } +} + +function admitSessionBundleArtifact(input: SessionBundleArtifact): SessionBundleArtifact { + if (!isRecord(input)) throw new TypeError('Session Bundle artifact must be an object'); + const path = requireIdentifier(input.path, 'Bundle archive path', MAX_OBJECT_REF_LENGTH); + if (!isSha256Digest(input.archiveDigest)) { + throw new TypeError('Bundle archive digest must be SHA-256'); + } + if (!isByteCount(input.compressedBytes)) { + throw new TypeError('Bundle compressed byte count must be a non-negative safe integer'); + } + return { + ...input, + path, + archiveDigest: input.archiveDigest, + compressedBytes: input.compressedBytes, + }; +} + +function admitImmutableObjectInput(input: ImmutableObjectInput): ImmutableObjectInput { + if (!isRecord(input)) throw new TypeError('Immutable object input must be an object'); + if (!isSha256Digest(input.digest)) throw new TypeError('Immutable object digest must be SHA-256'); + if (!isByteCount(input.bytes)) { + throw new TypeError('Immutable object byte count must be a non-negative safe integer'); + } + const mediaType = requireIdentifier(input.mediaType, 'Immutable object media type'); + if (!isRecord(input.source)) throw new TypeError('Immutable object source must be an object'); + if (input.source.kind === 'file') { + return Object.freeze({ + digest: input.digest, + bytes: input.bytes, + mediaType, + source: Object.freeze({ + kind: 'file' as const, + path: requireIdentifier( + input.source.path, + 'Immutable object source path', + MAX_OBJECT_REF_LENGTH, + ), + }), + }); + } + if (input.source.kind === 'bytes' && input.source.value instanceof Uint8Array) { + return Object.freeze({ + digest: input.digest, + bytes: input.bytes, + mediaType, + source: Object.freeze({ kind: 'bytes' as const, value: Uint8Array.from(input.source.value) }), + }); + } + throw new TypeError('Immutable object source must contain file or byte content'); +} + +function admitImmutableObjectMaterializationInput( + input: ImmutableObjectMaterializationInput, +): ImmutableObjectMaterializationInput { + if (!isRecord(input)) + throw new TypeError('Immutable object materialization input must be an object'); + if (!isByteCount(input.maxBytes)) { + throw new TypeError( + 'Immutable object materialization byte limit must be a non-negative safe integer', + ); + } + return Object.freeze({ + ref: admitImmutableObjectRef(input.ref), + destination: requireIdentifier( + input.destination, + 'Immutable object materialization destination', + MAX_OBJECT_REF_LENGTH, + ), + maxBytes: input.maxBytes, + }); +} + +function admitImmutableObjectRef(input: ImmutableObjectRef): ImmutableObjectRef { + if (!isRecord(input)) throw new TypeError('Immutable object reference must be an object'); + const objectRef = requireIdentifier( + input.objectRef, + 'Immutable object reference', + MAX_OBJECT_REF_LENGTH, + ); + if (!isSha256Digest(input.digest)) throw new TypeError('Immutable object digest must be SHA-256'); + if (!isByteCount(input.bytes)) { + throw new TypeError('Immutable object byte count must be a non-negative safe integer'); + } + return immutableObjectRef({ + objectRef, + digest: input.digest, + bytes: input.bytes, + mediaType: requireIdentifier(input.mediaType, 'Immutable object media type'), + }); +} + +function admitSessionCheckpointManifestV1( + input: SessionCheckpointManifestV1, +): SessionCheckpointManifestV1 { + if (!isRecord(input)) throw new TypeError('Session checkpoint Manifest must be an object'); + if (input.schemaVersion !== 1) { + throw new TypeError('Session checkpoint Manifest schema version must be 1'); + } + return createSessionCheckpointManifestV1(input.compatibilityBundle); +} + +function admitStoredSessionCheckpoint(input: StoredSessionCheckpoint): StoredSessionCheckpoint { + if (!isRecord(input)) throw new TypeError('Stored Session checkpoint must be an object'); + const manifest = admitImmutableObjectRef(input.manifest); + const value = admitSessionCheckpointManifestV1(input.value); + if (manifest.mediaType !== SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE) { + throw new TypeError('Session checkpoint Manifest has an unsupported media type'); + } + const encoded = encodeSessionCheckpointManifestV1(value); + if (manifest.digest !== digestBytes(encoded) || manifest.bytes !== encoded.byteLength) { + throw repositoryError( + 'integrity_mismatch', + 'Session checkpoint Manifest value does not match its immutable reference', + ); + } + return storedSessionCheckpoint({ manifest, value }); +} + +function admitCommitSessionRevisionInput( + input: CommitSessionRevisionInput, +): InternalCommitSessionRevisionInput { + if (!isRecord(input)) throw new TypeError('Session commit input must be an object'); + return Object.freeze({ + sessionId: requireIdentifier(input.sessionId, 'Session identity'), + expectedRevision: requireIdentifier(input.expectedRevision, 'Expected revision'), + checkpoint: admitStoredSessionCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { + lastCommittedActivationId: requireIdentifier( + input.lastCommittedActivationId, + 'Activation identity', + ), + }), + ...(input.commitId === undefined + ? {} + : { commitId: requireIdentifier(input.commitId, 'Commit identity') }), + }); +} + +function admitCreateSessionInput(input: CreateSessionInput): InternalCreateSessionInput { + if (!isRecord(input)) throw new TypeError('Session creation input must be an object'); + const forkedFrom = + input.forkedFrom === undefined ? undefined : admitRevisionRef(input.forkedFrom, 'Fork source'); + const createdByForkId = + input.createdByForkId === undefined + ? undefined + : requireIdentifier(input.createdByForkId, 'Fork identity'); + if ((forkedFrom === undefined) !== (createdByForkId === undefined)) { + throw new TypeError('Fork lineage and Fork identity must be supplied together'); + } + if (createdByForkId !== undefined && input.lastCommittedActivationId !== undefined) { + throw new TypeError('Fork-created Session must not carry an Activation identity'); + } + return Object.freeze({ + sessionId: requireIdentifier(input.sessionId, 'Session identity'), + agentId: requireIdentifier(input.agentId, 'Agent identity'), + checkpoint: admitStoredSessionCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { + lastCommittedActivationId: requireIdentifier( + input.lastCommittedActivationId, + 'Activation identity', + ), + }), + ...(forkedFrom === undefined ? {} : { forkedFrom }), + ...(createdByForkId === undefined ? {} : { createdByForkId }), + }); +} + +function admitClaimForkInput(input: ClaimForkInput): InternalClaimForkInput { + if (!isRecord(input)) throw new TypeError('Fork claim input must be an object'); + return Object.freeze({ + forkId: requireIdentifier(input.forkId, 'Fork identity'), + source: admitRevisionRef(input.source, 'Fork source'), + targetSessionId: requireIdentifier(input.targetSessionId, 'Fork target Session identity'), + }); +} + +function admitRevisionRef(input: SessionRevisionRef, label: string): SessionRevisionRef { + if (!isRecord(input)) throw new TypeError(`${label} must be an object`); + return copyRevisionRef({ + sessionId: requireIdentifier(input.sessionId, `${label} Session identity`), + revision: requireIdentifier(input.revision, `${label} revision`), + }); +} + +function committedRevision(input: { + readonly sessionId: string; + readonly revision: SessionRepositoryRevision; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; +}): CommittedSessionRevision { + return Object.freeze({ + ref: copyRevisionRef({ sessionId: input.sessionId, revision: input.revision }), + agentId: input.agentId, + checkpoint: copyStoredSessionCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { lastCommittedActivationId: input.lastCommittedActivationId }), + ...(input.forkedFrom === undefined ? {} : { forkedFrom: copyRevisionRef(input.forkedFrom) }), + }); +} + +function immutableObjectRef(input: ImmutableObjectRef): ImmutableObjectRef { + return Object.freeze({ + objectRef: input.objectRef, + digest: input.digest, + bytes: input.bytes, + mediaType: input.mediaType, + }); +} + +function copyImmutableObjectRef(input: ImmutableObjectRef): ImmutableObjectRef { + return immutableObjectRef(input); +} + +function storedSessionCheckpoint(input: StoredSessionCheckpoint): StoredSessionCheckpoint { + return Object.freeze({ + manifest: copyImmutableObjectRef(input.manifest), + value: Object.freeze({ + schemaVersion: 1, + compatibilityBundle: copyImmutableObjectRef(input.value.compatibilityBundle), + }), + }); +} + +function copyStoredSessionCheckpoint(input: StoredSessionCheckpoint): StoredSessionCheckpoint { + return storedSessionCheckpoint(input); +} + +function copyRevisionRef(input: SessionRevisionRef): SessionRevisionRef { + return Object.freeze({ sessionId: input.sessionId, revision: input.revision }); +} + +function copyCommittedSessionRevision(input: CommittedSessionRevision): CommittedSessionRevision { + return committedRevision({ + sessionId: input.ref.sessionId, + revision: input.ref.revision, + agentId: input.agentId, + checkpoint: input.checkpoint, + ...(input.lastCommittedActivationId === undefined + ? {} + : { lastCommittedActivationId: input.lastCommittedActivationId }), + ...(input.forkedFrom === undefined ? {} : { forkedFrom: input.forkedFrom }), + }); +} + +function copyForkOperation(input: InternalForkOperation): ForkOperation { + if (input.state === 'pending') { + return Object.freeze({ + state: 'pending', + forkId: input.forkId, + source: copyRevisionRef(input.source), + sourceAgentId: input.sourceAgentId, + sourceCheckpoint: copyStoredSessionCheckpoint(input.sourceCheckpoint), + targetSessionId: input.targetSessionId, + }); + } + return copyCompletedForkOperation(input); +} + +function copyCompletedForkOperation(input: InternalCompletedForkOperation): CompletedForkOperation { + return Object.freeze({ + state: 'completed', + forkId: input.forkId, + source: copyRevisionRef(input.source), + sourceAgentId: input.sourceAgentId, + sourceCheckpoint: copyStoredSessionCheckpoint(input.sourceCheckpoint), + targetSessionId: input.targetSessionId, + target: copyRevisionRef(input.target), + }); +} + +function sameImmutableObjectRef(left: ImmutableObjectRef, right: ImmutableObjectRef): boolean { + return ( + left.objectRef === right.objectRef && + left.digest === right.digest && + left.bytes === right.bytes && + left.mediaType === right.mediaType + ); +} + +function sameSessionCheckpointManifestV1( + left: SessionCheckpointManifestV1, + right: SessionCheckpointManifestV1, +): boolean { + return ( + left.schemaVersion === right.schemaVersion && + sameImmutableObjectRef(left.compatibilityBundle, right.compatibilityBundle) + ); +} + +function sameStoredSessionCheckpoint( + left: StoredSessionCheckpoint, + right: StoredSessionCheckpoint, +): boolean { + return ( + sameImmutableObjectRef(left.manifest, right.manifest) && + sameSessionCheckpointManifestV1(left.value, right.value) + ); +} + +function sameRevisionRef(left: SessionRevisionRef, right: SessionRevisionRef): boolean { + return left.sessionId === right.sessionId && left.revision === right.revision; +} + +function sameCommitInput( + left: InternalCommitSessionRevisionInput, + right: InternalCommitSessionRevisionInput, +): boolean { + return ( + left.sessionId === right.sessionId && + left.expectedRevision === right.expectedRevision && + sameStoredSessionCheckpoint(left.checkpoint, right.checkpoint) && + left.lastCommittedActivationId === right.lastCommittedActivationId && + left.commitId === right.commitId + ); +} + +function sameCreateInput( + created: CommittedSessionRevision, + agentId: string, + forkedFrom: SessionRevisionRef | undefined, + input: InternalCreateSessionInput, +): boolean { + return ( + created.agentId === agentId && + created.agentId === input.agentId && + sameStoredSessionCheckpoint(created.checkpoint, input.checkpoint) && + created.lastCommittedActivationId === input.lastCommittedActivationId && + sameOptionalRevisionRef(forkedFrom, input.forkedFrom) + ); +} + +function sameOptionalRevisionRef( + left: SessionRevisionRef | undefined, + right: SessionRevisionRef | undefined, +): boolean { + return left === undefined || right === undefined ? left === right : sameRevisionRef(left, right); +} + +function sameForkClaim(operation: InternalForkOperation, input: InternalClaimForkInput): boolean { + return ( + operation.targetSessionId === input.targetSessionId && + sameRevisionRef(operation.source, input.source) + ); +} + +function nextRevision(session: SessionState): SessionRepositoryRevision { + const revision = `r${session.nextRevisionNumber}`; + session.nextRevisionNumber += 1; + return revision; +} + +function requireIdentifier( + value: unknown, + label: string, + maximumLength = MAX_IDENTIFIER_LENGTH, +): string { + if (!isNonEmptyUnicodeString(value) || value.length > maximumLength) { + throw new TypeError(`${label} must be a bounded non-empty Unicode string`); + } + return value; +} + +function requireImmutableObjectStore(value: unknown): ImmutableObjectStore { + if ( + !isRecord(value) || + typeof value.publish !== 'function' || + typeof value.assertReadable !== 'function' || + typeof value.materialize !== 'function' + ) { + throw new TypeError( + 'Immutable Object Store must implement publish, assertReadable, and materialize', + ); + } + return value as unknown as ImmutableObjectStore; +} + +function isByteCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function digestBytes(value: Uint8Array): Sha256Digest { + return `sha256:${createHash('sha256').update(value).digest('hex')}` as Sha256Digest; +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function normalizeObjectStoreError(error: unknown): SessionRepositoryError { + if (error instanceof SessionRepositoryError) return error; + return repositoryError('io_failure', 'Immutable Object Store operation failed', error); +} + +function repositoryError( + code: SessionRepositoryErrorCode, + message: string, + cause?: unknown, +): SessionRepositoryError { + return new SessionRepositoryError(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/904c9b7253dd6c4c9c4115b804ea2661b0a0f04a2216145e5ec719c0c9ec2635.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/904c9b7253dd6c4c9c4115b804ea2661b0a0f04a2216145e5ec719c0c9ec2635.source new file mode 100644 index 0000000000..1384f09cc2 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/904c9b7253dd6c4c9c4115b804ea2661b0a0f04a2216145e5ec719c0c9ec2635.source @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { tryAcquireFileLifetimeOwner } from '../file-lifetime-owner.js'; + +test('a file lifetime owner fails closed and recovers after owner death', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-file-lifetime-owner-')); + const path = join(root, 'nested', 'publication.lease'); + const holder = fork( + new URL('./fixtures/file-lifetime-owner-holder.js', import.meta.url), + [path], + { + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }, + ); + t.after(async () => { + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + holder.once('message', (message) => { + if (message === 'owned') resolve(); + else reject(new Error(`Unexpected file owner message: ${String(message)}`)); + }); + holder.once('error', reject); + holder.once('exit', (code, signal) => { + reject(new Error(`File owner exited before acquisition (${String(code)}, ${signal})`)); + }); + }); + + assert.equal(await tryAcquireFileLifetimeOwner(path), undefined); + + holder.kill('SIGKILL'); + await new Promise((resolve) => holder.once('exit', () => resolve())); + const successor = await tryAcquireFileLifetimeOwner(path); + assert.ok(successor); + await Promise.all([successor.close(), successor.close()]); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9233ff65304ac6ebc67d2f9192f2d9cb02b3289bf591239131a2beab0bd909e2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9233ff65304ac6ebc67d2f9192f2d9cb02b3289bf591239131a2beab0bd909e2.source new file mode 100644 index 0000000000..1d95375bbc --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9233ff65304ac6ebc67d2f9192f2d9cb02b3289bf591239131a2beab0bd909e2.source @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + assertSessionBundleLimits, + copyOpaqueStateIdentityDescriptor, + isSha256Digest, + isValidUnicodeString, + SESSION_BUNDLE_LIMIT_NAMES, + SessionBundleFileError, + type SessionBundleLimits, +} from '../session-bundle-contract.js'; + +const limits: SessionBundleLimits = { + maxCompressedBytes: 0, + maxDecompressedTarBytes: 0, + maxPayloadBytes: 0, + maxFileBytes: 0, + maxEntryCount: 0, + maxManifestBytes: 0, + maxStateIdentityBytes: 0, + maxPathBytes: 0, + maxPathDepth: 0, +}; + +test('exposes strict lowercase SHA-256 digest validation', () => { + const digest = `sha256:${'ab'.repeat(32)}`; + assert.equal(isSha256Digest(digest), true); + assert.equal(isSha256Digest(`sha256:${'AB'.repeat(32)}`), false); + assert.equal(isSha256Digest(`sha256:${'ab'.repeat(31)}`), false); + assert.equal(isSha256Digest(`sha512:${'ab'.repeat(32)}`), false); + assert.equal(isSha256Digest(42), false); +}); + +test('validates Unicode strings safely at the runtime boundary', () => { + assert.equal(isValidUnicodeString('session-🚀'), true); + assert.equal(isValidUnicodeString('\ud800'), false); + assert.equal(isValidUnicodeString('\udc00'), false); + assert.equal(isValidUnicodeString(42), false); + assert.equal(isValidUnicodeString(null), false); + assert.equal(isValidUnicodeString({}), false); +}); + +test('requires every explicit quota while permitting a fail-closed zero budget', () => { + assert.doesNotThrow(() => assertSessionBundleLimits(limits)); + assert.deepEqual([...SESSION_BUNDLE_LIMIT_NAMES].sort(), Object.keys(limits).sort()); + + const missing = { ...limits } as Record; + delete missing.maxPathDepth; + assert.throws( + () => assertSessionBundleLimits(missing as unknown as SessionBundleLimits), + TypeError, + ); + assert.throws( + () => + assertSessionBundleLimits({ + ...limits, + maxPathDepth: -1, + }), + RangeError, + ); + assert.throws( + () => + assertSessionBundleLimits({ + ...limits, + maxPathDepth: 1.5, + }), + RangeError, + ); + assert.throws( + () => + assertSessionBundleLimits({ + ...limits, + unexpected: 1, + } as SessionBundleLimits), + TypeError, + ); +}); + +test('copies opaque state identity bytes without interpreting or aliasing them', () => { + const source = new Uint8Array([0, 255, 123, 34, 0, 10]); + const copied = copyOpaqueStateIdentityDescriptor({ + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: source, + }); + + assert.equal(copied.mediaType, 'application/vnd.maka.session-state-identity+json;version=1'); + assert.deepEqual(copied.bytes, source); + assert.notEqual(copied.bytes, source); + + source[0] = 99; + assert.equal(copied.bytes[0], 0); + + assert.throws( + () => copyOpaqueStateIdentityDescriptor(null as unknown as typeof copied), + TypeError, + ); + assert.throws( + () => + copyOpaqueStateIdentityDescriptor({ + mediaType: '\ud800', + bytes: new Uint8Array(), + }), + TypeError, + ); +}); + +test('keeps stable bundle failures bounded and preserves their causes', () => { + const cause = new Error('filesystem failure'); + const error = new SessionBundleFileError('io_failure', 'Session bundle I/O failed', { + cause, + details: { + operation: 'inspect', + quota: 'maxCompressedBytes', + limit: 10, + observed: 11, + }, + }); + + assert.equal(error.name, 'SessionBundleFileError'); + assert.equal(error.code, 'io_failure'); + assert.equal(error.cause, cause); + assert.deepEqual(error.details, { + operation: 'inspect', + quota: 'maxCompressedBytes', + limit: 10, + observed: 11, + }); + assert.equal(Object.isFrozen(error.details), true); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9258498671071e7cfecc47c4dc7c623362e2b8334589db059641658db6ae779b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9258498671071e7cfecc47c4dc7c623362e2b8334589db059641658db6ae779b.source new file mode 100644 index 0000000000..5788a95ecd --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9258498671071e7cfecc47c4dc7c623362e2b8334589db059641658db6ae779b.source @@ -0,0 +1,349 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; + +test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const created = await coordinator.createConnection({ + expectedCatalogRevision: 0, + connection: { + slug: 'custom-openai', + name: 'Custom OpenAI', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['custom-model'], + }, + }); + assert.equal(created.kind, 'committed'); + assert.equal(Object.isFrozen(created), true); + if (created.kind === 'committed') assert.equal(Object.isFrozen(created.snapshot), true); + await writeModelFacts(root, { 'ollama:custom-model': { contextWindow: 64_000 } }); + const snapshot = await coordinator.getCatalogSnapshot(); + const model = snapshot.connections[0]?.models.find( + (candidate) => candidate.id === 'custom-model', + ); + assert.equal(model?.contextWindow, 64_000); + const prepared = await coordinator.beginConnectionTest( + snapshot.connections[0]!.connectionId, + null, + ); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind === 'ready') { + const tested = await coordinator.completeConnectionTest(prepared.ticket, { + status: 'verified', + checkedAt: '2026-08-01T00:00:00.000Z', + }); + assert.equal(tested.kind, 'committed'); + } + assert.equal( + (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + const restarted = new RuntimePolicyCoordinator((operation) => operation(root)); + const persisted = await restarted.getCatalogSnapshot(); + assert.equal( + persisted.connections[0]?.models.find((candidate) => candidate.id === 'custom-model') + ?.contextWindow, + 64_000, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('legacy connection verification survives unrelated model facts overrides', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-legacy-verification-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + const prepared = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + prepared.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + } + + const catalogPath = join(root, 'connection-catalog.json'); + const catalog = JSON.parse(await readFile(catalogPath, 'utf8')) as { + connections: Array>; + }; + delete catalog.connections[0]!.lastTestModelFactsFingerprint; + await writeFile(catalogPath, `${JSON.stringify(catalog)}\n`, 'utf8'); + + await writeModelFacts(root, { 'openai:unrelated-model': { contextWindow: 64_000 } }); + assert.equal( + (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('display-only model facts preserve verification and in-flight tests', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-display-only-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + const initial = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(initial.kind, 'ready'); + if (initial.kind !== 'ready') return; + assert.equal( + ( + await coordinator.completeConnectionTest( + initial.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + + const inFlight = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(inFlight.kind, 'ready'); + await writeModelFacts(root, { 'ollama:custom-model': { displayName: 'Friendly name' } }); + assert.equal( + (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + if (inFlight.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + inFlight.ticket, + verifiedAt('2026-08-01T00:01:00.000Z'), + ) + ).kind, + 'committed', + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('model fetch keeps an enabled facts-backed model outside provider inventory', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-refresh-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await writeModelFacts(root, { + 'ollama:custom-model': { contextWindow: 64_000 }, + 'ollama:unselected-model': { contextWindow: 128_000 }, + }); + const beforeRefresh = await coordinator.getCatalogSnapshot(); + const defaulted = await coordinator.setDefaultTarget({ + expectedCatalogRevision: beforeRefresh.revision, + target: { connectionId, modelId: 'custom-model' }, + }); + assert.equal(defaulted.kind, 'committed'); + + const fetch = await coordinator.beginModelFetch(connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + const refreshed = await coordinator.completeModelFetch(fetch.ticket, { + models: [{ id: 'live-model' }], + source: 'fetched', + fetchedAt: 1, + }); + assert.equal(refreshed.kind, 'committed'); + if (refreshed.kind !== 'committed') return; + + const raw = await ( + coordinator as unknown as { + catalog: { + read(root: string): Promise<{ + connections: readonly { models: readonly unknown[] }[]; + }>; + }; + } + ).catalog.read(root); + assert.deepEqual(raw.connections[0]?.models, [{ id: 'live-model' }]); + const projected = refreshed.snapshot.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['custom-model']); + assert.deepEqual(refreshed.snapshot.defaultTarget, { + connectionId, + modelId: 'custom-model', + }); + assert.equal( + projected?.models.find((model) => model.id === 'custom-model')?.contextWindow, + 64_000, + ); + assert.equal( + projected?.models.some((model) => model.id === 'unselected-model'), + false, + ); + + const execution = await coordinator.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'custom-openai', + }); + assert.equal(execution.kind, 'ready'); + if (execution.kind === 'ready') { + assert.equal( + execution.connection.models?.some((model) => model.id === 'custom-model'), + true, + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('model fetch keeps enabled facts-backed models when provider inventory fills the bound', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-refresh-bound-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await writeModelFacts(root, { 'ollama:custom-model': { contextWindow: 64_000 } }); + const fetch = await coordinator.beginModelFetch(connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + const refreshed = await coordinator.completeModelFetch(fetch.ticket, { + models: Array.from({ length: 2_048 }, (_, index) => ({ id: `live-model-${index}` })), + source: 'fetched', + fetchedAt: 1, + }); + assert.equal(refreshed.kind, 'committed'); + if (refreshed.kind !== 'committed') return; + const projected = refreshed.snapshot.connections[0]; + assert.equal(projected?.models.length, 2_048); + assert.equal(projected?.models.at(-1)?.id, 'custom-model'); + assert.equal(projected?.models.at(-1)?.contextWindow, 64_000); + assert.equal( + projected?.models.some((model) => model.id === 'live-model-2047'), + false, + ); + + const execution = await coordinator.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'custom-openai', + }); + assert.equal(execution.kind, 'ready'); + if (execution.kind === 'ready') { + const model = execution.connection.models?.find((entry) => entry.id === 'custom-model'); + assert.equal(model?.contextWindow, 64_000); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('protocol model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); + const emitWarning = process.emitWarning; + const warnings: string[] = []; + process.emitWarning = ((warning: string | Error) => { + warnings.push(String(warning)); + }) as typeof process.emitWarning; + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await writeModelFacts(root, { 'ollama:custom-model': { contextWindow: 64_000 } }); + const verified = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(verified.kind, 'ready'); + if (verified.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + verified.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + } + const ticket = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(ticket.kind, 'ready'); + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ + schemaVersion: 1, + overrides: { 'ollama:custom-model': { apiProtocol: 'openai-responses' } }, + }), + 'utf8', + ); + if (ticket.kind === 'ready') { + assert.deepEqual( + await coordinator.completeConnectionTest( + ticket.ticket, + verifiedAt('2026-08-01T00:01:00.000Z'), + ), + { kind: 'superseded', changed: ['connection'] }, + ); + } + assert.equal((await coordinator.getCatalogSnapshot()).connections[0]?.lastTest, undefined); + + await writeFile(join(root, 'model-facts.json'), '{not-json}', 'utf8'); + const snapshot = await coordinator.getCatalogSnapshot(); + assert.equal( + snapshot.connections[0]?.models.find((model) => model.id === 'custom-model')?.contextWindow, + undefined, + ); + assert.equal( + warnings.some((warning) => warning.includes('model-facts.json')), + true, + ); + } finally { + process.emitWarning = emitWarning; + await rm(root, { recursive: true, force: true }); + } +}); + +async function createTestConnection(coordinator: RuntimePolicyCoordinator): Promise { + const created = await coordinator.createConnection({ + expectedCatalogRevision: 0, + connection: { + slug: 'custom-openai', + name: 'Custom OpenAI', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['custom-model'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') throw new Error('Expected connection creation to commit'); + return created.snapshot.connections[0]!.connectionId; +} + +function verifiedAt(checkedAt: string) { + return { status: 'verified' as const, checkedAt }; +} + +async function writeModelFacts(root: string, overrides: Record): Promise { + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ schemaVersion: 1, overrides }), + 'utf8', + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/92a3dcf20fbd73d6cc64a24aefde2468c1d6f835d3e84366eaff76a109a9de7e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/92a3dcf20fbd73d6cc64a24aefde2468c1d6f835d3e84366eaff76a109a9de7e.source new file mode 100644 index 0000000000..6fef705c5f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/92a3dcf20fbd73d6cc64a24aefde2468c1d6f835d3e84366eaff76a109a9de7e.source @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/// + +import { constants as fsConstants } from 'node:fs'; +import { lstat, mkdir, open, realpath, type FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; +import { unlock, waitForLock } from 'fs-native-extensions'; +import { withArtifactWriterBootstrapLock } from './artifact-writer-bootstrap-lock.js'; +import { + prepareArtifactWriterBootstrapAuthority, + prepareArtifactWriterLockAuthorityForMarkedRoot, + type ArtifactWriterLockAuthority, +} from './root-authority.js'; + +const lockGates = new Map>(); + +// Operation-scoped and intentionally non-reentrant for the same workspace root. +export async function withArtifactWriterLock( + workspaceRoot: string, + operation: (canonicalRoot: string) => Promise, +): Promise { + await mkdir(workspaceRoot, { recursive: true }); + const requestedCanonicalRoot = await realpath(workspaceRoot); + const bootstrap = await prepareArtifactWriterBootstrapAuthority(requestedCanonicalRoot); + return withArtifactWriterBootstrapLock(bootstrap.lockPath, async () => { + await bootstrap.assertCurrentRoot(); + const authority = await prepareArtifactWriterLockAuthorityForMarkedRoot( + bootstrap.canonicalPath, + ); + if (!authority) return operation(bootstrap.canonicalPath); + if (authority.bootstrapLockPath !== bootstrap.lockPath) { + throw new Error('Storage root identity changed while acquiring its Artifact writer lock'); + } + return withAuthorityArtifactWriterLock(authority, () => operation(bootstrap.canonicalPath)); + }); +} + +export async function withLeaseBoundArtifactWriterLock( + authority: ArtifactWriterLockAuthority, + operation: () => Promise, +): Promise { + return withArtifactWriterBootstrapLock(authority.bootstrapLockPath, () => + withAuthorityArtifactWriterLock(authority, operation), + ); +} + +async function withAuthorityArtifactWriterLock( + authority: ArtifactWriterLockAuthority, + operation: () => Promise, +): Promise { + return withArtifactWriterLockPath( + join(authority.controlDirectory, ARTIFACT_WRITER_LOCK_FILE), + async () => { + await authority.assertCurrentRoot(); + return operation(); + }, + ); +} + +async function withArtifactWriterLockPath( + lockPath: string, + operation: () => Promise, +): Promise { + return runWithLockGate(lockPath, async () => { + const handle = await openArtifactWriterLock(lockPath); + let acquired = false; + try { + await assertStableRegularFile(handle, lockPath); + await waitForLock(handle.fd); + acquired = true; + await assertStableRegularFile(handle, lockPath); + return await operation(); + } finally { + if (acquired) releaseLock(handle); + await handle.close(); + } + }); +} + +async function runWithLockGate(lockPath: string, operation: () => Promise): Promise { + const previous = lockGates.get(lockPath); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + lockGates.set(lockPath, current); + await previous?.catch(() => {}); + try { + return await operation(); + } finally { + release(); + if (lockGates.get(lockPath) === current) lockGates.delete(lockPath); + } +} + +async function openArtifactWriterLock(lockPath: string): Promise { + const handle = await open( + lockPath, + fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + if (process.platform !== 'win32') await handle.chmod(0o600); + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function assertStableRegularFile(handle: FileHandle, lockPath: string): Promise { + const [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(lockPath, { bigint: true }), + ]); + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw new Error(`Artifact writer lock path is not one stable regular file: ${lockPath}`); + } +} + +function releaseLock(handle: FileHandle): void { + try { + unlock(handle.fd); + } catch { + // Closing the handle is the final OS-level release path. + } +} +export const ARTIFACT_WRITER_LOCK_FILE = '.maka-artifact-writer.lock'; diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/93ce31066a037900868ab5dcfeb62853150c587ae27f1b17695374488fb68857.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/93ce31066a037900868ab5dcfeb62853150c587ae27f1b17695374488fb68857.source new file mode 100644 index 0000000000..89d67c9640 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/93ce31066a037900868ab5dcfeb62853150c587ae27f1b17695374488fb68857.source @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + assertShellRunIdentifier, + assertShellRunPatch, + assertShellRunSessionId, + nextShellRunRecord, + normalizeShellRunRecord, + shellRunNotFoundError, + type ShellRunRecord, + type ShellRunPatch, + type ShellRunStore, +} from '@maka/core/shell-run'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; + +export interface ClosableShellRunStore extends ShellRunStore { + ready(): Promise; + close(): void; +} + +export function createSqliteShellRunStore(workspaceRoot: string): ClosableShellRunStore { + return new SqliteShellRunStore(workspaceRoot); +} + +class SqliteShellRunStore implements ClosableShellRunStore { + readonly #lease: OperationalStateDatabaseLease; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(workspaceRoot); + } + + ready(): Promise { + return Promise.resolve(); + } + + async createShellRun(record: ShellRunRecord): Promise { + assertShellRunSessionId(record.sessionId); + assertShellRunIdentifier(record.shellRunId); + const normalized = normalizeShellRunRecord(record, record.sessionId, record.shellRunId); + this.#lease.transaction('write', () => { + const result = this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_shell_runs( + session_id, shell_run_id, started_at, record_json + ) VALUES (?, ?, ?, ?) + `) + .run( + normalized.sessionId, + normalized.shellRunId, + normalized.startedAt, + JSON.stringify(normalized, sanitizeJson), + ); + if (result.changes !== 1) { + throw new Error(`ShellRun already exists: ${normalized.shellRunId}`); + } + }); + return normalized; + } + + async updateShellRun( + sessionId: string, + shellRunId: string, + patch: ShellRunPatch, + ): Promise { + assertShellRunSessionId(sessionId); + assertShellRunIdentifier(shellRunId); + assertShellRunPatch(patch); + return this.#lease.transaction('write', () => { + const current = readSqliteShellRun(this.#lease.database, sessionId, shellRunId); + const next = nextShellRunRecord(current, patch); + if (next === current) return current; + const result = this.#lease.database + .prepare(` + UPDATE core_shell_runs + SET started_at = ?, record_json = ? + WHERE session_id = ? AND shell_run_id = ? + `) + .run(next.startedAt, JSON.stringify(next, sanitizeJson), sessionId, shellRunId); + if (result.changes !== 1) throw new Error(`Failed to update shell run ${shellRunId}`); + return next; + }); + } + + async readShellRun(sessionId: string, shellRunId: string): Promise { + assertShellRunSessionId(sessionId); + assertShellRunIdentifier(shellRunId); + return readSqliteShellRun(this.#lease.database, sessionId, shellRunId); + } + + async listSessionShellRuns(sessionId: string): Promise { + assertShellRunSessionId(sessionId); + const rows = this.#lease.database + .prepare(` + SELECT shell_run_id, record_json + FROM core_shell_runs + WHERE session_id = ? + ORDER BY started_at, shell_run_id + `) + .all(sessionId) as Array<{ shell_run_id?: unknown; record_json?: unknown }>; + return rows.map((row) => { + if (typeof row.shell_run_id !== 'string' || typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite ShellRun row'); + } + return normalizeShellRunRecord(JSON.parse(row.record_json), sessionId, row.shell_run_id); + }); + } + + close(): void { + this.#lease.close(); + } +} + +function readSqliteShellRun( + db: import('node:sqlite').DatabaseSync, + sessionId: string, + shellRunId: string, +): ShellRunRecord { + const row = db + .prepare(` + SELECT record_json + FROM core_shell_runs + WHERE session_id = ? AND shell_run_id = ? + `) + .get(sessionId, shellRunId) as { record_json?: unknown } | undefined; + if (!row) throw shellRunNotFoundError(shellRunId); + if (typeof row.record_json !== 'string') throw new Error('Invalid SQLite ShellRun row'); + return normalizeShellRunRecord(JSON.parse(row.record_json), sessionId, shellRunId); +} + +function sanitizeJson(_key: string, value: unknown): unknown { + return value === undefined ? undefined : value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/953b5adc48bd5b60864af4a55adfd8339e78f487d4b7e3ac5c14e71694b71c4d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/953b5adc48bd5b60864af4a55adfd8339e78f487d4b7e3ac5c14e71694b71c4d.source new file mode 100644 index 0000000000..34c4bdfaa5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/953b5adc48bd5b60864af4a55adfd8339e78f487d4b7e3ac5c14e71694b71c4d.source @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { basename } from 'node:path'; + +const [rootArgument, markerFile] = process.argv.slice(2); +if (!rootArgument || !markerFile || !process.send) { + throw new Error('usage: root-initialization-race '); +} + +const root = fs.realpathSync(rootArgument); +const markerTempPrefix = `${markerFile}.`; +const originalOpen = fs.promises.open; +let intercepted = false; + +fs.promises.open = (async (path, flags, mode) => { + // This child initializes one root. Match its unique marker basename so the + // cut does not depend on Windows long/short, namespaced, or case spelling. + if ( + !intercepted && + typeof path === 'string' && + basename(path).startsWith(markerTempPrefix) && + basename(path).endsWith('.tmp') + ) { + intercepted = true; + await send({ type: 'marker_open_pending' }); + await waitForResume(); + } + return originalOpen(path, flags, mode); +}) as typeof fs.promises.open; + +// Import after the interposition: marker-file captures the intrinsic at module +// evaluation, while production code must ignore later global mutations. +const { resolveStorageRoot, StorageRootAuthorityError } = await import('../../root-authority.js'); + +const parentDisconnected = new Promise((resolvePromise) => + process.once('disconnect', resolvePromise), +); +try { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + await send({ type: 'resolved' }); +} catch (error) { + await send({ + type: 'error', + code: error instanceof StorageRootAuthorityError ? error.code : 'unexpected', + }); +} +await parentDisconnected; + +function waitForResume(): Promise { + return new Promise((resolvePromise, reject) => { + const onMessage = (message: unknown) => { + if (message !== 'resume') return; + cleanup(); + resolvePromise(); + }; + const onDisconnect = () => { + cleanup(); + reject(new Error('parent disconnected before resuming marker initialization')); + }; + const cleanup = () => { + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + }; + process.on('message', onMessage); + process.once('disconnect', onDisconnect); + }); +} + +function send(message: object): Promise { + return new Promise((resolvePromise, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolvePromise(); + }); + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/968b14ff33c871e7656f44e4d1fccbc96a548904842a3d964aab0704496b06f5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/968b14ff33c871e7656f44e4d1fccbc96a548904842a3d964aab0704496b06f5.source new file mode 100644 index 0000000000..bd83bda721 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/968b14ff33c871e7656f44e4d1fccbc96a548904842a3d964aab0704496b06f5.source @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + findInteractiveOAuthLoginReceipt, + MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS, + readInteractiveOAuthLoginReceipts, + upsertInteractiveOAuthLoginReceipt, +} from './oauth-login-receipt-document.js'; + +test('OAuth receipt retention has one explicit 256-attempt idempotency window', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-oauth-receipts-')); + try { + for (let index = 0; index <= MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS; index += 1) { + await upsertInteractiveOAuthLoginReceipt(root, receipt(index)); + } + const retained = await readInteractiveOAuthLoginReceipts(root); + assert.equal(retained.receipts.length, MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS); + assert.equal(findInteractiveOAuthLoginReceipt(retained, 'attempt-0'), undefined); + assert.equal(findInteractiveOAuthLoginReceipt(retained, 'attempt-1')?.completionOrder, 2); + assert.equal( + findInteractiveOAuthLoginReceipt(retained, `attempt-${MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS}`) + ?.completionOrder, + MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS + 1, + ); + + // Eviction intentionally ends the old idempotency claim: the same key can + // name a later attempt and receives a new completion order. + const reused = await upsertInteractiveOAuthLoginReceipt(root, receipt(0)); + assert.equal(reused.completionOrder, MAX_INTERACTIVE_OAUTH_LOGIN_RECEIPTS + 2); + assert.equal( + findInteractiveOAuthLoginReceipt(await readInteractiveOAuthLoginReceipts(root), 'attempt-0') + ?.completionOrder, + reused.completionOrder, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a retained OAuth attempt cannot be rebound to another target or entity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-oauth-receipts-')); + try { + const original = await upsertInteractiveOAuthLoginReceipt(root, receipt(7)); + assert.deepEqual(await upsertInteractiveOAuthLoginReceipt(root, receipt(7)), original); + await assert.rejects( + upsertInteractiveOAuthLoginReceipt(root, { + ...receipt(7), + target: { kind: 'create', providerType: 'xai-oauth' }, + connection: { + ...receipt(7).connection, + slug: 'xai-oauth', + providerType: 'xai-oauth', + }, + }), + /receipt conflicts/u, + ); + await assert.rejects( + upsertInteractiveOAuthLoginReceipt(root, { + ...receipt(7), + connection: { ...receipt(8).connection }, + }), + /receipt conflicts/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function receipt(index: number) { + return { + attemptId: `attempt-${index}`, + target: { kind: 'create' as const, providerType: 'openai-codex' as const }, + connection: { + connectionId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + slug: index === 0 ? 'codex-subscription' : `codex-subscription-${index + 1}`, + providerType: 'openai-codex' as const, + }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/98af3582700f1deebe248e1e36259f156cd1315201010548bbea6b8d53f2ee7f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/98af3582700f1deebe248e1e36259f156cd1315201010548bbea6b8d53f2ee7f.source new file mode 100644 index 0000000000..1aff861e80 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/98af3582700f1deebe248e1e36259f156cd1315201010548bbea6b8d53f2ee7f.source @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import type { + ContextOffloadLimits, + ContextOffloadOwner, + ContextOffloadStore, +} from '@maka/core/context-offload'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { + CONTEXT_OFFLOAD_DATABASE_NAME, + SqliteContextOffloadStore, +} from './sqlite-context-offload-store.js'; + +const writerBrand: unique symbol = Symbol('InteractiveContextOffloadWriter'); +const readerBrand: unique symbol = Symbol('InteractiveContextOffloadReader'); +const writers = new WeakSet(); +const readers = new WeakSet(); +const readerByWriter = new WeakMap(); +const writerByLease = new WeakMap< + object, + { readonly writer: InteractiveContextOffloadWriter; readonly limitsKey: string } +>(); +const writerOpeningByLease = new WeakMap< + object, + { readonly pending: Promise; readonly limitsKey: string } +>(); +const writerClosingByLease = new WeakMap< + object, + { readonly pending: Promise; readonly limitsKey: string } +>(); + +export interface OpenInteractiveContextOffloadStoreOptions { + readonly limits: ContextOffloadLimits; +} + +export interface InteractiveContextOffloadWriter extends Omit { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + close(): Promise; +} + +export interface InteractiveContextOffloadReader extends Pick { + readonly kind: 'interactive'; + readonly access: 'read'; + readonly [readerBrand]: true; +} + +export function authenticateInteractiveContextOffloadWriter( + writer: InteractiveContextOffloadWriter, +): InteractiveContextOffloadWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive context-offload writer', + ); + } + return writer; +} + +export function authenticateInteractiveContextOffloadReader( + reader: InteractiveContextOffloadReader, +): InteractiveContextOffloadReader { + if (!readers.has(reader)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive context-offload reader', + ); + } + return reader; +} + +/** Narrows an authenticated writer to the read-only authority used by model hydration. */ +export function createInteractiveContextOffloadReader( + writer: InteractiveContextOffloadWriter, +): InteractiveContextOffloadReader { + const authenticated = authenticateInteractiveContextOffloadWriter(writer); + const existing = readerByWriter.get(authenticated); + if (existing) return existing; + const reader: InteractiveContextOffloadReader = Object.freeze({ + kind: 'interactive', + access: 'read', + [readerBrand]: true as const, + read: (input: Parameters[0]) => + authenticated.read(Object.freeze({ ...input })), + }); + readers.add(reader); + readerByWriter.set(authenticated, reader); + return reader; +} + +/** + * Opens context-offload storage through an authenticated interactive write + * lease. Production callers must use this facade instead of constructing the + * low-level SQLite Store directly. + */ +export async function openInteractiveContextOffloadStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, + options: OpenInteractiveContextOffloadStoreOptions, +): Promise { + const limits = snapshotLimits(options.limits); + const limitsKey = serializeLimits(limits); + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) { + assertSameLimits(existing.limitsKey, limitsKey); + return existing.writer; + } + const opening = writerOpeningByLease.get(lease); + if (opening) { + assertSameLimits(opening.limitsKey, limitsKey); + return opening.pending; + } + const closing = writerClosingByLease.get(lease); + if (closing) { + assertSameLimits(closing.limitsKey, limitsKey); + await closing.pending; + return openInteractiveContextOffloadStoreForWrite(lease, { limits }); + } + + const pending = Promise.resolve().then(async () => { + let store: SqliteContextOffloadStore | undefined; + try { + store = await runWithStorageRootLease( + lease, + 'interactive', + 'write', + async (root) => + new SqliteContextOffloadStore(join(root, CONTEXT_OFFLOAD_DATABASE_NAME), { limits }), + ); + await assertStorageRootLease(lease, 'interactive', 'write'); + const raced = writerByLease.get(lease); + if (raced) { + store.close(); + assertSameLimits(raced.limitsKey, limitsKey); + return raced.writer; + } + const writer = createWriterFacade(lease, store, limitsKey); + writers.add(writer); + writerByLease.set(lease, { writer, limitsKey }); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, { pending, limitsKey }); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease)?.pending === pending) { + writerOpeningByLease.delete(lease); + } + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: SqliteContextOffloadStore, + limitsKey: string, +): InteractiveContextOffloadWriter { + let closed = false; + let closeTask: Promise | undefined; + const activeOperations = new Set>(); + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Context-offload writer is closed'), + ); + } + const pending = runWithStorageRootLease(lease, 'interactive', 'write', operation); + activeOperations.add(pending); + void pending.finally(() => activeOperations.delete(pending)).catch(() => undefined); + return pending; + }; + const writer: InteractiveContextOffloadWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + put: (input) => { + const accepted = Object.freeze({ + sessionId: input.sessionId, + owner: Object.freeze({ ...input.owner }), + bytes: new Uint8Array(input.bytes), + mediaType: input.mediaType, + ...(input.expectedSha256 === undefined ? {} : { expectedSha256: input.expectedSha256 }), + }); + return run(() => store.put(accepted)); + }, + read: (input) => { + const accepted = Object.freeze({ ...input }); + return run(() => store.read(accepted)); + }, + copyReferences: (input) => { + const accepted = Object.freeze({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + references: Object.freeze( + input.references.map((reference) => + Object.freeze({ + sourceRefId: reference.sourceRefId, + targetOwner: Object.freeze({ ...reference.targetOwner }), + }), + ), + ), + }); + return run(() => store.copyReferences(accepted)); + }, + releaseReference: (input) => { + const accepted = Object.freeze({ ...input }); + return run(() => store.releaseReference(accepted)); + }, + retireSession: (sessionId) => run(() => store.retireSession(sessionId)), + collectGarbage: (input) => { + const accepted = Object.freeze({ ...input }); + return run(() => store.collectGarbage(accepted)); + }, + usage: (sessionId) => run(() => store.usage(sessionId)), + close: () => { + if (closeTask) return closeTask; + closed = true; + if (writerByLease.get(lease)?.writer === writer) writerByLease.delete(lease); + const reader = readerByWriter.get(writer); + if (reader) readers.delete(reader); + writers.delete(writer); + let pending!: Promise; + pending = (async () => { + try { + await Promise.allSettled([...activeOperations]); + store.close(); + } finally { + if (writerClosingByLease.get(lease)?.pending === pending) { + writerClosingByLease.delete(lease); + } + } + })(); + closeTask = pending; + writerClosingByLease.set(lease, { pending, limitsKey }); + return pending; + }, + }; + return Object.freeze(writer); +} + +function snapshotLimits(limits: ContextOffloadLimits): ContextOffloadLimits { + const ownerMaxBytes = Object.freeze({ + read_image_snapshot: readLimit( + limits.ownerMaxBytes?.read_image_snapshot, + 'Read image snapshot byte limit', + ), + tool_result_archive: readLimit( + limits.ownerMaxBytes?.tool_result_archive, + 'Tool Result archive byte limit', + ), + }) satisfies Readonly>; + return Object.freeze({ + ownerMaxBytes, + sessionLogicalBytes: readLimit(limits.sessionLogicalBytes, 'Session context quota'), + workspacePhysicalBytes: readLimit(limits.workspacePhysicalBytes, 'Workspace context quota'), + }); +} + +function readLimit(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } + return value; +} + +function serializeLimits(limits: ContextOffloadLimits): string { + return [ + limits.ownerMaxBytes.read_image_snapshot, + limits.ownerMaxBytes.tool_result_archive, + limits.sessionLogicalBytes, + limits.workspacePhysicalBytes, + ].join(':'); +} + +function assertSameLimits(existing: string, requested: string): void { + if (existing !== requested) { + throw new Error('Context-offload writer is already bound to different limits for this lease'); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9af30c25beb573d02bcfc5a15e29f34811f1396019383deb95ef9c970e3b6ecf.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9af30c25beb573d02bcfc5a15e29f34811f1396019383deb95ef9c970e3b6ecf.source new file mode 100644 index 0000000000..38740d15fc --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9af30c25beb573d02bcfc5a15e29f34811f1396019383deb95ef9c970e3b6ecf.source @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, open, readFile, rm, writeFile, type FileHandle } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { openFileSessionRepository } from '../file-session-repository.js'; +import { + createInMemoryImmutableObjectStore, + createInMemorySessionRepository, + publishSessionCheckpointV1, + SESSION_BUNDLE_OBJECT_MEDIA_TYPE, + SessionRepositoryError, + type ImmutableObjectRef, + type ImmutableObjectStore, + type SessionRepository, + type StoredSessionCheckpoint, +} from '../session-repository.js'; +import type { Sha256Digest } from '../session-bundle-contract.js'; + +for (const backend of ['memory', 'file'] as const) { + test(`${backend}: preserves structured commit identities across reads and reopen`, async () => { + await withRepository(backend, async ({ directory, repository, objectStore, reopen }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + const next = await publishCheckpoint(objectStore, directory, 'next'); + const identities = [ + { sessionId: 'a', commitId: 'b\u0000c' }, + { sessionId: 'a\u0000b', commitId: 'c' }, + ]; + const unrelated = await repository.createSession({ + sessionId: 'unrelated', + agentId: 'agent', + checkpoint, + }); + for (const { sessionId } of identities) { + await repository.createSession({ sessionId, agentId: 'agent', checkpoint }); + } + const inputs = identities.map((identity) => ({ + ...identity, + expectedRevision: 'r1', + checkpoint: next, + })); + const results = []; + for (const input of inputs) results.push(await repository.commit(input)); + assert.deepEqual( + results.map((result) => result.ref.revision), + ['r2', 'r2'], + ); + + for (const reader of [repository, await reopen()]) { + assert.deepEqual(await reader.checkoutCurrent('unrelated'), unrelated); + for (const [index, input] of inputs.entries()) { + assert.deepEqual(await reader.checkoutCurrent(input.sessionId), results[index]); + assert.deepEqual(await reader.checkoutExact(results[index].ref), results[index]); + assert.deepEqual(await reader.commit(input), results[index]); + await assert.rejects( + reader.commit({ ...input, checkpoint }), + hasRepositoryCode('idempotency_conflict'), + ); + } + } + const writer = await reopen(); + for (const input of inputs) { + const advanced = await writer.commit({ + ...input, + commitId: 'after-reopen', + expectedRevision: 'r2', + checkpoint, + }); + assert.equal(advanced.ref.revision, 'r3'); + assert.deepEqual(await (await reopen()).checkoutCurrent(input.sessionId), advanced); + } + }); + }); + + for (const existing of ['caller data', 'previous materialization'] as const) { + test(`${backend}: failed materialization preserves ${existing}`, async () => { + await withRepository(backend, async ({ directory, objectStore }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + const ref = checkpoint.value.compatibilityBundle; + const destination = join(directory, 'materialized.tar.zst'); + const input = { ref, destination, maxBytes: ref.bytes }; + const expected = existing === 'caller data' ? 'unrelated caller contents' : 'initial'; + if (existing === 'caller data') await writeFile(destination, expected); + else await objectStore.materialize(input); + + await assert.rejects(objectStore.materialize(input), hasRepositoryCode('io_failure')); + assert.equal(await readFile(destination, 'utf8'), expected); + }); + }); + } + + test(`${backend}: concurrent materialization preserves the exclusive winner`, async () => { + await withRepository(backend, async ({ directory, objectStore }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + const ref = checkpoint.value.compatibilityBundle; + const destination = join(directory, 'materialized.tar.zst'); + const input = { ref, destination, maxBytes: ref.bytes }; + const results = await Promise.allSettled([ + objectStore.materialize(input), + objectStore.materialize(input), + ]); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + const failure = results.find((result) => result.status === 'rejected'); + assert.ok(failure?.status === 'rejected'); + assert.ok(hasRepositoryCode('io_failure')(failure.reason)); + assert.equal(await readFile(destination, 'utf8'), 'initial'); + }); + }); + + for (const concurrent of ['identical', 'conflicting', 'absent'] as const) { + test(`${backend}: reconciles ${concurrent} Fork claim before rejecting an advanced source`, { + timeout: 10_000, + }, async (t) => { + await withRepository(backend, async ({ directory, repository, objectStore, reopen }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + const next = await publishCheckpoint(objectStore, directory, 'next'); + const source = await repository.createSession({ + sessionId: 'source', + agentId: 'agent', + checkpoint, + }); + const input = { forkId: 'fork', source: source.ref, targetSessionId: 'target' }; + const reading = deferred(); + const released = deferred(); + const assertReadable = objectStore.assertReadable.bind(objectStore); + let blockNextBundleRead = true; + t.mock.method(objectStore, 'assertReadable', async (ref: ImmutableObjectRef) => { + await assertReadable(ref); + if (blockNextBundleRead && ref.mediaType === SESSION_BUNDLE_OBJECT_MEDIA_TYPE) { + blockNextBundleRead = false; + reading.resolve(); + await released.promise; + } + }); + + const claim = repository.claimFork(input); + try { + await Promise.race([ + reading.promise, + claim.then(() => { + throw new Error('Claim completed before verification barrier'); + }), + ]); + const winningInput = { + ...input, + targetSessionId: concurrent === 'conflicting' ? 'other-target' : 'target', + }; + const winner = + concurrent === 'absent' ? undefined : await (await reopen()).claimFork(winningInput); + const advanced = await (await reopen()).commit({ + sessionId: source.ref.sessionId, + expectedRevision: source.ref.revision, + checkpoint: next, + }); + released.resolve(); + + if (concurrent === 'identical') { + assert.deepEqual(await claim, winner); + } else { + await assert.rejects( + claim, + hasRepositoryCode( + concurrent === 'conflicting' + ? 'idempotency_conflict' + : 'source_revision_not_available', + ), + ); + } + const reader = await reopen(); + assert.deepEqual(await reader.checkoutCurrent('source'), advanced); + if (winner) { + assert.deepEqual(winner.sourceCheckpoint, checkpoint); + assert.deepEqual(await reader.claimFork(winningInput), winner); + } else { + // The rejected attempt must not leave a claim behind. + const admitted = await reader.claimFork({ ...input, source: advanced.ref }); + assert.deepEqual(admitted.source, advanced.ref); + assert.deepEqual(admitted.sourceCheckpoint, next); + } + } finally { + released.resolve(); + await Promise.allSettled([claim]); + } + }); + }); + } +} + +test('memory: cleans up a partial file only after owning its exclusive creation', async (t) => { + await withRepository('memory', async ({ directory, objectStore }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + const ref = checkpoint.value.compatibilityBundle; + const destination = join(directory, 'materialized.tar.zst'); + const input = { ref, destination, maxBytes: ref.bytes }; + const probe = await open(join(directory, 'handle-probe'), 'wx'); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const failure = new Error('Injected failure after partial write'); + const write = prototype.writeFile; + const mocked = t.mock.method(prototype, 'writeFile', async function (this: FileHandle) { + await write.call(this, Buffer.from('partial')); + throw failure; + }); + try { + await assert.rejects( + objectStore.materialize(input), + (error: unknown) => + error instanceof SessionRepositoryError && + error.code === 'io_failure' && + error.cause === failure, + ); + await assert.rejects(readFile(destination), { code: 'ENOENT' }); + } finally { + mocked.mock.restore(); + } + await objectStore.materialize(input); + assert.equal(await readFile(destination, 'utf8'), 'initial'); + }); +}); + +test('file: still rejects a genuinely duplicated commit identity on reopen', async () => { + await withRepository('file', async ({ directory, repository, objectStore, reopen }) => { + const checkpoint = await publishCheckpoint(objectStore, directory, 'initial'); + await repository.createSession({ sessionId: 'a\u0000b', agentId: 'agent', checkpoint }); + await repository.commit({ + sessionId: 'a\u0000b', + commitId: 'c', + expectedRevision: 'r1', + checkpoint, + }); + const path = join(directory, 'repository', 'session-repository-v1.json'); + const state = JSON.parse(await readFile(path, 'utf8')) as { commits: unknown[] }; + state.commits.push(state.commits[0]); + await writeFile(path, JSON.stringify(state)); + await assert.rejects( + (await reopen()).checkoutCurrent('a\u0000b'), + (error: unknown) => + error instanceof SessionRepositoryError && + error.code === 'integrity_mismatch' && + error.cause instanceof Error && + error.cause.message === 'Commit identity is duplicated', + ); + }); +}); + +interface RepositoryContext { + directory: string; + repository: SessionRepository; + objectStore: ImmutableObjectStore; + /** The memory implementation shares its state; the file implementation reopens from disk. */ + reopen(): Promise; +} + +async function withRepository( + backend: 'memory' | 'file', + operation: (context: RepositoryContext) => Promise, +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'maka-repository-conformance-')); + try { + if (backend === 'file') { + const storageRoot = join(directory, 'repository'); + const repository = await openFileSessionRepository({ storageRoot }); + await operation({ + directory, + repository, + objectStore: repository.objectStore, + reopen: () => openFileSessionRepository({ storageRoot }), + }); + } else { + const objectStore = createInMemoryImmutableObjectStore(); + const repository = createInMemorySessionRepository({ objectStore }); + await operation({ directory, repository, objectStore, reopen: async () => repository }); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function publishCheckpoint( + objectStore: ImmutableObjectStore, + directory: string, + contents: string, +): Promise { + const bytes = Buffer.from(contents); + const path = join(directory, `${contents}.tar.zst`); + await writeFile(path, bytes); + return publishSessionCheckpointV1({ + objectStore, + compatibilityBundle: { + path, + archiveDigest: `sha256:${createHash('sha256').update(bytes).digest('hex')}` as Sha256Digest, + compressedBytes: bytes.byteLength, + decompressedTarBytes: bytes.byteLength, + payloadBytes: bytes.byteLength, + entryCount: 1, + }, + }); +} + +function hasRepositoryCode(code: SessionRepositoryError['code']): (error: unknown) => boolean { + return (error) => error instanceof SessionRepositoryError && error.code === code; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b270a2da44b49129749e862b68155f47a7b2097e9a2017ac5ea794ba64ebbb6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b270a2da44b49129749e862b68155f47a7b2097e9a2017ac5ea794ba64ebbb6.source new file mode 100644 index 0000000000..481d952d51 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b270a2da44b49129749e862b68155f47a7b2097e9a2017ac5ea794ba64ebbb6.source @@ -0,0 +1,455 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ConnectionCatalogEntry, + ConnectionCatalogSnapshot, + ConnectionCredentialTarget, + ConnectionVersionBasis, + ConnectionModelDiscoveryResult, + ConnectionOnboardingTarget, + ConnectionTestSummary, + CredentialMutationResult, + CredentialLocator, + SetCredentialInput, + CredentialStatus, + CredentialVersionBasis, + RuntimePolicy, + NetworkProxyCredentialTarget, + UpdateNetworkProxyInput, + UpdateNetworkProxyResult, + RequestHeaderUpdate, + SavedRequestHeaders, +} from '@maka/core/runtime-policy'; +import type { ProviderDefaults } from '@maka/core/llm-connections'; + +declare const operationTicketBrand: unique symbol; + +export type ProviderAuthKind = ProviderDefaults['authKind']; +export type ConnectionEffectChangedDomain = 'connection' | 'credential' | 'network_proxy'; + +export interface RuntimePolicyCredentialMaterial extends CredentialVersionBasis { + readonly secret: string; + readonly proxyTarget?: NetworkProxyCredentialTarget; +} + +export type BoundCredentialMaterialExportResult = + | { + readonly kind: 'exported'; + readonly material: RuntimePolicyCredentialMaterial | null; + } + | { + readonly kind: 'connection_stale'; + readonly expected: ConnectionVersionBasis; + readonly actual: ConnectionVersionBasis | null; + }; + +export interface RuntimePolicyOperationSecretMaterial { + readonly connection?: RuntimePolicyCredentialMaterial; + readonly requestHeaders?: RuntimePolicyCredentialMaterial; + readonly networkProxy?: RuntimePolicyCredentialMaterial; +} + +export type ResolveWebSearchExecutionResult = + | { readonly kind: 'privacy_mode' } + | { + readonly kind: 'disabled'; + readonly provider: RuntimePolicy['webSearch']['defaultProvider']; + } + | { + readonly kind: 'model_native_only'; + readonly provider: 'model'; + } + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + | { + readonly kind: 'ready'; + readonly provider: 'tavily'; + readonly secretMaterial: { + readonly webSearch: RuntimePolicyCredentialMaterial; + readonly networkProxy?: RuntimePolicyCredentialMaterial; + }; + readonly networkProxy: RuntimePolicy['networkProxy']; + }; + +export interface ResolveWebSearchExecutionInput { + readonly provider?: 'tavily'; + readonly secretOverride?: string; + readonly bypassFeatureGate?: boolean; +} + +export interface ResolveNetworkProxyExecutionInput { + readonly networkProxy?: RuntimePolicy['networkProxy']; + readonly secretOverride?: string; +} + +export type ResolveNetworkProxyExecutionResult = + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + | { + readonly kind: 'ready'; + readonly networkProxy: RuntimePolicy['networkProxy']; + readonly secretMaterial: Pick; + }; + +/** + * Admission for a Host request that goes out over plain HTTP rather than to a + * configured model provider: the WebFetch tool, the models.dev catalog + * refresh. Privacy mode refuses it outright, and a configured proxy is + * mandatory rather than best effort. + */ +export type ResolveHostOutboundExecutionResult = + | { readonly kind: 'privacy_mode' } + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + | { + readonly kind: 'ready'; + readonly networkProxy: RuntimePolicy['networkProxy']; + readonly secretMaterial: Pick; + }; + +export type OAuthCredentialLocator = Omit< + Extract, + 'kind' +> & { + readonly kind: 'oauth_token'; +}; + +export interface CompareAndSetOAuthCredentialInput { + readonly locator: OAuthCredentialLocator; + readonly expected: Pick; + readonly secret: string; +} + +export type CompareAndSetOAuthCredentialResult = + | { + readonly kind: 'committed'; + readonly credentialId: string; + readonly revision: number; + } + | { readonly kind: 'superseded' }; + +export type CredentialStatusQueryResult = + | { readonly kind: 'status'; readonly status: CredentialStatus } + | { readonly kind: 'connection_not_found' }; + +export interface ModelFetchTicket { + readonly [operationTicketBrand]: 'model_fetch'; +} + +export interface ConnectionTestTicket { + readonly [operationTicketBrand]: 'connection_test'; +} + +export interface InteractiveOAuthLoginTicket { + readonly [operationTicketBrand]: 'interactive_oauth_login'; +} + +export type InteractiveOAuthLoginProvider = Extract< + ConnectionCatalogEntry['providerType'], + 'openai-codex' | 'xai-oauth' | 'github-copilot' +>; + +export type InteractiveOAuthLoginTarget = + | { readonly kind: 'create'; readonly providerType: InteractiveOAuthLoginProvider } + | { readonly kind: 'existing'; readonly connectionId: string }; + +export interface InteractiveOAuthLoginInput { + readonly attemptId: string; + readonly target: InteractiveOAuthLoginTarget; +} + +export type InteractiveOAuthConnectionIdentity = Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' +> & { readonly providerType: InteractiveOAuthLoginProvider }; + +export type QueryInteractiveOAuthLoginResult = + | { readonly kind: 'not_found' } + | { + readonly kind: 'authenticated'; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + }; + +export type BeginInteractiveOAuthLoginResult = + | { + readonly kind: 'authenticated'; + readonly target: InteractiveOAuthLoginTarget; + readonly connection: InteractiveOAuthConnectionIdentity; + } + | { readonly kind: 'connection_not_found' } + | { readonly kind: 'connection_disabled' } + | { readonly kind: 'catalog_full' } + | { readonly kind: 'attempt_conflict' } + | { readonly kind: 'provider_action_unavailable' } + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + | { + readonly kind: 'ready'; + readonly ticket: InteractiveOAuthLoginTicket; + readonly target: InteractiveOAuthLoginTarget; + readonly identity: InteractiveOAuthConnectionIdentity; + readonly connection: ConnectionCatalogEntry & { + readonly providerType: InteractiveOAuthLoginProvider; + }; + readonly secretMaterial: Pick; + readonly networkProxy: RuntimePolicy['networkProxy']; + }; + +export type InteractiveOAuthLoginCompletionResult = + | { + readonly kind: 'committed'; + readonly credentialId: string; + readonly revision: number; + readonly connection: InteractiveOAuthConnectionIdentity; + } + | { + readonly kind: 'superseded'; + readonly changed: readonly Extract< + ConnectionEffectChangedDomain, + 'connection' | 'credential' + >[]; + }; + +export type ConnectionEffectPreparationFailure = + | { readonly kind: 'connection_not_found' } + | { readonly kind: 'connection_disabled' } + | { readonly kind: 'provider_action_unavailable' } + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus }; + +export type BeginModelFetchResult = + | ConnectionEffectPreparationFailure + | { + readonly kind: 'ready'; + readonly ticket: ModelFetchTicket; + readonly connection: ConnectionCatalogEntry; + readonly secretMaterial: RuntimePolicyOperationSecretMaterial; + readonly networkProxy: RuntimePolicy['networkProxy']; + }; + +export type BeginConnectionTestResult = + | ConnectionEffectPreparationFailure + | { + readonly kind: 'ready'; + readonly ticket: ConnectionTestTicket; + readonly connection: ConnectionCatalogEntry; + readonly modelId: string | null; + readonly secretMaterial: RuntimePolicyOperationSecretMaterial; + readonly networkProxy: RuntimePolicy['networkProxy']; + }; + +export type ConnectionEffectCompletionResult = + | { readonly kind: 'committed'; readonly snapshot: ConnectionCatalogSnapshot } + | { + readonly kind: 'superseded'; + readonly changed: readonly ConnectionEffectChangedDomain[]; + }; + +export interface ConnectionOnboardingTicket { + readonly [operationTicketBrand]: 'connection_onboarding'; +} + +export interface BeginConnectionOnboardingInput { + readonly target: ConnectionOnboardingTarget; + readonly baseUrl: string | null; +} + +/** + * Discovery-basis handoff for onboarding: `begin` snapshots the connection + * revision, credential status, and effective proxy the caller will discover + * against and issues a one-shot ticket; `complete` revalidates that exact + * basis under the write lane before committing, so a model inventory can + * never be persisted onto an endpoint or credential it was not discovered + * from (#3467 review). + */ +export type BeginConnectionOnboardingResult = + // The explicitly targeted connection does not exist or changed provider type. + | { readonly kind: 'target_missing' } + | { readonly kind: 'provider_unsupported' } + | { readonly kind: 'catalog_full' } + // The create target's caller-requested slug already belongs to another + // connection. Nothing is derived or renamed silently — the caller picks a + // different slug (or omits it for the derived identity) and retries. + | { readonly kind: 'slug_taken' } + | { + readonly kind: 'ready'; + readonly ticket: ConnectionOnboardingTicket; + readonly candidate: Pick; + /** The targeted persisted connection, or null when onboarding creates one. */ + readonly existingConnection: ConnectionCatalogEntry | null; + /** Provider-normalized endpoint override pinned into the ticket. */ + readonly baseUrl: string | null; + /** The target's stored API key, for blank-key reuse during discovery. */ + readonly storedSecret: string | null; + /** + * The target's custom request-headers secret, so the discovery probe + * carries the same header customization the models path applies. + */ + readonly requestHeadersSecret: string | null; + /** + * The proxy discovery must run through — pinned here, like + * beginModelFetch pins it, so the basis certifies the egress the + * inventory actually travelled. + */ + readonly networkProxy: RuntimePolicy['networkProxy']; + readonly proxySecret: string | null; + /** The proxy requires a credential the vault does not hold. */ + readonly proxyCredentialMissing: boolean; + }; + +export interface CommitConnectionOnboardingInput { + readonly suppliedSecret: string | null; + readonly enabledModelIds: readonly string[]; + readonly discovery: ConnectionModelDiscoveryResult; +} + +export type CommitConnectionOnboardingResult = + | { + readonly kind: 'committed'; + readonly snapshot: ConnectionCatalogSnapshot; + readonly changed: boolean; + readonly connection: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' | 'revision' + >; + } + | { readonly kind: 'catalog_full' } + // The explicitly targeted connection no longer exists (or changed provider + // type) between the caller's snapshot and this commit. + | { readonly kind: 'target_missing' } + // The create target's caller-requested slug was taken between begin and + // this commit. A derived slug colliding stays `superseded` — a retry + // re-derives — but a requested slug is the caller's choice to fix. + | { readonly kind: 'slug_taken' } + // The discovery basis (connection revision, credential, or proxy) changed + // between begin and complete: committing would bind another endpoint or + // credential to a model inventory it never produced. + | { + readonly kind: 'superseded'; + readonly changed: readonly ConnectionEffectChangedDomain[]; + }; + +export type ResolveExecutionConnectionResult = + | { readonly kind: 'not_found' } + | { readonly kind: 'identity_mismatch' } + | { readonly kind: 'disabled' } + /** + * The provider was retired. Distinct from `disabled`, which the user chose + * and can undo, and from `credential_not_configured`, which a sign-in would + * fix — this connection keeps a usable credential and still cannot execute. + */ + | { readonly kind: 'provider_retired' } + | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } + | { + readonly kind: 'ready'; + readonly connection: ConnectionCatalogEntry; + readonly secretMaterial: RuntimePolicyOperationSecretMaterial; + readonly networkProxy: RuntimePolicy['networkProxy']; + }; + +export type ExecutionConnectionRef = + | { + readonly kind: 'bound'; + readonly connectionId: string; + readonly connectionSlug: string; + } + | { + readonly kind: 'catalog_slug'; + readonly connectionSlug: string; + }; + +export type ReplaceConnectionRequestHeadersResult = + | ({ readonly kind: 'committed' | 'unchanged' } & SavedRequestHeaders) + | { readonly kind: 'connection_not_found' }; + +export interface RuntimePolicyOperationCoordinator { + updateNetworkProxy(input: UpdateNetworkProxyInput): Promise; + exportCredentialMaterial( + locator: CredentialLocator, + ): Promise; + exportCredentialMaterial( + locator: CredentialLocator, + expectedConnection: ConnectionCredentialTarget, + ): Promise; + getConnectionRequestHeaders(connectionId: string): Promise; + replaceConnectionRequestHeaders( + connectionId: string, + updates: readonly RequestHeaderUpdate[], + ): Promise; + resolveExecutionConnection( + ref: ExecutionConnectionRef, + ): Promise; + resolveWebSearchExecution( + input?: ResolveWebSearchExecutionInput, + ): Promise; + resolveHostOutboundExecution(): Promise; + resolveNetworkProxyExecution( + input?: ResolveNetworkProxyExecutionInput, + ): Promise; + compareAndSetOAuthCredential( + input: CompareAndSetOAuthCredentialInput, + ): Promise; + importConnectionCredential(input: SetCredentialInput): Promise; + beginInteractiveOAuthLogin( + input: InteractiveOAuthLoginInput, + ): Promise; + queryInteractiveOAuthLogin(attemptId: string): Promise; + completeInteractiveOAuthLogin( + ticket: InteractiveOAuthLoginTicket, + secret: string, + ): Promise; + beginModelFetch(connectionId: string): Promise; + completeModelFetch( + ticket: ModelFetchTicket, + result: ConnectionModelDiscoveryResult, + ): Promise; + beginConnectionOnboarding( + input: BeginConnectionOnboardingInput, + ): Promise; + completeConnectionOnboarding( + ticket: ConnectionOnboardingTicket, + input: CommitConnectionOnboardingInput, + ): Promise; + beginConnectionTest( + connectionId: string, + modelId: string | null, + ): Promise; + completeConnectionTest( + ticket: ConnectionTestTicket, + result: ConnectionTestSummary, + ): Promise; +} + +export function connectionCredentialLocator( + connectionId: string, + authKind: ProviderAuthKind, +): Extract | null { + switch (authKind) { + case 'api_key': + case 'optional_api_key': + return { scope: 'connection', connectionId, kind: 'api_key' }; + case 'oauth_token': + return { scope: 'connection', connectionId, kind: 'oauth_token' }; + case 'none': + return null; + } +} + +export function connectionRequestHeadersLocator( + connectionId: string, +): Extract & { readonly kind: 'request_headers' } { + return { scope: 'connection', connectionId, kind: 'request_headers' }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b5d6d06535bbda2cad79ece6b14d68e5fcd7b43f696ddd6a69caa8c75171d06.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b5d6d06535bbda2cad79ece6b14d68e5fcd7b43f696ddd6a69caa8c75171d06.source new file mode 100644 index 0000000000..26a8084bc2 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9b5d6d06535bbda2cad79ece6b14d68e5fcd7b43f696ddd6a69caa8c75171d06.source @@ -0,0 +1,1007 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { createSqliteDeepResearchStore } from '../deep-research-store.js'; +import { + createOperationalStateBackup, + restoreOperationalStateBackup, +} from '../operational-state-backup.js'; +import { openInteractiveScheduledTaskStoreForWrite } from '../scheduled-task-store.js'; +import { createSqlitePlanStore } from '../plan-store.js'; +import { createSqliteSessionTodoStore } from '../session-todo-store.js'; +import { SQLITE_WORKFLOW_SCHEMA_VERSION } from '../sqlite-workflow-schema.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +const SESSION_ID = 'session-workflow'; + +describe('SQLite workflow stores', () => { + test('persists Plan exclusively through events', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root, { + newId: (() => { + let id = 0; + return () => `plan-${++id}`; + })(), + now: () => 100, + }); + const submitted = await store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'SQLite plan', + steps: [{ id: 'one', title: 'Persist state', description: 'Write one transaction' }], + }); + store.close(); + + const reopened = createSqlitePlanStore(root); + try { + assert.equal( + (await reopened.readState(SESSION_ID)).latestProposalId, + submitted.state.latestProposalId, + ); + } finally { + reopened.close(); + } + + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(rowCount(database, 'workflow_plan_events'), 1); + assert.equal(tableExists(database, 'workflow_plan_projections'), false); + } finally { + database.close(); + } + }); + }); + + test('migrates released workflow schema 9 projections to the current workflow schema', async () => { + await withRoot(async (root) => { + const planStore = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 }); + const submitted = await planStore.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Preserve Plan events', + steps: [{ id: 'one', title: 'Replay', description: 'Ignore stale projection bytes' }], + }); + planStore.close(); + + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released); + released + .prepare( + 'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, '{not-json'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 999, '{not-json'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + const migratedPlan = createSqlitePlanStore(root); + try { + assert.equal( + (await migratedPlan.readState(SESSION_ID)).latestProposalId, + submitted.state.latestProposalId, + ); + } finally { + migratedPlan.close(); + } + + const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION); + assert.equal(tableExists(verified, 'workflow_task_ledger_projections'), false); + assert.equal(tableExists(verified, 'workflow_plan_projections'), false); + assert.equal(rowCount(verified, 'workflow_plan_events'), 1); + } finally { + verified.close(); + } + }); + }); + + test('restores SessionTodo storage and drops Task Ledger events from workflow schema 10', async () => { + await withRoot(async (root) => { + createSqliteSessionTodoStore(root).close(); + + // Recreate the schema-10 shape: SessionTodo storage did not exist yet and + // Task Ledger events did, so the migration has to add one and drop the other. + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + released.exec(` + CREATE TABLE workflow_task_ledger_events ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + event_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, sequence), + UNIQUE (session_id, event_id) + ); + `); + released + .prepare(` + INSERT INTO workflow_task_ledger_events(session_id, sequence, event_id, record_json) + VALUES (?, 0, 'retired-event', '{}') + `) + .run(SESSION_ID); + released.exec('DROP TABLE workflow_session_todo_documents'); + setWorkflowSchemaVersion(released, 10); + } finally { + released.close(); + } + + const migrated = createSqliteSessionTodoStore(root); + try { + assert.deepEqual(await migrated.readOrBootstrap(SESSION_ID), { items: [] }); + } finally { + migrated.close(); + } + + const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION); + assert.equal(tableExists(verified, 'workflow_task_ledger_events'), false); + assert.equal(rowCount(verified, 'workflow_session_todo_documents'), 1); + } finally { + verified.close(); + } + }); + }); + + test('preserves every released projection when one table has unfamiliar DDL', async () => { + await withRoot(async (root) => { + createSqlitePlanStore(root).close(); + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released, { planVersionFloor: -1 }); + released + .prepare( + 'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, 'task-sentinel'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 0, 'plan-sentinel'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unfamiliar released shape/u.test(error.message), + ); + + assertReleasedProjectionStatePreserved(root); + }); + }); + + test('preserves every released projection when one table carries an extra trigger', async () => { + await withRoot(async (root) => { + createSqlitePlanStore(root).close(); + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released); + released.exec(` + CREATE TRIGGER workflow_task_ledger_projection_guard + AFTER INSERT ON workflow_task_ledger_projections + BEGIN + SELECT 1; + END; + `); + released + .prepare( + 'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, 'task-sentinel'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 0, 'plan-sentinel'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unexpected object/u.test(error.message), + ); + + assertReleasedProjectionStatePreserved(root); + }); + }); + + test('preserves an unfamiliar projection whose table name differs only by case', async () => { + await withRoot(async (root) => { + createSqlitePlanStore(root).close(); + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released, { + taskTableName: 'WORKFLOW_TASK_LEDGER_PROJECTIONS', + }); + released + .prepare( + 'INSERT INTO WORKFLOW_TASK_LEDGER_PROJECTIONS(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, 'task-sentinel'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 0, 'plan-sentinel'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unfamiliar released shape/u.test(error.message), + ); + + assertReleasedProjectionStatePreserved(root); + }); + }); + + test('preserves released projections with a sqliteX-prefixed trigger', async () => { + await withRoot(async (root) => { + createSqlitePlanStore(root).close(); + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released); + released.exec(` + CREATE TRIGGER sqliteX_projection_guard + AFTER INSERT ON workflow_task_ledger_projections + BEGIN + SELECT 1; + END; + `); + released + .prepare( + 'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, 'task-sentinel'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 0, 'plan-sentinel'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unexpected object/u.test(error.message), + ); + + assertReleasedProjectionStatePreserved(root); + }); + }); + + test('preserves released projections with a sqliteX-prefixed dependent view', async () => { + await withRoot(async (root) => { + createSqlitePlanStore(root).close(); + const released = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + installReleasedProjectionTables(released); + released.exec(` + CREATE VIEW sqliteX_projection_guard AS + SELECT session_id, record_json + FROM workflow_task_ledger_projections; + `); + released + .prepare( + 'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)', + ) + .run(SESSION_ID, 'task-sentinel'); + released + .prepare( + 'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)', + ) + .run(SESSION_ID, 0, 'plan-sentinel'); + setWorkflowSchemaVersion(released, 9); + } finally { + released.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unexpected schema object view:sqliteX_projection_guard/u.test(error.message), + ); + + assertReleasedProjectionStatePreserved(root); + const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(tableExists(preserved, 'sqliteX_projection_guard', 'view'), true); + } finally { + preserved.close(); + } + }); + }); + + test('an older workflow reader rejects the newer schema without changing it', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 }); + await store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Preserve newer workflow state', + steps: [{ id: 'one', title: 'Persist', description: 'Write one event' }], + }); + store.close(); + + const newer = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + setWorkflowSchemaVersion(newer, SQLITE_WORKFLOW_SCHEMA_VERSION + 1); + newer.exec(` + CREATE TABLE workflow_future_sentinel (value TEXT NOT NULL); + INSERT INTO workflow_future_sentinel(value) VALUES ('preserved'); + `); + } finally { + newer.close(); + } + + assert.throws( + () => createSqlitePlanStore(root), + /Operational schema workflow is newer than supported/u, + ); + + const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(workflowSchemaVersion(preserved), SQLITE_WORKFLOW_SCHEMA_VERSION + 1); + assert.equal(rowCount(preserved, 'workflow_plan_events'), 1); + assert.equal( + ( + preserved.prepare('SELECT value FROM workflow_future_sentinel').get() as { + value?: unknown; + } + ).value, + 'preserved', + ); + } finally { + preserved.close(); + } + }); + }); + + test('backs up and restores Plan and initialized SessionTodo state', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workflow-backup-')); + const stateRoot = join(base, 'state'); + const backupRoot = join(base, 'backup'); + const restoreRoot = join(base, 'restore'); + await mkdir(stateRoot); + try { + const planStore = createSqlitePlanStore(stateRoot, { + newId: () => 'backup-proposal', + now: () => 100, + }); + const submitted = await planStore.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-backup', + title: 'Restore Plan event', + steps: [{ id: 'restore', title: 'Restore', description: 'Replay the event ledger' }], + }); + planStore.close(); + + const todoStore = createSqliteSessionTodoStore(stateRoot); + await todoStore.replaceAll('todo-non-empty', [ + { content: 'Restore current Todo', status: 'in_progress' }, + ]); + await todoStore.replaceAll('todo-empty', []); + todoStore.close(); + + await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 }); + await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot }); + + const restoredPlan = createSqlitePlanStore(restoreRoot); + try { + assert.equal( + (await restoredPlan.readState(SESSION_ID)).latestProposalId, + submitted.state.latestProposalId, + ); + } finally { + restoredPlan.close(); + } + const restoredTodos = createSqliteSessionTodoStore(restoreRoot); + try { + assert.deepEqual(await restoredTodos.readOrBootstrap('todo-non-empty'), { + items: [{ content: 'Restore current Todo', status: 'in_progress' }], + }); + assert.deepEqual(await restoredTodos.readOrBootstrap('todo-empty'), { items: [] }); + } finally { + restoredTodos.close(); + } + + const restored = new DatabaseSync(join(restoreRoot, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(tableExists(restored, 'workflow_task_ledger_projections'), false); + assert.equal(tableExists(restored, 'workflow_plan_projections'), false); + assert.equal(rowCount(restored, 'workflow_plan_events'), 1); + assert.equal(rowCount(restored, 'workflow_session_todo_documents'), 2); + } finally { + restored.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } + }); + + test('reconciles exact Plan retries through durable operation identity', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root, { + newId: (() => { + let id = 0; + return () => `generated-${++id}`; + })(), + now: () => 100, + }); + try { + const input = { + operationId: 'submit-operation', + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Stable plan', + steps: [{ id: 'one', title: 'Persist once', description: 'Commit one event' }], + }; + const submitted = await store.submitProposal(input); + await store.requestRevision({ + operationId: 'revision-operation', + sessionId: SESSION_ID, + proposalId: + submitted.event.type === 'plan_submitted' ? submitted.event.proposal.proposalId : '', + }); + + const retried = await store.submitProposal(input); + assert.equal(retried.event.id, 'submit-operation'); + assert.equal(retried.state.storeVersion, 1); + assert.equal( + (await store.readOperationReceipt(SESSION_ID, input.operationId, input))?.storeVersion, + 1, + ); + await assert.rejects( + store.submitProposal({ ...input, title: 'Reused identity' }), + /identity was reused/, + ); + await assert.rejects( + store.readOperationReceipt(SESSION_ID, input.operationId, { + ...input, + title: 'Reused identity', + }), + /identity was reused/, + ); + assert.equal((await store.readState(SESSION_ID)).storeVersion, 2); + } finally { + store.close(); + } + }); + }); + + test('rejects Plan data that the Runtime Host projection cannot represent', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root); + try { + await assert.rejects( + store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Invalid identifiers', + steps: [{ id: 'step one', title: 'Reject input', description: 'Invalid id' }], + }), + /canonical entity id/, + ); + await assert.rejects( + store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-2', + title: 'Oversized text', + steps: [ + { + id: 'step-1', + title: 'Reject input', + description: 'x'.repeat(16 * 1024 + 1), + }, + ], + }), + /text limit/, + ); + await assert.rejects( + store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-3', + title: 'Oversized projection', + steps: Array.from({ length: 16 }, (_, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + description: 'x'.repeat(4_000), + })), + }), + /projection item limit/, + ); + assert.equal((await store.readState(SESSION_ID)).storeVersion, 0); + } finally { + store.close(); + } + }); + }); + + test('reserves enough projection space for the complete Plan lifecycle', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root); + try { + const submitted = await store.submitProposal({ + operationId: 'submit-lifecycle', + sessionId: SESSION_ID, + turnId: 'turn-lifecycle', + title: 'Lifecycle-safe plan', + steps: Array.from({ length: 50 }, (_, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + description: 'x'.repeat(900), + })), + }); + assert.equal(submitted.event.type, 'plan_submitted'); + if (submitted.event.type !== 'plan_submitted') return; + const approval = { + sessionId: SESSION_ID, + proposalId: submitted.event.proposal.proposalId, + expectedRevision: submitted.event.proposal.revision, + expectedStoreVersion: submitted.state.storeVersion, + }; + const approved = await store.approveProposal({ + ...approval, + operationId: 'approve-lifecycle', + }); + await assert.rejects( + store.approveProposal({ ...approval, operationId: 'approve-again' }), + /already approved by another operation/, + ); + assert.equal(approved.event.type, 'plan_approved'); + if (approved.event.type !== 'plan_approved') return; + + await store.interruptActiveExecution(SESSION_ID, 'i'.repeat(1024), 'interrupt-lifecycle'); + const cancelled = await store.cancelExecution({ + operationId: 'cancel-lifecycle', + sessionId: SESSION_ID, + executionId: approved.event.execution.executionId, + reason: 'c'.repeat(1024), + }); + + assert.equal(cancelled.state.storeVersion, 4); + assert.equal(cancelled.state.executions[0]?.status, 'cancelled'); + } finally { + store.close(); + } + }); + }); + + test('rejects proposals whose later lifecycle projection would overflow', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root); + try { + await assert.rejects( + store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-lifecycle-overflow', + title: 'Lifecycle overflow', + steps: Array.from({ length: 50 }, (_, index) => ({ + id: `step-${index}`, + title: `Step ${index}`, + description: 'x'.repeat(1_100), + })), + }), + /projection item limit/, + ); + } finally { + store.close(); + } + }); + }); + + test('purges Plan events for retired Sessions', async () => { + await withRoot(async (root) => { + const store = createSqlitePlanStore(root); + try { + await store.submitProposal({ + sessionId: SESSION_ID, + turnId: 'turn-1', + title: 'Disposable plan', + steps: [{ id: 'one', title: 'Remove state', description: 'Purge the ledger' }], + }); + await store.purgeSessionState(SESSION_ID); + assert.deepEqual(await store.readState(SESSION_ID), { + schemaVersion: 1, + sessionId: SESSION_ID, + storeVersion: 0, + proposals: [], + executions: [], + }); + } finally { + store.close(); + } + }); + }); + + test('persists Deep Research events', async () => { + await withRoot(async (root) => { + const store = createSqliteDeepResearchStore(root, { + newId: () => 'research-1', + now: () => 200, + }); + await store.start(SESSION_ID, 'Map the SQLite authority', 'deep'); + store.close(); + + const reopened = createSqliteDeepResearchStore(root); + try { + assert.equal((await reopened.read(SESSION_ID))?.objective, 'Map the SQLite authority'); + } finally { + reopened.close(); + } + }); + }); + + test('purges Deep Research events for retired Sessions', async () => { + await withRoot(async (root) => { + const store = createSqliteDeepResearchStore(root); + try { + await store.start(SESSION_ID, 'Remove the retired research workspace', 'standard'); + await store.purgeSessionState(SESSION_ID); + assert.equal(await store.read(SESSION_ID), undefined); + assert.deepEqual(await store.readEvents(SESSION_ID), []); + } finally { + store.close(); + } + }); + }); + + test('persists Scheduled Tasks and admits each fire once', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + const task = await store.create( + { + title: 'Review SQLite', + intentBody: '', + schedule: { kind: 'once', runAt: now + 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }, + now, + ); + const claims = await Promise.all([ + store.claimNextDue(now + 1_000), + store.claimNextDue(now + 1_000), + ]); + const claim = claims.map((entry) => entry.claim).find((entry) => entry !== null); + assert.ok(claim); + assert.equal(claims.filter((entry) => entry.claim !== null).length, 1); + assert.equal((await store.claimNextDue(now + 1_000)).claim, null); + await store.settleFire(claim.id, { + at: now + 1_000, + outcome: 'ok', + message: 'done', + }); + store.close(); + + const reopened = await open(); + try { + const persisted = (await reopened.list())[0]; + assert.equal(persisted?.id, task.id); + assert.equal(persisted?.status, 'completed'); + assert.equal(persisted?.fireCount, 1); + } finally { + reopened.close(); + await owner.close(); + } + }); + }); + + test('persists the exact ScheduledTask Agent execution identity before admission', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + const task = await store.create( + { + title: 'Durable Agent fire', + intentBody: 'Continue the release', + schedule: { kind: 'once', runAt: now + 1_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/workspace', + backend: 'ai-sdk', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'test-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }, + now, + ); + assert.equal( + task.effect.kind === 'agent_run' ? task.effect.execution.llmConnectionId : undefined, + 'connection-default', + ); + const claim = await store.claimNow(task.id, now); + await store.bindFireExecution(claim.id, { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + userMessageId: 'message-1', + }); + store.close(); + + const reopened = await open(); + try { + assert.deepEqual((await reopened.listPendingFires())[0]?.execution, { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + userMessageId: 'message-1', + }); + } finally { + reopened.close(); + await owner.close(); + } + }); + }); + + test('folds retired permission modes in tasks and pending fire claims', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + await assert.rejects( + () => + store.create( + { + title: 'Reject retired input', + intentBody: 'run', + schedule: { kind: 'once', runAt: now + 1_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/workspace', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'test-model', + permissionMode: 'execute', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }, + now, + ), + /execution.permissionMode is required/, + ); + const task = await store.create( + { + title: 'Decode retired rows', + intentBody: 'run', + schedule: { kind: 'once', runAt: now + 1_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/workspace', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'test-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + createdBy: { kind: 'user' }, + }, + now, + ); + await store.claimNow(task.id, now); + store.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database.exec(` + UPDATE workflow_scheduled_tasks + SET record_json = json_set(record_json, '$.effect.execution.permissionMode', 'execute'); + UPDATE workflow_scheduled_task_fires + SET record_json = json_set(record_json, '$.task.effect.execution.permissionMode', 'execute'); + `); + } finally { + database.close(); + } + + const reopened = await open(); + try { + const decodedTask = (await reopened.list())[0]; + const decodedClaim = (await reopened.listPendingFires())[0]; + assert.equal( + decodedTask?.effect.kind === 'agent_run' + ? decodedTask.effect.execution.permissionMode + : undefined, + 'ask', + ); + assert.equal( + decodedClaim?.task.effect.kind === 'agent_run' + ? decodedClaim.task.effect.execution.permissionMode + : undefined, + 'ask', + ); + } finally { + reopened.close(); + await owner.close(); + } + }); + }); + + test('does not lower maxFires below the task fire count', async () => { + await withRoot(async (root) => { + const now = Date.now(); + const { owner, open } = await scheduledTaskStoreRoot(root); + const store = await open(); + try { + const task = await store.create( + { + title: 'Bounded recurrence', + intentBody: '', + schedule: { kind: 'interval', everySeconds: 60, startAt: now + 1_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }, + now, + ); + const claim = await store.claimNow(task.id, now); + await store.settleFire(claim.id, { at: now, outcome: 'ok', message: 'done' }); + await assert.rejects( + () => store.update(task.id, { maxFires: 1 }, now + 1), + /maxFires must be greater than the current fireCount/, + ); + } finally { + store.close(); + await owner.close(); + } + }); + }); +}); + +async function scheduledTaskStoreRoot(root: string) { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to acquire the ScheduledTask test root'); + return { + owner, + open: () => openInteractiveScheduledTaskStoreForWrite(owner.lease), + }; +} + +async function withRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-workflow-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function tableExists(database: DatabaseSync, name: string, type = 'table'): boolean { + return ( + database + .prepare('SELECT 1 AS present FROM sqlite_schema WHERE type = ? AND name = ?') + .get(type, name) !== undefined + ); +} + +function rowCount(database: DatabaseSync, table: string): number { + const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { + count?: unknown; + }; + assert.equal(typeof row.count, 'number'); + return row.count as number; +} + +function installReleasedProjectionTables( + database: DatabaseSync, + options: { planVersionFloor?: number; taskTableName?: string } = {}, +): void { + const taskTableName = options.taskTableName ?? 'workflow_task_ledger_projections'; + database.exec(` + CREATE TABLE ${taskTableName} ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL + ); + CREATE TABLE workflow_plan_projections ( + session_id TEXT PRIMARY KEY, + store_version INTEGER NOT NULL CHECK (store_version >= ${options.planVersionFloor ?? 0}), + record_json TEXT NOT NULL + ); + `); +} + +function setWorkflowSchemaVersion(database: DatabaseSync, version: number): void { + database + .prepare("UPDATE operational_schema_migrations SET version = ? WHERE scope = 'workflow'") + .run(version); +} + +function workflowSchemaVersion(database: DatabaseSync): number { + const row = database + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'workflow'") + .get() as { version?: unknown } | undefined; + const version = row?.version; + assert.equal(typeof version, 'number'); + return version as number; +} + +function assertReleasedProjectionStatePreserved(root: string): void { + const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal(workflowSchemaVersion(preserved), 9); + assert.equal(rowCount(preserved, 'workflow_task_ledger_projections'), 1); + assert.equal(rowCount(preserved, 'workflow_plan_projections'), 1); + } finally { + preserved.close(); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9cf7d4e97283673cf06fc02db8b76dfe66aa61af99dd8cc6296f5d7e8aa616ea.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9cf7d4e97283673cf06fc02db8b76dfe66aa61af99dd8cc6296f5d7e8aa616ea.source new file mode 100644 index 0000000000..e3dfa9d792 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9cf7d4e97283673cf06fc02db8b76dfe66aa61af99dd8cc6296f5d7e8aa616ea.source @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES as MAX_BYTES, + TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_TRANSITIONS as MAX_TRANSITIONS, + type ToolResultArchiveEvidenceReader, + type ToolResultArchiveEvidence, +} from '@maka/core/tool-result-archive-evidence'; +import { + assertStorageRootLease, + runWithStorageRootLease, + type StorageRootLease, +} from './root-authority.js'; +import { decodeRuntimeEvent } from '@maka/core/runtime-event'; +import { decodeAgentRunEvent } from '@maka/core/agent-run'; + +import { MODEL_PROJECTION_TARGET_SQL as TARGET } from './sqlite-core-execution-schema.js'; +const TRANSITIONS = 'core_agent_run_events INDEXED BY core_model_projection_target'; +const KIND = "event_type = 'model_projection_transition_recorded'"; +// SQLite may parse the containing JSON, but only these reconstruction fields +// cross into JS or count toward the evidence budget. Raw tool bytes never do. +const EVENT_EVIDENCE = `CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, + '$.id', '$.sessionId', '$.runId', '$.invocationId', '$.turnId', '$.ts', '$.partial', + '$.author', '$.role', '$.content.kind', '$.content.id', '$.content.name', + '$.content.modelProjection', '$.content.providerExecuted') END`; + +/** Reader-first foundation; no archive writer or fallback is activated by opening it. */ +export async function openToolResultArchiveEvidenceReader( + lease: StorageRootLease<'interactive', 'read'> | StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', lease.access); + const { acquireOperationalStateDatabase } = await import('./operational-state-store.js'); + await assertStorageRootLease(lease, 'interactive', lease.access); + const database = acquireOperationalStateDatabase(lease.canonicalPath, { + schemaMigration: lease.access === 'read' ? 'require_current' : 'migrate', + }); + let closed = false; + return { + close() { + if (!closed) { + closed = true; + database.close(); + } + }, + async read(input): Promise { + const accepted = { ...input }; + if (closed) return { ok: false, reason: 'unavailable' }; + try { + return await runWithStorageRootLease(lease, 'interactive', lease.access, async () => { + if (closed) return { ok: false, reason: 'unavailable' }; + return database.transaction('read', () => { + const db = database.database; + const { sessionId, runtimeEventId } = accepted; + const eventSize = db + .prepare( + `SELECT length(CAST(${EVENT_EVIDENCE} AS BLOB)) AS bytes FROM runtime_events WHERE event_id = ? AND session_id = ?`, + ) + .get(runtimeEventId, sessionId); + if (!eventSize) return { ok: false, reason: 'not_found' }; + if (eventSize.bytes === null) return { ok: false, reason: 'corrupt' }; + // An unscoped unreadable transition prevents proving completeness. + if ( + db + .prepare( + `SELECT 1 FROM ${TRANSITIONS} WHERE ${KIND} AND session_id = ? AND ${TARGET} IS NULL LIMIT 1`, + ) + .get(sessionId) + ) + return { ok: false, reason: 'corrupt' }; + const sizes = db + .prepare(`SELECT run_id, sequence, length(CAST(record_json AS BLOB)) AS bytes FROM ${TRANSITIONS} + WHERE ${KIND} AND session_id = ? AND ${TARGET} = ? LIMIT ?`) + .all(sessionId, runtimeEventId, MAX_TRANSITIONS + 1); + if (sizes.length > MAX_TRANSITIONS) return { ok: false, reason: 'too_large' }; + let bytes = Number(eventSize.bytes); + for (const row of sizes) bytes += Number(row.bytes); + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_BYTES) + return { ok: false, reason: 'too_large' }; + const raw = db + .prepare( + `SELECT ${EVENT_EVIDENCE} AS evidence_json FROM runtime_events WHERE event_id = ? AND session_id = ?`, + ) + .get(runtimeEventId, sessionId); + let event: ReturnType; + try { + const [ + id, + sessionId, + runId, + invocationId, + turnId, + ts, + partial, + author, + role, + kind, + callId, + name, + modelProjection, + providerExecuted, + ] = JSON.parse(String(raw?.evidence_json)); + event = decodeRuntimeEvent({ + id, + sessionId, + runId, + invocationId, + turnId, + ts, + partial, + author, + role, + content: { + kind, + id: callId, + name, + result: null, + ...(modelProjection === null ? {} : { modelProjection }), + ...(providerExecuted === null ? {} : { providerExecuted }), + }, + }); + } catch { + return { ok: false, reason: 'corrupt' }; + } + if (event.sessionId !== sessionId || event.id !== runtimeEventId) + return { ok: false, reason: 'corrupt' }; + const read = db.prepare( + 'SELECT record_json FROM core_agent_run_events WHERE session_id = ? AND run_id = ? AND sequence = ?', + ); + const transitions: ReturnType[] = []; + for (const row of sizes) { + const stored = read.get(sessionId, row.run_id!, row.sequence!); + let transition: ReturnType; + try { + transition = decodeAgentRunEvent(JSON.parse(String(stored?.record_json))); + } catch { + return { ok: false, reason: 'corrupt' }; + } + if (transition.sessionId !== sessionId) return { ok: false, reason: 'corrupt' }; + transitions.push(transition); + } + return { ok: true, event, transitions, storedBytes: bytes }; + }); + }); + } catch { + // Failure to acquire/read the store is not evidence that its records are invalid. + return { ok: false, reason: 'unavailable' }; + } + }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e3a00cfd2fbe588ca0e4382b4b25bd1a08c8a17c15f2400fcd9bfdb3571ea01.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e3a00cfd2fbe588ca0e4382b4b25bd1a08c8a17c15f2400fcd9bfdb3571ea01.source new file mode 100644 index 0000000000..17fa2dc68f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e3a00cfd2fbe588ca0e4382b4b25bd1a08c8a17c15f2400fcd9bfdb3571ea01.source @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Buffer } from 'node:buffer'; +import { isValidUnicodeString, SessionBundleFileError } from './session-bundle-contract.js'; + +export const SESSION_BUNDLE_USTAR_BLOCK_BYTES = 512; + +const USTAR_NAME_BYTES = 100; +const USTAR_PREFIX_BYTES = 155; +const USTAR_SIZE_MAX = 0o77_777_777_777; +const ASCII_ZERO = '0'.charCodeAt(0); +const ASCII_SPACE = ' '.charCodeAt(0); +const WINDOWS_FORBIDDEN_PATH_CHARACTERS = /[\u0000-\u001f<>:"|?*]/u; +const WINDOWS_RESERVED_PATH_SEGMENT = + /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\..*)?$/iu; + +export interface SessionBundleUstarHeader { + kind: 'directory' | 'file'; + path: string; + mode: 0o644 | 0o755; + size: number; +} + +/** Return whether a path belongs to the exact portable path language used by Bundle V1. */ +export function isSessionBundleUstarPathV1(path: string): boolean { + try { + splitUstarPath(path); + return true; + } catch (error) { + if (error instanceof SessionBundleFileError && error.code === 'unsafe_path') return false; + throw error; + } +} + +/** + * Encode the exact POSIX USTAR header admitted by Session Bundle codec V1. + * Paths use a portable subset on every host: Windows device names, forbidden + * characters, and trailing dots/spaces are rejected even when running on POSIX. + */ +export function encodeSessionBundleUstarHeaderV1(value: SessionBundleUstarHeader): Uint8Array { + const path = splitUstarPath(value.path); + if (value.kind === 'directory') { + if (value.mode !== 0o755 || value.size !== 0 || !value.path.endsWith('/')) { + throw unsupportedEntry('Session bundle directory metadata is not canonical'); + } + } else if ( + (value.mode !== 0o644 && value.mode !== 0o755) || + !Number.isSafeInteger(value.size) || + value.size < 0 || + value.size > USTAR_SIZE_MAX || + value.path.endsWith('/') + ) { + throw unsupportedEntry('Session bundle regular-file metadata is not representable in V1'); + } + + const header = Buffer.alloc(SESSION_BUNDLE_USTAR_BLOCK_BYTES); + Buffer.from(path.name, 'utf8').copy(header, 0); + writeOctal(header, 100, 8, value.mode); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, value.size); + writeOctal(header, 136, 12, 0); + header.fill(ASCII_SPACE, 148, 156); + header[156] = value.kind === 'directory' ? '5'.charCodeAt(0) : '0'.charCodeAt(0); + Buffer.from('ustar\0', 'ascii').copy(header, 257); + Buffer.from('00', 'ascii').copy(header, 263); + writeOctal(header, 329, 8, 0); + writeOctal(header, 337, 8, 0); + Buffer.from(path.prefix, 'utf8').copy(header, 345); + + const checksum = header.reduce((sum, byte) => sum + byte, 0); + writeChecksum(header, checksum); + return header; +} + +/** Decode a header and reject every representation outside the exact V1 subset. */ +export function decodeSessionBundleUstarHeaderV1(value: Uint8Array): SessionBundleUstarHeader { + if (!(value instanceof Uint8Array) || value.byteLength !== SESSION_BUNDLE_USTAR_BLOCK_BYTES) { + throw integrityError('Session bundle USTAR header is truncated'); + } + const header = Buffer.from(value); + const expectedChecksum = parseOctal(header.subarray(148, 156)); + const checksumHeader = Buffer.from(header); + checksumHeader.fill(ASCII_SPACE, 148, 156); + const actualChecksum = checksumHeader.reduce((sum, byte) => sum + byte, 0); + if (expectedChecksum !== actualChecksum) { + throw integrityError('Session bundle USTAR checksum does not match'); + } + + const name = decodeNulTerminatedUtf8(header.subarray(0, 100)); + const prefix = decodeNulTerminatedUtf8(header.subarray(345, 500)); + const path = prefix.length === 0 ? name : `${prefix}/${name}`; + if (path.length === 0) throw unsafePath('Session bundle USTAR path is empty'); + + const type = header[156]; + const kind = + type === '0'.charCodeAt(0) ? 'file' : type === '5'.charCodeAt(0) ? 'directory' : undefined; + if (kind === undefined) { + throw unsupportedEntry('Session bundle USTAR entry type is not supported'); + } + + const mode = parseOctal(header.subarray(100, 108)); + const size = parseOctal(header.subarray(124, 136)); + const decoded: SessionBundleUstarHeader = { + kind, + path, + mode: mode as 0o644 | 0o755, + size, + }; + const canonical = encodeSessionBundleUstarHeaderV1(decoded); + if (!header.equals(canonical)) { + throw unsupportedEntry('Session bundle USTAR metadata is not canonical'); + } + return decoded; +} + +export function sessionBundleUstarPaddingBytes(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) { + throw unsupportedEntry('Session bundle USTAR entry size is invalid'); + } + return ( + (SESSION_BUNDLE_USTAR_BLOCK_BYTES - (size % SESSION_BUNDLE_USTAR_BLOCK_BYTES)) % + SESSION_BUNDLE_USTAR_BLOCK_BYTES + ); +} + +export function isSessionBundleUstarZeroBlock(value: Uint8Array): boolean { + return value.byteLength === SESSION_BUNDLE_USTAR_BLOCK_BYTES && value.every((byte) => byte === 0); +} + +function splitUstarPath(path: string): { name: string; prefix: string } { + if ( + !isValidUnicodeString(path) || + path.length === 0 || + path.includes('\0') || + path.includes('\\') || + path.startsWith('/') || + /^[A-Za-z]:/.test(path) + ) { + throw unsafePath('Session bundle path is not valid for USTAR'); + } + const logicalPath = path.endsWith('/') ? path.slice(0, -1) : path; + const segments = logicalPath.split('/'); + if ( + logicalPath.length === 0 || + segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + WINDOWS_FORBIDDEN_PATH_CHARACTERS.test(segment) || + segment.endsWith('.') || + segment.endsWith(' ') || + WINDOWS_RESERVED_PATH_SEGMENT.test(segment), + ) + ) { + throw unsafePath('Session bundle path is not valid for USTAR'); + } + if (Buffer.byteLength(path, 'utf8') <= USTAR_NAME_BYTES) return { name: path, prefix: '' }; + + for (let index = path.length - 1; index > 0; index = path.lastIndexOf('/', index - 1)) { + const prefix = path.slice(0, index); + const name = path.slice(index + 1); + if ( + name.length > 0 && + Buffer.byteLength(prefix, 'utf8') <= USTAR_PREFIX_BYTES && + Buffer.byteLength(name, 'utf8') <= USTAR_NAME_BYTES + ) { + return { name, prefix }; + } + } + throw unsafePath('Session bundle path is not representable by USTAR V1'); +} + +function writeOctal(target: Buffer, offset: number, length: number, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw unsupportedEntry('Session bundle USTAR numeric field is invalid'); + } + const octal = value.toString(8); + if (octal.length > length - 1) { + throw unsupportedEntry('Session bundle USTAR numeric field exceeds V1'); + } + target.fill(ASCII_ZERO, offset, offset + length - 1); + target.write(octal, offset + length - 1 - octal.length, octal.length, 'ascii'); + target[offset + length - 1] = 0; +} + +function writeChecksum(target: Buffer, value: number): void { + const octal = value.toString(8); + if (octal.length > 6) throw integrityError('Session bundle USTAR checksum exceeds its field'); + target.fill(ASCII_ZERO, 148, 154); + target.write(octal, 154 - octal.length, octal.length, 'ascii'); + target[154] = 0; + target[155] = ASCII_SPACE; +} + +function parseOctal(value: Buffer): number { + const nul = value.indexOf(0); + const body = value + .subarray(0, nul < 0 ? value.length : nul) + .toString('ascii') + .trim(); + if (body.length === 0 || !/^[0-7]+$/.test(body)) { + throw unsupportedEntry('Session bundle USTAR numeric field is malformed'); + } + if (nul >= 0 && value.subarray(nul + 1).some((byte) => byte !== 0 && byte !== ASCII_SPACE)) { + throw unsupportedEntry('Session bundle USTAR numeric field has unsupported trailing bytes'); + } + const parsed = Number.parseInt(body, 8); + return parsed; +} + +function decodeNulTerminatedUtf8(value: Buffer): string { + const nul = value.indexOf(0); + const bytes = value.subarray(0, nul < 0 ? value.length : nul); + if (nul >= 0 && value.subarray(nul + 1).some((byte) => byte !== 0)) { + throw unsupportedEntry('Session bundle USTAR string field has unsupported trailing bytes'); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw unsafePath('Session bundle USTAR path is not valid UTF-8'); + } +} + +function unsafePath(message: string): SessionBundleFileError { + return new SessionBundleFileError('unsafe_path', message); +} + +function unsupportedEntry(message: string): SessionBundleFileError { + return new SessionBundleFileError('unsupported_entry', message); +} + +function integrityError(message: string): SessionBundleFileError { + return new SessionBundleFileError('integrity_mismatch', message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e55ff7008104a1d7ab7bd9d0497e0b546c88af6aab8c62ac1945a924fea7731.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e55ff7008104a1d7ab7bd9d0497e0b546c88af6aab8c62ac1945a924fea7731.source new file mode 100644 index 0000000000..d0e1f27812 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e55ff7008104a1d7ab7bd9d0497e0b546c88af6aab8c62ac1945a924fea7731.source @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_WORKFLOW_SCHEMA_VERSION = 12; + +const RELEASED_WORKFLOW_PROJECTION_TABLES = [ + { + name: 'workflow_task_ledger_projections', + sql: `CREATE TABLE workflow_task_ledger_projections ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL + )`, + }, + { + name: 'workflow_plan_projections', + sql: `CREATE TABLE workflow_plan_projections ( + session_id TEXT PRIMARY KEY, + store_version INTEGER NOT NULL CHECK (store_version >= 0), + record_json TEXT NOT NULL + )`, + }, +] as const; + +export function migrateSqliteWorkflowDatabase(db: DatabaseSync): void { + retireReleasedWorkflowProjections(db); + db.exec(` + DROP INDEX IF EXISTS workflow_plan_reminders_order; + DROP TABLE IF EXISTS workflow_plan_reminders; + DROP TABLE IF EXISTS workflow_task_ledger_events; + + CREATE TABLE IF NOT EXISTS workflow_session_todo_documents ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_plan_events ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + event_id TEXT NOT NULL, + store_version INTEGER NOT NULL CHECK (store_version > 0), + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, sequence), + UNIQUE (session_id, event_id), + UNIQUE (session_id, store_version) + ); + + CREATE TABLE IF NOT EXISTS workflow_deep_research_events ( + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence >= 0), + event_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, sequence), + UNIQUE (session_id, event_id) + ); + + CREATE TABLE IF NOT EXISTS workflow_scheduled_tasks ( + task_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS workflow_scheduled_tasks_order + ON workflow_scheduled_tasks(created_at, task_id); + + CREATE TABLE IF NOT EXISTS workflow_scheduled_task_fires ( + claim_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + claimed_at INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_quote_companion_cleanup ( + session_id TEXT PRIMARY KEY, + tracked_at INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_daily_review_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + config_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS workflow_daily_review_authority_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + revision INTEGER NOT NULL CHECK (revision >= 0) + ); + + CREATE TABLE IF NOT EXISTS workflow_daily_review_archives ( + archive_id TEXT PRIMARY KEY, + generated_at INTEGER NOT NULL, + day_from_ms INTEGER NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS workflow_daily_review_archives_order + ON workflow_daily_review_archives(generated_at DESC, day_from_ms DESC, archive_id); + + CREATE TABLE IF NOT EXISTS workflow_work_board_items ( + item_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK (revision >= 1), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + scope_kind TEXT NOT NULL CHECK (scope_kind IN ('inbox', 'project')), + project_id TEXT, + archived INTEGER NOT NULL CHECK (archived IN (0, 1)), + record_json TEXT NOT NULL, + CHECK ( + (scope_kind = 'inbox' AND project_id IS NULL) + OR + (scope_kind = 'project' AND project_id IS NOT NULL) + ) + ); + + -- The intermediate Phase 0 dev build shipped this index with item_id ASC; + -- drop it once so such databases converge on the released definition. + DROP INDEX IF EXISTS workflow_work_board_items_scope_order; + CREATE INDEX IF NOT EXISTS workflow_work_board_items_scope_order + ON workflow_work_board_items(scope_kind, project_id, updated_at DESC, item_id DESC); + CREATE INDEX IF NOT EXISTS workflow_work_board_items_order + ON workflow_work_board_items(updated_at DESC, item_id DESC); + CREATE INDEX IF NOT EXISTS workflow_work_board_items_active_scope_order + ON workflow_work_board_items(scope_kind, project_id, updated_at DESC, item_id DESC) + WHERE archived = 0; + CREATE INDEX IF NOT EXISTS workflow_work_board_items_active_order + ON workflow_work_board_items(updated_at DESC, item_id DESC) + WHERE archived = 0; + + CREATE TABLE IF NOT EXISTS workflow_goal_authority ( + session_id TEXT PRIMARY KEY, + authority_revision INTEGER NOT NULL CHECK (authority_revision >= 0), + goal_id TEXT NOT NULL, + goal_revision INTEGER NOT NULL CHECK (goal_revision >= 0), + status TEXT NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS workflow_goal_authority_status + ON workflow_goal_authority(status, session_id); + `); + + const cleanupColumns = new Set( + ( + db.prepare('PRAGMA table_info(workflow_quote_companion_cleanup)').all() as Array<{ + name: string; + }> + ).map(({ name }) => name), + ); + if (!cleanupColumns.has('record_json')) { + db.exec('ALTER TABLE workflow_quote_companion_cleanup ADD COLUMN record_json TEXT'); + } + db.prepare(` + UPDATE workflow_quote_companion_cleanup + SET record_json = json_object( + 'version', 1, + 'sessionId', session_id, + 'trackedAt', tracked_at, + 'phase', 'cleanup', + 'cancelRequested', json('true') + ) + WHERE record_json IS NULL + `).run(); +} + +function retireReleasedWorkflowProjections(db: DatabaseSync): void { + for (const table of RELEASED_WORKFLOW_PROJECTION_TABLES) { + assertReleasedWorkflowProjectionShape(db, table); + } + db.exec(` + DROP TABLE IF EXISTS workflow_task_ledger_projections; + DROP TABLE IF EXISTS workflow_plan_projections; + `); +} + +function assertReleasedWorkflowProjectionShape( + db: DatabaseSync, + table: (typeof RELEASED_WORKFLOW_PROJECTION_TABLES)[number], +): void { + const objects = db + .prepare(` + SELECT type, name, sql + FROM sqlite_schema + WHERE tbl_name COLLATE NOCASE = ? + AND type IN ('table', 'index', 'trigger', 'view') + AND sql IS NOT NULL + ORDER BY type, name + `) + .all(table.name) as Array<{ type: string; name: string; sql: string }>; + if (objects.length === 0) return; + + const releasedTable = objects.find( + (object) => object.type === 'table' && object.name === table.name, + ); + if (!releasedTable || normalizeSql(releasedTable.sql) !== normalizeSql(table.sql)) { + throw new Error(`Workflow projection table ${table.name} has an unfamiliar released shape`); + } + const unexpected = objects.find((object) => object !== releasedTable); + if (unexpected) { + throw new Error( + `Workflow projection table ${table.name} carries an unexpected object ` + + `${unexpected.type}:${unexpected.name}`, + ); + } +} + +function normalizeSql(value: string): string { + return value + .replace(/\s+/gu, ' ') + .replace(/\s*([(),;])\s*/gu, '$1') + .trim() + .toUpperCase(); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e9d5fe8aeaa0b804fdd59bc3a9c1dd7e1894019815760082c71ae4597e87dd2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e9d5fe8aeaa0b804fdd59bc3a9c1dd7e1894019815760082c71ae4597e87dd2.source new file mode 100644 index 0000000000..7d02186dd5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9e9d5fe8aeaa0b804fdd59bc3a9c1dd7e1894019815760082c71ae4597e87dd2.source @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function throwDeduplicatedFailures(message: string, failures: readonly unknown[]): void { + const unique = [...new Set(failures)]; + if (unique.length === 0) return; + if (unique.length === 1) throw unique[0]; + throw new AggregateError(unique, message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9ee9c4b9361396d90a775e7034698e3fe5f17d9416fab3b35024ef6eae00b9eb.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9ee9c4b9361396d90a775e7034698e3fe5f17d9416fab3b35024ef6eae00b9eb.source new file mode 100644 index 0000000000..4a149ae51a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9ee9c4b9361396d90a775e7034698e3fe5f17d9416fab3b35024ef6eae00b9eb.source @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + WorkspaceBaselineAuthorityInput, + WorkspaceBaselineCommitResult, + WorkspaceHeadRecordV1, + WorkspaceSuccessorAuthorityInput, +} from '@maka/core/workspace-version-authority'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +type WorkspaceBaselineAuthorityWriter = ( + input: WorkspaceBaselineAuthorityInput, + rootId: string, +) => Promise; +type WorkspaceStorageRootBinder = (rootId: string) => void; +export interface WorkspaceSuccessorCommitInput { + /** Opaque capability issued by the repository candidate owner. */ + candidateOutcome: object; + toolOutcome: { + operationId: string; + journalEventId: string; + runtimeEvent: RuntimeEvent; + committedAt: number; + }; +} +interface VerifiedWorkspaceSuccessorCommitInput { + successor: WorkspaceSuccessorAuthorityInput; + toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; +} +export interface WorkspaceSuccessorCommitResult { + created: boolean; + /** Historical successor accepted by this operation, not necessarily the current head. */ + committedSuccessor: WorkspaceHeadRecordV1; + outcomeRuntimeEventSeq: number; +} +export interface ManagedMutationTerminalCommitInput { + /** Opaque capability issued after the mutation owner proves that no workspace effect occurred. */ + noEffectOutcome: object; + toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; +} +export interface ManagedMutationNoEffectClaimV1 { + readonly operationId: string; + readonly dispatchEventId: string; + readonly workspaceInstanceId: string; + readonly terminalKind: 'no_workspace_change' | 'operation_failed_no_effect'; +} +interface VerifiedManagedMutationTerminalCommitInput { + noEffect: ManagedMutationNoEffectClaimV1; + toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; +} +export interface ManagedMutationTerminalCommitResult { + created: boolean; + outcomeRuntimeEventSeq: number; +} +type ManagedMutationTerminalAuthorityWriter = ( + input: VerifiedManagedMutationTerminalCommitInput, + rootId: string, +) => Promise; +type WorkspaceSuccessorAuthorityWriter = ( + input: VerifiedWorkspaceSuccessorCommitInput, + rootId: string, +) => Promise; +type WorkspaceSuccessorCandidateVerifier = ( + candidateOutcome: object, +) => WorkspaceSuccessorAuthorityInput; +type ManagedMutationNoEffectVerifier = (noEffectOutcome: object) => ManagedMutationNoEffectClaimV1; +export interface ManagedMutationReservationRecordV1 { + readonly workspaceInstanceId: string; + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly operationId: string; + readonly dispatchEventId: string; + readonly baseWorkspaceVersionId: string; + readonly baseAcceptedEventId: string; + readonly baseHeadRevision: number; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly expectedPath: string; + readonly executionProfileDigest: string; + readonly reservedAt: number; +} +type ManagedMutationReservationReader = ( + workspaceInstanceId: string, +) => Promise; + +interface WorkspaceBaselineAuthorityRegistration { + readonly writer: WorkspaceBaselineAuthorityWriter; + readonly successorWriter: WorkspaceSuccessorAuthorityWriter; + candidateVerifier?: WorkspaceSuccessorCandidateVerifier; + noEffectVerifier?: ManagedMutationNoEffectVerifier; + readonly terminalWriter: ManagedMutationTerminalAuthorityWriter; + readonly readActiveManagedMutation: ManagedMutationReservationReader; + readonly bindStorageRoot: WorkspaceStorageRootBinder; + boundRootId?: string; +} + +const workspaceBaselineAuthorityWriters = new WeakMap< + object, + WorkspaceBaselineAuthorityRegistration +>(); + +export function registerWorkspaceBaselineAuthorityWriterInternal( + store: object, + writer: WorkspaceBaselineAuthorityWriter, + successorWriter: WorkspaceSuccessorAuthorityWriter, + terminalWriter: ManagedMutationTerminalAuthorityWriter, + bindStorageRoot: WorkspaceStorageRootBinder, + readActiveManagedMutation: ManagedMutationReservationReader, +): void { + if (workspaceBaselineAuthorityWriters.has(store)) { + throw new Error('Workspace baseline authority writer is already registered'); + } + workspaceBaselineAuthorityWriters.set(store, { + writer, + successorWriter, + terminalWriter, + readActiveManagedMutation, + bindStorageRoot, + }); +} + +export function readActiveManagedMutationInternal( + store: object, + workspaceInstanceId: string, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Managed mutation reservation reader is unavailable'); + return registration.readActiveManagedMutation(workspaceInstanceId); +} + +/** + * Storage-internal authority seam. This module is deliberately absent from the + * @maka/storage package exports. The schema-9 reader, migration, and projection + * rebuild remain supported even though no production baseline writer is + * currently composed. Focused persistence tests use this seam to prove the + * SQLite transaction and historical read contract. + */ +export function commitWorkspaceBaselineInternal( + store: object, + input: WorkspaceBaselineAuthorityInput, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace baseline authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace baseline authority store has no durable storage-root binding'); + } + return registration.writer(input, registration.boundRootId); +} + +export function commitWorkspaceSuccessorInternal( + store: object, + input: WorkspaceSuccessorCommitInput, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace successor authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace successor authority store has no durable storage-root binding'); + } + if (!registration.candidateVerifier) { + throw new Error('Workspace successor candidate verifier is unavailable'); + } + const successor = registration.candidateVerifier(input.candidateOutcome); + return registration.successorWriter( + { successor, toolOutcome: input.toolOutcome }, + registration.boundRootId, + ); +} + +export function registerWorkspaceSuccessorCandidateVerifierInternal( + store: object, + verifier: WorkspaceSuccessorCandidateVerifier, +): void { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace successor authority writer is unavailable'); + if (registration.candidateVerifier) { + throw new Error('Workspace successor candidate verifier is already registered'); + } + registration.candidateVerifier = verifier; +} + +export function registerManagedMutationNoEffectVerifierInternal( + store: object, + verifier: ManagedMutationNoEffectVerifier, +): void { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Managed mutation terminal authority writer is unavailable'); + if (registration.noEffectVerifier) { + throw new Error('Managed mutation no-effect verifier is already registered'); + } + registration.noEffectVerifier = verifier; +} + +export function commitManagedMutationTerminalInternal( + store: object, + input: ManagedMutationTerminalCommitInput, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Managed mutation terminal authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace successor authority store has no durable storage-root binding'); + } + if (!registration.noEffectVerifier) { + throw new Error('Managed mutation owner-issued no-effect proof verifier is unavailable'); + } + if (!input.noEffectOutcome || typeof input.noEffectOutcome !== 'object') { + throw new Error('Managed mutation terminal requires an owner-issued no-effect proof'); + } + const noEffect = registration.noEffectVerifier(input.noEffectOutcome); + return registration.terminalWriter( + { noEffect, toolOutcome: input.toolOutcome }, + registration.boundRootId, + ); +} + +export function bindWorkspaceBaselineAuthorityStoreRootInternal( + store: object, + rootId: string, +): void { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace baseline authority writer is unavailable'); + if (!/^[a-f0-9]{64}$/u.test(rootId)) { + throw new Error('Invalid durable storage-root identity'); + } + registration.bindStorageRoot(rootId); + registration.boundRootId = rootId; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9fc00f676d440ca8ce68c510a46ff79a0f87dc10b46ac4399e53f5f8f536f716.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9fc00f676d440ca8ce68c510a46ff79a0f87dc10b46ac4399e53f5f8f536f716.source new file mode 100644 index 0000000000..b7ed6773b4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/9fc00f676d440ca8ce68c510a46ff79a0f87dc10b46ac4399e53f5f8f536f716.source @@ -0,0 +1,1491 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import type { BigIntStats } from 'node:fs'; +import { chmod, lstat, mkdir, open, realpath, stat, type FileHandle } from 'node:fs/promises'; +import { userInfo } from 'node:os'; +import { isAbsolute, join, normalize, parse, resolve } from 'node:path'; +import { tryLock, unlock, waitForLock } from 'fs-native-extensions'; + +import { withArtifactWriterBootstrapLock } from './artifact-writer-bootstrap-lock.js'; +import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js'; +import { syncDirectoryChain } from './stable-storage.js'; + +export const STORAGE_ROOT_MARKER_FILE = '.maka-storage-root.json'; +export const STORAGE_ROOT_MARKER_SCHEMA_VERSION = 1 as const; +const MAX_STORAGE_ROOT_MARKER_BYTES = 1_024; +const ARTIFACT_WRITER_BOOTSTRAP_DIRECTORY = 'artifact-writer-bootstrap'; + +export type StorageRootKind = 'interactive'; +export type StorageRootAccess = 'read' | 'write'; + +const capabilityBrand: unique symbol = Symbol('StorageRootCapability'); +const leaseBrand: unique symbol = Symbol('StorageRootLease'); +const repairBrand: unique symbol = Symbol('StorageRootIdentityRepairCandidate'); +const artifactWriterBootstrapAuthorityBrand: unique symbol = Symbol( + 'ArtifactWriterBootstrapAuthority', +); +const artifactWriterLockAuthorityBrand: unique symbol = Symbol('ArtifactWriterLockAuthority'); + +export interface StorageRootCapability { + readonly kind: K; + readonly canonicalPath: string; + readonly rootId: string; + readonly [capabilityBrand]: true; +} + +export type DiscoveredStorageRootCapability = StorageRootCapability<'interactive'>; + +export interface StorageRootLease< + K extends StorageRootKind = StorageRootKind, + A extends StorageRootAccess = StorageRootAccess, +> { + readonly kind: K; + readonly access: A; + readonly canonicalPath: string; + readonly rootId: string; + readonly [leaseBrand]: true; +} + +export interface ArtifactWriterLockAuthority { + readonly bootstrapLockPath: string; + readonly controlDirectory: string; + readonly assertCurrentRoot: () => Promise; + readonly [artifactWriterLockAuthorityBrand]: true; +} + +export interface ArtifactWriterBootstrapAuthority { + readonly lockPath: string; + readonly canonicalPath: string; + readonly assertCurrentRoot: () => Promise; + readonly [artifactWriterBootstrapAuthorityBrand]: true; +} + +export interface ResolveStorageRootInput { + path: string; + kind: K; +} + +export interface DiscoverStorageRootInput { + path: string; +} + +export interface ResolveExistingStorageRootInput + extends ResolveStorageRootInput { + expectedRootId: string; +} + +export type AdoptStorageRootOnImportInput = + ResolveExistingStorageRootInput; + +export interface RepairStorageRootAfterRemountInput + extends ResolveStorageRootInput { + expectedRootId?: string; +} + +export interface StorageRootIdentityRepairCandidate { + readonly kind: K; + readonly canonicalPath: string; + readonly rootId: string; + readonly [repairBrand]: true; +} + +export interface StateRootOwner { + readonly capability: StorageRootCapability; + readonly lease: StorageRootLease; + readonly controlDirectory: string; + readonly lockPath: string; + readonly closed: boolean; + close(): Promise; +} + +export interface StateRootReader { + readonly capability: StorageRootCapability; + readonly lease: StorageRootLease; + readonly controlDirectory: string; + readonly lockPath: string; + readonly closed: boolean; + close(): Promise; +} + +export type InteractiveRootOwner = StateRootOwner<'interactive'>; +export type InteractiveRootReader = StateRootReader<'interactive'>; + +interface RootIdentity { + dev: bigint; + ino: bigint; +} + +interface CapabilityRecord { + kind: K; + canonicalPath: string; + rootId: string; + identity: RootIdentity; +} + +interface LeaseRecord< + K extends StorageRootKind = StorageRootKind, + A extends StorageRootAccess = StorageRootAccess, +> extends CapabilityRecord { + access: A; + isActive: () => boolean; + beginOperation: () => () => void; +} + +interface RootMarker { + schemaVersion: typeof STORAGE_ROOT_MARKER_SCHEMA_VERSION; + kind: StorageRootKind; + rootId: string; + rootIdentity: { + dev: string; + ino: string; + }; +} + +interface StorageRootIdentityRepairRecord + extends CapabilityRecord { + marker: RootMarker; +} + +const capabilities = new WeakMap(); +const leases = new WeakMap(); +const stateRootLocks = new WeakMap(); +const storageRootIdentityRepairs = new WeakMap(); + +export type StorageRootAuthorityErrorCode = + | 'invalid_root' + | 'invalid_root_kind' + | 'root_not_found' + | 'root_unmarked' + | 'invalid_marker' + | 'root_identity_collision' + | 'root_identity_changed' + | 'invalid_repair' + | 'invalid_capability' + | 'invalid_lease' + | 'invalid_owner' + | 'invalid_lock_artifact' + | 'insecure_control_directory' + | 'root_io_failed' + | 'control_io_failed' + | 'lock_failed'; + +export class StorageRootAuthorityError extends Error { + constructor( + readonly code: StorageRootAuthorityErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'StorageRootAuthorityError'; + } +} + +function assertStorageRootKind(kind: unknown): asserts kind is StorageRootKind { + if (kind !== 'interactive') { + throw new StorageRootAuthorityError( + 'invalid_root_kind', + `Unsupported storage root kind: ${String(kind)}`, + ); + } +} + +export async function resolveStorageRoot( + input: ResolveStorageRootInput, +): Promise> { + assertStorageRootKind(input.kind); + return withAuthorityFailure('root_io_failed', 'Unable to resolve the storage root', () => + resolveStorageRootUnchecked(input), + ); +} + +export async function discoverMarkedStorageRoot( + input: DiscoverStorageRootInput, +): Promise { + return withAuthorityFailure('root_io_failed', 'Unable to discover the storage root', async () => { + const { canonicalPath, rootStat } = await resolveExistingRootPath(input.path); + + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + const marker = await confirmRootSnapshot({ + root: canonicalPath, + identity, + readMarker: () => readRootMarker(canonicalPath), + markerMismatchCode: 'root_identity_collision', + markerMismatchMessage: `Storage root marker belongs to a different directory: ${canonicalPath}`, + }); + return createCapability('interactive', canonicalPath, marker.rootId, identity); + }); +} + +async function resolveStorageRootUnchecked( + input: ResolveStorageRootInput, +): Promise> { + const requestedPath = resolve(input.path); + await ensureRootDirectory(requestedPath); + const canonicalPath = canonicalizePath(await realpath(requestedPath)); + const rootStat = await stat(canonicalPath, { bigint: true }); + if (!rootStat.isDirectory()) { + throw new StorageRootAuthorityError( + 'invalid_root', + `Storage root is not a directory: ${canonicalPath}`, + ); + } + + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + const marker = await confirmRootSnapshot({ + root: canonicalPath, + identity, + readMarker: () => ensureRootMarker(canonicalPath, input.kind, identity), + markerMismatchCode: 'root_identity_collision', + markerMismatchMessage: `Storage root marker belongs to a different directory: ${canonicalPath}`, + }); + return createCapability(input.kind, canonicalPath, marker.rootId, identity); +} + +export async function resolveExistingStorageRoot( + input: ResolveExistingStorageRootInput, +): Promise> { + assertStorageRootKind(input.kind); + return withAuthorityFailure( + 'root_io_failed', + 'Unable to resolve the existing storage root', + async () => { + const { canonicalPath, rootStat } = await resolveExistingRootPath(input.path); + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + const marker = await confirmRootSnapshot({ + root: canonicalPath, + identity, + readMarker: () => readAndValidateRootMarker(canonicalPath, input.kind), + expectedRootId: input.expectedRootId, + markerMismatchCode: 'root_identity_changed', + markerMismatchMessage: `Storage root identity does not match the expected root: ${canonicalPath}`, + }); + return createCapability(input.kind, canonicalPath, marker.rootId, identity); + }, + ); +} + +/** + * Explicit import boundary for a storage root copied through an archive. + * The durable rootId stays authoritative while the host-local dev/ino binding + * is atomically adopted for the extracted directory. + */ +export async function adoptStorageRootOnImport( + input: AdoptStorageRootOnImportInput, +): Promise> { + assertStorageRootKind(input.kind); + return withAuthorityFailure( + 'root_io_failed', + 'Unable to adopt the imported storage root', + async () => { + const { canonicalPath, rootStat } = await resolveExistingRootPath(input.path); + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + let marker = await readAndValidateRootMarker(canonicalPath, input.kind); + if (marker.rootId !== input.expectedRootId) { + throw new StorageRootAuthorityError( + 'root_identity_collision', + `Imported storage root does not match the expected root: ${canonicalPath}`, + ); + } + await assertRootPathIdentity( + canonicalPath, + identity, + `Storage root identity changed while adopting an import: ${canonicalPath}`, + ); + if (!markerMatchesIdentity(marker, identity)) { + marker = await replaceRootMarkerIdentity(canonicalPath, identity, marker); + } + await confirmRootSnapshot({ + root: canonicalPath, + identity, + readMarker: () => readAndValidateRootMarker(canonicalPath, input.kind), + expectedRootId: input.expectedRootId, + markerMismatchCode: 'root_identity_changed', + markerMismatchMessage: `Imported storage root identity changed: ${canonicalPath}`, + }); + return createCapability(input.kind, canonicalPath, marker.rootId, identity); + }, + ); +} + +export async function prepareStorageRootIdentityRepair( + input: ResolveStorageRootInput, +): Promise | undefined> { + assertStorageRootKind(input.kind); + return withAuthorityFailure( + 'root_io_failed', + 'Unable to prepare the storage root identity repair', + async () => { + const { canonicalPath, rootStat } = await resolveExistingRootPath(input.path); + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + const identityChangedMessage = `Storage root identity changed while preparing its repair: ${canonicalPath}`; + await assertRootPathIdentity(canonicalPath, identity, identityChangedMessage); + let marker: RootMarker; + try { + marker = await readAndValidateRootMarker(canonicalPath, input.kind); + } catch (error) { + await assertRootPathIdentity(canonicalPath, identity, identityChangedMessage); + throw error; + } + await assertRootPathIdentity(canonicalPath, identity, identityChangedMessage); + if (markerMatchesIdentity(marker, identity)) return undefined; + + const record: StorageRootIdentityRepairRecord = { + kind: input.kind, + canonicalPath, + rootId: marker.rootId, + identity, + marker, + }; + const candidate = Object.freeze({ + kind: record.kind, + canonicalPath: record.canonicalPath, + rootId: record.rootId, + }) as StorageRootIdentityRepairCandidate; + storageRootIdentityRepairs.set(candidate, record); + return candidate; + }, + ); +} + +/** + * Repairs only the mount-local portion of a root identity. This is for callers + * that already know their execution environment remounted the same filesystem: + * the inode must stay unchanged, and an expected durable root id may still pin + * the repair to an existing Client binding. + */ +export async function repairStorageRootAfterRemount( + input: RepairStorageRootAfterRemountInput, +): Promise | undefined> { + let candidate: StorageRootIdentityRepairCandidate | undefined; + try { + candidate = await prepareStorageRootIdentityRepair(input); + } catch (error) { + if ( + error instanceof StorageRootAuthorityError && + (error.code === 'root_not_found' || error.code === 'root_unmarked') + ) { + return undefined; + } + throw error; + } + if (!candidate) return undefined; + const record = storageRootIdentityRepairs.get(candidate) as + | StorageRootIdentityRepairRecord + | undefined; + if (!record) { + throw new StorageRootAuthorityError( + 'invalid_repair', + 'Expected a prepared storage root identity repair', + ); + } + if (input.expectedRootId !== undefined && record.rootId !== input.expectedRootId) { + storageRootIdentityRepairs.delete(candidate); + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Remounted storage root does not match the expected root: ${record.canonicalPath}`, + ); + } + if (record.marker.rootIdentity.ino !== record.identity.ino.toString()) { + storageRootIdentityRepairs.delete(candidate); + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Storage root directory changed across remount: ${record.canonicalPath}`, + ); + } + return repairStorageRootIdentity(candidate); +} + +/** + * Explicit recovery boundary for a root whose host-local filesystem identity + * is stale. Callers must obtain user intent for this exact candidate first. + */ +export async function repairStorageRootIdentity( + candidate: StorageRootIdentityRepairCandidate, +): Promise> { + const record = storageRootIdentityRepairs.get(candidate) as + | StorageRootIdentityRepairRecord + | undefined; + if (!record) { + throw new StorageRootAuthorityError( + 'invalid_repair', + 'Expected a prepared storage root identity repair', + ); + } + storageRootIdentityRepairs.delete(candidate); + + return withAuthorityFailure( + 'root_io_failed', + 'Unable to repair the storage root identity', + async () => { + const identityChangedMessage = `Storage root identity changed while repairing its marker: ${record.canonicalPath}`; + await assertRootPathIdentity(record.canonicalPath, record.identity, identityChangedMessage); + const marker = await readAndValidateRootMarker(record.canonicalPath, record.kind); + await assertRootPathIdentity(record.canonicalPath, record.identity, identityChangedMessage); + if (!rootMarkersEqual(marker, record.marker)) { + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Storage root marker changed while awaiting repair: ${record.canonicalPath}`, + ); + } + const repaired = await replaceRootMarkerIdentity( + record.canonicalPath, + record.identity, + record.marker, + ); + await confirmRootSnapshot({ + root: record.canonicalPath, + identity: record.identity, + readMarker: () => readAndValidateRootMarker(record.canonicalPath, record.kind), + expectedRootId: record.rootId, + markerMismatchCode: 'root_identity_changed', + markerMismatchMessage: `Repaired storage root identity changed: ${record.canonicalPath}`, + }); + return createCapability(record.kind, record.canonicalPath, repaired.rootId, record.identity); + }, + ); +} + +async function resolveExistingRootPath(path: string): Promise<{ + canonicalPath: string; + rootStat: BigIntStats; +}> { + let canonicalPath: string; + try { + canonicalPath = canonicalizePath(await realpath(resolve(path))); + } catch (error) { + if (isMissingPathError(error)) { + throw new StorageRootAuthorityError( + 'root_not_found', + `Storage root does not exist: ${resolve(path)}`, + ); + } + throw error; + } + let rootStat: BigIntStats; + try { + rootStat = await stat(canonicalPath, { bigint: true }); + } catch (error) { + if (isMissingPathError(error)) { + throw new StorageRootAuthorityError( + 'root_not_found', + `Storage root does not exist: ${resolve(path)}`, + ); + } + throw error; + } + if (!rootStat.isDirectory()) { + throw new StorageRootAuthorityError( + 'invalid_root', + `Storage root is not a directory: ${canonicalPath}`, + ); + } + return { canonicalPath, rootStat }; +} + +function createCapability( + kind: K, + canonicalPath: string, + rootId: string, + identity: RootIdentity, +): StorageRootCapability { + const record: CapabilityRecord = { + kind, + canonicalPath, + rootId, + identity, + }; + const capability = Object.freeze({ + kind: record.kind, + canonicalPath: record.canonicalPath, + rootId: record.rootId, + }) as StorageRootCapability; + capabilities.set(capability, record); + return capability; +} + +async function ensureRootDirectory(path: string): Promise { + try { + await mkdir(path, { recursive: true, mode: 0o700 }); + } catch (error) { + const existing = await statRootIfPresent(path); + if (existing && !existing.isDirectory()) { + throw new StorageRootAuthorityError( + 'invalid_root', + `Storage root is not a directory: ${path}`, + ); + } + throw error; + } +} + +export function resolveRootControlNamespace(): string { + try { + const accountHome = userInfo().homedir; + if (!isAbsolute(accountHome)) { + throw new Error('OS account home must be an absolute path'); + } + if (process.platform === 'darwin') { + return join(accountHome, 'Library', 'Caches', 'Maka', 'runtime-hosts'); + } + if (process.platform === 'win32') { + return join(accountHome, 'AppData', 'Local', 'Maka', 'runtime-hosts'); + } + return join(accountHome, '.cache', 'maka', 'runtime-hosts'); + } catch (error) { + throw normalizeAuthorityFailure( + error, + 'control_io_failed', + 'Unable to resolve the Runtime Host control namespace', + ); + } +} + +/** + * Resolves the durable namespace that owns State Root process election. + * + * Endpoint registrations and diagnostics remain in the disposable control + * namespace. The owner lock must not: deleting a cache directory while a Host + * is running must never make the same State Root acquirable again. + */ +export function resolveRootOwnershipNamespace(): string { + try { + const accountHome = userInfo().homedir; + if (!isAbsolute(accountHome)) { + throw new Error('OS account home must be an absolute path'); + } + if (process.platform === 'darwin') { + return join(accountHome, 'Library', 'Application Support', 'Maka', 'state-root-owners'); + } + if (process.platform === 'win32') { + return join(accountHome, 'AppData', 'Local', 'Maka', 'state-root-owners'); + } + return join(accountHome, '.local', 'share', 'Maka', 'state-root-owners'); + } catch (error) { + throw normalizeAuthorityFailure( + error, + 'control_io_failed', + 'Unable to resolve the State Root ownership namespace', + ); + } +} + +export async function tryAcquireInteractiveRootOwner( + capability: StorageRootCapability<'interactive'>, +): Promise { + return tryAcquireStateRootOwner(capability); +} + +export async function tryAcquireStateRootOwner( + capability: StorageRootCapability, +): Promise | undefined> { + return withAuthorityFailure('lock_failed', 'Unable to acquire the storage root owner lock', () => + acquireStateRootLock(capability, 'write'), + ); +} + +export async function prepareStorageRootControlDirectory( + capability: StorageRootCapability, +): Promise<{ controlRoot: string; controlDirectory: string }> { + return withAuthorityFailure( + 'control_io_failed', + 'Unable to prepare the Runtime Host control directory', + async () => { + const record = requireCapability(capability, capability.kind); + return prepareStorageRootControlDirectoryForRecord(record); + }, + ); +} + +export async function resolveExistingStorageRootControlDirectory( + capability: StorageRootCapability, +): Promise<{ controlRoot: string; controlDirectory: string }> { + return withAuthorityFailure( + 'control_io_failed', + 'Unable to validate the existing Runtime Host control directory', + async () => { + const record = requireCapability(capability, capability.kind); + await assertRootIdentity(record); + const controlRoot = resolve(resolveRootControlNamespace()); + const controlDirectory = join(controlRoot, record.rootId); + await assertPrivateDirectory(controlRoot); + await assertPrivateDirectory(controlDirectory); + await assertRootIdentity(record); + return { controlRoot, controlDirectory }; + }, + ); +} + +export async function prepareArtifactWriterBootstrapAuthority( + path: string, +): Promise { + return withAuthorityFailure( + 'control_io_failed', + 'Unable to prepare the Artifact writer bootstrap authority', + async () => { + const { canonicalPath, rootStat } = await resolveExistingRootPath(path); + const identity = { dev: rootStat.dev, ino: rootStat.ino }; + const identityChangedMessage = `Storage root identity changed while preparing its Artifact writer bootstrap lock: ${canonicalPath}`; + await assertRootPathIdentity(canonicalPath, identity, identityChangedMessage); + const controlRoot = await preparePrivateControlRoot(); + const lockPath = await prepareArtifactWriterBootstrapLockPathForIdentity( + controlRoot, + identity, + ); + await assertRootPathIdentity(canonicalPath, identity, identityChangedMessage); + return Object.freeze({ + lockPath, + canonicalPath, + assertCurrentRoot: () => + assertRootPathIdentity(canonicalPath, identity, identityChangedMessage), + [artifactWriterBootstrapAuthorityBrand]: true as const, + }); + }, + ); +} + +export async function prepareArtifactWriterLockAuthorityForLease( + lease: StorageRootLease, + expectedKind: K, +): Promise { + return withAuthorityFailure( + 'control_io_failed', + 'Unable to prepare the Artifact writer lock control path', + async () => { + const record = requireLease(lease, expectedKind, 'write'); + const authority = await prepareArtifactWriterLockAuthorityForRecord(record); + requireLease(lease, expectedKind, 'write'); + return authority; + }, + ); +} + +export async function prepareArtifactWriterLockAuthorityForMarkedRoot( + path: string, +): Promise { + let capability: DiscoveredStorageRootCapability; + try { + capability = await discoverMarkedStorageRoot({ path }); + } catch (error) { + if (error instanceof StorageRootAuthorityError && error.code === 'root_unmarked') { + return undefined; + } + throw error; + } + const record = requireCapability(capability, capability.kind); + return withAuthorityFailure( + 'control_io_failed', + 'Unable to prepare the Artifact writer lock control path', + () => prepareArtifactWriterLockAuthorityForRecord(record), + ); +} + +export async function tryAcquireInteractiveRootReader( + capability: StorageRootCapability<'interactive'>, +): Promise { + return withAuthorityFailure( + 'lock_failed', + 'Unable to acquire the interactive storage root reader lock', + () => acquireStateRootLock(capability, 'read'), + ); +} + +export async function assertStorageRootLease< + K extends StorageRootKind, + A extends StorageRootAccess, +>(lease: StorageRootLease, expectedKind: K, expectedAccess: A): Promise { + const record = requireLease(lease, expectedKind, expectedAccess); + await assertRootIdentity(record); + requireLease(lease, expectedKind, expectedAccess); +} + +export function createStorageRootLeaseIdentityGuard< + K extends StorageRootKind, + A extends StorageRootAccess, +>(lease: StorageRootLease, expectedKind: K, expectedAccess: A): () => Promise { + const record = requireLease(lease, expectedKind, expectedAccess); + return () => assertRootIdentity(record); +} + +export async function runWithStorageRootLease< + K extends StorageRootKind, + A extends StorageRootAccess, + T, +>( + lease: StorageRootLease, + expectedKind: K, + expectedAccess: A, + operation: (canonicalPath: string) => Promise, +): Promise { + const record = requireLease(lease, expectedKind, expectedAccess); + const finishOperation = record.beginOperation(); + try { + await assertRootIdentity(record); + return await operation(record.canonicalPath); + } finally { + finishOperation(); + } +} + +export async function assertStorageRootCapability( + capability: StorageRootCapability, + expectedKind: K, +): Promise { + const record = requireCapability(capability, expectedKind); + await assertRootIdentity(record); +} + +export async function assertInteractiveRootOwner(owner: InteractiveRootOwner): Promise { + return assertStateRootOwner(owner, 'interactive'); +} + +export async function assertStateRootOwner( + owner: StateRootOwner, + expectedKind: K, +): Promise { + const authenticOwner = authenticateStateRootOwner(owner, expectedKind); + const capabilityRecord = requireCapability(authenticOwner.capability, expectedKind); + requireLease(authenticOwner.lease, expectedKind, 'write'); + await assertRootIdentity(capabilityRecord); + requireLease(authenticOwner.lease, expectedKind, 'write'); +} + +export function authenticateInteractiveRootOwner( + owner: InteractiveRootOwner, +): InteractiveRootOwner { + return authenticateStateRootOwner(owner, 'interactive'); +} + +export function authenticateStateRootOwner( + owner: StateRootOwner, + expectedKind: K, +): StateRootOwner { + const record = stateRootLocks.get(owner); + if (record?.kind !== expectedKind || record.access !== 'write') { + throw new StorageRootAuthorityError( + 'invalid_owner', + `Expected an authentic ${expectedKind} storage root owner`, + ); + } + return owner; +} + +function acquireStateRootLock( + capability: StorageRootCapability, + access: 'write', +): Promise | undefined>; +function acquireStateRootLock( + capability: StorageRootCapability, + access: 'read', +): Promise | undefined>; +async function acquireStateRootLock( + capability: StorageRootCapability, + access: StorageRootAccess, +): Promise | StateRootReader | undefined> { + const capabilityRecord = requireCapability(capability, capability.kind); + const ownershipRoot = resolve(resolveRootOwnershipNamespace()); + await ensureDurablePrivateDirectory(ownershipRoot); + const lockPath = join(ownershipRoot, `${capabilityRecord.rootId}.lock`); + const durableHandle = await tryAcquireStableRootLock(lockPath, access); + if (!durableHandle) return undefined; + + let compatibilityHandle: FileHandle | undefined; + let controlDirectory: string; + try { + ({ controlDirectory } = await prepareStorageRootControlDirectory(capability)); + compatibilityHandle = await tryAcquireStableRootLock( + join(controlDirectory, 'owner.lock'), + access, + ); + if (!compatibilityHandle) { + releaseLock(durableHandle); + await durableHandle.close(); + return undefined; + } + await assertRootIdentity(capabilityRecord); + } catch (error) { + if (compatibilityHandle) { + releaseLock(compatibilityHandle); + await compatibilityHandle.close().catch(() => undefined); + } + releaseLock(durableHandle); + await durableHandle.close().catch(() => undefined); + throw error; + } + + let active = true; + let activeOperations = 0; + const operationDrainWaiters = new Set<() => void>(); + let closePromise: Promise | undefined; + const beginOperation = () => { + if (!active) throw invalidLease(capabilityRecord.kind, access); + activeOperations += 1; + let finished = false; + return () => { + if (finished) return; + finished = true; + activeOperations -= 1; + if (activeOperations !== 0) return; + for (const resolve of operationDrainWaiters) resolve(); + operationDrainWaiters.clear(); + }; + }; + const waitForOperations = () => + activeOperations === 0 + ? Promise.resolve() + : new Promise((resolve) => operationDrainWaiters.add(resolve)); + const close = () => { + if (closePromise) return closePromise; + active = false; + closePromise = withAuthorityFailure( + 'lock_failed', + 'Unable to close the storage root lock', + async () => { + await waitForOperations(); + const errors: unknown[] = []; + releaseLock(compatibilityHandle); + await compatibilityHandle.close().catch((error: unknown) => errors.push(error)); + releaseLock(durableHandle); + await durableHandle.close().catch((error: unknown) => errors.push(error)); + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close every State Root owner lock'); + } + }, + ); + return closePromise; + }; + return createStateRootLock( + capability, + capabilityRecord, + access, + controlDirectory, + lockPath, + () => active, + beginOperation, + close, + ); +} + +async function tryAcquireStableRootLock( + lockPath: string, + access: StorageRootAccess, +): Promise { + const existingLock = await lstatPathIfPresent(lockPath); + if (existingLock && !existingLock.isFile()) throw invalidLockArtifact(lockPath); + const handle = await open(lockPath, 'a+', 0o600); + try { + await assertStableLockArtifact(handle, lockPath); + await handle.chmod(0o600); + if (!tryLock(handle.fd, { shared: access === 'read' })) { + await handle.close(); + return undefined; + } + await assertStableLockArtifact(handle, lockPath); + return handle; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +function createStateRootLock( + capability: StorageRootCapability, + capabilityRecord: CapabilityRecord, + access: StorageRootAccess, + controlDirectory: string, + lockPath: string, + isActive: () => boolean, + beginOperation: () => () => void, + close: () => Promise, +): StateRootOwner | StateRootReader { + const lock = Object.freeze({ + capability, + lease: createLease(capabilityRecord, access, isActive, beginOperation), + controlDirectory, + lockPath, + get closed() { + return !isActive(); + }, + close, + }) as StateRootOwner | StateRootReader; + stateRootLocks.set(lock, { kind: capabilityRecord.kind, access }); + return lock; +} + +function createLease( + capability: CapabilityRecord, + access: A, + isActive: () => boolean, + beginOperation: () => () => void = () => { + if (!isActive()) throw invalidLease(capability.kind, access); + return () => {}; + }, +): StorageRootLease { + const lease = Object.freeze({ + kind: capability.kind, + access, + canonicalPath: capability.canonicalPath, + rootId: capability.rootId, + }) as StorageRootLease; + leases.set(lease, { ...capability, access, isActive, beginOperation }); + return lease; +} + +function requireCapability( + capability: StorageRootCapability, + expectedKind: K, +): CapabilityRecord { + const record = capabilities.get(capability); + if (!record || record.kind !== expectedKind) { + throw new StorageRootAuthorityError( + 'invalid_capability', + `Expected a ${expectedKind} storage root capability`, + ); + } + return record as CapabilityRecord; +} + +function requireLease( + lease: StorageRootLease, + expectedKind: K, + expectedAccess: A, +): LeaseRecord { + const record = leases.get(lease); + if ( + !record || + record.kind !== expectedKind || + record.access !== expectedAccess || + !record.isActive() + ) { + throw invalidLease(expectedKind, expectedAccess); + } + return record as LeaseRecord; +} + +function invalidLease(kind: StorageRootKind, access: StorageRootAccess): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + `Expected an active ${kind} ${access} storage root lease`, + ); +} + +async function prepareStorageRootControlDirectoryForRecord( + record: CapabilityRecord, +): Promise<{ controlRoot: string; controlDirectory: string }> { + await assertRootIdentity(record); + const controlRoot = await preparePrivateControlRoot(); + const controlDirectory = join(controlRoot, record.rootId); + await ensurePrivateDirectory(controlDirectory); + await assertRootIdentity(record); + return { controlRoot, controlDirectory }; +} + +async function prepareArtifactWriterLockAuthorityForRecord( + record: CapabilityRecord, +): Promise { + const { controlRoot, controlDirectory } = + await prepareStorageRootControlDirectoryForRecord(record); + const bootstrapLockPath = await prepareArtifactWriterBootstrapLockPathForIdentity( + controlRoot, + record.identity, + ); + await assertRootIdentity(record); + return createArtifactWriterLockAuthority(record, bootstrapLockPath, controlDirectory); +} + +function createArtifactWriterLockAuthority( + record: CapabilityRecord, + bootstrapLockPath: string, + controlDirectory: string, +): ArtifactWriterLockAuthority { + return Object.freeze({ + bootstrapLockPath, + controlDirectory, + assertCurrentRoot: () => assertRootIdentity(record), + [artifactWriterLockAuthorityBrand]: true as const, + }); +} + +async function preparePrivateControlRoot(): Promise { + const controlRoot = resolve(resolveRootControlNamespace()); + await ensurePrivateDirectory(controlRoot); + return controlRoot; +} + +async function ensureDurablePrivateDirectory(path: string): Promise { + let existingAncestor = path; + while ((await lstatPathIfPresent(existingAncestor)) === undefined) { + const parent = parse(existingAncestor).dir; + if (parent === existingAncestor) break; + existingAncestor = parent; + } + await ensurePrivateDirectory(path); + await syncDirectoryChain(path, existingAncestor); +} + +async function prepareArtifactWriterBootstrapLockPathForIdentity( + controlRoot: string, + identity: RootIdentity, +): Promise { + const directory = join(controlRoot, ARTIFACT_WRITER_BOOTSTRAP_DIRECTORY); + await ensurePrivateDirectory(directory); + const identityHash = createHash('sha256') + .update(`${identity.dev.toString()}:${identity.ino.toString()}`) + .digest('hex'); + return join(directory, `${identityHash}.lock`); +} + +async function assertRootIdentity(record: CapabilityRecord): Promise { + await withAuthorityFailure( + 'root_io_failed', + `Unable to validate storage root identity: ${record.canonicalPath}`, + async () => { + await confirmRootSnapshot({ + root: record.canonicalPath, + identity: record.identity, + readMarker: () => readAndValidateRootMarker(record.canonicalPath, record.kind), + expectedRootId: record.rootId, + markerMismatchCode: 'root_identity_changed', + markerMismatchMessage: `Storage root marker identity changed: ${record.canonicalPath}`, + }); + }, + ); +} + +interface ConfirmRootSnapshotInput { + root: string; + identity: RootIdentity; + readMarker(): Promise; + expectedRootId?: string; + markerMismatchCode: 'root_identity_collision' | 'root_identity_changed'; + markerMismatchMessage: string; +} + +async function confirmRootSnapshot(input: ConfirmRootSnapshotInput): Promise { + const identityChangedMessage = `Storage root identity changed while validating its marker: ${input.root}`; + await assertRootPathIdentity(input.root, input.identity, identityChangedMessage); + let marker: RootMarker; + try { + marker = await input.readMarker(); + } catch (error) { + await assertRootPathIdentity(input.root, input.identity, identityChangedMessage); + throw error; + } + await assertRootPathIdentity(input.root, input.identity, identityChangedMessage); + if ( + (input.expectedRootId !== undefined && marker.rootId !== input.expectedRootId) || + !markerMatchesIdentity(marker, input.identity) + ) { + throw new StorageRootAuthorityError(input.markerMismatchCode, input.markerMismatchMessage); + } + return marker; +} + +async function ensureRootMarker( + root: string, + kind: StorageRootKind, + identity: RootIdentity, +): Promise { + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + try { + await lstat(markerPath); + return await readAndValidateRootMarker(root, kind); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + } + + const marker: RootMarker = { + schemaVersion: STORAGE_ROOT_MARKER_SCHEMA_VERSION, + kind, + rootId: randomBytes(32).toString('hex'), + rootIdentity: { + dev: identity.dev.toString(), + ino: identity.ino.toString(), + }, + }; + await publishMarkerFile({ + root, + markerFile: STORAGE_ROOT_MARKER_FILE, + contents: `${JSON.stringify(marker)}\n`, + maxBytes: MAX_STORAGE_ROOT_MARKER_BYTES, + publication: 'create', + beforePublish: () => + assertRootPathIdentity( + root, + identity, + `Storage root identity changed before publishing its marker: ${root}`, + ), + invalidFile: () => + new StorageRootAuthorityError( + 'invalid_marker', + `Storage root marker candidate exceeds the size limit: ${markerPath}`, + ), + }); + return readAndValidateRootMarker(root, kind); +} + +async function replaceRootMarkerIdentity( + root: string, + identity: RootIdentity, + sourceMarker: RootMarker, +): Promise { + return withExclusiveRootMarker(root, identity, sourceMarker.kind, async (current) => { + assertRootMarkerUnchanged(root, current, sourceMarker); + const marker: RootMarker = { + ...current, + rootIdentity: { + dev: identity.dev.toString(), + ino: identity.ino.toString(), + }, + }; + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + await publishMarkerFile({ + root, + markerFile: STORAGE_ROOT_MARKER_FILE, + contents: `${JSON.stringify(marker)}\n`, + maxBytes: MAX_STORAGE_ROOT_MARKER_BYTES, + publication: 'replace', + beforePublish: async () => { + await assertRootPathIdentity( + root, + identity, + `Storage root identity changed before updating its marker: ${root}`, + ); + assertRootMarkerUnchanged( + root, + await readAndValidateRootMarker(root, sourceMarker.kind), + sourceMarker, + ); + }, + invalidFile: () => + new StorageRootAuthorityError( + 'invalid_marker', + `Storage root marker candidate exceeds the size limit: ${markerPath}`, + ), + }); + await assertRootPathIdentity( + root, + identity, + `Storage root identity changed after updating its marker: ${root}`, + ); + const adopted = await readAndValidateRootMarker(root, sourceMarker.kind); + if (adopted.rootId !== sourceMarker.rootId || !markerMatchesIdentity(adopted, identity)) { + throw new StorageRootAuthorityError( + 'root_identity_changed', + `Storage root marker changed while updating its identity: ${root}`, + ); + } + return adopted; + }); +} + +async function withExclusiveRootMarker( + root: string, + identity: RootIdentity, + expectedKind: StorageRootKind, + operation: (marker: RootMarker) => Promise, +): Promise { + const controlRoot = await preparePrivateControlRoot(); + const lockPath = await prepareArtifactWriterBootstrapLockPathForIdentity(controlRoot, identity); + return withArtifactWriterBootstrapLock(lockPath, async () => { + await assertRootPathIdentity( + root, + identity, + `Storage root identity changed while acquiring its marker publication lock: ${root}`, + ); + const marker = await readAndValidateRootMarker(root, expectedKind); + return operation(marker); + }); +} + +function assertRootMarkerUnchanged(root: string, current: RootMarker, expected: RootMarker): void { + if (!rootMarkersEqual(current, expected)) { + throw new StorageRootAuthorityError( + 'root_identity_collision', + `Storage root marker changed while updating its identity: ${root}`, + ); + } +} + +function invalidRootMarker(markerPath: string, cause?: unknown): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_marker', + `Invalid storage root marker at ${markerPath}${cause instanceof Error ? `: ${cause.message}` : ''}`, + cause === undefined ? undefined : { cause }, + ); +} + +async function assertRootPathIdentity( + root: string, + identity: RootIdentity, + message: string, +): Promise { + const rootStat = await statRootIfPresent(root); + if (!rootStat?.isDirectory() || rootStat.dev !== identity.dev || rootStat.ino !== identity.ino) { + throw new StorageRootAuthorityError('root_identity_changed', message); + } +} + +async function readAndValidateRootMarker( + root: string, + _expectedKind: StorageRootKind, +): Promise { + return readRootMarker(root); +} + +async function readRootMarker(root: string): Promise { + const markerPath = join(root, STORAGE_ROOT_MARKER_FILE); + let contents: string; + try { + contents = await readBoundedMarkerFile({ + path: markerPath, + maxBytes: MAX_STORAGE_ROOT_MARKER_BYTES, + invalidFile: () => + new StorageRootAuthorityError( + 'invalid_marker', + `Storage root marker must be one bounded regular file: ${markerPath}`, + ), + }); + } catch (error) { + if (error instanceof StorageRootAuthorityError) throw error; + if (isNodeError(error, 'ENOENT')) { + throw new StorageRootAuthorityError('root_unmarked', `Storage root is not marked: ${root}`); + } + if (isInvalidMarkerPathError(error)) throw invalidRootMarker(markerPath, error); + throw error; + } + return parseRootMarker(contents, markerPath); +} + +function parseRootMarker(contents: string, markerPath: string): RootMarker { + let marker: unknown; + try { + marker = JSON.parse(contents); + } catch (error) { + throw invalidRootMarker(markerPath, error); + } + if (!isRootMarker(marker)) { + throw invalidRootMarker(markerPath); + } + return marker; +} + +function isRootMarker(value: unknown): value is RootMarker { + if (!value || typeof value !== 'object') return false; + const marker = value as Record; + return ( + marker.schemaVersion === STORAGE_ROOT_MARKER_SCHEMA_VERSION && + marker.kind === 'interactive' && + typeof marker.rootId === 'string' && + /^[a-f0-9]{64}$/.test(marker.rootId) && + isMarkerRootIdentity(marker.rootIdentity) + ); +} + +function isMarkerRootIdentity(value: unknown): value is RootMarker['rootIdentity'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const identity = value as Record; + return ( + typeof identity.dev === 'string' && + /^\d+$/.test(identity.dev) && + typeof identity.ino === 'string' && + /^\d+$/.test(identity.ino) + ); +} + +/** + * Whether the marker describes the directory that was just stat'd. + * + * Both fields, and no classification of how a mismatch came about. A moved + * `dev` with a matching `ino` reads like a remount — the kernel hands out a + * device number per mount, so an unmoved directory reports a new one after its + * volume is mounted again — but a workspace restored onto another volume + * presents the same pair, because inode numbers are unique only within one + * mounted filesystem. Naming that case a remount would let a second, unrelated + * directory inherit the original's rootId without anyone confirming it, and + * nothing in the marker can tell the two apart. It stays a question for the + * person; `adoptStorageRootOnImport` is the sanctioned way in for a copy, + * where the caller states the rootId it expects. + */ +function markerMatchesIdentity(marker: RootMarker, identity: RootIdentity): boolean { + return ( + marker.rootIdentity.dev === identity.dev.toString() && + marker.rootIdentity.ino === identity.ino.toString() + ); +} + +function rootMarkersEqual(left: RootMarker, right: RootMarker): boolean { + return ( + left.schemaVersion === right.schemaVersion && + left.kind === right.kind && + left.rootId === right.rootId && + left.rootIdentity.dev === right.rootIdentity.dev && + left.rootIdentity.ino === right.rootIdentity.ino + ); +} + +async function ensurePrivateDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + let directoryStat = await lstat(path); + if (!directoryStat.isDirectory()) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not a directory: ${path}`, + ); + } + if (process.platform === 'win32') return; + if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not owned by the current user: ${path}`, + ); + } + await chmod(path, 0o700); + directoryStat = await lstat(path); + if (!directoryStat.isDirectory() || (directoryStat.mode & 0o077) !== 0) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not private: ${path}`, + ); + } +} + +async function assertPrivateDirectory(path: string): Promise { + const directoryStat = await lstat(path); + if (!directoryStat.isDirectory()) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not a directory: ${path}`, + ); + } + if (process.platform === 'win32') return; + if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not owned by the current user: ${path}`, + ); + } + if ((directoryStat.mode & 0o077) !== 0) { + throw new StorageRootAuthorityError( + 'insecure_control_directory', + `Runtime Host control path is not private: ${path}`, + ); + } +} + +async function assertStableLockArtifact(handle: FileHandle, path: string): Promise { + let stable = false; + try { + const [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(path, { bigint: true }), + ]); + stable = + handleStat.isFile() && + pathStat.isFile() && + handleStat.dev === pathStat.dev && + handleStat.ino === pathStat.ino; + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + if (!stable) { + throw invalidLockArtifact(path); + } +} + +function invalidLockArtifact(path: string): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lock_artifact', + `Storage root lock path is not one stable regular file: ${path}`, + ); +} + +function releaseLock(handle: FileHandle): void { + try { + unlock(handle.fd); + } catch { + // Closing the OS handle is the authoritative release path. + } +} + +function canonicalizePath(path: string): string { + const normalized = normalize(path); + const root = parse(normalized).root; + return normalized === root ? normalized : normalized.replace(/[\\/]+$/, ''); +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} + +function isMissingPathError(error: unknown): boolean { + return isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'); +} + +function isInvalidMarkerPathError(error: unknown): boolean { + return isMissingPathError(error) || isNodeError(error, 'ELOOP') || isNodeError(error, 'ENXIO'); +} + +async function statRootIfPresent(path: string): Promise { + try { + return await stat(path, { bigint: true }); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +async function lstatPathIfPresent(path: string): Promise { + try { + return await lstat(path, { bigint: true }); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +async function withAuthorityFailure( + code: Extract< + StorageRootAuthorityErrorCode, + 'root_io_failed' | 'control_io_failed' | 'lock_failed' + >, + message: string, + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + throw normalizeAuthorityFailure(error, code, message); + } +} + +function normalizeAuthorityFailure( + error: unknown, + code: Extract< + StorageRootAuthorityErrorCode, + 'root_io_failed' | 'control_io_failed' | 'lock_failed' + >, + message: string, +): StorageRootAuthorityError { + if (error instanceof StorageRootAuthorityError) return error; + return new StorageRootAuthorityError(code, message, { cause: error }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a0605375810efdb61551d27732149dfa1cd462c3eb03418b1ae695f18ca2a4c6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a0605375810efdb61551d27732149dfa1cd462c3eb03418b1ae695f18ca2a4c6.source new file mode 100644 index 0000000000..42f2fa4212 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a0605375810efdb61551d27732149dfa1cd462c3eb03418b1ae695f18ca2a4c6.source @@ -0,0 +1,1087 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Buffer } from 'node:buffer'; +import { constants as fsConstants } from 'node:fs'; +import type { BigIntStats } from 'node:fs'; +import { lstat, mkdir, open, readdir, realpath } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { + assertSessionBundleLimits, + type OpaqueStateIdentityDescriptor, + type SessionBundleArtifact, + type SessionBundleFileService, + type SessionBundleLimits, +} from './session-bundle-contract.js'; +import { createSessionBundleFileService } from './session-bundle-file-service.js'; +import { exportSessionBundleState } from './session-bundle-policy.js'; +import { + createFileQuiescentSessionSnapshotCoordinator, + createFileSessionSnapshotStagingCleanupAuthority, + type PreparedSessionBundleHandle, + type SessionSnapshotCancellation, + SessionSnapshotError, + type SessionSnapshotPrivateStagingRootAuthority, + type SessionSnapshotQuiescenceAuthority, + type SessionSnapshotStagingCleanupRecovery, + type SessionSnapshotWorkspaceConfirmationResolver, + type SessionSnapshotWorkspaceConfirmationAuthority, + type SessionSnapshotWorkspaceEntry, + type SessionSnapshotWorkspaceExclusionCategory, + type SessionSnapshotWorkspacePolicy, + type SessionSnapshotWorkspacePreparer, + type SessionSnapshotWorkspacePreparation, + type SessionSnapshotStatePreparer, +} from './quiescent-session-snapshot.js'; +import { isSessionBundleUstarPathV1 } from './session-bundle-ustar.js'; +import type { ProcessLifetimeOwner } from './process-lifetime-owner.js'; +import { isSafeSessionId } from './session-store.js'; + +/** State-layer-owned descriptor format; the Bundle codec only transports these bytes. */ +export const SESSION_SNAPSHOT_STATE_IDENTITY_MEDIA_TYPE = + 'application/vnd.maka.session-state-identity+json;version=1'; + +const NO_FOLLOW_OPEN_FLAG = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; +const WORKSPACE_COPY_CHUNK_BYTES = 64 * 1024; + +export interface FileSessionSnapshotStatePreparerOptions { + /** Session-owned state root, never the host configuration root. */ + readonly stateRoot: string; + /** Host configuration root; it is checked by the exporter but never copied. */ + readonly configRoot: string; +} + +/** + * Creates the production state adapter for #2369. The existing state exporter + * owns semantic filtering and SQLite backup; this adapter only binds it to the + * quiescent-snapshot contract and emits the opaque identity descriptor. + */ +export function createFileSessionSnapshotStatePreparer( + options: FileSessionSnapshotStatePreparerOptions, +): SessionSnapshotStatePreparer { + const stateRoot = requireAbsolutePath(options.stateRoot, 'stateRoot'); + const configRoot = requireAbsolutePath(options.configRoot, 'configRoot'); + return { + async prepareState(input): Promise { + assertSnapshotActive(input.cancellation, 'state'); + const destinationRoot = requireAbsolutePath(input.destinationRoot, 'destinationRoot'); + try { + await exportSessionBundleState({ + stateRoot, + configRoot, + destinationRoot, + sessionId: input.makaSessionId, + }); + } catch (error) { + throw asSnapshotError( + 'io_failure', + 'Unable to export Session snapshot state', + 'state', + error, + ); + } + assertSnapshotActive(input.cancellation, 'state'); + return createSessionSnapshotStateIdentity(input.makaSessionId); + }, + }; +} + +export function createSessionSnapshotStateIdentity( + makaSessionId: string, +): OpaqueStateIdentityDescriptor { + if (typeof makaSessionId !== 'string' || makaSessionId.length === 0) { + throw new TypeError('Session snapshot Maka Session identity is required'); + } + return Object.freeze({ + mediaType: SESSION_SNAPSHOT_STATE_IDENTITY_MEDIA_TYPE, + bytes: Uint8Array.from( + Buffer.from(`${JSON.stringify({ schemaVersion: 1, makaSessionId })}\n`, 'utf8'), + ), + }); +} + +export interface FileSessionSnapshotWorkspacePreparerOptions { + /** The live workspace root for one Session. */ + readonly workspaceRoot: string; + /** Required, bounded Bundle policy; snapshot copying never has unlimited quotas. */ + readonly limits: SessionBundleLimits; + /** Internal production composition hook that reserves already-staged state budget. */ + readonly remainingLimitsForDestinationRoot?: (destinationRoot: string) => SessionBundleLimits; +} + +/** + * Copies a workspace under the coordinator's pinned policy. It deliberately + * does not accept a caller-supplied policy: the coordinator supplies that + * policy at invocation time and this preparer applies every decision. + */ +export function createFileSessionSnapshotWorkspacePreparer( + options: FileSessionSnapshotWorkspacePreparerOptions, +): SessionSnapshotWorkspacePreparer { + const workspaceRoot = requireAbsolutePath(options.workspaceRoot, 'workspaceRoot'); + assertSessionBundleLimits(options.limits); + const limits = Object.freeze({ ...options.limits }); + return { + async prepareWorkspace(input): Promise { + assertSnapshotActive(input.cancellation, 'workspace'); + const destinationRoot = requireAbsolutePath(input.destinationRoot, 'destinationRoot'); + const effectiveLimits = + options.remainingLimitsForDestinationRoot?.(destinationRoot) ?? limits; + assertSessionBundleLimits(effectiveLimits); + const sourceRoot = await canonicalWorkspaceRoot(workspaceRoot); + await assertMissing(destinationRoot, 'Workspace snapshot destination already exists'); + await mkdir(destinationRoot, { mode: 0o700 }); + + const budget: WorkspaceCopyBudget = { + includedEntries: 0, + excludedEntries: 0, + payloadBytes: 0, + exclusions: emptyExclusionCounts(), + seenCaseFoldedPaths: new Map(), + }; + try { + await copyWorkspaceDirectory({ + sourceDirectory: sourceRoot.path, + destinationDirectory: destinationRoot, + relativeDirectory: '', + expectedDirectoryIdentity: sourceRoot.identity, + policy: input.policy, + confirmation: input.confirmation, + cancellation: input.cancellation, + limits: effectiveLimits, + budget, + }); + } catch (error) { + if (error instanceof SessionSnapshotError) throw error; + throw asSnapshotError( + 'io_failure', + 'Unable to prepare Session snapshot workspace', + 'workspace', + error, + ); + } + assertSnapshotActive(input.cancellation, 'workspace'); + return Object.freeze({ + includedEntries: budget.includedEntries, + excludedEntries: budget.excludedEntries, + excludedEntriesByCategory: Object.freeze({ ...budget.exclusions }), + payloadBytes: budget.payloadBytes, + }); + }, + }; +} + +export interface FileProductionSessionSnapshotServiceOptions { + /** + * Host-authorized binding for this one production snapshot service. Callers + * cannot select a Maka Session, workspace, and Cloud envelope independently. + */ + readonly session: ProductionSessionSnapshotBinding; + readonly stateRoot: string; + readonly configRoot: string; + readonly workspaceRoot: string; + readonly stagingParent: string; + /** Separate durable root for the staging-cleanup lease database. */ + readonly cleanupStateRoot: string; + readonly processLifetimeOwner: ProcessLifetimeOwner; + readonly quiescence: SessionSnapshotQuiescenceAuthority; + readonly limits: SessionBundleLimits; + readonly bundleFileService?: SessionBundleFileService; + readonly privateStagingRootAuthority?: SessionSnapshotPrivateStagingRootAuthority; + readonly confirmationAuthority?: SessionSnapshotWorkspaceConfirmationAuthority; +} + +export interface ProductionSessionSnapshotBinding { + /** Runtime/storage identity whose state and workspace this service snapshots. */ + readonly makaSessionId: string; + /** Control-plane identity embedded in and verified against the Bundle envelope. */ + readonly cloudSessionId: string; +} + +export interface PackQuiescentSessionBundleInput { + readonly destination: string; + readonly lastCommittedActivationId?: string; + readonly confirmationGrantId?: string; + readonly signal?: AbortSignal; + readonly deadlineAt?: number; +} + +/** + * Cleanup of the private staging copy after the immutable Bundle is written. + * A pending cleanup does not invalidate the already-written Bundle; callers + * should record the failure. Its persisted lease becomes eligible for recovery + * after the current process lifetime ends. + */ +export type SessionSnapshotPackCleanup = + | { readonly state: 'released' } + | { readonly state: 'pending_recovery'; readonly error: SessionSnapshotError }; + +/** + * A production Bundle artifact plus the status of its separate private-staging + * cleanup. The artifact fields remain available at the top level so existing + * consumers can use it as an ordinary SessionBundleArtifact. + */ +export interface ProductionSessionBundleArtifact extends SessionBundleArtifact { + readonly snapshotCleanup: SessionSnapshotPackCleanup; +} + +export interface FileProductionSessionSnapshotService { + recover(): Promise; + prepare(input: { + readonly confirmationGrantId?: string; + readonly signal?: AbortSignal; + readonly deadlineAt?: number; + }): Promise; + pack(input: PackQuiescentSessionBundleInput): Promise; +} + +/** + * Production composition and first codec call site for #2369. Startup awaits + * persisted staging recovery before this function resolves, so callers cannot + * serve snapshot requests while an earlier process's private staging is still + * unreconciled. + */ +export async function createFileProductionSessionSnapshotService( + options: FileProductionSessionSnapshotServiceOptions, +): Promise { + const session = requireProductionSessionSnapshotBinding(options.session); + assertSessionBundleLimits(options.limits); + const limits = Object.freeze({ ...options.limits }); + const stagingCleanup = createFileSessionSnapshotStagingCleanupAuthority({ + cleanupStateRoot: requireAbsolutePath(options.cleanupStateRoot, 'cleanupStateRoot'), + stagingParent: requireAbsolutePath(options.stagingParent, 'stagingParent'), + processLifetimeOwner: options.processLifetimeOwner, + privateStagingRootAuthority: options.privateStagingRootAuthority, + }); + await stagingCleanup.recover(); + await assertProductionRootsSeparate({ + stateRoot: options.stateRoot, + configRoot: options.configRoot, + workspaceRoot: options.workspaceRoot, + stagingParent: stagingCleanup.stagingParent, + cleanupStateRoot: options.cleanupStateRoot, + }); + const stateBudgetsByStagingRoot = new Map(); + const fileStatePreparer = createFileSessionSnapshotStatePreparer({ + stateRoot: options.stateRoot, + configRoot: options.configRoot, + }); + const state: SessionSnapshotStatePreparer = { + async prepareState(input): Promise { + const identity = await fileStatePreparer.prepareState(input); + const payload = await measurePreparedStatePayload( + input.destinationRoot, + limits, + input.cancellation, + ); + const budget = reserveStateBundleBudget(payload, identity, limits); + stateBudgetsByStagingRoot.set(dirname(input.destinationRoot), budget); + return identity; + }, + }; + const workspace = createFileSessionSnapshotWorkspacePreparer({ + workspaceRoot: options.workspaceRoot, + limits, + remainingLimitsForDestinationRoot(destinationRoot) { + const stagingRoot = dirname(destinationRoot); + const stateBudget = stateBudgetsByStagingRoot.get(stagingRoot); + if (!stateBudget) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot state budget is unavailable for workspace copying', + { details: { phase: 'workspace' } }, + ); + } + return deriveWorkspaceLimits(limits, stateBudget); + }, + }); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: stagingCleanup.stagingParent, + stagingCleanup, + quiescence: options.quiescence, + state, + workspace, + confirmationAuthority: options.confirmationAuthority, + privateStagingRootAuthority: options.privateStagingRootAuthority, + }); + const bundleFileService = options.bundleFileService ?? createSessionBundleFileService(); + const prepare = async (input: { + readonly confirmationGrantId?: string; + readonly signal?: AbortSignal; + readonly deadlineAt?: number; + }): Promise => { + try { + return await coordinator.prepare({ ...input, makaSessionId: session.makaSessionId }); + } finally { + stateBudgetsByStagingRoot.clear(); + } + }; + return Object.freeze({ + recover: () => stagingCleanup.recover(), + prepare, + async pack(input: PackQuiescentSessionBundleInput): Promise { + const prepared = await prepare(input); + let artifact: SessionBundleArtifact | undefined; + let primaryFailure: unknown; + try { + artifact = await bundleFileService.pack({ + snapshot: prepared.snapshot, + envelope: { + sessionId: session.cloudSessionId, + ...(input.lastCommittedActivationId === undefined + ? {} + : { lastCommittedActivationId: input.lastCommittedActivationId }), + }, + destination: input.destination, + limits, + }); + } catch (error) { + primaryFailure = error; + } + let cleanup: SessionSnapshotPackCleanup = { state: 'released' }; + try { + await prepared.release(); + } catch (cleanupFailure) { + const error = normalizePackCleanupFailure(cleanupFailure); + if (primaryFailure !== undefined) { + throw new AggregateError( + [primaryFailure, error], + 'Session Bundle packing failed and snapshot cleanup also failed', + ); + } + cleanup = { state: 'pending_recovery', error }; + } + if (primaryFailure !== undefined) throw primaryFailure; + if (!artifact) throw new Error('Session Bundle packing completed without an artifact'); + return Object.freeze({ ...artifact, snapshotCleanup: Object.freeze(cleanup) }); + }, + }); +} + +function normalizePackCleanupFailure(error: unknown): SessionSnapshotError { + if (error instanceof SessionSnapshotError && error.code === 'cleanup_failed') return error; + return new SessionSnapshotError('cleanup_failed', 'Session snapshot cleanup failed', { + cause: error, + details: { phase: 'cleanup' }, + }); +} + +type WorkspaceCopyBudget = { + includedEntries: number; + excludedEntries: number; + payloadBytes: number; + exclusions: Record; + seenCaseFoldedPaths: Map; +}; + +type SnapshotStagingBudget = { + /** Includes state-identity.json and the state/ + workspace/ root entries. */ + readonly entryCount: number; + /** Includes state files and state-identity.json, but never directories. */ + readonly payloadBytes: number; +}; + +async function measurePreparedStatePayload( + root: string, + limits: SessionBundleLimits, + cancellation: SessionSnapshotCancellation, +): Promise<{ readonly entryCount: number; readonly payloadBytes: number }> { + const budget = { entryCount: 0, payloadBytes: 0 }; + await measurePreparedStateDirectory(root, budget, limits, cancellation); + return budget; +} + +async function measurePreparedStateDirectory( + directory: string, + budget: { entryCount: number; payloadBytes: number }, + limits: SessionBundleLimits, + cancellation: SessionSnapshotCancellation, +): Promise { + assertSnapshotActive(cancellation, 'state'); + const metadata = await lstatBigInt(directory); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', 'Prepared Session state is unsafe', { + details: { phase: 'state' }, + }); + } + const children = await readdir(directory, { encoding: 'buffer', withFileTypes: true }); + for (const child of children) { + assertSnapshotActive(cancellation, 'state'); + const name = decodeFilesystemName(child.name); + const path = join(directory, name); + const childMetadata = await lstatBigInt(path); + if (childMetadata.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', 'Prepared Session state is unsafe', { + details: { phase: 'state' }, + }); + } + if (childMetadata.isDirectory()) { + budget.entryCount = reserveStateEntry(budget.entryCount, limits); + await measurePreparedStateDirectory(path, budget, limits, cancellation); + continue; + } + if (!childMetadata.isFile()) { + throw new SessionSnapshotError('unsafe_source', 'Prepared Session state is unsafe', { + details: { phase: 'state' }, + }); + } + if (childMetadata.nlink !== 1n) { + throw new SessionSnapshotError('unsafe_source', 'Prepared Session state is unsafe', { + details: { phase: 'state' }, + }); + } + const bytes = safeSize(childMetadata.size); + if (bytes > limits.maxFileBytes) throw stateQuotaExceeded(); + budget.payloadBytes = reserveStatePayload(budget.payloadBytes, bytes, limits); + budget.entryCount = reserveStateEntry(budget.entryCount, limits); + } +} + +function reserveStateBundleBudget( + state: { readonly entryCount: number; readonly payloadBytes: number }, + identity: OpaqueStateIdentityDescriptor, + limits: SessionBundleLimits, +): SnapshotStagingBudget { + const identityBytes = identity.bytes.byteLength; + if (identityBytes > limits.maxStateIdentityBytes || identityBytes > limits.maxFileBytes) { + throw stateQuotaExceeded(); + } + const payloadBytes = reserveStatePayload(state.payloadBytes, identityBytes, limits); + // state-identity.json, state/, and workspace/ are always emitted by the codec. + const entryCount = reserveStateEntry( + reserveStateEntry(reserveStateEntry(state.entryCount, limits), limits), + limits, + ); + return Object.freeze({ entryCount, payloadBytes }); +} + +function deriveWorkspaceLimits( + limits: SessionBundleLimits, + reserved: SnapshotStagingBudget, +): SessionBundleLimits { + if ( + reserved.payloadBytes > limits.maxPayloadBytes || + reserved.entryCount > limits.maxEntryCount + ) { + throw stateQuotaExceeded(); + } + return Object.freeze({ + ...limits, + maxPayloadBytes: limits.maxPayloadBytes - reserved.payloadBytes, + maxEntryCount: limits.maxEntryCount - reserved.entryCount, + }); +} + +function reserveStatePayload(current: number, added: number, limits: SessionBundleLimits): number { + const next = safeAdd(current, added); + if (next > limits.maxPayloadBytes) throw stateQuotaExceeded(); + return next; +} + +function reserveStateEntry(current: number, limits: SessionBundleLimits): number { + const next = safeAdd(current, 1); + if (next > limits.maxEntryCount) throw stateQuotaExceeded(); + return next; +} + +function stateQuotaExceeded(): SessionSnapshotError { + return new SessionSnapshotError('quota_exceeded', 'Session snapshot state exceeds Bundle quota', { + details: { phase: 'state' }, + }); +} + +async function copyWorkspaceDirectory(input: { + readonly sourceDirectory: string; + readonly destinationDirectory: string; + readonly relativeDirectory: string; + readonly expectedDirectoryIdentity?: FilesystemIdentity; + readonly policy: SessionSnapshotWorkspacePolicy; + readonly confirmation: SessionSnapshotWorkspaceConfirmationResolver; + readonly cancellation: SessionSnapshotCancellation; + readonly limits: SessionBundleLimits; + readonly budget: WorkspaceCopyBudget; +}): Promise { + assertSnapshotActive(input.cancellation, 'workspace'); + const directoryBefore = await readDirectoryFingerprint(input.sourceDirectory); + if ( + input.expectedDirectoryIdentity !== undefined && + !sameFilesystemIdentity(directoryBefore, input.expectedDirectoryIdentity) + ) { + throw new SessionSnapshotError('source_changed', 'Workspace root changed before copying', { + details: { phase: 'workspace' }, + }); + } + const children = await readdir(input.sourceDirectory, { + encoding: 'buffer', + withFileTypes: true, + }); + const normalized = children + .map((child) => decodeFilesystemName(child.name)) + .sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + for (const name of normalized) { + assertSnapshotActive(input.cancellation, 'workspace'); + const relativePath = + input.relativeDirectory.length === 0 ? name : `${input.relativeDirectory}/${name}`; + const sourcePath = join(input.sourceDirectory, name); + const destinationPath = join(input.destinationDirectory, name); + const metadata = await lstatBigInt(sourcePath); + const entry = classifyFilesystemEntry(relativePath, metadata); + registerWorkspacePath(input.budget, relativePath); + assertWorkspacePathBudget(relativePath, entry.kind, input.limits); + + const policyDecision = input.policy.classify(entry); + if (policyDecision.kind === 'reject') { + throw new SessionSnapshotError( + 'policy_rejected', + 'Workspace snapshot policy rejected an entry', + { details: { phase: 'workspace', policyCategory: policyDecision.category } }, + ); + } + if (policyDecision.kind === 'exclude') { + recordExclusion(input.budget, policyDecision.category); + continue; + } + if (policyDecision.kind === 'confirm') { + const resolution = await input.confirmation.resolve(entry); + assertSnapshotActive(input.cancellation, 'workspace'); + if (resolution.kind === 'exclude') { + recordExclusion(input.budget, resolution.category); + continue; + } + } + + if (entry.kind === 'directory') { + await mkdir(destinationPath, { mode: 0o700 }); + recordIncludedEntry(input.budget, input.limits); + await copyWorkspaceDirectory({ + ...input, + sourceDirectory: sourcePath, + destinationDirectory: destinationPath, + relativeDirectory: relativePath, + expectedDirectoryIdentity: undefined, + }); + continue; + } + + const before = fileFingerprint(metadata); + const bytes = safeSize(metadata.size); + assertWorkspaceFileQuota(bytes, input.budget, input.limits); + recordIncludedEntry(input.budget, input.limits); + await copyWorkspaceFile({ + sourcePath, + destinationPath, + expected: before, + expectedSize: bytes, + sourceMetadata: metadata, + cancellation: input.cancellation, + }); + const after = await lstatBigInt(sourcePath); + const destination = await lstatBigInt(destinationPath); + if ( + !after.isFile() || + after.nlink !== 1n || + !sameFileFingerprint(before, fileFingerprint(after)) || + !destination.isFile() || + destination.isSymbolicLink() || + destination.nlink !== 1n || + destination.size !== metadata.size + ) { + throw new SessionSnapshotError('source_changed', 'Workspace changed while being copied', { + details: { phase: 'workspace' }, + }); + } + input.budget.payloadBytes += bytes; + } + const directoryAfter = await readDirectoryFingerprint(input.sourceDirectory); + if (!sameDirectoryFingerprint(directoryBefore, directoryAfter)) { + throw new SessionSnapshotError('source_changed', 'Workspace changed while being copied', { + details: { phase: 'workspace' }, + }); + } +} + +function classifyFilesystemEntry( + relativePath: string, + metadata: BigIntStats, +): SessionSnapshotWorkspaceEntry { + if (metadata.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', 'Workspace contains a symbolic link', { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } + if (metadata.isDirectory()) return { relativePath, kind: 'directory' }; + if (metadata.isFile()) { + if (metadata.nlink !== 1n) { + throw new SessionSnapshotError('unsafe_source', 'Workspace contains a hard-linked file', { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } + return { relativePath, kind: 'file' }; + } + throw new SessionSnapshotError('unsafe_source', 'Workspace contains an unsupported entry', { + details: { phase: 'workspace', policyCategory: 'unsupported_entry' }, + }); +} + +function assertWorkspacePathBudget( + relativePath: string, + kind: SessionSnapshotWorkspaceEntry['kind'], + limits: SessionBundleLimits, +): void { + const archivePath = `workspace/${relativePath}${kind === 'directory' ? '/' : ''}`; + if (!isSessionBundleUstarPathV1(archivePath)) { + throw new SessionSnapshotError( + 'unsafe_source', + 'Workspace path is not portable in Session Bundles', + { + details: { + phase: 'workspace', + policyCategory: 'unsupported_portable_path', + observed: 1, + }, + }, + ); + } + if ( + Buffer.byteLength(archivePath, 'utf8') > limits.maxPathBytes || + archivePath.replace(/\/$/u, '').split('/').length > limits.maxPathDepth + ) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace path exceeds Bundle quota', { + details: { phase: 'workspace' }, + }); + } +} + +function assertWorkspaceFileQuota( + bytes: number, + budget: WorkspaceCopyBudget, + limits: SessionBundleLimits, +): void { + if (bytes > limits.maxFileBytes || safeAdd(budget.payloadBytes, bytes) > limits.maxPayloadBytes) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace payload exceeds Bundle quota', { + details: { phase: 'workspace' }, + }); + } +} + +function recordIncludedEntry(budget: WorkspaceCopyBudget, limits: SessionBundleLimits): void { + if (safeAdd(budget.includedEntries, 1) > limits.maxEntryCount) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace entry count exceeds Bundle quota', { + details: { phase: 'workspace' }, + }); + } + budget.includedEntries += 1; +} + +function recordExclusion( + budget: WorkspaceCopyBudget, + category: SessionSnapshotWorkspaceExclusionCategory, +): void { + budget.excludedEntries += 1; + budget.exclusions[category] += 1; +} + +function emptyExclusionCounts(): Record { + return { + dependency_tree: 0, + source_control: 0, + cache: 0, + log: 0, + runtime_scratch: 0, + confirmed_secret_path: 0, + }; +} + +function registerWorkspacePath(budget: WorkspaceCopyBudget, path: string): void { + const caseFolded = path.toLocaleLowerCase('en-US'); + const previous = budget.seenCaseFoldedPaths.get(caseFolded); + if (previous !== undefined && previous !== path) { + throw new SessionSnapshotError('unsafe_source', 'Workspace contains case-conflicting paths', { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } + budget.seenCaseFoldedPaths.set(caseFolded, path); +} + +async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<{ + readonly path: string; + readonly identity: FilesystemIdentity; +}> { + let root; + try { + root = await lstatBigInt(workspaceRoot); + } catch (error) { + throw asSnapshotError('unsafe_source', 'Workspace root is unavailable', 'workspace', error); + } + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', 'Workspace root is unsafe', { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } + try { + const canonical = await realpath(workspaceRoot); + const configuredAfter = await lstatBigInt(workspaceRoot); + const canonicalMetadata = await lstatBigInt(canonical); + if ( + !configuredAfter.isDirectory() || + configuredAfter.isSymbolicLink() || + !canonicalMetadata.isDirectory() || + canonicalMetadata.isSymbolicLink() || + !sameFilesystemIdentity(root, configuredAfter) || + !sameFilesystemIdentity(root, canonicalMetadata) + ) { + throw new SessionSnapshotError('source_changed', 'Workspace root changed while resolving', { + details: { phase: 'workspace' }, + }); + } + return { + path: canonical, + identity: filesystemIdentity(root), + }; + } catch (error) { + if (error instanceof SessionSnapshotError) throw error; + throw asSnapshotError('unsafe_source', 'Workspace root is unavailable', 'workspace', error); + } +} + +async function assertMissing(path: string, message: string): Promise { + try { + await lstat(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw error; + } + throw new SessionSnapshotError('unsafe_source', message, { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); +} + +type FileFingerprint = { + readonly dev: bigint; + readonly ino: bigint; + readonly size: bigint; + readonly mtimeNs: bigint; + readonly ctimeNs: bigint; +}; + +type DirectoryFingerprint = Pick; +type FilesystemIdentity = Pick; + +async function copyWorkspaceFile(input: { + readonly sourcePath: string; + readonly destinationPath: string; + readonly expected: FileFingerprint; + readonly expectedSize: number; + readonly sourceMetadata: BigIntStats; + readonly cancellation: SessionSnapshotCancellation; +}): Promise { + let source: Awaited> | undefined; + let destination: Awaited> | undefined; + try { + source = await open(input.sourcePath, fsConstants.O_RDONLY | NO_FOLLOW_OPEN_FLAG); + const openedSource = (await source.stat({ bigint: true })) as BigIntStats; + if ( + !openedSource.isFile() || + openedSource.nlink !== 1n || + !sameFileFingerprint(input.expected, fileFingerprint(openedSource)) + ) { + throw sourceChangedDuringWorkspaceCopy(); + } + destination = await open( + input.destinationPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW_OPEN_FLAG, + destinationFileMode(input.sourceMetadata), + ); + const buffer = Buffer.allocUnsafe(Math.min(WORKSPACE_COPY_CHUNK_BYTES, input.expectedSize)); + let copied = 0; + while (copied < input.expectedSize) { + assertSnapshotActive(input.cancellation, 'workspace'); + const { bytesRead } = await source.read( + buffer, + 0, + Math.min(buffer.byteLength, input.expectedSize - copied), + copied, + ); + if (bytesRead === 0) throw sourceChangedDuringWorkspaceCopy(); + let written = 0; + while (written < bytesRead) { + assertSnapshotActive(input.cancellation, 'workspace'); + const result = await destination.write( + buffer, + written, + bytesRead - written, + copied + written, + ); + if (result.bytesWritten === 0) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot copy made no progress', { + details: { phase: 'workspace' }, + }); + } + written += result.bytesWritten; + } + copied += bytesRead; + } + assertSnapshotActive(input.cancellation, 'workspace'); + const sourceAfter = (await source.stat({ bigint: true })) as BigIntStats; + const destinationAfter = (await destination.stat({ bigint: true })) as BigIntStats; + if ( + !sourceAfter.isFile() || + sourceAfter.nlink !== 1n || + !sameFileFingerprint(input.expected, fileFingerprint(sourceAfter)) || + !destinationAfter.isFile() || + destinationAfter.isSymbolicLink() || + destinationAfter.nlink !== 1n || + destinationAfter.size !== BigInt(input.expectedSize) + ) { + throw sourceChangedDuringWorkspaceCopy(); + } + } finally { + try { + await destination?.close(); + } finally { + await source?.close(); + } + } +} + +function destinationFileMode(metadata: BigIntStats): number { + return (metadata.mode & 0o111n) === 0n ? 0o600 : 0o700; +} + +function sourceChangedDuringWorkspaceCopy(): SessionSnapshotError { + return new SessionSnapshotError('source_changed', 'Workspace changed while being copied', { + details: { phase: 'workspace' }, + }); +} + +function fileFingerprint(metadata: BigIntStats): FileFingerprint { + return { + dev: metadata.dev, + ino: metadata.ino, + size: metadata.size, + mtimeNs: metadata.mtimeNs, + ctimeNs: metadata.ctimeNs, + }; +} + +function filesystemIdentity(metadata: Pick): FilesystemIdentity { + return { dev: metadata.dev, ino: metadata.ino }; +} + +function sameFilesystemIdentity( + left: Pick, + right: Pick, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +async function readDirectoryFingerprint(path: string): Promise { + const metadata = await lstatBigInt(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new SessionSnapshotError( + 'source_changed', + 'Workspace directory changed while being copied', + { + details: { phase: 'workspace' }, + }, + ); + } + return fileFingerprint(metadata); +} + +function sameFileFingerprint(left: FileFingerprint, right: FileFingerprint): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function sameDirectoryFingerprint( + left: DirectoryFingerprint, + right: DirectoryFingerprint, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +async function lstatBigInt(path: string): Promise { + return (await lstat(path, { bigint: true })) as BigIntStats; +} + +function decodeFilesystemName(value: string | Buffer): string { + if (typeof value === 'string') { + if (value.length === 0 || value.includes('\0') || value.includes('/') || value.includes('\\')) { + throw new SessionSnapshotError('unsafe_source', 'Workspace entry name is unsafe', { + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } + return value; + } + try { + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(value); + return decodeFilesystemName(decoded); + } catch (error) { + if (error instanceof SessionSnapshotError) throw error; + throw new SessionSnapshotError('unsafe_source', 'Workspace entry name is not valid UTF-8', { + cause: error, + details: { phase: 'workspace', policyCategory: 'unsafe_path' }, + }); + } +} + +function safeSize(value: bigint): number { + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace file size is unsupported', { + details: { phase: 'workspace' }, + }); + } + return Number(value); +} + +function safeAdd(left: number, right: number): number { + if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right) || left < 0 || right < 0) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace quota accounting is invalid', { + details: { phase: 'workspace' }, + }); + } + const result = left + right; + if (!Number.isSafeInteger(result)) { + throw new SessionSnapshotError('quota_exceeded', 'Workspace quota accounting overflowed', { + details: { phase: 'workspace' }, + }); + } + return result; +} + +function assertSnapshotActive( + cancellation: SessionSnapshotCancellation, + phase: 'state' | 'workspace', +): void { + if (!cancellation.signal.aborted) return; + throw new SessionSnapshotError( + 'snapshot_cancelled', + 'Session snapshot preparation was cancelled', + { + details: { phase }, + }, + ); +} + +function requireAbsolutePath(value: string, label: string): string { + if (typeof value !== 'string' || !isAbsolute(value)) { + throw new TypeError(`Session snapshot ${label} must be an absolute path`); + } + return resolve(value); +} + +function requireProductionSessionSnapshotBinding( + value: ProductionSessionSnapshotBinding, +): Readonly { + if (!value || typeof value !== 'object') { + throw new TypeError('Session snapshot production Session binding is required'); + } + if (!isSafeSessionId(value.makaSessionId)) { + throw new TypeError('Session snapshot production Maka Session identity is invalid'); + } + if (typeof value.cloudSessionId !== 'string' || value.cloudSessionId.length === 0) { + throw new TypeError('Session snapshot production Cloud Session identity is required'); + } + return Object.freeze({ + makaSessionId: value.makaSessionId, + cloudSessionId: value.cloudSessionId, + }); +} + +async function assertProductionRootsSeparate(input: { + readonly stateRoot: string; + readonly configRoot: string; + readonly workspaceRoot: string; + readonly stagingParent: string; + readonly cleanupStateRoot: string; +}): Promise { + const roots = await Promise.all( + Object.entries(input).map(async ([label, path]) => ({ + label, + path: await canonicalDirectoryRoot(path, label), + })), + ); + for (let leftIndex = 0; leftIndex < roots.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < roots.length; rightIndex += 1) { + const left = roots[leftIndex]!; + const right = roots[rightIndex]!; + if (rootsOverlap(left.path, right.path)) { + throw new SessionSnapshotError( + 'unsafe_source', + 'Session snapshot production roots overlap', + { details: { phase: 'staging' } }, + ); + } + } + } +} + +async function canonicalDirectoryRoot(path: string, label: string): Promise { + const absolute = requireAbsolutePath(path, label); + try { + const canonical = await realpath(absolute); + const metadata = await lstatBigInt(canonical); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('root is not a directory'); + } + return canonical; + } catch (error) { + throw new SessionSnapshotError('unsafe_source', 'Session snapshot root is unsafe', { + cause: error, + details: { phase: 'staging' }, + }); + } +} + +function rootsOverlap(left: string, right: string): boolean { + const leftToRight = relative(left, right); + const rightToLeft = relative(right, left); + return ( + leftToRight === '' || + rightToLeft === '' || + (!leftToRight.startsWith('..') && !isAbsolute(leftToRight)) || + (!rightToLeft.startsWith('..') && !isAbsolute(rightToLeft)) + ); +} + +function asSnapshotError( + code: 'io_failure' | 'unsafe_source', + message: string, + phase: 'state' | 'workspace', + cause: unknown, +): SessionSnapshotError { + if (cause instanceof SessionSnapshotError) return cause; + return new SessionSnapshotError(code, message, { cause, details: { phase } }); +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a15a6285fc16a1e88b94f9efe840a227c3505b2e811992190ff37c689c538723.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a15a6285fc16a1e88b94f9efe840a227c3505b2e811992190ff37c689c538723.source new file mode 100644 index 0000000000..aa55029a42 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a15a6285fc16a1e88b94f9efe840a227c3505b2e811992190ff37c689c538723.source @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { openInteractiveArtifactStoreForWrite } from './artifact-stores.js'; +import type { ContextOffloadLimits } from '@maka/core/context-offload'; +import { openInteractiveContextOffloadStoreForWrite } from './context-offload-store.js'; +import { openInteractiveDailyReviewAuthorityForWrite } from './daily-review-authority.js'; +import { openInteractiveDeepResearchStoreForWrite } from './deep-research-authority.js'; +import { openInteractiveExecutionStoresForWrite } from './execution-stores.js'; +import { openInteractiveGoalAuthorityForWrite } from './goal-authority.js'; +import { openInteractiveLongTermMemoryStoreForWrite } from './long-term-memory-store.js'; +import { openInteractiveMemoryBundleStoreForWrite } from './memory-bundle-store.js'; +import { openInteractivePlanStoreForWrite } from './plan-authority.js'; +import { openInteractiveProjectCatalogForWrite } from './project-catalog-authority.js'; +import { assertStorageRootLease, type StorageRootLease } from './root-authority.js'; +import { openInteractiveRuntimePolicyStoresForWrite } from './runtime-policy-stores.js'; +import { openInteractiveScheduledTaskStoreForWrite } from './scheduled-task-store.js'; +import { openInteractiveSessionTodoStoreForWrite } from './session-todo-authority.js'; +import { openInteractiveShellRunStoreForWrite } from './shell-run-authority.js'; +import { openInteractiveUsageStoresForWrite } from './usage-stores.js'; + +export interface OpenStorageWriterCompositionOptions { + /** Runs after the runtime-policy stores open and before the remaining writers open. */ + afterRuntimePolicyOpened?: ( + stores: Awaited>, + ) => void | Promise; + /** Opens the context-offload authority only when a reader or writer is composed. */ + contextOffloadLimits?: ContextOffloadLimits; +} + +export interface StorageWriterComposition { + readonly execution: Awaited>; + readonly projectCatalog: Awaited>; + readonly runtimePolicy: Awaited>; + readonly scheduledTasks: Awaited>; + readonly plan: Awaited>; + readonly deepResearch: Awaited>; + readonly dailyReview: Awaited>; + readonly goal: Awaited>; + readonly memoryBundle: Awaited>; + readonly longTermMemory: Awaited>; + readonly sessionTodo: Awaited>; + readonly artifacts: Awaited>; + readonly contextOffload?: Awaited>; + /** Present when the optional context-offload capability could not be opened. */ + readonly contextOffloadUnavailable?: { readonly cause: unknown }; + readonly usage: Awaited>; + readonly shellRuns: Awaited>; + close(): Promise; +} + +const activeCompositions = new WeakSet(); + +export async function openStorageWriterComposition( + lease: StorageRootLease<'interactive', 'write'>, + options: OpenStorageWriterCompositionOptions = {}, +): Promise { + if (activeCompositions.has(lease)) { + throw new Error('Storage writer composition is already active for this lease'); + } + await assertStorageRootLease(lease, 'interactive', 'write'); + if (activeCompositions.has(lease)) { + throw new Error('Storage writer composition is already active for this lease'); + } + activeCompositions.add(lease); + return createComposition(lease, options); +} + +async function createComposition( + lease: StorageRootLease<'interactive', 'write'>, + options: OpenStorageWriterCompositionOptions, +): Promise { + const closes: Array<() => void | Promise> = []; + let closeTask: Promise | undefined; + const close = () => + (closeTask ??= closeInReverse(closes).then(() => { + // A failed close may leave a writer handle open, so keep this lease unavailable. + activeCompositions.delete(lease); + })); + const failOpen = async (error: unknown): Promise => { + try { + await close(); + } catch (closeError) { + throw new AggregateError([error, closeError], 'Unable to open storage composition'); + } + throw error; + }; + const openWriter = async ( + operation: () => Promise, + closeWriter?: (writer: T) => void | Promise, + ): Promise => { + try { + const writer = await operation(); + if (closeWriter) closes.push(() => closeWriter(writer)); + return writer; + } catch (error) { + return failOpen(error); + } + }; + + const execution = await openWriter( + () => openInteractiveExecutionStoresForWrite(lease), + (writer) => writer.sessionStore.close?.(), + ); + try { + await execution.sessionStore.ready(); + } catch (error) { + await failOpen(error); + } + const projectCatalog = await openWriter( + () => openInteractiveProjectCatalogForWrite(lease), + closeWriter, + ); + const runtimePolicy = await openWriter(() => openInteractiveRuntimePolicyStoresForWrite(lease)); + try { + await options.afterRuntimePolicyOpened?.(runtimePolicy); + } catch (error) { + await failOpen(error); + } + const scheduledTasks = await openWriter( + () => openInteractiveScheduledTaskStoreForWrite(lease), + closeWriter, + ); + const plan = await openWriter(() => openInteractivePlanStoreForWrite(lease), closeWriter); + const deepResearch = await openWriter( + () => openInteractiveDeepResearchStoreForWrite(lease), + closeWriter, + ); + const dailyReview = await openWriter( + () => openInteractiveDailyReviewAuthorityForWrite(lease), + closeWriter, + ); + const goal = await openWriter(() => openInteractiveGoalAuthorityForWrite(lease), closeWriter); + const memoryBundle = await openWriter(() => openInteractiveMemoryBundleStoreForWrite(lease)); + const longTermMemory = await openWriter( + () => openInteractiveLongTermMemoryStoreForWrite(lease), + closeWriter, + ); + const sessionTodo = await openWriter( + () => openInteractiveSessionTodoStoreForWrite(lease), + closeWriter, + ); + const artifacts = await openWriter( + () => openInteractiveArtifactStoreForWrite(lease), + closeWriter, + ); + const contextOffloadLimits = options.contextOffloadLimits; + let contextOffload: + | Awaited> + | undefined; + let contextOffloadUnavailable: { readonly cause: unknown } | undefined; + if (contextOffloadLimits) { + try { + const openedContextOffload = await openInteractiveContextOffloadStoreForWrite(lease, { + limits: contextOffloadLimits, + }); + contextOffload = openedContextOffload; + closes.push(() => closeWriter(openedContextOffload)); + } catch (cause) { + contextOffloadUnavailable = Object.freeze({ cause }); + } + } + const usage = await openWriter(() => openInteractiveUsageStoresForWrite(lease), closeWriter); + const shellRuns = await openWriter( + () => openInteractiveShellRunStoreForWrite(lease), + closeWriter, + ); + return Object.freeze({ + execution, + projectCatalog, + runtimePolicy, + scheduledTasks, + plan, + deepResearch, + dailyReview, + goal, + memoryBundle, + longTermMemory, + sessionTodo, + artifacts, + ...(contextOffload ? { contextOffload } : {}), + ...(contextOffloadUnavailable ? { contextOffloadUnavailable } : {}), + usage, + shellRuns, + close, + }); +} + +function closeWriter(writer: { close(): void | Promise }): void | Promise { + return writer.close(); +} + +async function closeInReverse(closes: Array<() => void | Promise>): Promise { + const errors: unknown[] = []; + for (const close of closes.reverse()) { + try { + await close(); + } catch (error) { + errors.push(error); + } + } + closes.length = 0; + if (errors.length) throw new AggregateError(errors, 'Unable to close storage composition'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a311b196030a210ac27edc0383a06164abebed68c5fe86ba75d7ccd1f83439e6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a311b196030a210ac27edc0383a06164abebed68c5fe86ba75d7ccd1f83439e6.source new file mode 100644 index 0000000000..2cb66c03e3 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a311b196030a210ac27edc0383a06164abebed68c5fe86ba75d7ccd1f83439e6.source @@ -0,0 +1,709 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { + PricingConfig, + UsageBucket, + UsageGroupBy, + UsageLogRow, + UsageQuery, + UsageSummaryV2, +} from '@maka/core/usage-stats/types'; +import { clampCacheReadTokens } from '@maka/core/model-call-usage-projection'; +import { + canonicalPricingConfigsEqual, + comparePricingModelKeys, + normalizePricingConfig, + normalizePricingModelKey, +} from '@maka/core/usage-stats/pricing'; +import { usageBucketKey } from '@maka/core/usage-stats/bucket-key'; +import { + PricingRevisionConflictError, + PricingStoreClosedError, + PricingStoreNotLoadedError, + PricingStorePublicationError, + PricingValidationError, + type CreatePricingStoreOptions, + type PricingMutationResult, + type PricingSnapshot, + type PricingStore, +} from './pricing-store.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { + decodePersistedLlmCallRecord, + decodePersistedToolInvocationRecord, + type PersistedLlmCallRecord, + type PersistedToolInvocationRecord, +} from './telemetry-file-schema.js'; +import { + resolveRange, + TelemetryQueryValidationError, + TelemetryRepoClosedError, + TelemetryRepoNotLoadedError, + TelemetryRepoPublicationError, + type CreateTelemetryRepoOptions, + type TelemetryRepo, + type ToolUsageQuery, +} from './telemetry-repo.js'; + +export interface CreateSqliteUsageStoreOptions {} + +export function createSqliteTelemetryRepo( + workspaceRoot: string, + options: CreateTelemetryRepoOptions & CreateSqliteUsageStoreOptions = {}, +): TelemetryRepo { + return new SqliteTelemetryRepo( + workspaceRoot, + options.createIfMissing ?? true, + options.managePricing ?? true, + ); +} + +export function createSqlitePricingStore( + workspaceRoot: string, + options: CreatePricingStoreOptions & CreateSqliteUsageStoreOptions = {}, +): PricingStore { + return new SqlitePricingStore(workspaceRoot, options.createIfMissing ?? true); +} + +class SqliteTelemetryRepo implements TelemetryRepo { + readonly #root: string; + readonly #lease: OperationalStateDatabaseLease; + readonly #pricingStore: PricingStore | undefined; + #loaded = false; + #state: 'open' | 'draining' | 'closed' = 'open'; + #queue: Promise = Promise.resolve(); + #loadPromise: Promise | undefined; + #closePromise: Promise | undefined; + + constructor(workspaceRoot: string, createIfMissing: boolean, managePricing: boolean) { + this.#root = resolve(workspaceRoot); + this.#lease = acquireOperationalStateDatabase(this.#root); + this.#pricingStore = managePricing + ? createSqlitePricingStore(this.#root, { createIfMissing }) + : undefined; + } + + load(): Promise { + if (this.#loaded) return Promise.resolve(); + this.assertOpen(); + if (this.#loadPromise) return this.#loadPromise; + const operation = (async () => { + if (this.#pricingStore) await this.#pricingStore.load(); + this.#loaded = true; + })(); + this.#loadPromise = operation; + void operation.catch(() => { + if (this.#state === 'open' && this.#loadPromise === operation) { + this.#loadPromise = undefined; + } + }); + return operation; + } + + insertLlmCall(record: PersistedLlmCallRecord): Promise { + let admitted: PersistedLlmCallRecord; + try { + admitted = decodePersistedLlmCallRecord(record); + } catch (error) { + return Promise.reject(error); + } + return this.enqueueMutation(() => { + this.#lease.database + .prepare(` + INSERT INTO usage_llm_calls(storage_key, id, ts, record_json, session_id) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(storage_key) DO UPDATE SET + id = excluded.id, + ts = excluded.ts, + record_json = excluded.record_json, + session_id = excluded.session_id + `) + .run( + usageIdentityKey(admitted.id), + admitted.id, + admitted.ts, + JSON.stringify(admitted), + admitted.sessionId ?? null, + ); + }); + } + + insertToolInvocation(record: PersistedToolInvocationRecord): Promise { + let admitted: PersistedToolInvocationRecord; + try { + admitted = decodePersistedToolInvocationRecord(record); + } catch (error) { + return Promise.reject(error); + } + return this.enqueueMutation(() => { + this.#lease.database + .prepare(` + INSERT INTO usage_tool_invocations(storage_key, id, ts, record_json) + VALUES (?, ?, ?, ?) + ON CONFLICT(storage_key) DO UPDATE SET + id = excluded.id, + ts = excluded.ts, + record_json = excluded.record_json + `) + .run(usageIdentityKey(admitted.id), admitted.id, admitted.ts, JSON.stringify(admitted)); + }); + } + + summary(query: UsageQuery): UsageSummaryV2 { + this.assertReady(); + const { from, to } = resolveRange(query.range); + const rows = this.filteredUsageRows(query, from, to); + return detached({ + range: { from, to }, + totalRequests: rows.length, + totalCostUsd: sum(rows.map((row) => row.costUsd)), + totalDurationMs: sum(rows.map((row) => row.latencyMs)), + totalTokens: { + input: sum(rows.map((row) => row.inputTokens)), + output: sum(rows.map((row) => row.outputTokens)), + cacheMiss: sum(rows.map((row) => row.cacheMissInputTokens)), + cacheRead: sum( + rows.map((row) => clampCacheReadTokens(row.inputTokens, row.cacheHitInputTokens)), + ), + cacheWrite: sum(rows.map((row) => row.cacheWriteInputTokens)), + reasoning: sum(rows.map((row) => row.reasoningTokens)), + total: sum(rows.map((row) => row.totalTokens)), + }, + cacheHitRequests: rows.filter( + (row) => clampCacheReadTokens(row.inputTokens, row.cacheHitInputTokens) > 0, + ).length, + cacheCreateRequests: rows.filter((row) => row.cacheWriteInputTokens > 0).length, + errorRequests: rows.filter((row) => row.status === 'error').length, + }); + } + + toolSummary(query: UsageQuery): { requests: number; durationMs: number } { + this.assertReady(); + const { from, to } = resolveRange(query.range); + const rows = this.filteredToolRows(query, from, to); + return detached({ + requests: rows.length, + durationMs: sum(rows.map((row) => row.durationMs)), + }); + } + + buckets(query: UsageQuery, groupBy: UsageGroupBy): UsageBucket[] { + this.assertReady(); + const { from, to } = resolveRange(query.range); + if (groupBy === 'tool') { + return detached(toolBuckets(this.filteredToolRows(query, from, to))); + } + const groups = new Map(); + for (const row of this.filteredUsageRows(query, from, to)) { + const key = usageBucketKey(row, groupBy); + const group = groups.get(key); + if (group) group.push(row); + else groups.set(key, [row]); + } + return detached( + [...groups.entries()] + .map(([key, rows]) => usageBucket(key, rows)) + .sort((left, right) => right.requests - left.requests), + ); + } + + logs(query: UsageQuery, offset = 0, limit = 100): { rows: UsageLogRow[]; total: number } { + this.assertReady(); + if (query.toolName !== undefined) { + throw new TelemetryQueryValidationError('toolName is not applicable to LLM logs'); + } + const { from, to } = resolveRange(query.range); + const rows = this.filteredUsageRows(query, from, to).sort((left, right) => right.ts - left.ts); + return detached({ + rows: rows.slice(offset, offset + limit).map(toUsageLogRow), + total: rows.length, + }); + } + + toolLogs( + query: ToolUsageQuery, + offset = 0, + limit = 100, + ): { rows: PersistedToolInvocationRecord[]; total: number } { + this.assertReady(); + assertToolUsageQuery(query); + const { from, to } = resolveRange(query.range); + const rows = this.filteredToolRows(query, from, to).sort((left, right) => right.ts - left.ts); + return detached({ rows: rows.slice(offset, offset + limit), total: rows.length }); + } + + latestLlmRuntimeProbe(connectionSlug: string, modelId?: string): UsageLogRow | undefined { + return this.logs({ range: 'all', connectionSlug, ...(modelId ? { modelId } : {}) }, 0, 1) + .rows[0]; + } + + listPricingOverrides(): PricingConfig[] { + return this.requireManagedPricing() + .snapshot() + .overrides.map((item) => ({ ...item })); + } + + async upsertPricing(pricing: PricingConfig): Promise { + const store = this.requireManagedPricing(); + await store.upsert(store.snapshot().revision, pricing); + } + + async deletePricing(modelKey: string): Promise { + const store = this.requireManagedPricing(); + await store.delete(store.snapshot().revision, modelKey); + } + + async flush(): Promise { + this.assertLoaded(); + await this.#queue; + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.#state = 'draining'; + this.#closePromise = Promise.allSettled([ + this.#loadPromise ?? Promise.resolve(), + this.#queue, + this.#pricingStore?.close() ?? Promise.resolve(), + ]) + .then((results) => { + const failed = results.find((result) => result.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; + }) + .finally(() => { + this.#state = 'closed'; + this.#lease.close(); + }); + return this.#closePromise; + } + + private filteredUsageRows(query: UsageQuery, from: number, to: number) { + return this.readLlmRows(from, to, query.sessionId).filter((row) => { + if (query.sessionId && row.sessionId !== query.sessionId) return false; + if (query.connectionSlug && row.connectionSlug !== query.connectionSlug) return false; + if (query.providerId && row.providerId !== query.providerId) return false; + if (query.modelId && row.modelId !== query.modelId) return false; + if (query.status && query.status !== 'all' && row.status !== query.status) return false; + return true; + }); + } + + private filteredToolRows(query: UsageQuery | ToolUsageQuery, from: number, to: number) { + // One filter policy for every tool read — summary, buckets, and logs — so + // two views of one query cannot disagree. Fields the narrower query types + // do not carry simply never match a row out of range. + return this.readToolRows(from, to).filter((row) => { + if (query.toolName && row.toolName !== query.toolName) return false; + if (query.status && query.status !== 'all' && row.status !== query.status) return false; + if ('sessionId' in query && query.sessionId && row.sessionId !== query.sessionId) { + return false; + } + if ('providerId' in query && query.providerId && row.providerId !== query.providerId) { + return false; + } + if ('modelId' in query && query.modelId && row.modelId !== query.modelId) return false; + return true; + }); + } + + private readLlmRows(from: number, to: number, sessionId?: string): PersistedLlmCallRecord[] { + return ( + this.#lease.database + .prepare( + sessionId + ? `SELECT record_json FROM usage_llm_calls + WHERE session_id = ? AND ts >= ? AND ts <= ?` + : `SELECT record_json FROM usage_llm_calls + WHERE ts >= ? AND ts <= ?`, + ) + .all(...(sessionId ? [sessionId, from, to] : [from, to])) as Array<{ + record_json: string; + }> + ).map((row) => decodePersistedLlmCallRecord(JSON.parse(row.record_json))); + } + + // The ts range goes into the query, not the decode: the invocation ledger + // has no retention, and a full-table decode per summary read would only grow + // with it. The `(ts, id)` index answers the range; the remaining filters run + // over the few decoded rows it returns. + private readToolRows(from: number, to: number): PersistedToolInvocationRecord[] { + return ( + this.#lease.database + .prepare('SELECT record_json FROM usage_tool_invocations WHERE ts >= ? AND ts <= ?') + .all(from, to) as Array<{ record_json: string }> + ).map((row) => decodePersistedToolInvocationRecord(JSON.parse(row.record_json))); + } + + private enqueueMutation(operation: () => void): Promise { + this.assertReady(); + const accepted = this.#queue.then(() => { + try { + this.#lease.transaction('write', operation); + } catch (cause) { + throw new TelemetryRepoPublicationError(false, { cause }); + } + }); + this.#queue = accepted.catch(() => undefined); + return accepted; + } + + private requireManagedPricing(): PricingStore { + this.assertReady(); + if (!this.#pricingStore) { + throw new Error('Telemetry repository does not own the managed pricing store'); + } + return this.#pricingStore; + } + + private assertLoaded(): void { + if (!this.#loaded) throw new TelemetryRepoNotLoadedError(); + } + + private assertOpen(): void { + if (this.#state !== 'open') throw new TelemetryRepoClosedError(); + } + + private assertReady(): void { + this.assertOpen(); + this.assertLoaded(); + } +} + +class SqlitePricingStore implements PricingStore { + readonly #root: string; + readonly #lease: OperationalStateDatabaseLease; + #loaded = false; + #state: 'open' | 'draining' | 'closed' = 'open'; + #queue: Promise = Promise.resolve(); + #loadPromise: Promise | undefined; + #closePromise: Promise | undefined; + + constructor(workspaceRoot: string, createIfMissing: boolean) { + this.#root = resolve(workspaceRoot); + this.#lease = acquireOperationalStateDatabase(this.#root); + } + + load(): Promise { + if (this.#loaded) return Promise.resolve(); + this.assertOpen(); + if (this.#loadPromise) return this.#loadPromise; + const operation = Promise.resolve().then(() => { + this.#loaded = true; + }); + this.#loadPromise = operation; + void operation.catch(() => { + if (this.#state === 'open' && this.#loadPromise === operation) { + this.#loadPromise = undefined; + } + }); + return operation; + } + + snapshot(): PricingSnapshot { + this.assertReady(); + return readPricingSnapshot(this.#lease); + } + + upsert(expectedRevision: number, pricing: PricingConfig): Promise { + assertRevision(expectedRevision, 'expectedRevision'); + const normalized = normalizePricingConfig(pricing); + if (!normalized.ok) throw new PricingValidationError(normalized.error); + return this.enqueueMutation(expectedRevision, (current) => { + const existing = current.find((item) => item.modelKey === normalized.value.modelKey); + if (existing && canonicalPricingConfigsEqual(existing, normalized.value)) return current; + return [ + ...current.filter((item) => item.modelKey !== normalized.value.modelKey), + normalized.value, + ].sort((left, right) => comparePricingModelKeys(left.modelKey, right.modelKey)); + }); + } + + delete(expectedRevision: number, modelKey: string): Promise { + assertRevision(expectedRevision, 'expectedRevision'); + const normalized = normalizePricingModelKey(modelKey); + if (!normalized.ok) throw new PricingValidationError(normalized.error); + return this.enqueueMutation(expectedRevision, (current) => + current.some((item) => item.modelKey === normalized.value) + ? current.filter((item) => item.modelKey !== normalized.value) + : current, + ); + } + + async flush(): Promise { + this.assertLoaded(); + await this.#queue; + } + + beginDrain(): Promise { + if (this.#state === 'open') this.#state = 'draining'; + return this.flush(); + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.#state = 'draining'; + this.#closePromise = Promise.allSettled([this.#loadPromise ?? Promise.resolve(), this.#queue]) + .then((results) => { + const failed = results.find((result) => result.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; + }) + .finally(() => { + this.#state = 'closed'; + this.#lease.close(); + }); + return this.#closePromise; + } + + private enqueueMutation( + expectedRevision: number, + mutate: (current: readonly Readonly[]) => readonly Readonly[], + ): Promise { + this.assertReady(); + const operation = this.#queue.then(() => { + try { + return this.#lease.transaction('write', () => { + const current = readPricingSnapshot(this.#lease); + if (current.revision !== expectedRevision) { + throw new PricingRevisionConflictError(expectedRevision, current.revision); + } + const overrides = mutate(current.overrides); + if (overrides === current.overrides) { + return { committed: false, changed: false, snapshot: current }; + } + if (current.revision === Number.MAX_SAFE_INTEGER) { + throw new PricingValidationError( + 'revision cannot advance beyond Number.MAX_SAFE_INTEGER', + ); + } + const revision = current.revision + 1; + this.#lease.database.prepare('DELETE FROM usage_pricing_overrides').run(); + const insert = this.#lease.database.prepare(` + INSERT INTO usage_pricing_overrides(model_key, record_json) + VALUES (?, ?) + `); + for (const override of overrides) { + insert.run(override.modelKey, JSON.stringify(override)); + } + this.#lease.database + .prepare('UPDATE usage_pricing_authority SET revision = ? WHERE singleton = 1') + .run(revision); + return { + committed: true, + changed: true, + snapshot: freezePricingSnapshot(revision, overrides), + }; + }); + } catch (error) { + if ( + error instanceof PricingRevisionConflictError || + error instanceof PricingValidationError + ) { + throw error; + } + throw new PricingStorePublicationError({ cause: error }); + } + }); + this.#queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private assertLoaded(): void { + if (!this.#loaded) throw new PricingStoreNotLoadedError(); + } + + private assertOpen(): void { + if (this.#state !== 'open') throw new PricingStoreClosedError(); + } + + private assertReady(): void { + this.assertOpen(); + this.assertLoaded(); + } +} + +function readPricingSnapshot(lease: OperationalStateDatabaseLease): PricingSnapshot { + const authority = lease.database + .prepare('SELECT revision FROM usage_pricing_authority WHERE singleton = 1') + .get() as { revision?: unknown } | undefined; + if (!authority || !Number.isSafeInteger(authority.revision)) { + throw new PricingValidationError('SQLite pricing authority is missing or invalid'); + } + const overrides = ( + lease.database + .prepare('SELECT record_json FROM usage_pricing_overrides ORDER BY model_key') + .all() as Array<{ record_json: string }> + ).map((row) => { + const normalized = normalizePricingConfig(JSON.parse(row.record_json)); + if (!normalized.ok) throw new PricingValidationError(normalized.error); + return normalized.value; + }); + return freezePricingSnapshot(authority.revision as number, overrides); +} + +function freezePricingSnapshot( + revision: number, + overrides: readonly Readonly[], +): PricingSnapshot { + return Object.freeze({ + revision, + overrides: Object.freeze(overrides.map((value) => Object.freeze({ ...value }))), + }); +} + +function countRows( + database: OperationalStateDatabaseLease['database'], + table: 'usage_llm_calls' | 'usage_tool_invocations' | 'usage_pricing_overrides', +): number { + const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { + count?: unknown; + }; + if (!Number.isSafeInteger(row.count)) throw new Error(`Invalid row count for ${table}`); + return row.count as number; +} + +function usageIdentityKey(id: string): string { + return createHash('sha256').update(JSON.stringify(id)).digest('hex'); +} + +function toUsageLogRow(row: PersistedLlmCallRecord): UsageLogRow { + return { + id: row.id, + ts: row.ts, + ...(row.callKind ? { callKind: row.callKind } : {}), + ...(row.callId ? { callId: row.callId } : {}), + ...(row.connectionSlug ? { connectionSlug: row.connectionSlug } : {}), + providerId: row.providerId, + modelId: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheMissTokens: row.cacheMissInputTokens, + cacheReadTokens: row.cacheHitInputTokens, + cacheWriteTokens: row.cacheWriteInputTokens, + ...(row.cacheMissInputSource ? { cacheMissInputSource: row.cacheMissInputSource } : {}), + reasoningTokens: row.reasoningTokens, + totalTokens: row.totalTokens, + costUsd: row.costUsd, + latencyMs: row.latencyMs, + status: row.status, + ...(row.errorClass ? { errorClass: row.errorClass } : {}), + ...(row.sessionId ? { sessionId: row.sessionId } : {}), + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.systemPromptHash ? { systemPromptHash: row.systemPromptHash } : {}), + ...(row.prefixHash ? { prefixHash: row.prefixHash } : {}), + ...(row.prefixChangeReason ? { prefixChangeReason: row.prefixChangeReason } : {}), + ...(row.requestShapeHash ? { requestShapeHash: row.requestShapeHash } : {}), + ...(row.requestShapeChangeReason + ? { requestShapeChangeReason: row.requestShapeChangeReason } + : {}), + ...(row.toolSchemaChangeReason ? { toolSchemaChangeReason: row.toolSchemaChangeReason } : {}), + ...(row.toolAvailability ? { toolAvailability: row.toolAvailability } : {}), + ...(row.promptSegments ? { promptSegments: row.promptSegments } : {}), + ...(row.contextBudget ? { contextBudget: row.contextBudget } : {}), + }; +} + +function usageBucket(key: string, rows: readonly PersistedLlmCallRecord[]): UsageBucket { + const errors = rows.filter((row) => row.status === 'error').length; + return { + key, + label: key, + requests: rows.length, + inputTokens: sum(rows.map((row) => row.inputTokens)), + outputTokens: sum(rows.map((row) => row.outputTokens)), + cacheMissTokens: sum(rows.map((row) => row.cacheMissInputTokens)), + cacheReadTokens: sum( + rows.map((row) => clampCacheReadTokens(row.inputTokens, row.cacheHitInputTokens)), + ), + cacheWriteTokens: sum(rows.map((row) => row.cacheWriteInputTokens)), + reasoningTokens: sum(rows.map((row) => row.reasoningTokens)), + totalTokens: sum(rows.map((row) => row.totalTokens)), + costUsd: sum(rows.map((row) => row.costUsd)), + avgLatencyMs: rows.length ? Math.round(sum(rows.map((row) => row.latencyMs)) / rows.length) : 0, + errorRate: rows.length ? errors / rows.length : 0, + }; +} + +function toolBuckets(rows: readonly PersistedToolInvocationRecord[]): UsageBucket[] { + const groups = new Map(); + for (const row of rows) { + const group = groups.get(row.toolName); + if (group) group.push(row); + else groups.set(row.toolName, [row]); + } + return [...groups.entries()] + .map(([key, group]) => { + const errors = group.filter((row) => row.status === 'error').length; + const bytesIn = sum(group.map((row) => row.bytesIn)); + const bytesOut = sum(group.map((row) => row.bytesOut)); + return { + key, + label: key, + requests: group.length, + inputTokens: bytesIn, + outputTokens: bytesOut, + cacheMissTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: bytesIn + bytesOut, + costUsd: 0, + avgLatencyMs: group.length + ? Math.round(sum(group.map((row) => row.durationMs)) / group.length) + : 0, + errorRate: group.length ? errors / group.length : 0, + }; + }) + .sort((left, right) => right.requests - left.requests); +} + +function sum(values: readonly number[]): number { + return values.reduce((total, value) => total + value, 0); +} + +function assertToolUsageQuery(query: ToolUsageQuery): void { + if (Object.keys(query).some((key) => !['range', 'toolName', 'status'].includes(key))) { + throw new TelemetryQueryValidationError('tool logs accept only range, toolName, and status'); + } +} + +function assertRevision(value: unknown, label: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new PricingValidationError(`${label} must be a nonnegative safe integer`); + } +} + +function detached(value: T): T { + return deepFreeze(structuredClone(value)); +} + +function deepFreeze(value: T): T { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const nested of Object.values(value)) deepFreeze(nested); + return value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4317e02deb291f32358532c9605fc59e2afd6d40c8f4763d41a6acbc27db88c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4317e02deb291f32358532c9605fc59e2afd6d40c8f4763d41a6acbc27db88c.source new file mode 100644 index 0000000000..a85596b083 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4317e02deb291f32358532c9605fc59e2afd6d40c8f4763d41a6acbc27db88c.source @@ -0,0 +1,1067 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Dirent } from 'node:fs'; +import { open, readdir, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; +import type { StoredMessage } from '@maka/core/session'; +import { isSupportedCodexThreadSource, sanitizeForeignTitle } from '@maka/core/foreign-session'; +import { externalSessionMatchesQuery } from '@maka/core/external-session'; +import type { + ExternalMakaSession, + ExternalSessionAdapter, + ExternalSessionQuery, + ExternalSessionSummary, +} from '@maka/core/external-session'; + +export const CODEX_SESSION_ADAPTER_ID = 'codex'; +export const CODEX_ROLLOUT_MAX_BYTES = 2 * 1024 * 1024 * 1024; + +const CODEX_ROLLOUT_HEAD_BYTES = 512 * 1024; +const CODEX_ROLLOUT_READ_BYTES = 64 * 1024; +const CODEX_ROLLOUT_MAX_RECORD_BYTES = 64 * 1024 * 1024; +const CODEX_ROLLOUT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024; +const CODEX_ROLLOUT_MAX_MESSAGES = 250_000; +const CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +const CODEX_UNSAFE_PATH_CHARS = + /[\u0000-\u001F\u007F\u0080-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/; + +export interface CodexSessionAdapterOptions { + /** Codex's state root. Defaults to `$CODEX_HOME`, then `~/.codex`. */ + codexHome?: string; + /** Maximum source bytes scanned from one fixed rollout snapshot. */ + maxRolloutBytes?: number; + /** Maximum bytes buffered for one JSONL record. */ + maxRecordBytes?: number; + /** Maximum serialized bytes retained across converted messages. */ + maxConvertedBytes?: number; + /** Maximum number of converted messages retained in memory. */ + maxMessages?: number; +} + +interface CodexCatalogEntry extends ExternalSessionSummary { + rolloutPath: string; +} + +interface CodexThreadRow { + id?: unknown; + rollout_path?: unknown; + cwd?: unknown; + name?: unknown; + title?: unknown; + preview?: unknown; + first_user_message?: unknown; + created_at_ms?: unknown; + created_at?: unknown; + updated_at_ms?: unknown; + updated_at?: unknown; + archived?: unknown; + source?: unknown; +} + +type JsonRecord = Record; + +/** + * Read-only adapter for Codex rollout JSONL. + * + * Codex persists presentation history as `event_msg` records and provider + * protocol facts as `response_item` records. Presentation messages use either + * the legacy `user_message` / `agent_*` events or the newer `item_completed` + * event. Reading both shapes from `event_msg` avoids importing their + * response-item mirrors twice. Tool calls/results come from response items + * because they own the stable call identity and raw arguments/output. + */ +export class CodexSessionAdapter implements ExternalSessionAdapter { + readonly id = CODEX_SESSION_ADAPTER_ID; + + private readonly codexHome: string; + private readonly maxRolloutBytes: number; + private readonly maxRecordBytes: number; + private readonly maxConvertedBytes: number; + private readonly maxMessages: number; + + constructor(options: CodexSessionAdapterOptions = {}) { + this.codexHome = resolve( + options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex'), + ); + this.maxRolloutBytes = options.maxRolloutBytes ?? CODEX_ROLLOUT_MAX_BYTES; + this.maxRecordBytes = options.maxRecordBytes ?? CODEX_ROLLOUT_MAX_RECORD_BYTES; + this.maxConvertedBytes = options.maxConvertedBytes ?? CODEX_ROLLOUT_MAX_CONVERTED_BYTES; + this.maxMessages = options.maxMessages ?? CODEX_ROLLOUT_MAX_MESSAGES; + assertPositiveSafeInteger(this.maxRolloutBytes, 'Codex rollout byte limit'); + assertPositiveSafeInteger(this.maxRecordBytes, 'Codex rollout record byte limit'); + assertPositiveSafeInteger(this.maxConvertedBytes, 'Codex converted message byte limit'); + assertPositiveSafeInteger(this.maxMessages, 'Codex converted message count limit'); + } + + async detect(): Promise { + return ( + (await isDirectory(join(this.codexHome, 'sessions'))) || + (await isDirectory(join(this.codexHome, 'archived_sessions'))) || + (await codexStateDbsNewestFirst(this.codexHome)).length > 0 + ); + } + + async listSessions(query: ExternalSessionQuery = {}): Promise { + const entries = await this.listCatalog(query); + return entries.map(({ rolloutPath: _rolloutPath, ...summary }) => summary); + } + + async readSession(sessionId: string): Promise { + assertSafeCodexSessionId(sessionId); + const catalogEntry = await this.findCatalogEntry(sessionId); + if (!catalogEntry) throw new Error(`Codex Session not found: ${sessionId}`); + + const rolloutPath = await this.resolveRolloutPath(catalogEntry.rolloutPath, sessionId); + if (!rolloutPath) throw new Error(`Codex rollout is unavailable: ${sessionId}`); + return convertCodexRollout(rolloutPath, sessionId, catalogEntry.name, catalogEntry.cwd, { + maxRolloutBytes: this.maxRolloutBytes, + maxRecordBytes: this.maxRecordBytes, + maxConvertedBytes: this.maxConvertedBytes, + maxMessages: this.maxMessages, + }); + } + + private async listCatalog(query: ExternalSessionQuery): Promise { + for (const dbPath of await codexStateDbsNewestFirst(this.codexHome)) { + const rows = await readCodexThreadRows(dbPath, query); + if (rows === undefined) continue; + const entries = await Promise.all(rows.map((row) => this.entryFromRow(row))); + return entries + .filter((entry): entry is CodexCatalogEntry => entry !== undefined) + .filter((entry) => matchesQuery(entry, query)) + .sort(compareCatalogEntries); + } + + return this.scanRolloutCatalog(query); + } + + private async findCatalogEntry(sessionId: string): Promise { + for (const dbPath of await codexStateDbsNewestFirst(this.codexHome)) { + const rows = await readCodexThreadRows(dbPath, { includeArchived: true }, sessionId); + if (rows === undefined) continue; + for (const row of rows) { + const entry = await this.entryFromRow(row); + if (entry?.id === sessionId) return entry; + } + break; + } + + return this.findRolloutEntry(sessionId); + } + + private async entryFromRow(row: CodexThreadRow): Promise { + if (!isSafeCodexSessionId(row.id)) return undefined; + if (typeof row.rollout_path !== 'string' || row.rollout_path.length === 0) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; + + const rolloutPath = await this.resolveRolloutPath(row.rollout_path, row.id); + if (!rolloutPath) return undefined; + const name = + firstNonEmptyTitle(row.name, row.title, row.preview, row.first_user_message) ?? row.id; + const createdAt = normalizeEpochMs(row.created_at_ms) ?? normalizeEpochMs(row.created_at); + const updatedAt = normalizeEpochMs(row.updated_at_ms) ?? normalizeEpochMs(row.updated_at); + + return { + id: row.id, + name, + cwd: safeCodexCwd(row.cwd), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + archived: row.archived === true || row.archived === 1, + rolloutPath, + }; + } + + private async scanRolloutCatalog(query: ExternalSessionQuery): Promise { + const candidates = [ + ...(await walkRolloutFiles(join(this.codexHome, 'sessions'), false)), + ...(query.includeArchived + ? await walkRolloutFiles(join(this.codexHome, 'archived_sessions'), true) + : []), + ].sort((a, b) => b.mtimeMs - a.mtimeMs); + const entries: CodexCatalogEntry[] = []; + for (const candidate of candidates) { + const head = await readUtf8Prefix(candidate.path, CODEX_ROLLOUT_HEAD_BYTES).catch( + () => undefined, + ); + if (head === undefined) continue; + const entry = catalogEntryFromRolloutHead(head, candidate); + if (!entry || !matchesQuery(entry, query)) continue; + const rolloutPath = await this.resolveRolloutPath(candidate.path, entry.id); + if (rolloutPath) entries.push({ ...entry, rolloutPath }); + } + return entries.sort(compareCatalogEntries); + } + + private async findRolloutEntry(sessionId: string): Promise { + for (const [root, archived] of [ + [join(this.codexHome, 'sessions'), false], + [join(this.codexHome, 'archived_sessions'), true], + ] as const) { + for (const candidate of await walkRolloutFiles(root, archived)) { + if (!rolloutFilenameMatchesId(basename(candidate.path), sessionId)) continue; + const head = await readUtf8Prefix(candidate.path, CODEX_ROLLOUT_HEAD_BYTES).catch( + () => undefined, + ); + if (head === undefined) continue; + const entry = catalogEntryFromRolloutHead(head, candidate); + if (entry?.id !== sessionId) continue; + const rolloutPath = await this.resolveRolloutPath(candidate.path, sessionId); + if (rolloutPath) return { ...entry, rolloutPath }; + } + } + return undefined; + } + + private async resolveRolloutPath( + candidatePath: string, + expectedId: string, + ): Promise { + try { + const root = await realpath(this.codexHome); + const candidate = await realpath(resolve(candidatePath)); + if (candidate !== root && !candidate.startsWith(root + sep)) return undefined; + if (!rolloutFilenameMatchesId(basename(candidate), expectedId)) return undefined; + if (!(await stat(candidate)).isFile()) return undefined; + return candidate; + } catch { + return undefined; + } + } +} + +interface RolloutCandidate { + path: string; + mtimeMs: number; + archived: boolean; +} + +interface CodexRolloutLimits { + maxRolloutBytes: number; + maxRecordBytes: number; + maxConvertedBytes: number; + maxMessages: number; +} + +interface ParsedRolloutRecord { + line: number; + value: JsonRecord; +} + +async function convertCodexRollout( + path: string, + expectedSessionId: string, + fallbackName: string, + fallbackCwd: string, + limits: CodexRolloutLimits, +): Promise { + const converter = new CodexRolloutConverter(expectedSessionId, fallbackName, fallbackCwd, limits); + for await (const record of readCodexRolloutRecords(path, expectedSessionId, limits)) { + converter.accept(record); + } + return converter.finish(); +} + +class CodexRolloutConverter { + private readonly messages: StoredMessage[] = []; + private readonly failedTurnIds = new Set(); + private activeTurnId: string | undefined; + private activeTurnIsExplicit = false; + private activeModel = 'codex'; + private lastTimestamp = 0; + private firstUserText: string | undefined; + private metaCwd = ''; + private hasSessionMeta = false; + private convertedBytes = 0; + + constructor( + private readonly expectedSessionId: string, + private readonly fallbackName: string, + private readonly fallbackCwd: string, + private readonly limits: CodexRolloutLimits, + ) {} + + accept(record: ParsedRolloutRecord): void { + const envelope = record.value; + if (envelope.type === 'session_meta' && !this.hasSessionMeta) { + this.hasSessionMeta = true; + const metaPayload = asRecord(envelope.payload); + const actualSessionId = + stringField(metaPayload, 'session_id') ?? stringField(metaPayload, 'id'); + if (actualSessionId !== this.expectedSessionId) { + throw new Error(`Codex rollout Session id mismatch: expected ${this.expectedSessionId}`); + } + this.metaCwd = safeCodexCwd(metaPayload?.cwd); + this.activeModel = stringField(metaPayload, 'model_provider') ?? this.activeModel; + const timestamp = normalizeEpochMs(envelope.timestamp); + if (timestamp !== undefined) this.lastTimestamp = Math.max(this.lastTimestamp, timestamp); + return; + } + + const payload = asRecord(envelope.payload); + if (!payload) return; + + if (envelope.type === 'turn_context') { + this.activeTurnId = stringField(payload, 'turn_id') ?? this.activeTurnId; + this.activeModel = stringField(payload, 'model') ?? this.activeModel; + return; + } + + if (envelope.type === 'event_msg') { + const eventType = stringField(payload, 'type'); + if (eventType === 'task_started' || eventType === 'turn_started') { + const turnId = stringField(payload, 'turn_id'); + if (turnId) { + this.activeTurnId = turnId; + this.activeTurnIsExplicit = true; + } + return; + } + + if (eventType === 'item_completed') { + const item = asRecord(payload.item); + const itemType = stringField(item, 'type')?.toLowerCase(); + const eventTurnId = stringField(payload, 'turn_id'); + if (eventTurnId) { + this.activeTurnId = eventTurnId; + this.activeTurnIsExplicit = true; + } + + if (itemType === 'usermessage') { + if (!this.activeTurnIsExplicit) { + this.activeTurnId = generatedCodexId(this.expectedSessionId, 'turn', record.line); + } + const text = codexCompletedItemText(item) || codexCompletedItemMediaText(item); + if (text.length === 0) return; + this.firstUserText ??= text; + this.append({ + type: 'user', + id: + stringField(item, 'client_id') ?? + stringField(item, 'id') ?? + generatedCodexId(this.expectedSessionId, 'user', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + text, + }); + return; + } + + if (itemType === 'agentmessage') { + const text = codexCompletedItemText(item); + if (text.length === 0) return; + const providerOptions = codexAssistantProviderOptions(item); + this.append({ + type: 'assistant', + id: + stringField(item, 'id') ?? + generatedCodexId(this.expectedSessionId, 'assistant', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + text, + ...(providerOptions !== undefined ? { providerOptions } : {}), + modelId: this.activeModel, + contentOrder: ['text'], + }); + return; + } + + if (itemType === 'reasoning') { + const reasoning = codexCompletedReasoningText(item); + if (reasoning.length === 0) return; + this.append({ + type: 'assistant', + id: + stringField(item, 'id') ?? + generatedCodexId(this.expectedSessionId, 'reasoning', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + text: '', + thinking: { text: reasoning }, + contentOrder: ['thinking'], + modelId: this.activeModel, + }); + return; + } + } + + if (eventType === 'user_message') { + if (!this.activeTurnIsExplicit) { + this.activeTurnId = generatedCodexId(this.expectedSessionId, 'turn', record.line); + } + const text = stringField(payload, 'message') ?? mediaOnlyUserText(payload); + if (text.length === 0) return; + this.firstUserText ??= text; + const turnId = this.ensureTurnId(record.line); + this.append({ + type: 'user', + id: + stringField(payload, 'client_id') ?? + generatedCodexId(this.expectedSessionId, 'user', record.line), + turnId, + ts: this.timestampFor(record), + text, + }); + return; + } + + if (eventType === 'agent_message') { + const text = stringField(payload, 'message'); + if (!text) return; + const providerOptions = codexAssistantProviderOptions(payload); + this.append({ + type: 'assistant', + id: generatedCodexId(this.expectedSessionId, 'assistant', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + text, + ...(providerOptions !== undefined ? { providerOptions } : {}), + modelId: this.activeModel, + contentOrder: ['text'], + }); + return; + } + + if (eventType === 'agent_reasoning') { + const reasoning = stringField(payload, 'text'); + if (!reasoning) return; + this.append({ + type: 'assistant', + id: generatedCodexId(this.expectedSessionId, 'reasoning', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + text: '', + thinking: { text: reasoning }, + contentOrder: ['thinking'], + modelId: this.activeModel, + }); + return; + } + + if (eventType === 'context_compacted') { + this.append({ + type: 'system_note', + id: generatedCodexId(this.expectedSessionId, 'compact', record.line), + turnId: this.activeTurnId, + ts: this.timestampFor(record), + kind: 'context_compacted', + }); + return; + } + + if (eventType === 'error') { + if (this.activeTurnId && codexErrorAffectsTurnStatus(payload)) { + this.failedTurnIds.add(this.activeTurnId); + } + this.append({ + type: 'system_note', + id: generatedCodexId(this.expectedSessionId, 'error', record.line), + turnId: this.activeTurnId, + ts: this.timestampFor(record), + kind: 'error', + data: JSON.parse(JSON.stringify(payload)) as unknown, + }); + return; + } + + if (eventType === 'task_complete' || eventType === 'turn_complete') { + const turnId = stringField(payload, 'turn_id') ?? this.ensureTurnId(record.line); + const failed = this.failedTurnIds.has(turnId) || payload.error != null; + this.append({ + type: 'turn_state', + id: generatedCodexId(this.expectedSessionId, 'turn-state', record.line), + turnId, + ts: this.timestampFor(record), + status: failed ? 'failed' : 'completed', + ...(failed ? { errorClass: 'codex_error' } : {}), + }); + this.failedTurnIds.delete(turnId); + if (this.activeTurnId === turnId) { + this.activeTurnId = undefined; + this.activeTurnIsExplicit = false; + } + return; + } + + if (eventType === 'turn_aborted') { + const turnId = stringField(payload, 'turn_id') ?? this.ensureTurnId(record.line); + const ts = this.timestampFor(record); + this.append({ + type: 'turn_state', + id: generatedCodexId(this.expectedSessionId, 'turn-state', record.line), + turnId, + ts, + status: 'aborted', + abortedAt: normalizeEpochMs(payload.completed_at) ?? ts, + abortSource: stringField(payload, 'reason') ?? 'codex', + }); + if (this.activeTurnId === turnId) { + this.activeTurnId = undefined; + this.activeTurnIsExplicit = false; + } + return; + } + } + + if (envelope.type !== 'response_item') return; + const itemType = stringField(payload, 'type'); + if (itemType === 'function_call' || itemType === 'custom_tool_call') { + const callId = stringField(payload, 'call_id'); + const toolName = namespacedToolName(payload); + if (!callId || !toolName) return; + const rawArgs = + itemType === 'function_call' + ? stringField(payload, 'arguments') + : stringField(payload, 'input'); + this.append({ + type: 'tool_call', + id: callId, + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + toolName, + args: parseJsonString(rawArgs), + }); + return; + } + + if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { + const callId = stringField(payload, 'call_id'); + if (!callId) return; + this.append({ + type: 'tool_result', + id: + stringField(payload, 'id') ?? + generatedCodexId(this.expectedSessionId, 'tool-result', record.line), + turnId: this.ensureTurnId(record.line), + ts: this.timestampFor(record), + toolUseId: callId, + // Codex persists the output body but not FunctionCallOutputPayload.success. + // Preserve the raw body and avoid guessing failure from its text. + isError: false, + content: { kind: 'text', text: codexToolOutputText(payload.output) }, + }); + } + } + + finish(): ExternalMakaSession { + if (!this.hasSessionMeta) { + throw new Error(`Codex rollout Session id mismatch: expected ${this.expectedSessionId}`); + } + const name = + sanitizeForeignTitle(this.fallbackName) || + sanitizeForeignTitle(this.firstUserText) || + this.expectedSessionId; + return { + sourceSessionId: this.expectedSessionId, + metadata: { name, cwd: this.metaCwd || this.fallbackCwd }, + messages: this.messages, + }; + } + + private timestampFor(record: ParsedRolloutRecord): number { + const parsed = normalizeEpochMs(record.value.timestamp); + if (parsed !== undefined) this.lastTimestamp = Math.max(this.lastTimestamp, parsed); + else this.lastTimestamp += 1; + return parsed ?? this.lastTimestamp; + } + + private ensureTurnId(line: number): string { + this.activeTurnId ??= generatedCodexId(this.expectedSessionId, 'turn', line); + return this.activeTurnId; + } + + private append(message: StoredMessage): void { + if (this.messages.length >= this.limits.maxMessages) { + throw new Error(`Codex rollout converts to more than ${this.limits.maxMessages} messages`); + } + const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (encodedBytes > this.limits.maxConvertedBytes - this.convertedBytes) { + throw new Error(`Codex rollout converts to more than ${this.limits.maxConvertedBytes} bytes`); + } + this.convertedBytes += encodedBytes; + this.messages.push(message); + } +} + +async function* readCodexRolloutRecords( + path: string, + sessionId: string, + limits: CodexRolloutLimits, +): AsyncGenerator { + const handle = await open(path, 'r'); + try { + const metadata = await handle.stat(); + if (!metadata.isFile()) throw new Error('Codex rollout is not a regular file'); + if (metadata.size > limits.maxRolloutBytes) { + throw new Error(`Codex rollout exceeds ${limits.maxRolloutBytes} bytes`); + } + + const snapshotBytes = metadata.size; + const pending: Buffer[] = []; + let pendingBytes = 0; + let observedBytes = 0; + let line = 0; + if (snapshotBytes > 0) { + for await (const value of handle.createReadStream({ + autoClose: false, + emitClose: false, + start: 0, + end: snapshotBytes - 1, + highWaterMark: CODEX_ROLLOUT_READ_BYTES, + })) { + const chunk = Buffer.from(value); + observedBytes += chunk.byteLength; + if (observedBytes > snapshotBytes) { + throw new Error('Codex rollout changed while being read'); + } + let start = 0; + for (;;) { + const newline = chunk.indexOf(0x0a, start); + if (newline === -1) break; + const segment = chunk.subarray(start, newline); + assertCodexRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, line + 1); + line += 1; + const record = parseCodexRolloutLine( + pending.length === 0 + ? segment + : Buffer.concat([...pending, segment], pendingBytes + segment.byteLength), + sessionId, + line, + false, + ); + if (record) yield record; + pending.length = 0; + pendingBytes = 0; + start = newline + 1; + } + if (start < chunk.byteLength) { + const segment = chunk.subarray(start); + assertCodexRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, line + 1); + pending.push(segment); + pendingBytes += segment.byteLength; + } + } + } + if (observedBytes !== snapshotBytes) throw new Error('Codex rollout changed while being read'); + + if (pendingBytes > 0) { + line += 1; + const record = parseCodexRolloutLine( + pending.length === 1 ? pending[0]! : Buffer.concat(pending, pendingBytes), + sessionId, + line, + true, + ); + if (record) yield record; + } + } finally { + await handle.close(); + } +} + +function parseCodexRolloutLine( + bytes: Buffer, + sessionId: string, + line: number, + tolerateTornTail: boolean, +): ParsedRolloutRecord | undefined { + const text = bytes.toString('utf8'); + if (text.trim().length === 0) return undefined; + try { + const value = JSON.parse(text) as unknown; + if (!isRecord(value)) throw new Error('record is not an object'); + return { line, value }; + } catch (error) { + if (tolerateTornTail) return undefined; + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid Codex rollout ${sessionId} at line ${line}: ${detail}`); + } +} + +function assertCodexRecordSize(actualBytes: number, maxBytes: number, line: number): void { + if (actualBytes > maxBytes) { + throw new Error(`Codex rollout record at line ${line} exceeds ${maxBytes} bytes`); + } +} + +function assertPositiveSafeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive safe integer`); + } +} + +function catalogEntryFromRolloutHead( + text: string, + candidate: RolloutCandidate, +): Omit | undefined { + const lines = text.split('\n'); + let id: string | undefined; + let cwd = ''; + let createdAt: number | undefined; + let firstUserText: string | undefined; + for (const line of lines) { + let record: JsonRecord; + try { + const parsed = JSON.parse(line) as unknown; + if (!isRecord(parsed)) continue; + record = parsed; + } catch { + continue; + } + const payload = asRecord(record.payload); + if (!payload) continue; + if (record.type === 'session_meta') { + if (!isSupportedCodexThreadSource(payload.source)) return undefined; + id = stringField(payload, 'session_id') ?? stringField(payload, 'id') ?? id; + cwd = safeCodexCwd(payload.cwd) || cwd; + createdAt = + normalizeEpochMs(record.timestamp) ?? normalizeEpochMs(payload.timestamp) ?? createdAt; + } else if (record.type === 'event_msg' && firstUserText === undefined) { + if (payload.type === 'user_message') { + firstUserText = stringField(payload, 'message'); + } else if (payload.type === 'item_completed') { + const item = asRecord(payload.item); + if (stringField(item, 'type')?.toLowerCase() === 'usermessage') { + firstUserText = codexCompletedItemText(item) || codexCompletedItemMediaText(item); + } + } + } + if (id && firstUserText !== undefined) break; + } + if (!isSafeCodexSessionId(id)) return undefined; + if (!rolloutFilenameMatchesId(basename(candidate.path), id)) return undefined; + return { + id, + name: sanitizeForeignTitle(firstUserText) || id, + cwd, + ...(createdAt !== undefined ? { createdAt } : {}), + updatedAt: candidate.mtimeMs, + archived: candidate.archived, + }; +} + +async function readCodexThreadRows( + dbPath: string, + query: ExternalSessionQuery, + exactId?: string, +): Promise { + try { + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + const columns = new Set( + (db.prepare('PRAGMA table_info(threads)').all() as { name?: unknown }[]) + .map((column) => (typeof column.name === 'string' ? column.name : '')) + .filter(Boolean), + ); + if (!columns.has('id') || !columns.has('rollout_path')) return undefined; + const wanted = [ + 'id', + 'rollout_path', + 'cwd', + 'name', + 'title', + 'preview', + 'first_user_message', + 'created_at_ms', + 'created_at', + 'updated_at_ms', + 'updated_at', + 'archived', + 'source', + ].filter((column) => columns.has(column)); + const where: string[] = []; + const params: Array = []; + if (exactId !== undefined) { + where.push('id = ?'); + params.push(exactId); + } + if (!query.includeArchived && columns.has('archived')) { + where.push('(archived IS NULL OR archived = 0)'); + } + // No cwd clause. `cwd IN (...)` enumerated spelling variants of the + // query, but SQLite compares them exactly: a row stored `C:\\Repo\\App` + // was discarded before `matchesQuery` could see that `c:/repo/app` names + // the same project. A prefilter that cannot express the matcher's own + // equivalence is not an optimization, it is a second, weaker rule — so + // the shared matcher below is the only authority on which project a row + // belongs to. The archived clause stays: that one is an exact boolean + // and agrees with the matcher by construction. + // + // The statement has no LIMIT, so dropping the clause widens the read + // rather than truncating it. + const orderColumn = columns.has('updated_at_ms') + ? 'updated_at_ms' + : columns.has('updated_at') + ? 'updated_at' + : 'id'; + const sql = + `SELECT ${wanted.join(', ')} FROM threads` + + (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') + + ` ORDER BY ${orderColumn} DESC`; + return db.prepare(sql).all(...params) as CodexThreadRow[]; + } finally { + db.close(); + } + } catch { + return undefined; + } +} + +async function codexStateDbsNewestFirst(codexHome: string): Promise { + try { + const root = await realpath(codexHome); + const candidates = (await readdir(codexHome)) + .filter((name) => /^state_\d+\.sqlite$/.test(name)) + .sort((a, b) => stateGeneration(b) - stateGeneration(a)); + const databases: string[] = []; + for (const name of candidates) { + const candidate = await realpath(join(codexHome, name)).catch(() => undefined); + if (!candidate || (candidate !== root && !candidate.startsWith(root + sep))) continue; + if ((await stat(candidate).catch(() => undefined))?.isFile()) databases.push(candidate); + } + return databases; + } catch { + return []; + } +} + +async function walkRolloutFiles(root: string, archived: boolean): Promise { + const files: RolloutCandidate[] = []; + const visit = async (directory: string): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await visit(path); + } else if ( + entry.isFile() && + entry.name.startsWith('rollout-') && + entry.name.endsWith('.jsonl') + ) { + try { + files.push({ path, mtimeMs: (await stat(path)).mtimeMs, archived }); + } catch { + // The external store may change while it is being scanned. + } + } + } + }; + await visit(root); + return files; +} + +async function readUtf8Prefix(path: string, maxBytes: number): Promise { + const handle = await open(path, 'r'); + try { + if (!(await handle.stat()).isFile()) throw new Error('Codex rollout is not a regular file'); + const buffer = Buffer.allocUnsafe(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return buffer.subarray(0, bytesRead).toString('utf8'); + } finally { + await handle.close(); + } +} + +function asRecord(value: unknown): JsonRecord | undefined { + return isRecord(value) ? value : undefined; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(record: JsonRecord | undefined, field: string): string | undefined { + const value = record?.[field]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function codexAssistantProviderOptions( + record: JsonRecord | undefined, +): Record | undefined { + const phase = record?.phase; + if (phase !== 'commentary' && phase !== 'final_answer') return undefined; + return { + openai: { + phase, + }, + }; +} + +function isSafeCodexSessionId(value: unknown): value is string { + return typeof value === 'string' && CODEX_SESSION_ID_PATTERN.test(value); +} + +function assertSafeCodexSessionId(value: string): void { + if (!isSafeCodexSessionId(value)) throw new Error(`Invalid Codex Session id: ${value}`); +} + +function safeCodexCwd(value: unknown): string { + return typeof value === 'string' && !CODEX_UNSAFE_PATH_CHARS.test(value) ? value : ''; +} + +function firstNonEmptyTitle(...values: unknown[]): string | undefined { + for (const value of values) { + const title = sanitizeForeignTitle(value); + if (title.length > 0) return title; + } + return undefined; +} + +function codexErrorAffectsTurnStatus(payload: JsonRecord): boolean { + const info = payload.codex_error_info; + if (info === 'thread_rollback_failed' || info === 'active_turn_not_steerable') return false; + return !(isRecord(info) && Object.hasOwn(info, 'active_turn_not_steerable')); +} + +function normalizeEpochMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value >= 1_000_000_000_000 ? value : value * 1000; + } + if (typeof value === 'string' && value.length > 0) { + const numeric = Number(value); + if (Number.isFinite(numeric)) return normalizeEpochMs(numeric); + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function matchesQuery(entry: ExternalSessionSummary, query: ExternalSessionQuery): boolean { + // The single authority on whether a row answers a query, shared with every + // other adapter. The local path helpers this file used to keep were only + // reachable from the SQL prefilter that has been removed. + return externalSessionMatchesQuery(entry, query); +} + +function compareCatalogEntries(a: CodexCatalogEntry, b: CodexCatalogEntry): number { + return (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0); +} + +function stateGeneration(path: string): number { + return Number(path.match(/\d+/)?.[0] ?? 0); +} + +function rolloutFilenameMatchesId(filename: string, sessionId: string): boolean { + return filename.endsWith(`-${sessionId}.jsonl`); +} + +function generatedCodexId(sessionId: string, kind: string, line: number): string { + return `codex-${sessionId}-${kind}-${line}`; +} + +function namespacedToolName(payload: JsonRecord): string | undefined { + const name = stringField(payload, 'name'); + if (!name) return undefined; + const namespace = stringField(payload, 'namespace'); + return namespace ? `${namespace}.${name}` : name; +} + +function parseJsonString(value: string | undefined): unknown { + if (value === undefined) return ''; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function codexToolOutputText(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + const texts = value.flatMap((item) => { + const record = asRecord(item); + return record?.type === 'input_text' && typeof record.text === 'string' ? [record.text] : []; + }); + if (texts.length > 0) return texts.join('\n'); + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function codexCompletedItemText(item: JsonRecord | undefined): string { + if (!item) return ''; + const direct = stringField(item, 'content'); + if (direct) return direct; + if (!Array.isArray(item.content)) return ''; + return item.content + .flatMap((part) => { + const record = asRecord(part); + const type = stringField(record, 'type')?.toLowerCase(); + return type === 'text' || type === 'input_text' || type === 'output_text' + ? [stringField(record, 'text') ?? ''] + : []; + }) + .filter((text) => text.length > 0) + .join(''); +} + +function codexCompletedReasoningText(item: JsonRecord | undefined): string { + if (!item) return ''; + const summary = codexTextFragments(item.summary_text); + if (summary.length > 0) return summary.join('\n'); + return codexCompletedItemText(item); +} + +function codexTextFragments(value: unknown): string[] { + if (typeof value === 'string') return value.length > 0 ? [value] : []; + if (!Array.isArray(value)) return []; + return value.flatMap((part) => { + if (typeof part === 'string') return part.length > 0 ? [part] : []; + const text = stringField(asRecord(part), 'text'); + return text ? [text] : []; + }); +} + +function codexCompletedItemMediaText(item: JsonRecord | undefined): string { + if (!item || !Array.isArray(item.content)) return ''; + const contentTypes = item.content.flatMap((part) => { + const type = stringField(asRecord(part), 'type')?.toLowerCase(); + return type ? [type] : []; + }); + if (contentTypes.some((type) => type.includes('image'))) return '[Image]'; + return contentTypes.some((type) => type.includes('audio')) ? '[Audio]' : ''; +} + +function mediaOnlyUserText(payload: JsonRecord): string { + const images = Array.isArray(payload.images) ? payload.images : []; + const localImages = Array.isArray(payload.local_images) ? payload.local_images : []; + if (images.length > 0 || localImages.length > 0) return '[Image]'; + const audio = Array.isArray(payload.audio) ? payload.audio : []; + const localAudio = Array.isArray(payload.local_audio) ? payload.local_audio : []; + return audio.length > 0 || localAudio.length > 0 ? '[Audio]' : ''; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4cd691ba4b3e5ddc599b5303cafae915c4730a08d8b325a3df9be8dca0def15.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4cd691ba4b3e5ddc599b5303cafae915c4730a08d8b325a3df9be8dca0def15.source new file mode 100644 index 0000000000..2e786bb301 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a4cd691ba4b3e5ddc599b5303cafae915c4730a08d8b325a3df9be8dca0def15.source @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + type ArtifactAuthorityStore, + createSqliteArtifactStoreWriteAuthority, + type CreateArtifactInput, +} from '../../artifact-store.js'; +import { + withArtifactWriterLock, + withLeaseBoundArtifactWriterLock, +} from '../../artifact-writer-lock.js'; +import { + prepareArtifactWriterLockAuthorityForLease, + resolveExistingStorageRoot, + tryAcquireInteractiveRootOwner, +} from '../../root-authority.js'; + +const [workspaceRoot, modeOrTransientResidueSessionId, modeArgument] = process.argv.slice(2); +if (!workspaceRoot || !process.send) { + throw new Error( + 'usage: artifact-writer-lock-holder [transient-residue-session-id | --public-writer | --authority-holder ]', + ); +} + +async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) { + return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records; +} + +try { + if (modeOrTransientResidueSessionId === '--public-writer') { + if (!modeArgument) throw new Error('public writer session id is required'); + await runPublicWriter(workspaceRoot, modeArgument); + } else if (modeOrTransientResidueSessionId === '--authority-holder') { + if (!modeArgument) throw new Error('authority holder root id is required'); + await runAuthorityLockHolder(workspaceRoot, modeArgument); + } else { + await runLockHolder(workspaceRoot, modeOrTransientResidueSessionId); + } +} catch (error) { + await send({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }).catch(() => {}); + process.exitCode = 1; +} finally { + process.disconnect?.(); +} + +async function runLockHolder( + workspaceRoot: string, + transientResidueSessionId: string | undefined, +): Promise { + await withArtifactWriterLock(workspaceRoot, async () => { + const residuePath = transientResidueSessionId + ? await createTransientPublicationResidue(workspaceRoot, transientResidueSessionId) + : undefined; + try { + await send({ type: 'locked' }); + await waitForRelease(); + } finally { + if (residuePath) await rm(residuePath, { force: true }); + } + }); + await send({ type: 'released' }); +} + +async function runAuthorityLockHolder(workspaceRoot: string, rootId: string): Promise { + const capability = await resolveExistingStorageRoot({ + path: workspaceRoot, + kind: 'interactive', + expectedRootId: rootId, + }); + const owner = await tryAcquireInteractiveRootOwner(capability); + if (!owner) throw new Error('interactive storage root is already owned'); + try { + const authority = await prepareArtifactWriterLockAuthorityForLease(owner.lease, 'interactive'); + await withLeaseBoundArtifactWriterLock(authority, async () => { + await send({ type: 'locked' }); + await waitForRelease(); + }); + await send({ type: 'released' }); + } finally { + await owner.close(); + } +} + +async function runPublicWriter(workspaceRoot: string, sessionId: string): Promise { + const authority = createSqliteArtifactStoreWriteAuthority(workspaceRoot); + const store = authority.store; + try { + await listArtifacts(store, sessionId); + await send({ type: 'ready' }); + const command = await waitForCreateCommand(); + const mutation = store.create({ + ...command.input, + content: repeatedPayload(command.payloadUnit, command.payloadBytes), + }); + await new Promise((resolve) => setImmediate(resolve)); + await send({ type: 'queued' }); + await send({ type: 'created', record: await mutation }); + } finally { + authority.close(); + } +} + +function waitForRelease(): Promise { + return new Promise((resolve, reject) => { + process.once('message', resolve); + process.once('disconnect', () => reject(new Error('parent disconnected before release'))); + }); +} + +function waitForCreateCommand(): Promise<{ + input: Omit; + payloadUnit: string; + payloadBytes: number; +}> { + return new Promise((resolve, reject) => { + process.once('message', (message: unknown) => { + if (!isCreateCommand(message)) { + reject(new Error('public writer received an invalid create command')); + return; + } + resolve(message); + }); + process.once('disconnect', () => + reject(new Error('parent disconnected before public writer create')), + ); + }); +} + +function isCreateCommand(message: unknown): message is { + type: 'create'; + input: Omit; + payloadUnit: string; + payloadBytes: number; +} { + if (!message || typeof message !== 'object') return false; + const candidate = message as Record; + return ( + candidate.type === 'create' && + typeof candidate.input === 'object' && + candidate.input !== null && + typeof candidate.payloadUnit === 'string' && + candidate.payloadUnit.length > 0 && + Number.isSafeInteger(candidate.payloadBytes) && + (candidate.payloadBytes as number) >= 0 + ); +} + +function repeatedPayload(unit: string, bytes: number): string { + return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes); +} + +async function createTransientPublicationResidue( + workspaceRoot: string, + sessionId: string, +): Promise { + const sessionRoot = join(workspaceRoot, 'artifacts', sessionId); + const targetHash = createHash('sha256').update('transient.txt').digest('hex'); + const residuePath = join( + sessionRoot, + `.artifact-publish.${targetHash}.00000000-0000-4000-8000-000000000000.tmp`, + ); + await mkdir(sessionRoot, { recursive: true }); + await writeFile(residuePath, 'transient publication'); + return residuePath; +} + +function send(message: object): Promise { + return new Promise((resolve, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a59e6d66207e21bda9af6cec947236d232215df2399a91ee83e307bc261f9e4a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a59e6d66207e21bda9af6cec947236d232215df2399a91ee83e307bc261f9e4a.source new file mode 100644 index 0000000000..854f6dda58 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a59e6d66207e21bda9af6cec947236d232215df2399a91ee83e307bc261f9e4a.source @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import type { GoalAuthorityRecord } from '@maka/core/goal'; +import { openInteractiveExecutionStoresForWrite } from '../execution-stores.js'; +import { openInteractiveGoalAuthorityForWrite } from '../goal-authority.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +test('Goal authority commits one revisioned record and preserves its execution reference', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-goal-authority-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveGoalAuthorityForWrite(owner.lease); + try { + const initial = goalRecord(0, null); + const created = await writer.commit({ + sessionId: initial.goal.sessionId, + expectedAuthorityRevision: null, + record: initial, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + assert.equal(created.snapshot?.authorityRevision, 0); + + const running = goalRecord(0, { + execution: { + sessionId: initial.goal.sessionId, + turnId: 'turn_goal_1', + runId: 'run_goal_1', + }, + checkpoint: { goalId: initial.goal.id, revision: 0 }, + controlLease: initial.controlLease, + }); + const updated = await writer.commit({ + sessionId: running.goal.sessionId, + expectedAuthorityRevision: 0, + record: running, + }); + assert.equal(updated.kind, 'committed'); + assert.deepEqual((await writer.read(running.goal.sessionId))?.record, running); + + const stale = await writer.commit({ + sessionId: running.goal.sessionId, + expectedAuthorityRevision: 0, + record: { ...running, goal: { ...running.goal, revision: 1 } }, + }); + assert.deepEqual(stale, { + kind: 'revision_conflict', + actualAuthorityRevision: 1, + }); + assert.equal((await writer.list()).length, 1); + } finally { + await writer.close(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('Goal authority close lets an admitted commit finish before releasing storage', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-goal-authority-close-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const writer = await openInteractiveGoalAuthorityForWrite(owner.lease); + const record = goalRecord(0, null); + const committing = writer.commit({ + sessionId: record.goal.sessionId, + expectedAuthorityRevision: null, + record, + }); + const closing = writer.close(); + + assert.equal((await committing).kind, 'committed'); + await closing; + await owner.close(); + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const reopened = await openInteractiveGoalAuthorityForWrite(successor.lease); + assert.equal((await reopened.read(record.goal.sessionId))?.record.goal.id, record.goal.id); + await reopened.close(); + } finally { + await successor.close(); + } + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('Session retirement atomically removes its Goal authority', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-goal-authority-retirement-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const goals = await openInteractiveGoalAuthorityForWrite(owner.lease); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + try { + const sessions = await Promise.all( + ['archive', 'remove'].map((name) => + stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name, + }), + ), + ); + for (const session of sessions) { + const record = goalRecord(0, null, session.id); + assert.equal( + ( + await goals.commit({ + sessionId: session.id, + expectedAuthorityRevision: null, + record, + }) + ).kind, + 'committed', + ); + } + + const [archived, removed] = await Promise.all( + sessions.map((session) => stores.sessionStore.readHeaderRecordSnapshot(session.id)), + ); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: archived.header.id, expectedVersion: archived.revision }], + true, + ); + await stores.sessionStore.removeSessionsVersioned([ + { sessionId: removed.header.id, expectedVersion: removed.revision }, + ]); + + assert.equal(await goals.read(archived.header.id), null); + assert.equal(await goals.read(removed.header.id), null); + } finally { + await goals.close(); + await stores.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +function goalRecord( + revision: number, + currentExecution: GoalAuthorityRecord['currentExecution'], + sessionId = 'session_goal_1', +): GoalAuthorityRecord { + return { + schemaVersion: 1, + goal: { + id: 'goal_1', + revision, + sessionId, + condition: 'Finish the durable Goal refactor.', + status: 'active', + setAt: 1, + iterations: 0, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 0, + tokensBaselinePending: true, + }, + controlLease: { goalId: 'goal_1', generation: 0 }, + currentExecution, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a63f10f0db1adc9f555b633d83feb0ed6de45e36b3141eae8cf0078e46af8565.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a63f10f0db1adc9f555b633d83feb0ed6de45e36b3141eae8cf0078e46af8565.source new file mode 100644 index 0000000000..90ace71c6e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a63f10f0db1adc9f555b633d83feb0ed6de45e36b3141eae8cf0078e46af8565.source @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MAX_ATTACHMENT_BYTES, + MAX_READ_IMAGE_BYTES, + READ_IMAGE_TOO_LARGE_MESSAGE, + type AttachmentByteReader, +} from '@maka/core/attachments'; +import { + isArtifactTurnKey, + isCanonicalArtifactEntityId, + normalizeArtifactImagePreviewMime, +} from '@maka/core/artifacts'; +import { createHash } from 'node:crypto'; +import { type StorageRef, type ToolResultContent } from '@maka/core/events'; +import type { ReadImageSnapshotReader } from '@maka/core/context-offload'; +import type { ArtifactAuthorityStore, DurableArtifactAttachmentReader } from './artifact-store.js'; +import { sanitizeArtifactName } from './artifact-store.js'; + +export interface ArtifactAttachmentResourceReader { + readAttachmentResource( + sessionId: string, + artifactId: string, + abortSignal: AbortSignal, + ): Promise; +} + +/** Read a user-uploaded Artifact without exposing its storage path. */ +export function createArtifactAttachmentResourceReader(input: { + artifactStore: Pick; +}): ArtifactAttachmentResourceReader { + return Object.freeze({ + async readAttachmentResource( + sessionId: string, + artifactId: string, + abortSignal: AbortSignal, + ): Promise { + abortSignal.throwIfAborted(); + const record = (await input.artifactStore.getInSession(sessionId, artifactId)).record; + if (!record || record.source !== 'user_upload') { + throw new Error('Attachment was not found in this Session'); + } + if (record.sessionId !== sessionId) { + throw new Error('Attachment was not found in this Session'); + } + if (record.kind === 'image') { + if (!record.mimeType) throw new Error('Attachment image has no media type'); + return { + kind: 'image', + mimeType: record.mimeType, + ref: { kind: 'session_file', sessionId, relativePath: artifactId }, + }; + } + if (record.kind === 'pdf') { + throw new Error('PDF attachments cannot be decoded by Read'); + } + const read = await input.artifactStore.readTextInSession(sessionId, artifactId); + abortSignal.throwIfAborted(); + if (!read.ok) throw new Error(`Attachment could not be read: ${read.reason}`); + return { kind: 'text', text: read.text }; + }, + }); +} + +export function createAttachmentByteReader(input: { + artifactStore: DurableArtifactAttachmentReader; + sessionId: string; + readImageSnapshots?: ReadImageSnapshotReader; + readImageSnapshotsUnavailable?: boolean; + maxBytes?: number; +}): AttachmentByteReader { + const maxBytes = input.maxBytes ?? MAX_ATTACHMENT_BYTES; + return async (ref) => { + if (ref.kind === 'session_context') { + if (ref.sessionId !== input.sessionId) return { ok: false, reason: 'session_mismatch' }; + if (!input.readImageSnapshots) { + return { + ok: false, + reason: input.readImageSnapshotsUnavailable ? 'unavailable' : 'unsupported_ref_kind', + }; + } + const result = await input.readImageSnapshots.read(ref); + return result.ok + ? { ok: true, bytes: new Uint8Array(result.bytes) } + : { ok: false, reason: result.reason }; + } + if (ref.kind !== 'session_file') return { ok: false, reason: 'unsupported_ref_kind' }; + if (ref.sessionId !== input.sessionId) return { ok: false, reason: 'session_mismatch' }; + const result = await input.artifactStore.readDurableAttachmentBinary({ + artifactId: ref.relativePath, + sessionId: input.sessionId, + maxBytes, + }); + return result.ok + ? { ok: true, bytes: Buffer.from(result.base64, 'base64') } + : { ok: false, reason: result.reason }; + }; +} + +interface ReadImageSnapshotInput { + sessionId: string; + turnId: string; + name: string; + bytes: Uint8Array; + mimeType: string; +} + +export interface ReadImageSnapshotPlan { + ref: Extract; + persist(): Promise; +} + +export type ReadImageSnapshotArtifactStore = Pick; + +export function createReadImageSnapshotPlanner(artifactStore: ReadImageSnapshotArtifactStore) { + return (input: ReadImageSnapshotInput): ReadImageSnapshotPlan => { + if (input.bytes.byteLength > MAX_READ_IMAGE_BYTES) { + throw new Error(READ_IMAGE_TOO_LARGE_MESSAGE); + } + if (normalizeArtifactImagePreviewMime(input.mimeType) !== input.mimeType) { + throw new Error('Image media type is not canonical or safe'); + } + if (!isCanonicalArtifactEntityId(input.sessionId)) { + throw new Error('Image Session id is not canonical'); + } + if (!isArtifactTurnKey(input.turnId)) { + throw new Error('Image turn id is not canonical'); + } + const accepted = Object.freeze({ + sessionId: input.sessionId, + turnId: input.turnId, + name: sanitizeArtifactName(input.name), + bytes: input.bytes.slice(), + mimeType: input.mimeType, + }); + const id = `image_${createHash('sha256') + .update(accepted.sessionId, 'utf8') + .update('\0', 'utf8') + .update(accepted.turnId, 'utf8') + .update('\0', 'utf8') + .update(accepted.name, 'utf8') + .update('\0', 'utf8') + .update(accepted.mimeType, 'utf8') + .update('\0', 'utf8') + .update(accepted.bytes) + .digest('hex')}`; + let publication: Promise | undefined; + // Content-derived identities are shared within the Turn. Their bytes live + // until Session cleanup, regardless of which individual projection succeeds. + const ref = Object.freeze({ + kind: 'session_file' as const, + sessionId: accepted.sessionId, + relativePath: id, + }); + return Object.freeze({ + ref, + persist() { + const input = { + id, + sessionId: accepted.sessionId, + turnId: accepted.turnId, + name: accepted.name, + kind: 'image' as const, + content: accepted.bytes, + mimeType: accepted.mimeType, + source: 'tool_result_projection' as const, + }; + publication ??= artifactStore.create(input).then((record) => { + if (record.id !== id) throw new Error('Artifact publication changed its planned id'); + }); + return publication; + }, + }); + }; +} + +export function createReadImageSnapshotter(artifactStore: Pick) { + const planSnapshot = createReadImageSnapshotPlanner(artifactStore); + return async ( + input: ReadImageSnapshotInput, + ): Promise> => { + const plan = planSnapshot(input); + await plan.persist(); + return plan.ref; + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a6f3e04c1b23c93c40cf7553422d48a974d5e60cf0b2a293f95ad981245190ff.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a6f3e04c1b23c93c40cf7553422d48a974d5e60cf0b2a293f95ad981245190ff.source new file mode 100644 index 0000000000..b92105102d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/a6f3e04c1b23c93c40cf7553422d48a974d5e60cf0b2a293f95ad981245190ff.source @@ -0,0 +1,1295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { createReadStream } from 'node:fs'; +import { mkdir, open, readFile, rename, rm, type FileHandle } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + isNonEmptyUnicodeString, + isSha256Digest, + type Sha256Digest, +} from './session-bundle-contract.js'; +import { syncDirectory, syncDirectoryChain } from './stable-storage.js'; +import { + createSessionCheckpointManifestV1, + encodeSessionCheckpointManifestV1, + SESSION_BUNDLE_OBJECT_MEDIA_TYPE, + SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE, + SessionRepositoryError, + type ClaimForkInput, + type CommitSessionRevisionInput, + type CommittedSessionRevision, + type CompleteForkInput, + type CompletedForkOperation, + type CreateSessionInput, + type ForkOperation, + type ImmutableObjectInput, + type ImmutableObjectMaterializationInput, + type ImmutableObjectRef, + type ImmutableObjectStore, + type PendingForkOperation, + type SessionCheckpointManifestV1, + type SessionRepository, + type SessionRepositoryErrorCode, + type SessionRepositoryRevision, + type SessionRevisionRef, + type StoredSessionCheckpoint, +} from './session-repository.js'; +import { withProcessLifetimeFileUpdateLock } from './process-lifetime-file-update-lock.js'; + +const MAX_IDENTIFIER_LENGTH = 512; +const MAX_OBJECT_REF_LENGTH = 2_048; +const STATE_FILE_NAME = 'session-repository-v1.json'; + +export interface OpenFileSessionRepositoryInput { + /** Directory owned by this local adapter. It must not be a live Session root. */ + readonly storageRoot: string; +} + +export interface FileSessionRepository extends SessionRepository { + readonly objectStore: ImmutableObjectStore; +} + +/** + * Opens the durable local adapter. Object bytes are immutable files while the + * small Session control plane is one atomically replaced state document. + */ +export async function openFileSessionRepository( + input: OpenFileSessionRepositoryInput, +): Promise { + if (!isRecord(input)) throw new TypeError('File Session Repository options must be an object'); + const storageRoot = requireIdentifier(input.storageRoot, 'Storage root', MAX_OBJECT_REF_LENGTH); + await mkdir(storageRoot, { recursive: true, mode: 0o700 }); + const objectStore = new FileImmutableObjectStore(join(storageRoot, 'objects'), storageRoot); + return new FileSessionRepositoryAdapter(join(storageRoot, STATE_FILE_NAME), objectStore); +} + +class FileSessionRepositoryAdapter implements FileSessionRepository { + readonly forkIdempotencyRetention = 'indefinite' as const; + + constructor( + private readonly statePath: string, + readonly objectStore: ImmutableObjectStore, + ) {} + + async checkoutCurrent(sessionId: string): Promise { + const admittedSessionId = requireIdentifier(sessionId, 'Session identity'); + const state = await this.readState(); + const session = findSession(state, admittedSessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + const result = copyCommittedSessionRevision(session.head); + await assertCheckpointReadable(this.objectStore, result.checkpoint); + return result; + } + + async checkoutExact(ref: SessionRevisionRef): Promise { + const requested = admitRevisionRef(ref, 'Session revision reference'); + const state = await this.readState(); + const session = findSession(state, requested.sessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (session.head.ref.revision !== requested.revision) { + throw repositoryError( + 'revision_not_available', + 'Requested Session revision is not available', + ); + } + const result = copyCommittedSessionRevision(session.head); + await assertCheckpointReadable(this.objectStore, result.checkpoint); + return result; + } + + async createSession(input: CreateSessionInput): Promise { + const admitted = admitCreateSessionInput(input); + // Match the contract's linearization/error precedence: an existing Session + // is authoritative before an unrelated candidate object is consulted. + const beforeVerification = await this.readState(); + const existing = findSession(beforeVerification, admitted.sessionId); + if (existing) { + const reconciled = reconcileExistingSessionCreate(existing, admitted); + await assertCheckpointReadable(this.objectStore, reconciled.checkpoint); + return copyCommittedSessionRevision(reconciled); + } + await assertCheckpointReadable(this.objectStore, admitted.checkpoint); + const result = await this.mutate((state) => { + const concurrent = findSession(state, admitted.sessionId); + if (concurrent) return reconcileExistingSessionCreate(concurrent, admitted); + if (admitted.createdByForkId !== undefined) assertPendingForkCreate(state, admitted); + const initial = committedRevision({ + sessionId: admitted.sessionId, + revision: 'r1', + agentId: admitted.agentId, + checkpoint: admitted.checkpoint, + lastCommittedActivationId: admitted.lastCommittedActivationId, + forkedFrom: admitted.forkedFrom, + }); + state.sessions.push({ + sessionId: admitted.sessionId, + agentId: admitted.agentId, + head: initial, + nextRevisionNumber: 2, + ...(admitted.forkedFrom === undefined ? {} : { forkedFrom: admitted.forkedFrom }), + ...(admitted.createdByForkId === undefined + ? {} + : { createdByForkId: admitted.createdByForkId }), + createdRevision: initial, + }); + return initial; + }); + await assertCheckpointReadable(this.objectStore, result.checkpoint); + return copyCommittedSessionRevision(result); + } + + async commit(input: CommitSessionRevisionInput): Promise { + const admitted = admitCommitSessionRevisionInput(input); + const existing = await this.readState(); + const prior = findCommit(existing, admitted.sessionId, admitted.commitId); + if (prior) { + assertSameCommitInput(prior.input, admitted); + await assertCheckpointReadable(this.objectStore, prior.result.checkpoint); + return copyCommittedSessionRevision(prior.result); + } + const session = findSession(existing, admitted.sessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (session.head.ref.revision !== admitted.expectedRevision) { + throw repositoryError('revision_conflict', 'Cloud Session head changed before commit'); + } + await assertCheckpointReadable(this.objectStore, admitted.checkpoint); + const result = await this.mutate((state) => { + const repeated = findCommit(state, admitted.sessionId, admitted.commitId); + if (repeated) { + assertSameCommitInput(repeated.input, admitted); + return repeated.result; + } + const session = findSession(state, admitted.sessionId); + if (!session) throw repositoryError('session_not_found', 'Cloud Session was not found'); + if (session.head.ref.revision !== admitted.expectedRevision) { + throw repositoryError('revision_conflict', 'Cloud Session head changed before commit'); + } + const result = committedRevision({ + sessionId: session.sessionId, + revision: `r${session.nextRevisionNumber}`, + agentId: session.agentId, + checkpoint: admitted.checkpoint, + lastCommittedActivationId: admitted.lastCommittedActivationId, + forkedFrom: session.forkedFrom, + }); + session.nextRevisionNumber += 1; + session.head = result; + if (admitted.commitId !== undefined) { + state.commits.push({ + sessionId: admitted.sessionId, + commitId: admitted.commitId, + input: admitted, + result, + }); + } + return result; + }); + await assertCheckpointReadable(this.objectStore, result.checkpoint); + return copyCommittedSessionRevision(result); + } + + async claimFork(input: ClaimForkInput): Promise { + const admitted = admitClaimForkInput(input); + const state = await this.readState(); + const existing = findFork(state, admitted.forkId); + if (existing) return reconcileForkClaim(existing, admitted); + if (admitted.targetSessionId === admitted.source.sessionId) { + throw repositoryError( + 'invalid_fork_target', + 'Fork target Session must differ from its source Session', + ); + } + const source = requireCurrentForkSource(state, admitted.source); + const sourceCheckpoint = admitStoredCheckpoint(source.head.checkpoint); + await assertCheckpointReadable(this.objectStore, sourceCheckpoint); + return this.mutate((latest) => { + const raced = findFork(latest, admitted.forkId); + if (raced) return reconcileForkClaim(raced, admitted); + const stillCurrent = requireCurrentForkSource(latest, admitted.source); + const pending: PersistentPendingFork = { + state: 'pending', + forkId: admitted.forkId, + source: admitted.source, + sourceAgentId: stillCurrent.agentId, + sourceCheckpoint, + targetSessionId: admitted.targetSessionId, + }; + latest.forks.push(pending); + return copyForkOperation(pending); + }); + } + + async completeFork(input: CompleteForkInput): Promise { + const forkId = requireIdentifier(input?.forkId, 'Fork identity'); + const state = await this.readState(); + const operation = findFork(state, forkId); + if (!operation) throw repositoryError('idempotency_conflict', 'Fork identity was not claimed'); + if (operation.state === 'completed') return copyCompletedForkOperation(operation); + const target = requireValidForkTarget(state, operation); + await assertCheckpointReadable(this.objectStore, target.createdRevision.checkpoint); + return this.mutate((latest) => { + const current = findFork(latest, forkId); + if (!current) throw repositoryError('idempotency_conflict', 'Fork identity was not claimed'); + if (current.state === 'completed') return copyCompletedForkOperation(current); + const target = requireValidForkTarget(latest, current); + const completed: PersistentCompletedFork = { + ...current, + state: 'completed', + target: copyRevisionRef(target.createdRevision.ref), + }; + replaceFork(latest, completed); + return copyCompletedForkOperation(completed); + }); + } + + private async readState(): Promise { + try { + const bytes = await readFile(this.statePath); + return decodeState(bytes); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return emptyState(); + if (error instanceof SessionRepositoryError) throw error; + throw repositoryError('io_failure', 'Local Session Repository could not read state', error); + } + } + + private async mutate(operation: (state: PersistentState) => T): Promise { + await mkdir(dirname(this.statePath), { recursive: true, mode: 0o700 }); + try { + // The native lease is released on process death, allowing the next + // writer to reclaim an ownerless marker without stealing a live lock. + return await withProcessLifetimeFileUpdateLock(this.statePath, async () => { + const state = await this.readState(); + const result = operation(state); + await writeStateAtomically(this.statePath, state); + return result; + }); + } catch (error) { + if (error instanceof SessionRepositoryError) throw error; + throw repositoryError('io_failure', 'Local Session Repository could not update state', error); + } + } +} + +class FileImmutableObjectStore implements ImmutableObjectStore { + constructor( + private readonly objectsRoot: string, + private readonly storageRoot: string, + ) {} + + async publish(input: ImmutableObjectInput): Promise { + const admitted = admitImmutableObjectInput(input); + const ref = immutableObjectRef({ + objectRef: localObjectRef(admitted.digest, admitted.mediaType), + digest: admitted.digest, + bytes: admitted.bytes, + mediaType: admitted.mediaType, + }); + const destination = objectPath(this.objectsRoot, ref); + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + const temporary = `${destination}.${randomUUID()}.tmp`; + try { + await writePublishedObject(admitted, temporary); + try { + await linkNoReplace(temporary, destination); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + } + // An EEXIST result means another writer published the same immutable + // name. It is not readable until that writer's directory chain has a + // durability barrier too, so both paths wait for one before returning. + await syncDirectoryChain(dirname(destination), this.storageRoot); + } catch (error) { + throw normalizeFileError(error, 'Immutable object publication failed'); + } finally { + await rm(temporary, { force: true }).catch(() => {}); + } + await this.assertReadable(ref); + return ref; + } + + async assertReadable(input: ImmutableObjectRef): Promise { + const ref = admitImmutableObjectRef(input); + if (ref.objectRef !== localObjectRef(ref.digest, ref.mediaType)) { + throw repositoryError('integrity_mismatch', 'Immutable object reference is not canonical'); + } + try { + await assertFileMatchesImmutableRef(objectPath(this.objectsRoot, ref), ref); + } catch (error) { + if (error instanceof SessionRepositoryError) throw error; + if (isNodeError(error, 'ENOENT')) { + throw repositoryError('object_not_found', 'Immutable object was not found'); + } + throw repositoryError('io_failure', 'Immutable object could not be read', error); + } + } + + async materialize(input: ImmutableObjectMaterializationInput): Promise { + const request = admitImmutableObjectMaterializationInput(input); + if (request.ref.objectRef !== localObjectRef(request.ref.digest, request.ref.mediaType)) { + throw repositoryError('integrity_mismatch', 'Immutable object reference is not canonical'); + } + if (request.ref.bytes > request.maxBytes) { + throw repositoryError( + 'quota_exceeded', + 'Immutable object exceeds materialization byte limit', + ); + } + try { + // Verify the retained object before creating the caller-owned file. The + // copy below verifies it again while streaming, so a corrupt object can + // never be returned merely because it changed between the two reads. + await this.assertReadable(request.ref); + await copyImmutableFile( + objectPath(this.objectsRoot, request.ref), + request.destination, + request.ref, + ); + } catch (error) { + if (error instanceof SessionRepositoryError) throw error; + throw repositoryError('io_failure', 'Immutable object could not be materialized', error); + } + } +} + +interface PersistentState { + readonly schemaVersion: 1; + readonly sessions: PersistentSession[]; + readonly commits: PersistentCommit[]; + readonly forks: PersistentFork[]; +} + +interface PersistentSession { + readonly sessionId: string; + readonly agentId: string; + head: CommittedSessionRevision; + nextRevisionNumber: number; + readonly forkedFrom?: SessionRevisionRef; + readonly createdByForkId?: string; + readonly createdRevision: CommittedSessionRevision; +} + +interface PersistentCommit { + readonly sessionId: string; + readonly commitId: string; + readonly input: InternalCommitInput; + readonly result: CommittedSessionRevision; +} + +interface PersistentPendingFork extends PendingForkOperation {} +interface PersistentCompletedFork extends CompletedForkOperation {} +type PersistentFork = PersistentPendingFork | PersistentCompletedFork; + +interface InternalCommitInput { + readonly sessionId: string; + readonly expectedRevision: SessionRepositoryRevision; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly commitId?: string; +} + +interface InternalCreateInput { + readonly sessionId: string; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; + readonly createdByForkId?: string; +} + +interface InternalClaimForkInput { + readonly forkId: string; + readonly source: SessionRevisionRef; + readonly targetSessionId: string; +} + +function emptyState(): PersistentState { + return { schemaVersion: 1, sessions: [], commits: [], forks: [] }; +} + +function findSession(state: PersistentState, sessionId: string): PersistentSession | undefined { + return state.sessions.find((entry) => entry.sessionId === sessionId); +} + +function findCommit( + state: PersistentState, + sessionId: string, + commitId: string | undefined, +): PersistentCommit | undefined { + return commitId === undefined + ? undefined + : state.commits.find((entry) => entry.sessionId === sessionId && entry.commitId === commitId); +} + +function findFork(state: PersistentState, forkId: string): PersistentFork | undefined { + return state.forks.find((entry) => entry.forkId === forkId); +} + +function replaceFork(state: PersistentState, replacement: PersistentFork): void { + const index = state.forks.findIndex((entry) => entry.forkId === replacement.forkId); + if (index < 0) throw repositoryError('idempotency_conflict', 'Fork identity was not claimed'); + state.forks[index] = replacement; +} + +function requireCurrentForkSource( + state: PersistentState, + source: SessionRevisionRef, +): PersistentSession { + const session = findSession(state, source.sessionId); + if (!session || session.head.ref.revision !== source.revision) { + throw repositoryError( + 'source_revision_not_available', + 'Fork source Session revision is not available', + ); + } + return session; +} + +function requireValidForkTarget( + state: PersistentState, + operation: PersistentPendingFork, +): PersistentSession { + const target = findSession(state, operation.targetSessionId); + if (!target) throw repositoryError('session_not_found', 'Fork target Session was not found'); + if (target.agentId !== operation.sourceAgentId) { + throw repositoryError( + 'fork_agent_mismatch', + 'Fork target Agent does not match its source Agent', + ); + } + if ( + target.createdByForkId !== operation.forkId || + !target.forkedFrom || + !sameRevisionRef(target.forkedFrom, operation.source) + ) { + throw repositoryError('idempotency_conflict', 'Fork target was created by another operation'); + } + return target; +} + +function assertPendingForkCreate(state: PersistentState, input: InternalCreateInput): void { + const operation = + input.createdByForkId === undefined ? undefined : findFork(state, input.createdByForkId); + if ( + !operation || + operation.state !== 'pending' || + operation.targetSessionId !== input.sessionId || + !input.forkedFrom || + !sameRevisionRef(operation.source, input.forkedFrom) + ) { + throw repositoryError( + 'idempotency_conflict', + 'Fork target does not match its claimed operation', + ); + } + if (operation.sourceAgentId !== input.agentId) { + throw repositoryError('fork_agent_mismatch', 'Fork target Agent must match its source Agent'); + } +} + +function reconcileExistingSessionCreate( + existing: PersistentSession, + input: InternalCreateInput, +): CommittedSessionRevision { + if ( + input.createdByForkId !== undefined && + existing.createdByForkId === input.createdByForkId && + existing.agentId === input.agentId && + sameStoredCheckpoint(existing.createdRevision.checkpoint, input.checkpoint) && + existing.createdRevision.lastCommittedActivationId === input.lastCommittedActivationId && + sameOptionalRevisionRef(existing.forkedFrom, input.forkedFrom) + ) { + return existing.createdRevision; + } + throw repositoryError('session_already_exists', 'Cloud Session already exists'); +} + +function reconcileForkClaim( + operation: PersistentFork, + input: InternalClaimForkInput, +): ForkOperation { + if ( + operation.targetSessionId !== input.targetSessionId || + !sameRevisionRef(operation.source, input.source) + ) { + throw repositoryError('idempotency_conflict', 'Fork identity was reused with different input'); + } + return copyForkOperation(operation); +} + +function assertSameCommitInput(left: InternalCommitInput, right: InternalCommitInput): void { + if ( + left.sessionId !== right.sessionId || + left.expectedRevision !== right.expectedRevision || + !sameStoredCheckpoint(left.checkpoint, right.checkpoint) || + left.lastCommittedActivationId !== right.lastCommittedActivationId || + left.commitId !== right.commitId + ) { + throw repositoryError( + 'idempotency_conflict', + 'Commit identity was reused with different input', + ); + } +} + +async function assertCheckpointReadable( + objectStore: ImmutableObjectStore, + checkpoint: StoredSessionCheckpoint, +): Promise { + const admitted = admitStoredCheckpoint(checkpoint); + try { + await objectStore.assertReadable(admitted.manifest); + await objectStore.assertReadable(admitted.value.compatibilityBundle); + } catch (error) { + if (error instanceof SessionRepositoryError) throw error; + throw repositoryError('io_failure', 'Immutable Object Store operation failed', error); + } +} + +function admitCommitSessionRevisionInput(input: CommitSessionRevisionInput): InternalCommitInput { + if (!isRecord(input)) throw new TypeError('Session commit input must be an object'); + return { + sessionId: requireIdentifier(input.sessionId, 'Session identity'), + expectedRevision: requireIdentifier(input.expectedRevision, 'Expected revision'), + checkpoint: admitStoredCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { + lastCommittedActivationId: requireIdentifier( + input.lastCommittedActivationId, + 'Activation identity', + ), + }), + ...(input.commitId === undefined + ? {} + : { commitId: requireIdentifier(input.commitId, 'Commit identity') }), + }; +} + +function admitCreateSessionInput(input: CreateSessionInput): InternalCreateInput { + if (!isRecord(input)) throw new TypeError('Session creation input must be an object'); + const forkedFrom = + input.forkedFrom === undefined ? undefined : admitRevisionRef(input.forkedFrom, 'Fork source'); + const createdByForkId = + input.createdByForkId === undefined + ? undefined + : requireIdentifier(input.createdByForkId, 'Fork identity'); + if ((forkedFrom === undefined) !== (createdByForkId === undefined)) { + throw new TypeError('Fork lineage and Fork identity must be supplied together'); + } + if (createdByForkId !== undefined && input.lastCommittedActivationId !== undefined) { + throw new TypeError('Fork-created Session must not carry an Activation identity'); + } + return { + sessionId: requireIdentifier(input.sessionId, 'Session identity'), + agentId: requireIdentifier(input.agentId, 'Agent identity'), + checkpoint: admitStoredCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { + lastCommittedActivationId: requireIdentifier( + input.lastCommittedActivationId, + 'Activation identity', + ), + }), + ...(forkedFrom === undefined ? {} : { forkedFrom }), + ...(createdByForkId === undefined ? {} : { createdByForkId }), + }; +} + +function admitClaimForkInput(input: ClaimForkInput): InternalClaimForkInput { + if (!isRecord(input)) throw new TypeError('Fork claim input must be an object'); + return { + forkId: requireIdentifier(input.forkId, 'Fork identity'), + source: admitRevisionRef(input.source, 'Fork source'), + targetSessionId: requireIdentifier(input.targetSessionId, 'Fork target Session identity'), + }; +} + +function admitStoredCheckpoint(input: StoredSessionCheckpoint): StoredSessionCheckpoint { + if (!isRecord(input)) throw new TypeError('Stored Session checkpoint must be an object'); + const manifest = admitImmutableObjectRef(input.manifest); + if (!isRecord(input.value) || input.value.schemaVersion !== 1) { + throw new TypeError('Session checkpoint Manifest schema version must be 1'); + } + const value = createSessionCheckpointManifestV1( + admitImmutableObjectRef(input.value.compatibilityBundle), + ); + if (manifest.mediaType !== SESSION_CHECKPOINT_MANIFEST_MEDIA_TYPE) { + throw new TypeError('Session checkpoint Manifest has an unsupported media type'); + } + const bytes = encodeSessionCheckpointManifestV1(value); + if (manifest.digest !== digestBytes(bytes) || manifest.bytes !== bytes.byteLength) { + throw repositoryError('integrity_mismatch', 'Manifest value does not match its reference'); + } + return { manifest, value }; +} + +function admitImmutableObjectInput(input: ImmutableObjectInput): ImmutableObjectInput { + if (!isRecord(input) || !isSha256Digest(input.digest) || !isByteCount(input.bytes)) { + throw new TypeError('Immutable object input is invalid'); + } + const mediaType = requireIdentifier(input.mediaType, 'Immutable object media type'); + if (!isRecord(input.source)) throw new TypeError('Immutable object source is invalid'); + if (input.source.kind === 'file') { + return { + ...input, + mediaType, + source: { + kind: 'file', + path: requireIdentifier(input.source.path, 'Object source path', MAX_OBJECT_REF_LENGTH), + }, + }; + } + if (input.source.kind === 'bytes' && input.source.value instanceof Uint8Array) { + return { + ...input, + mediaType, + source: { kind: 'bytes', value: Uint8Array.from(input.source.value) }, + }; + } + throw new TypeError('Immutable object source is invalid'); +} + +function admitImmutableObjectMaterializationInput( + input: ImmutableObjectMaterializationInput, +): ImmutableObjectMaterializationInput { + if (!isRecord(input)) { + throw new TypeError('Immutable object materialization input must be an object'); + } + if (!isByteCount(input.maxBytes)) { + throw new TypeError( + 'Immutable object materialization byte limit must be a non-negative safe integer', + ); + } + return { + ref: admitImmutableObjectRef(input.ref), + destination: requireIdentifier( + input.destination, + 'Immutable object materialization destination', + MAX_OBJECT_REF_LENGTH, + ), + maxBytes: input.maxBytes, + }; +} + +function admitImmutableObjectRef(input: ImmutableObjectRef): ImmutableObjectRef { + if (!isRecord(input) || !isSha256Digest(input.digest) || !isByteCount(input.bytes)) { + throw new TypeError('Immutable object reference is invalid'); + } + return immutableObjectRef({ + objectRef: requireIdentifier( + input.objectRef, + 'Immutable object reference', + MAX_OBJECT_REF_LENGTH, + ), + digest: input.digest, + bytes: input.bytes, + mediaType: requireIdentifier(input.mediaType, 'Immutable object media type'), + }); +} + +function admitRevisionRef(input: SessionRevisionRef, label: string): SessionRevisionRef { + if (!isRecord(input)) throw new TypeError(`${label} must be an object`); + return copyRevisionRef({ + sessionId: requireIdentifier(input.sessionId, `${label} Session identity`), + revision: requireIdentifier(input.revision, `${label} revision`), + }); +} + +function committedRevision(input: { + readonly sessionId: string; + readonly revision: string; + readonly agentId: string; + readonly checkpoint: StoredSessionCheckpoint; + readonly lastCommittedActivationId?: string; + readonly forkedFrom?: SessionRevisionRef; +}): CommittedSessionRevision { + return { + ref: copyRevisionRef({ sessionId: input.sessionId, revision: input.revision }), + agentId: input.agentId, + checkpoint: admitStoredCheckpoint(input.checkpoint), + ...(input.lastCommittedActivationId === undefined + ? {} + : { lastCommittedActivationId: input.lastCommittedActivationId }), + ...(input.forkedFrom === undefined ? {} : { forkedFrom: copyRevisionRef(input.forkedFrom) }), + }; +} + +function copyCommittedSessionRevision(input: CommittedSessionRevision): CommittedSessionRevision { + return committedRevision({ + sessionId: input.ref.sessionId, + revision: input.ref.revision, + agentId: input.agentId, + checkpoint: input.checkpoint, + ...(input.lastCommittedActivationId === undefined + ? {} + : { lastCommittedActivationId: input.lastCommittedActivationId }), + ...(input.forkedFrom === undefined ? {} : { forkedFrom: input.forkedFrom }), + }); +} + +function immutableObjectRef(input: ImmutableObjectRef): ImmutableObjectRef { + return { + objectRef: input.objectRef, + digest: input.digest, + bytes: input.bytes, + mediaType: input.mediaType, + }; +} + +function copyRevisionRef(input: SessionRevisionRef): SessionRevisionRef { + return { sessionId: input.sessionId, revision: input.revision }; +} + +function copyForkOperation(input: PersistentFork): ForkOperation { + return input.state === 'pending' + ? { + state: 'pending', + forkId: input.forkId, + source: copyRevisionRef(input.source), + sourceAgentId: input.sourceAgentId, + sourceCheckpoint: admitStoredCheckpoint(input.sourceCheckpoint), + targetSessionId: input.targetSessionId, + } + : copyCompletedForkOperation(input); +} + +function copyCompletedForkOperation(input: PersistentCompletedFork): CompletedForkOperation { + return { + state: 'completed', + forkId: input.forkId, + source: copyRevisionRef(input.source), + sourceAgentId: input.sourceAgentId, + sourceCheckpoint: admitStoredCheckpoint(input.sourceCheckpoint), + targetSessionId: input.targetSessionId, + target: copyRevisionRef(input.target), + }; +} + +function sameStoredCheckpoint( + left: StoredSessionCheckpoint, + right: StoredSessionCheckpoint, +): boolean { + return ( + sameImmutableObjectRef(left.manifest, right.manifest) && + sameImmutableObjectRef(left.value.compatibilityBundle, right.value.compatibilityBundle) + ); +} + +function sameImmutableObjectRef(left: ImmutableObjectRef, right: ImmutableObjectRef): boolean { + return ( + left.objectRef === right.objectRef && + left.digest === right.digest && + left.bytes === right.bytes && + left.mediaType === right.mediaType + ); +} + +function sameRevisionRef(left: SessionRevisionRef, right: SessionRevisionRef): boolean { + return left.sessionId === right.sessionId && left.revision === right.revision; +} + +function sameOptionalRevisionRef( + left: SessionRevisionRef | undefined, + right: SessionRevisionRef | undefined, +): boolean { + return left === undefined || right === undefined ? left === right : sameRevisionRef(left, right); +} + +async function writePublishedObject( + input: ImmutableObjectInput, + destination: string, +): Promise { + if (input.source.kind === 'bytes') { + const bytes = Uint8Array.from(input.source.value); + assertImmutableBytesMatch(bytes, input.digest, input.bytes); + await writeNewFile(destination, async (handle) => writeAll(handle, bytes)); + return; + } + await copyImmutableFile(input.source.path, destination, { + digest: input.digest, + bytes: input.bytes, + }); +} + +/** + * Copies and verifies in chunks. The supplied reference is deliberately the + * bound: a malicious or changing source can never make this adapter retain an + * unbounded in-memory buffer or publish more bytes than it declared. + */ +async function copyImmutableFile( + source: string, + destination: string, + ref: Pick, +): Promise { + await writeNewFile(destination, async (handle) => { + const digest = createHash('sha256'); + let bytes = 0; + for await (const rawChunk of createReadStream(source)) { + const chunk = Buffer.from(rawChunk); + bytes += chunk.byteLength; + if (bytes > ref.bytes) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes exceed declared metadata', + ); + } + digest.update(chunk); + await writeAll(handle, chunk); + } + assertImmutableDigestMatch(bytes, digest.digest('hex'), ref.bytes, ref.digest); + }); +} + +async function assertFileMatchesImmutableRef(path: string, ref: ImmutableObjectRef): Promise { + const digest = createHash('sha256'); + let bytes = 0; + for await (const rawChunk of createReadStream(path)) { + const chunk = Buffer.from(rawChunk); + bytes += chunk.byteLength; + if (bytes > ref.bytes) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes no longer match metadata', + ); + } + digest.update(chunk); + } + assertImmutableDigestMatch(bytes, digest.digest('hex'), ref.bytes, ref.digest); +} + +async function writeNewFile( + path: string, + writer: (handle: FileHandle) => Promise, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, 'wx', 0o600); + await writer(handle); + await handle.sync(); + } catch (error) { + if (handle) { + await handle.close().catch(() => {}); + handle = undefined; + await rm(path, { force: true }).catch(() => {}); + } + throw error; + } finally { + if (handle) await handle.close(); + } +} + +async function writeAll(handle: FileHandle, value: Uint8Array): Promise { + const bytes = Buffer.from(value); + let offset = 0; + while (offset < bytes.byteLength) { + const result = await handle.write(bytes, offset, bytes.byteLength - offset, null); + if (result.bytesWritten <= 0) { + throw repositoryError('io_failure', 'Immutable object write made no progress'); + } + offset += result.bytesWritten; + } +} + +function assertImmutableBytesMatch( + bytes: Uint8Array, + expectedDigest: Sha256Digest, + expectedBytes: number, +): void { + if (bytes.byteLength !== expectedBytes || digestBytes(bytes) !== expectedDigest) { + throw repositoryError( + 'integrity_mismatch', + 'Immutable object bytes do not match declared metadata', + ); + } +} + +function assertImmutableDigestMatch( + actualBytes: number, + digestHex: string, + expectedBytes: number, + expectedDigest: Sha256Digest, +): void { + const actualDigest = `sha256:${digestHex}`; + if (actualBytes !== expectedBytes || actualDigest !== expectedDigest) { + throw repositoryError('integrity_mismatch', 'Immutable object bytes no longer match metadata'); + } +} + +function localObjectRef(digest: Sha256Digest, mediaType: string): string { + return `maka-local-object://v1/${createHash('sha256').update(`${digest}\u0000${mediaType}`).digest('hex')}`; +} + +function objectPath(objectsRoot: string, ref: ImmutableObjectRef): string { + const id = ref.objectRef.slice('maka-local-object://v1/'.length); + return join(objectsRoot, id.slice(0, 2), id); +} + +async function linkNoReplace(source: string, destination: string): Promise { + const { link } = await import('node:fs/promises'); + await link(source, destination); +} + +async function writeStateAtomically(path: string, state: PersistentState): Promise { + const temporary = `${path}.${randomUUID()}.tmp`; + const bytes = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8'); + try { + const handle = await open( + temporary, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, + 0o600, + ); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, path); + await syncDirectory(dirname(path)); + } finally { + await rm(temporary, { force: true }).catch(() => {}); + } +} + +function decodeState(bytes: Uint8Array): PersistentState { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch (error) { + throw repositoryError( + 'integrity_mismatch', + 'Local Session Repository state is invalid JSON', + error, + ); + } + if ( + !isRecord(parsed) || + parsed.schemaVersion !== 1 || + !Array.isArray(parsed.sessions) || + !Array.isArray(parsed.commits) || + !Array.isArray(parsed.forks) + ) { + throw repositoryError('integrity_mismatch', 'Local Session Repository state schema is invalid'); + } + try { + const state: PersistentState = { + schemaVersion: 1, + sessions: parsed.sessions.map(decodeSession), + commits: parsed.commits.map(decodeCommit), + forks: parsed.forks.map(decodeFork), + }; + assertUnique(state.sessions, (entry) => entry.sessionId, 'Session identity'); + assertUnique( + state.commits, + (entry) => JSON.stringify([entry.sessionId, entry.commitId]), + 'Commit identity', + ); + assertUnique(state.forks, (entry) => entry.forkId, 'Fork identity'); + assertPersistentStateConsistency(state); + return state; + } catch (error) { + if (error instanceof SessionRepositoryError) throw error; + throw repositoryError('integrity_mismatch', 'Local Session Repository state is invalid', error); + } +} + +function decodeSession(value: unknown): PersistentSession { + if (!isRecord(value)) throw new TypeError('Session state is invalid'); + const sessionId = requireIdentifier(value.sessionId, 'Session identity'); + const agentId = requireIdentifier(value.agentId, 'Agent identity'); + const forkedFrom = + value.forkedFrom === undefined + ? undefined + : admitRevisionRef(value.forkedFrom as SessionRevisionRef, 'Fork source'); + const createdByForkId = + value.createdByForkId === undefined + ? undefined + : requireIdentifier(value.createdByForkId, 'Fork identity'); + if ((forkedFrom === undefined) !== (createdByForkId === undefined)) { + throw new TypeError('Session Fork lineage and Fork identity must be supplied together'); + } + const head = decodeCommitted(value.head); + const createdRevision = decodeCommitted(value.createdRevision); + if ( + head.ref.sessionId !== sessionId || + head.agentId !== agentId || + createdRevision.ref.sessionId !== sessionId || + createdRevision.agentId !== agentId + ) { + throw new TypeError('Session state identity binding is invalid'); + } + return { + sessionId, + agentId, + head, + nextRevisionNumber: requireRevisionNumber(value.nextRevisionNumber), + ...(forkedFrom === undefined ? {} : { forkedFrom }), + ...(createdByForkId === undefined ? {} : { createdByForkId }), + createdRevision, + }; +} + +function decodeCommit(value: unknown): PersistentCommit { + if (!isRecord(value)) throw new TypeError('Commit state is invalid'); + const input = admitCommitSessionRevisionInput(value.input as CommitSessionRevisionInput); + if (input.commitId === undefined) throw new TypeError('Commit state lacks identity'); + const sessionId = requireIdentifier(value.sessionId, 'Session identity'); + const commitId = requireIdentifier(value.commitId, 'Commit identity'); + const result = decodeCommitted(value.result); + if ( + input.sessionId !== sessionId || + input.commitId !== commitId || + result.ref.sessionId !== sessionId + ) { + throw new TypeError('Commit state identity binding is invalid'); + } + return { + sessionId, + commitId, + input, + result, + }; +} + +function decodeFork(value: unknown): PersistentFork { + if (!isRecord(value)) throw new TypeError('Fork state is invalid'); + const base = { + forkId: requireIdentifier(value.forkId, 'Fork identity'), + source: admitRevisionRef(value.source as SessionRevisionRef, 'Fork source'), + sourceAgentId: requireIdentifier(value.sourceAgentId, 'Fork source Agent identity'), + sourceCheckpoint: admitStoredCheckpoint(value.sourceCheckpoint as StoredSessionCheckpoint), + targetSessionId: requireIdentifier(value.targetSessionId, 'Fork target Session identity'), + }; + if (value.state === 'pending') return { state: 'pending', ...base }; + if (value.state === 'completed') { + return { + state: 'completed', + ...base, + target: admitRevisionRef(value.target as SessionRevisionRef, 'Fork target'), + }; + } + throw new TypeError('Fork state is invalid'); +} + +function decodeCommitted(value: unknown): CommittedSessionRevision { + if (!isRecord(value)) throw new TypeError('Committed revision is invalid'); + return committedRevision({ + sessionId: admitRevisionRef(value.ref as SessionRevisionRef, 'Session revision reference') + .sessionId, + revision: admitRevisionRef(value.ref as SessionRevisionRef, 'Session revision reference') + .revision, + agentId: requireIdentifier(value.agentId, 'Agent identity'), + checkpoint: admitStoredCheckpoint(value.checkpoint as StoredSessionCheckpoint), + ...(value.lastCommittedActivationId === undefined + ? {} + : { + lastCommittedActivationId: requireIdentifier( + value.lastCommittedActivationId, + 'Activation identity', + ), + }), + ...(value.forkedFrom === undefined + ? {} + : { forkedFrom: admitRevisionRef(value.forkedFrom as SessionRevisionRef, 'Fork source') }), + }); +} + +function requireRevisionNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 2) + throw new TypeError('Next revision number is invalid'); + return value; +} + +/** + * JSON syntax and individual field validation are not sufficient for the + * control document: the records must describe one non-contradictory Session + * history before any writer derives a new revision from it. + */ +function assertPersistentStateConsistency(state: PersistentState): void { + for (const session of state.sessions) { + assertPersistentSessionConsistency(session); + assertPersistentCreatedForkTarget(state, session); + } + + const committedRevisions = new Set(); + for (const commit of state.commits) { + const session = findSession(state, commit.sessionId); + if (!session) throw new TypeError('Commit receipt references an unknown Session'); + const expectedRevision = revisionNumber( + commit.input.expectedRevision, + 'Commit expected revision', + ); + const resultRevision = revisionNumber(commit.result.ref.revision, 'Commit result revision'); + const headRevision = revisionNumber(session.head.ref.revision, 'Session head revision'); + if ( + resultRevision < 2 || + resultRevision > headRevision || + expectedRevision + 1 !== resultRevision || + commit.result.agentId !== session.agentId || + !sameStoredCheckpoint(commit.input.checkpoint, commit.result.checkpoint) || + commit.input.lastCommittedActivationId !== commit.result.lastCommittedActivationId || + !sameOptionalRevisionRef(commit.result.forkedFrom, session.forkedFrom) || + (resultRevision === headRevision && + !sameCommittedSessionRevision(commit.result, session.head)) + ) { + throw new TypeError('Commit receipt contradicts Session state'); + } + const key = `${commit.sessionId}\u0000${commit.result.ref.revision}`; + if (committedRevisions.has(key)) throw new TypeError('Commit result revision is duplicated'); + committedRevisions.add(key); + } + + for (const fork of state.forks) assertPersistentForkConsistency(state, fork); +} + +function assertPersistentSessionConsistency(session: PersistentSession): void { + const createdRevision = revisionNumber( + session.createdRevision.ref.revision, + 'Session creation revision', + ); + const headRevision = revisionNumber(session.head.ref.revision, 'Session head revision'); + if ( + createdRevision !== 1 || + headRevision >= Number.MAX_SAFE_INTEGER || + session.nextRevisionNumber !== headRevision + 1 || + !sameOptionalRevisionRef(session.createdRevision.forkedFrom, session.forkedFrom) || + !sameOptionalRevisionRef(session.head.forkedFrom, session.forkedFrom) || + (session.createdByForkId !== undefined && + session.createdRevision.lastCommittedActivationId !== undefined) + ) { + throw new TypeError('Session revision sequence is inconsistent'); + } + if (headRevision === 1 && !sameCommittedSessionRevision(session.head, session.createdRevision)) { + throw new TypeError('Session initial head contradicts creation revision'); + } +} + +function assertPersistentForkConsistency(state: PersistentState, fork: PersistentFork): void { + if (fork.source.sessionId === fork.targetSessionId) { + throw new TypeError('Fork target Session must differ from its source Session'); + } + const source = findSession(state, fork.source.sessionId); + if (!source || source.agentId !== fork.sourceAgentId) { + throw new TypeError('Fork source Agent binding is inconsistent'); + } + const target = findSession(state, fork.targetSessionId); + if (fork.state === 'completed') { + if ( + !target || + target.agentId !== fork.sourceAgentId || + target.createdByForkId !== fork.forkId || + !target.forkedFrom || + !sameRevisionRef(target.forkedFrom, fork.source) || + !sameRevisionRef(fork.target, target.createdRevision.ref) + ) { + throw new TypeError('Completed Fork target is inconsistent'); + } + } +} + +function assertPersistentCreatedForkTarget( + state: PersistentState, + session: PersistentSession, +): void { + if (session.createdByForkId === undefined || session.forkedFrom === undefined) return; + const fork = findFork(state, session.createdByForkId); + if ( + !fork || + fork.targetSessionId !== session.sessionId || + fork.sourceAgentId !== session.agentId || + !sameRevisionRef(fork.source, session.forkedFrom) + ) { + throw new TypeError('Fork-created Session does not match its claimed operation'); + } +} + +function revisionNumber(revision: string, label: string): number { + const match = /^r([1-9][0-9]*)$/u.exec(revision); + if (!match) throw new TypeError(`${label} is not a canonical revision`); + const value = Number(match[1]); + if (!Number.isSafeInteger(value)) throw new TypeError(`${label} is outside the safe range`); + return value; +} + +function sameCommittedSessionRevision( + left: CommittedSessionRevision, + right: CommittedSessionRevision, +): boolean { + return ( + sameRevisionRef(left.ref, right.ref) && + left.agentId === right.agentId && + sameStoredCheckpoint(left.checkpoint, right.checkpoint) && + left.lastCommittedActivationId === right.lastCommittedActivationId && + sameOptionalRevisionRef(left.forkedFrom, right.forkedFrom) + ); +} + +function assertUnique(values: readonly T[], key: (value: T) => string, label: string): void { + const seen = new Set(); + for (const value of values) { + const current = key(value); + if (seen.has(current)) throw new TypeError(`${label} is duplicated`); + seen.add(current); + } +} + +function requireIdentifier( + value: unknown, + label: string, + maximumLength = MAX_IDENTIFIER_LENGTH, +): string { + if (!isNonEmptyUnicodeString(value) || value.length > maximumLength) { + throw new TypeError(`${label} must be a bounded non-empty Unicode string`); + } + return value; +} + +function isByteCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function digestBytes(value: Uint8Array): Sha256Digest { + return `sha256:${createHash('sha256').update(value).digest('hex')}` as Sha256Digest; +} + +function repositoryError( + code: SessionRepositoryErrorCode, + message: string, + cause?: unknown, +): SessionRepositoryError { + return new SessionRepositoryError(code, message, cause === undefined ? {} : { cause }); +} + +function normalizeFileError(error: unknown, message: string): SessionRepositoryError { + if (error instanceof SessionRepositoryError) return error; + return repositoryError('io_failure', message, error); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aa2efed09f81648f7218260dc6b76168e75e28357c57b75a5c8f4b2e73164480.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aa2efed09f81648f7218260dc6b76168e75e28357c57b75a5c8f4b2e73164480.source new file mode 100644 index 0000000000..4f38aa5494 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aa2efed09f81648f7218260dc6b76168e75e28357c57b75a5c8f4b2e73164480.source @@ -0,0 +1,929 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { realpath, stat } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'; +import type { ProjectLocation, ProjectRecord } from '@maka/core/project'; +import type { SessionHeader } from '@maka/core/session'; +import { markPersisted } from '@maka/core/persisted-value'; +import { execGitText } from './git-exec.js'; +import { hasEnclosingGitEntry } from './git-entry.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { decodePersistedSessionHeader, normalizeSessionHeader } from './session-store.js'; + +export type { ProjectLocation, ProjectRecord } from '@maka/core/project'; + +export class ProjectPathMismatchError extends Error { + readonly name = 'ProjectPathMismatchError'; + readonly code = 'project_path_mismatch'; + + constructor( + readonly projectId: string, + readonly path: string, + ) { + super(`Path does not belong to project ${projectId}: ${path}`); + } +} + +export class ProjectNotFoundError extends Error { + readonly name = 'ProjectNotFoundError'; + readonly code = 'project_not_found'; + + constructor(readonly projectId: string) { + super(`No such project: ${projectId}`); + } +} + +export class ProjectArchivedError extends Error { + readonly name = 'ProjectArchivedError'; + readonly code = 'project_archived'; + + constructor(readonly projectId: string) { + super(`Project is archived: ${projectId}`); + } +} + +export class ProjectUnavailableError extends Error { + readonly name = 'ProjectUnavailableError'; + readonly code = 'project_unavailable'; + + constructor(readonly projectId: string) { + super(`Project is unavailable: ${projectId}`); + } +} + +export class ProjectPathConflictError extends Error { + readonly name = 'ProjectPathConflictError'; + readonly code = 'project_path_conflict'; + + constructor(readonly conflictingProjectId: string) { + super(`Project path already belongs to project: ${conflictingProjectId}`); + } +} + +export class ProjectPathBoundaryError extends TypeError { + readonly name = 'ProjectPathBoundaryError'; + readonly code = 'project_path_outside_boundary'; + + constructor(readonly path: string) { + super(`Project path is outside its registration boundary: ${path}`); + } +} + +export function isProjectPathMismatchError(error: unknown): error is ProjectPathMismatchError { + return error instanceof ProjectPathMismatchError; +} + +export interface ProjectCatalog { + list(): Promise; + register(path: string, options?: ProjectRegistrationOptions): Promise; + /** + * Resolve a path recorded by an existing session rather than chosen by the + * user. Unlike `register`, the directory may already be gone — a session + * outlives the folder it ran in — so a missing path still yields a stable + * folder identity instead of failing. + */ + resolveHistoricalPath(path: string, usedAt?: number): Promise; + select(projectId: string): Promise<{ project: ProjectRecord; path: string }>; + touch(projectId: string, path?: string): Promise; + relink(projectId: string, path: string): Promise; + relinkWithSessions( + projectId: string, + path: string, + ): Promise<{ project: ProjectRecord; updatedSessionIds: readonly string[] }>; + rename(projectId: string, name: string): Promise; + archive(projectId: string): Promise; + restore(projectId: string): Promise; + /** Release this catalog's share of the operational database. */ + close(): void; +} + +export interface ProjectRegistrationOptions { + /** + * Require the final canonical location persisted by the catalog to remain + * inside this directory. The check happens after path resolution so a + * pathname replaced between authorization and registration cannot escape + * its published boundary. + */ + readonly withinRoot?: string; + /** + * Whether an additional location should be recorded as recently used. A new + * project still establishes its sole location as the initial preference. + */ + readonly prefer?: boolean; +} + +interface PersistedProject { + id: string; + aliases?: string[]; + name: string; + identity: string; + locations: PersistedProjectLocation[]; + lastUsedAt: number; + archivedAt?: number; +} + +interface PersistedProjectLocation extends ProjectLocation { + lastUsedAt: number; +} + +interface ProjectCatalogFile { + schemaVersion: 1; + projects: PersistedProject[]; +} + +export function createProjectCatalog( + storageRoot: string, + deps: { + now?: () => number; + createId?: () => string; + relinkFailpoint?: (stage: 'after_session_updates') => void; + } = {}, +): ProjectCatalog { + return new SqliteProjectCatalog( + acquireOperationalStateDatabase(storageRoot), + deps.now ?? Date.now, + deps.createId ?? randomUUID, + deps.relinkFailpoint, + ); +} + +/** + * The project catalog is operational state: it decides how every session is + * organized, and it has to survive backup and restore alongside the sessions + * it groups. Keeping it in its own JSON file left it outside the operational + * database — and therefore outside `createOperationalStateBackup`, which only + * captures `runtime.sqlite` plus the Artifact tree. + * + * Only the persistence layer moved. Read-modify-write under a serial queue, + * the whole-catalog validation on every write, and each method's semantics are + * unchanged, so the catalog contract tests carry over as-is. + */ +class SqliteProjectCatalog implements ProjectCatalog { + private queue: Promise = Promise.resolve(); + + constructor( + private readonly lease: OperationalStateDatabaseLease, + private readonly now: () => number, + private readonly createId: () => string, + private readonly relinkFailpoint?: (stage: 'after_session_updates') => void, + ) {} + + close(): void { + this.lease.close(); + } + + async list(): Promise { + const projects = (await this.read()).projects; + projects.sort( + (a, b) => + Number(a.archivedAt !== undefined) - Number(b.archivedAt !== undefined) || + b.lastUsedAt - a.lastUsedAt || + a.id.localeCompare(b.id), + ); + return Promise.all(projects.map((project) => this.present(project))); + } + + async register(path: string, options?: ProjectRegistrationOptions): Promise { + const resolved = await resolveUserSelectedProjectLocation(path); + if (options?.withinRoot && !isPathWithin(options.withinRoot, resolved.canonicalPath)) { + throw new ProjectPathBoundaryError(resolved.canonicalPath); + } + return this.upsertResolvedProject(resolved, this.now(), options?.prefer !== false); + } + + async resolveHistoricalPath(path: string, usedAt: number = this.now()): Promise { + let resolved: ResolvedProjectLocation; + try { + resolved = await resolveProjectLocation({ path }); + } catch (error) { + if (!isUnreachablePathError(error)) throw error; + let pathIsMissing = false; + try { + await stat(path); + } catch (pathError) { + pathIsMissing = isUnreachablePathError(pathError); + } + if (!pathIsMissing) throw error; + const canonicalPath = await canonicalizeMissingPath(path); + resolved = { + canonicalPath, + identity: `folder:${canonicalPath}`, + kind: 'folder', + }; + } + return this.upsertResolvedProject(resolved, usedAt); + } + + private async upsertResolvedProject( + resolved: ResolvedProjectLocation, + timestamp: number, + prefer = true, + ): Promise { + const registered = await this.mutate((file) => { + const locationPath = + resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; + const existing = file.projects.find((project) => project.identity === resolved.identity); + if (existing) { + const location = existing.locations.find((item) => item.path === locationPath); + if (location) { + if (prefer) location.lastUsedAt = Math.max(location.lastUsedAt, timestamp); + location.isWorktree = resolved.git?.isWorktree ?? false; + } else { + existing.locations.push({ + path: locationPath, + isWorktree: resolved.git?.isWorktree ?? false, + lastUsedAt: prefer ? timestamp : 0, + }); + } + existing.lastUsedAt = Math.max(existing.lastUsedAt, timestamp); + return existing; + } + const project: PersistedProject = { + id: this.createId(), + name: defaultProjectName(resolved), + identity: resolved.identity, + locations: [ + { + path: locationPath, + isWorktree: resolved.git?.isWorktree ?? false, + lastUsedAt: timestamp, + }, + ], + lastUsedAt: timestamp, + }; + file.projects.push(project); + return project; + }); + return this.present(registered); + } + + async select(projectId: string): Promise<{ project: ProjectRecord; path: string }> { + let selected: PersistedProject | undefined; + let selectedPath: string | undefined; + await this.withQueue(async () => { + // Probing the filesystem cannot happen inside the write transaction, so + // availability is decided first and the choice is re-validated under it. + const existing = findProjectById((await this.read()).projects, projectId); + if (!existing) throw new ProjectNotFoundError(projectId); + const availablePaths = new Set( + ( + await Promise.all( + existing.locations.map(async (location) => + (await isDirectory(location.path)) ? location.path : undefined, + ), + ) + ).filter((path): path is string => path !== undefined), + ); + [selected, selectedPath] = await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + if (project.archivedAt !== undefined) { + throw new ProjectArchivedError(projectId); + } + const location = project.locations + .filter((item) => availablePaths.has(item.path)) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.path.localeCompare(b.path))[0]; + if (!location) throw new ProjectUnavailableError(projectId); + const timestamp = this.now(); + location.lastUsedAt = timestamp; + project.lastUsedAt = timestamp; + return [project, location.path] as const; + }); + }); + if (!selected || !selectedPath) throw new Error(`Failed to select project: ${projectId}`); + return { project: await this.present(selected), path: selectedPath }; + } + + async touch(projectId: string, path?: string): Promise { + let canonicalPath: string | undefined; + if (path) { + try { + canonicalPath = normalize(await realpath(resolve(path))); + } catch { + throw new ProjectUnavailableError(projectId); + } + } + const touched = await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + const location = canonicalPath + ? project.locations.find((item) => item.path === canonicalPath) + : [...project.locations].sort( + (a, b) => b.lastUsedAt - a.lastUsedAt || a.path.localeCompare(b.path), + )[0]; + if (canonicalPath && !location) { + throw new ProjectPathMismatchError(projectId, canonicalPath); + } + const timestamp = this.now(); + if (location) location.lastUsedAt = timestamp; + project.lastUsedAt = timestamp; + return project; + }); + return this.present(touched); + } + + async relink(projectId: string, path: string): Promise { + const resolved = await resolveUserSelectedProjectLocation(path); + const timestamp = this.now(); + const locationPath = + resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; + const relinked = await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + const conflict = file.projects.find( + (item) => item.id !== project.id && item.identity === resolved.identity, + ); + if (conflict) throw new ProjectPathConflictError(conflict.id); + return applyRelink(file, project, undefined, resolved, locationPath, timestamp); + }); + return this.present(relinked); + } + + async relinkWithSessions( + projectId: string, + path: string, + ): Promise<{ project: ProjectRecord; updatedSessionIds: readonly string[] }> { + const resolved = await resolveUserSelectedProjectLocation(path); + const timestamp = this.now(); + const locationPath = + resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; + let committed: + | { readonly project: PersistedProject; readonly updatedSessionIds: readonly string[] } + | undefined; + await this.withQueue(async () => { + committed = this.lease.transaction('write', () => { + const file = this.selectCatalog(); + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + const conflict = file.projects.find( + (item) => item.id !== project.id && item.identity === resolved.identity, + ); + const context = relinkContext(project, conflict, locationPath); + const updatedSessionIds = reassignProjectSessions(this.lease, context, timestamp); + this.relinkFailpoint?.('after_session_updates'); + const relinked = applyRelink(file, project, conflict, resolved, locationPath, timestamp); + this.replaceCatalog(normalizeProjectCatalogFile(file)); + return { + project: relinked, + updatedSessionIds, + }; + }); + }); + if (!committed) throw new Error(`Failed to relink project and Sessions: ${projectId}`); + return { + project: await this.present(committed.project), + updatedSessionIds: committed.updatedSessionIds, + }; + } + + async rename(projectId: string, name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) throw new TypeError('Project name cannot be empty.'); + return this.present( + await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + project.name = trimmed; + return project; + }), + ); + } + + async archive(projectId: string): Promise { + return this.present( + await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + project.archivedAt = this.now(); + return project; + }), + ); + } + + async restore(projectId: string): Promise { + return this.present( + await this.mutate((file) => { + const project = findProjectById(file.projects, projectId); + if (!project) throw new ProjectNotFoundError(projectId); + delete project.archivedAt; + return project; + }), + ); + } + + private async present(project: PersistedProject): Promise { + const availableLocations = ( + await Promise.all( + project.locations.map(async (location) => ({ + location, + available: await isDirectory(location.path), + })), + ) + ).filter((entry) => entry.available); + availableLocations.sort( + (a, b) => + b.location.lastUsedAt - a.location.lastUsedAt || + a.location.path.localeCompare(b.location.path), + ); + const locations = project.locations.map((location) => ({ + path: location.path, + isWorktree: location.isWorktree, + })); + return { + id: project.id, + ...(project.aliases ? { aliases: [...project.aliases] } : {}), + name: project.name, + locations, + ...(project.archivedAt !== undefined ? { archivedAt: project.archivedAt } : {}), + available: availableLocations.length > 0, + ...(availableLocations[0] ? { preferredPath: availableLocations[0].location.path } : {}), + }; + } + + private async read(): Promise { + return this.selectCatalog(); + } + + /** + * Read, change and rewrite the catalog inside one `BEGIN IMMEDIATE`. + * + * The serial queue only orders callers within a process, so a second Maka + * window reading the catalog between this one's read and write would rewrite + * the whole table from its own stale copy and silently discard the change — + * a rename or archive would simply revert. Holding SQLite's write lock across + * the whole read-modify-write makes the loser wait rather than clobber. + * + * Only mutations that are entirely synchronous can run here; `select` and + * `relink` await the filesystem mid-change and keep the two-phase form. + */ + private async mutate(change: (file: ProjectCatalogFile) => T): Promise { + return this.lease.transaction('write', () => { + const file = this.selectCatalog(); + const result = change(file); + this.replaceCatalog(normalizeProjectCatalogFile(file)); + return result; + }); + } + + private selectCatalog(): ProjectCatalogFile { + return this.lease.transaction('read', () => { + const database = this.lease.database; + const locations = new Map(); + for (const row of database + .prepare( + `SELECT project_id, path, is_worktree, last_used_at + FROM project_locations + ORDER BY project_id, path`, + ) + .all() as Array>) { + const bucket = locations.get(row.project_id as string) ?? []; + bucket.push({ + path: row.path as string, + isWorktree: row.is_worktree === 1, + lastUsedAt: row.last_used_at as number, + }); + locations.set(row.project_id as string, bucket); + } + const aliases = new Map(); + for (const row of database + .prepare('SELECT alias, project_id FROM project_aliases ORDER BY project_id, alias') + .all() as Array>) { + const bucket = aliases.get(row.project_id as string) ?? []; + bucket.push(row.alias as string); + aliases.set(row.project_id as string, bucket); + } + const projects = ( + database + .prepare( + `SELECT project_id, identity, name, last_used_at, archived_at + FROM projects + ORDER BY project_id`, + ) + .all() as Array> + ).map((row): PersistedProject => { + const id = row.project_id as string; + const projectAliases = aliases.get(id); + return { + id, + ...(projectAliases && projectAliases.length > 0 ? { aliases: projectAliases } : {}), + name: row.name as string, + identity: row.identity as string, + locations: locations.get(id) ?? [], + lastUsedAt: row.last_used_at as number, + ...(row.archived_at === null ? {} : { archivedAt: row.archived_at as number }), + }; + }); + return { schemaVersion: 1, projects }; + }); + } + + private replaceCatalog(file: ProjectCatalogFile): void { + this.lease.transaction('write', () => { + const database = this.lease.database; + // Catalogs are small and every mutation already rewrote the whole file, + // so a full replace keeps the previous read-modify-write semantics + // exactly, now with transactional atomicity instead of temp-file rename. + database.exec('DELETE FROM project_aliases'); + database.exec('DELETE FROM project_locations'); + database.exec('DELETE FROM projects'); + const insertProject = database.prepare( + `INSERT INTO projects(project_id, identity, name, last_used_at, archived_at) + VALUES (?, ?, ?, ?, ?)`, + ); + const insertLocation = database.prepare( + `INSERT INTO project_locations(project_id, path, is_worktree, last_used_at) + VALUES (?, ?, ?, ?)`, + ); + const insertAlias = database.prepare( + 'INSERT INTO project_aliases(alias, project_id) VALUES (?, ?)', + ); + for (const project of file.projects) { + insertProject.run( + project.id, + project.identity, + project.name, + project.lastUsedAt, + project.archivedAt ?? null, + ); + for (const location of project.locations) { + insertLocation.run( + project.id, + location.path, + location.isWorktree ? 1 : 0, + location.lastUsedAt, + ); + } + for (const alias of project.aliases ?? []) insertAlias.run(alias, project.id); + } + }); + } + + private withQueue(operation: () => Promise): Promise { + const next = this.queue.then(operation, operation); + this.queue = next.catch(() => {}); + return next; + } +} + +function defaultProjectName(location: ResolvedProjectLocation): string { + if (location.git) { + const gitName = + basename(location.git.commonDir) === '.git' + ? basename(dirname(location.git.commonDir)) + : basename(location.git.commonDir).replace(/\.git$/i, ''); + if (gitName) return gitName; + } + return basename(location.canonicalPath) || location.canonicalPath; +} + +function normalizeProjectCatalogFile(value: unknown): ProjectCatalogFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Invalid project catalog.'); + } + const record = value as Record; + if (record.schemaVersion !== 1 || !Array.isArray(record.projects)) { + throw new TypeError('Invalid project catalog.'); + } + const projects = record.projects.map(normalizePersistedProject); + const projectIds = projects.flatMap((project) => [project.id, ...(project.aliases ?? [])]); + if ( + new Set(projectIds).size !== projectIds.length || + new Set(projects.map((project) => project.identity)).size !== projects.length + ) { + throw new TypeError('Invalid project catalog.'); + } + return { schemaVersion: 1, projects }; +} + +function normalizePersistedProject(value: unknown): PersistedProject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Invalid project catalog.'); + } + const project = value as Record; + const aliases = + project.aliases === undefined + ? [] + : Array.isArray(project.aliases) && project.aliases.every(isNonEmptyString) + ? project.aliases + : undefined; + if ( + !isNonEmptyString(project.id) || + !isNonEmptyString(project.name) || + !isNonEmptyString(project.identity) || + !Array.isArray(project.locations) || + project.locations.length === 0 || + !isTimestamp(project.lastUsedAt) || + aliases === undefined || + new Set(aliases).size !== aliases.length || + aliases.includes(project.id as string) || + (project.archivedAt !== undefined && !isTimestamp(project.archivedAt)) + ) { + throw new TypeError('Invalid project catalog.'); + } + return { + id: project.id, + ...(aliases.length > 0 ? { aliases } : {}), + name: project.name, + identity: project.identity, + locations: project.locations.map(normalizeProjectLocation), + lastUsedAt: project.lastUsedAt, + ...(project.archivedAt !== undefined ? { archivedAt: project.archivedAt } : {}), + }; +} + +function findProjectById( + projects: readonly PersistedProject[], + projectId: string, +): PersistedProject | undefined { + return projects.find( + (project) => project.id === projectId || project.aliases?.includes(projectId), + ); +} + +interface ProjectSessionReassignment { + readonly projectId: string; + readonly projectAliases: readonly string[]; + readonly destinationPath: string; + readonly previousLocations: readonly ProjectLocation[]; + readonly conflictingProjectId?: string; + readonly conflictingProjectAliases?: readonly string[]; +} + +function relinkContext( + project: PersistedProject, + conflict: PersistedProject | undefined, + destinationPath: string, +): ProjectSessionReassignment { + return { + projectId: project.id, + projectAliases: [...(project.aliases ?? [])], + destinationPath, + previousLocations: project.locations.map((location) => ({ ...location })), + ...(conflict + ? { + conflictingProjectId: conflict.id, + conflictingProjectAliases: [...(conflict.aliases ?? [])], + } + : {}), + }; +} + +function applyRelink( + file: ProjectCatalogFile, + project: PersistedProject, + conflict: PersistedProject | undefined, + resolved: ResolvedProjectLocation, + locationPath: string, + timestamp: number, +): PersistedProject { + if (conflict) { + project.aliases = [ + ...new Set([...(project.aliases ?? []), conflict.id, ...(conflict.aliases ?? [])]), + ]; + file.projects = file.projects.filter((item) => item.id !== conflict.id); + } + project.identity = resolved.identity; + project.locations = [ + { + path: locationPath, + isWorktree: resolved.git?.isWorktree ?? false, + lastUsedAt: timestamp, + }, + ...(conflict?.locations + .filter((location) => location.path !== locationPath) + .map((location) => ({ ...location })) ?? []), + ]; + project.lastUsedAt = Math.max(timestamp, conflict?.lastUsedAt ?? 0); + return project; +} + +function reassignProjectSessions( + lease: OperationalStateDatabaseLease, + context: ProjectSessionReassignment, + committedAt: number, +): readonly string[] { + const survivingIds = new Set([context.projectId, ...context.projectAliases]); + const conflictingIds = new Set([ + ...(context.conflictingProjectId ? [context.conflictingProjectId] : []), + ...(context.conflictingProjectAliases ?? []), + ]); + const rows = lease.database + .prepare( + `SELECT session_id, payload_json, metadata_version + FROM session_metadata + ORDER BY session_id`, + ) + .all() as Array<{ + session_id: string; + payload_json: string; + metadata_version: number; + }>; + const update = lease.database.prepare( + `UPDATE session_metadata + SET payload_json = ?, metadata_version = ?, committed_at = ? + WHERE session_id = ? AND metadata_version = ?`, + ); + const updatedSessionIds: string[] = []; + for (const row of rows) { + const header = decodePersistedSessionHeader( + markPersisted(JSON.parse(row.payload_json)), + row.session_id, + ); + let patch: Pick | undefined; + if (header.projectId && survivingIds.has(header.projectId)) { + patch = { cwd: context.destinationPath, projectId: context.projectId }; + } else if (header.projectId && conflictingIds.has(header.projectId)) { + patch = { cwd: header.cwd, projectId: context.projectId }; + } + if (!patch) continue; + const next = normalizeSessionHeader({ ...header, ...patch }, row.session_id); + const nextVersion = row.metadata_version + 1; + const result = update.run( + JSON.stringify(next), + nextVersion, + committedAt, + row.session_id, + row.metadata_version, + ); + if (result.changes !== 1) { + throw new Error(`Session metadata compare-and-set failed: ${row.session_id}`); + } + updatedSessionIds.push(row.session_id); + } + return updatedSessionIds; +} + +function normalizeProjectLocation(value: unknown): PersistedProjectLocation { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Invalid project catalog.'); + } + const location = value as Record; + if ( + !isNonEmptyString(location.path) || + typeof location.isWorktree !== 'boolean' || + !isTimestamp(location.lastUsedAt) + ) { + throw new TypeError('Invalid project catalog.'); + } + return { + path: location.path, + isWorktree: location.isWorktree, + lastUsedAt: location.lastUsedAt, + }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +/** + * Canonicalize a path that no longer exists. + * + * `realpath` fails outright on a missing path, but its job — resolving symlinks + * — still matters here: on macOS `/tmp` is a link to `/private/tmp`, so naive + * normalization gives a deleted directory a different identity than the same + * directory had while it existed, splitting one project in two. Resolving the + * nearest surviving ancestor and re-appending the missing segments keeps the + * identity stable across the moment the directory disappears. + */ +async function canonicalizeMissingPath(path: string): Promise { + const absolute = normalize(resolve(path)); + const missingSegments: string[] = []; + let candidate = absolute; + for (;;) { + try { + return normalize(join(await realpath(candidate), ...missingSegments)); + } catch (error) { + if (!isUnreachablePathError(error)) throw error; + } + const parent = dirname(candidate); + if (parent === candidate) return absolute; + missingSegments.unshift(basename(candidate)); + candidate = parent; + } +} + +/** + * A path that cannot be reached, whether because a segment is gone (`ENOENT`) + * or because one of its ancestors is now a plain file (`ENOTDIR`). Both mean + * the same thing to a historical working directory: keep walking up. + */ +function isUnreachablePathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +export interface ResolvedProjectLocation { + canonicalPath: string; + identity: string; + kind: 'git' | 'folder'; + git?: { + commonDir: string; + worktreeRoot: string; + isWorktree?: boolean; + }; +} + +export async function resolveProjectLocation(input: { + path: string; +}): Promise { + const canonicalPath = normalize(await realpath(resolve(input.path))); + if (!(await hasEnclosingGitEntry(canonicalPath))) { + return { + canonicalPath, + identity: `folder:${canonicalPath}`, + kind: 'folder', + }; + } + const git = await resolveGitLocation(canonicalPath); + return { + canonicalPath, + identity: `git:${git.commonDir}`, + kind: 'git', + git, + }; +} + +/** + * A directory the user picked in the add/relink chooser. + * + * `resolveProjectLocation` still walks to the enclosing Git worktree so a + * historical session cwd inside a repository stays on that repository. + * The chooser must not do that: selecting `repo/child` would otherwise + * silently become `repo` and reopen the parent project. + */ +async function resolveUserSelectedProjectLocation(path: string): Promise { + const resolved = await resolveProjectLocation({ path }); + if ( + resolved.kind !== 'git' || + !resolved.git || + resolved.canonicalPath === resolved.git.worktreeRoot + ) { + return resolved; + } + return { + canonicalPath: resolved.canonicalPath, + identity: `folder:${resolved.canonicalPath}`, + kind: 'folder', + }; +} + +function isPathWithin(root: string, candidate: string): boolean { + const path = relative(normalize(root), normalize(candidate)); + return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`)); +} + +async function resolveGitLocation( + canonicalPath: string, +): Promise> { + const locationOutput = await execGitText( + canonicalPath, + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'], + { maxBuffer: 64 * 1024, timeoutMs: 3_000 }, + ); + const [worktreeRootRaw, gitDirRaw, commonDirRaw] = locationOutput.trim().split(/\r?\n/); + if (!worktreeRootRaw || !gitDirRaw || !commonDirRaw) { + throw new Error(`Git returned an incomplete project location for: ${canonicalPath}`); + } + const worktreeRoot = normalize(await realpath(worktreeRootRaw)); + const gitDir = normalize(await realpath(gitDirRaw)); + const commonDir = normalize(await realpath(commonDirRaw)); + return { + commonDir, + worktreeRoot, + isWorktree: gitDir !== commonDir, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aaf41c9b5ba506e18ba1ab73276398a30801e6ebd02885e12944bdf0eedb25fc.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aaf41c9b5ba506e18ba1ab73276398a30801e6ebd02885e12944bdf0eedb25fc.source new file mode 100644 index 0000000000..874ab86290 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aaf41c9b5ba506e18ba1ab73276398a30801e6ebd02885e12944bdf0eedb25fc.source @@ -0,0 +1,705 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { WorkspaceBaselineAuthorityInput } from '@maka/core/workspace-version-authority'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { scanToolLedger } from '@maka/core/tool-ledger-scanner'; +import { + SQLITE_RUNTIME_SCHEMA_VERSION, + createSqliteRuntimeStore, +} from '../sqlite-runtime-store.js'; +import { + acquireOperationalStateDatabase, + inspectOperationalStateSchema, +} from '../operational-state-store.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, + readActiveManagedMutationInternal, +} from '../workspace-version-authority-internal.js'; + +const WORKER_READY_TIMEOUT_MS = 15_000; +const WORKER_EXECUTION_TIMEOUT_MS = 30_000; +const WORKER_SHUTDOWN_TIMEOUT_MS = 5_000; + +interface WorkerResult { + code: number | null; + stdout: string; + stderr: string; +} + +interface WorkerHandle { + mode: string; + child: ReturnType; + ready: Promise; + opened: Promise; + result: Promise; + output(): Pick; +} + +// Race amplification (re-rolling the same interleaving many times) is stress +// coverage, not contract coverage. Same gate the other multi-process storage +// probes use, so there is one stress route rather than a flag per file. +const RUN_RACE_AMPLIFICATION = process.env.MAKA_STORAGE_STRESS === '1'; + +describe('SQLite recovery authority multi-process races', () => { + it('makes an exact concurrent recovery bundle idempotent', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['completed', 'completed']); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + + const store = createSqliteRuntimeStore(dbPath); + try { + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'recovery_completed', + ); + assert.equal((await store.readImmutableRuntimeEvents('session-1', 'run-1')).length, 5); + } finally { + store.close(); + } + }); + }); + + it('serializes conflicting completed and parked bundles to one terminal decision', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['completed', 'parked']); + assert.deepEqual(results.map(({ code }) => code).sort(), [0, 2]); + + const store = createSqliteRuntimeStore(dbPath); + try { + const operation = await store.readToolOperation('operation-1'); + assert.ok( + operation?.currentState === 'recovery_completed' || + operation?.currentState === 'recovery_parked', + ); + const events = await store.readImmutableRuntimeEvents('session-1', 'run-1'); + assert.equal(scanToolLedger(events).hasCorruption, false); + } finally { + store.close(); + } + }); + }); + + it('serializes projection rebuild against recovery commit', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['completed', 'rebuild']); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + + const store = createSqliteRuntimeStore(dbPath); + try { + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'recovery_completed', + ); + assert.equal( + scanToolLedger(await store.readImmutableRuntimeEvents('session-1', 'run-1')) + .hasCorruption, + false, + ); + } finally { + store.close(); + } + }); + }); + + it('grants provider authority to exactly one process for one continuation boundary', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['claim', 'claim']); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + assert.deepEqual( + results.flatMap(({ stdout }) => stdout.match(/CLAIM (acquired|existing)/g) ?? []).sort(), + ['CLAIM acquired', 'CLAIM existing'], + ); + }); + }); + + it('never claims an active source while its terminal append races in another process', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['claim_nonterminal', 'append_source']); + assert.deepEqual(results.map(({ code }) => code).sort(), [0, 2]); + + const store = createSqliteRuntimeStore(dbPath); + try { + const sourceEvents = await store.readImmutableRuntimeEvents('session-1', 'run-1'); + const claims = await store.listContinuationClaimsForRecovery('session-1'); + assert.equal(sourceEvents.length, 3); + assert.equal(claims.length, 0); + } finally { + store.close(); + } + }); + }); + + it('serializes a continuation claim against an ordinary first target event', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, ['claim_fixed_target', 'append_target']); + assert.deepEqual(results.map(({ code }) => code).sort(), [0, 2]); + + const store = createSqliteRuntimeStore(dbPath); + try { + const claims = await store.listContinuationClaimsForRecovery('session-1'); + const targetEvents = await store.readImmutableRuntimeEvents( + 'session-1', + 'fixed-target-run', + ); + assert.ok( + (claims.length === 1 && targetEvents.length === 0) || + (claims.length === 0 && targetEvents.length === 1), + ); + } finally { + store.close(); + } + }); + }); + + it('allows concurrent processes to keep the same initialized WAL database open', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runOpenWorkers(dbPath, startPath); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + }); + }); + + it('allows concurrent operational owners to initialize the same fresh WAL database', async () => { + // One round proves the contract: two owners racing to initialize the same + // fresh database both succeed, and the result is a WAL database at the + // current schema version. Repetition does not make that assertion stronger + // — it re-rolls the scheduler hoping to catch a rarer interleaving, which + // is stress, not contract coverage. The extra rounds stay available on the + // storage stress route (MAKA_STORAGE_STRESS=1) alongside the other + // multi-process probes, and out of every ordinary run. + const rounds = RUN_RACE_AMPLIFICATION ? 12 : 1; + for (let round = 0; round < rounds; round += 1) { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-fresh-open-race-')); + const dbPath = join(root, 'runtime.sqlite'); + const startPath = join(root, 'start'); + try { + const results = await runOpenWorkers(dbPath, startPath, 'operational_open_only'); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + `fresh concurrent operational open failed in round ${round + 1}: ${JSON.stringify(results)}`, + ); + + const database = new DatabaseSync(dbPath, { readOnly: true }); + try { + assert.equal( + (database.prepare('PRAGMA journal_mode').get() as { journal_mode: string }) + .journal_mode, + 'wal', + ); + assert.equal( + (database.prepare('PRAGMA user_version').get() as { user_version: number }) + .user_version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + } finally { + database.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + } + }); + + it('makes an exact concurrent workspace baseline open idempotent', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, [ + 'workspace_baseline_a', + 'workspace_baseline_a', + ]); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + assert.deepEqual( + results + .flatMap(({ stdout }) => + stdout.includes('BASELINE created') + ? ['created'] + : stdout.includes('BASELINE existing') + ? ['existing'] + : [], + ) + .sort(), + ['created', 'existing'], + ); + }); + }); + + it('accepts only one of two conflicting concurrent workspace baselines', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const results = await runWorkers(dbPath, startPath, [ + 'workspace_baseline_a', + 'workspace_baseline_b', + ]); + assert.deepEqual(results.map(({ code }) => code).sort(), [0, 2]); + assert.equal( + results.filter(({ stderr }) => /Workspace baseline authority conflict/.test(stderr)).length, + 1, + ); + + const store = createSqliteRuntimeStore(dbPath); + try { + const head = await store.readWorkspaceHead( + `workspace_${'2'.repeat(32)}`, + `epoch_${'3'.repeat(32)}`, + ); + assert.ok( + head?.workspaceVersionId === `version_${'5'.repeat(32)}` || + head?.workspaceVersionId === `version_${'9'.repeat(32)}`, + ); + } finally { + store.close(); + } + }); + }); + + it('grants durable managed mutation ownership to exactly one process', async () => { + await withPreparedDatabase(async ({ dbPath, startPath }) => { + const setupStore = createSqliteRuntimeStore(dbPath); + try { + bindWorkspaceBaselineAuthorityStoreRootInternal(setupStore, 'a'.repeat(64)); + await commitWorkspaceBaselineInternal(setupStore, workspaceBaselineInput('a')); + } finally { + setupStore.close(); + } + const results = await runWorkers(dbPath, startPath, [ + 'managed_mutation_a', + 'managed_mutation_b', + ]); + assert.deepEqual(results.map(({ code }) => code).sort(), [0, 2]); + assert.equal( + results.filter(({ stderr }) => /managed mutation reservation conflict/i.test(stderr)) + .length, + 1, + ); + + const store = createSqliteRuntimeStore(dbPath); + try { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + const reservation = await readActiveManagedMutationInternal( + store, + `instance_${'4'.repeat(32)}`, + ); + assert.ok( + reservation?.operationId === 'managed-mutation-a' || + reservation?.operationId === 'managed-mutation-b', + ); + } finally { + store.close(); + } + }); + }); + + it('serializes concurrent operational runtime migration', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-migration-race-')); + const dbPath = join(root, 'runtime.sqlite'); + const startPath = join(root, 'start'); + try { + acquireOperationalStateDatabase(root).close(); + const db = new DatabaseSync(dbPath); + try { + db.exec(` + DROP TABLE runtime_managed_mutation_reservations; + DROP INDEX runtime_events_by_session_kind; + DROP INDEX runtime_events_one_opening_per_invocation; + DROP TABLE runtime_legacy_invocation_openings; + DROP TABLE runtime_session_event_ordinals; + PRAGMA user_version = 10; + UPDATE operational_schema_migrations SET version = 10 WHERE scope = 'runtime'; + `); + } finally { + db.close(); + } + + const results = await runOpenWorkers(dbPath, startPath, 'operational_open_only'); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + + const upgraded = createSqliteRuntimeStore(dbPath); + try { + assert.equal(upgraded.schemaVersion(), SQLITE_RUNTIME_SCHEMA_VERSION); + } finally { + upgraded.close(); + } + const current = new DatabaseSync(dbPath, { readOnly: true }); + try { + assert.equal(inspectOperationalStateSchema(current).status, 'current'); + } finally { + current.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('captures a fast worker initialization failure after releasing the barrier', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-race-invalid-db-')); + try { + const results = await runWorkers(root, join(root, 'start'), ['completed']); + assert.equal(results[0]?.code, 2); + assert.match(results[0]?.stderr ?? '', /RESULT error/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +async function withPreparedDatabase( + run: (input: { dbPath: string; startPath: string }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-race-')); + const dbPath = join(root, 'runtime.sqlite'); + const startPath = join(root, 'start'); + const store = createSqliteRuntimeStore(dbPath); + try { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + await store.commitToolPrepared(preparedCommit()); + await store.appendRuntimeEvent('session-1', 'continuation-source-run', { + id: 'continuation-source-user', + sessionId: 'session-1', + invocationId: 'continuation-source-invocation', + runId: 'continuation-source-run', + turnId: 'continuation-source-turn', + ts: 10, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue after this completed boundary' }, + }); + await store.ensureTerminalRuntimeEventDurable('session-1', 'continuation-source-run', { + id: 'continuation-source-terminal', + sessionId: 'session-1', + invocationId: 'continuation-source-invocation', + runId: 'continuation-source-run', + turnId: 'continuation-source-turn', + ts: 11, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { + endInvocation: true, + stateDelta: { failureClass: 'runtime_interrupted' }, + }, + }); + store.close(); + await run({ dbPath, startPath }); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +async function runWorkers( + dbPath: string, + startPath: string, + modes: readonly string[], +): Promise { + const workers = modes.map((mode) => startWorker(dbPath, startPath, mode)); + try { + await withTimeout( + Promise.all(workers.map(({ ready }) => ready)), + WORKER_READY_TIMEOUT_MS, + 'workers to reach the start barrier', + ); + await writeFile(startPath, 'go'); + return await withTimeout( + Promise.all(workers.map(({ result }) => result)), + WORKER_EXECUTION_TIMEOUT_MS, + 'workers to finish their SQLite operations', + ); + } catch (error) { + await stopWorkers(workers); + const diagnostics = workers.map(formatWorkerDiagnostics).join('\n'); + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${diagnostics}`); + } +} + +async function runOpenWorkers( + dbPath: string, + startPath: string, + mode = 'open_only', +): Promise { + const stopPath = `${startPath}.stop`; + const workers = [mode, mode].map((workerMode) => + startWorker(dbPath, startPath, workerMode, stopPath), + ); + try { + await withTimeout( + Promise.all(workers.map(({ ready }) => ready)), + WORKER_READY_TIMEOUT_MS, + 'workers to reach the concurrent-open start barrier', + ); + await writeFile(startPath, 'go'); + await withTimeout( + Promise.all(workers.map(({ opened }) => opened)), + WORKER_READY_TIMEOUT_MS, + 'workers to open the same SQLite database', + ); + await writeFile(stopPath, 'close'); + return await withTimeout( + Promise.all(workers.map(({ result }) => result)), + WORKER_EXECUTION_TIMEOUT_MS, + 'concurrent-open workers to close', + ); + } catch (error) { + await stopWorkers(workers); + const diagnostics = workers.map(formatWorkerDiagnostics).join('\n'); + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${diagnostics}`); + } +} + +function startWorker( + dbPath: string, + startPath: string, + mode: string, + stopPath?: string, +): WorkerHandle { + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./fixtures/sqlite-recovery-concurrency-child.js', import.meta.url))], + { + env: { + ...process.env, + MAKA_SQLITE_RECOVERY_CONCURRENCY_MODE: mode, + MAKA_SQLITE_RECOVERY_CONCURRENCY_DB: dbPath, + MAKA_SQLITE_RECOVERY_CONCURRENCY_START: startPath, + ...(stopPath ? { MAKA_SQLITE_RECOVERY_CONCURRENCY_STOP: stopPath } : {}), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let stdout = ''; + let stderr = ''; + let readySeen = false; + let openedSeen = false; + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + let resolveOpened!: () => void; + let rejectOpened!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + const result = new Promise((resolve, reject) => { + child.once('error', (error) => { + rejectReady(error); + rejectOpened(error); + reject(error); + }); + child.once('close', (code) => { + if (!readySeen) { + rejectReady(new Error(`worker ${mode} exited before READY: ${code} ${stderr}`)); + } + if (!openedSeen) { + rejectOpened(new Error(`worker ${mode} exited before OPENED: ${code} ${stderr}`)); + } + resolve({ code, stdout, stderr }); + }); + }); + // A worker can fail before the coordinator reaches the phase that awaits one + // of these promises. Attach handlers immediately so the diagnostic path, not + // the process-level unhandled-rejection policy, owns the failure. + void opened.catch(() => {}); + void result.catch(() => {}); + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + if (!readySeen && stdout.includes('READY\n')) { + readySeen = true; + resolveReady(); + } + if (!openedSeen && stdout.includes('OPENED\n')) { + openedSeen = true; + resolveOpened(); + } + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + return { + mode, + child, + ready, + opened, + result, + output: () => ({ stdout, stderr }), + }; +} + +async function stopWorkers(workers: readonly WorkerHandle[]): Promise { + for (const { child } of workers) { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + } + await withTimeout( + Promise.allSettled(workers.map(({ result }) => result)), + WORKER_SHUTDOWN_TIMEOUT_MS, + 'workers to stop', + ).catch(() => {}); +} + +function formatWorkerDiagnostics(worker: WorkerHandle): string { + const { stdout, stderr } = worker.output(); + const state = + worker.child.exitCode !== null + ? `exit=${worker.child.exitCode}` + : worker.child.signalCode !== null + ? `signal=${worker.child.signalCode}` + : 'still-running'; + return [ + `worker mode=${worker.mode} pid=${worker.child.pid ?? 'unknown'} ${state}`, + `stdout=${JSON.stringify(stdout)}`, + `stderr=${JSON.stringify(stderr)}`, + ].join('\n'); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + description: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function preparedCommit() { + const args = { path: 'notes.txt', content: 'after' }; + const hash = canonicalToolArgsHash('Write', args); + return { + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: { + ...baseEvent('call-event-1', 1), + role: 'model' as const, + author: 'agent' as const, + content: { + kind: 'function_call' as const, + id: 'provider-call-1', + name: 'Write', + args, + }, + }, + dispatchRuntimeEvent: { + ...baseEvent('dispatch-event-1', 2), + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1' as const, + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: hash, + recoveryMode: 'reconcile' as const, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }, + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: hash, + recoveryMode: 'reconcile' as const, + committedAt: 2, + }; +} + +function workspaceBaselineInput(variant: 'a' | 'b'): WorkspaceBaselineAuthorityInput { + const alternate = variant === 'b'; + return { + epochOpenedEventId: alternate ? 'workspace-epoch-event-b' : 'workspace-epoch-event-a', + baselineAcceptedEventId: alternate ? 'workspace-version-event-b' : 'workspace-version-event-a', + committedAt: 1_700_000_000_000, + epoch: { + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: '1'.repeat(40), + sourceTreeOid: '2'.repeat(40), + materializationProfileDigest: `sha256:${'3'.repeat(64)}`, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: `sha256:${'4'.repeat(64)}`, + }, + baseline: { + workspaceVersionId: `version_${(alternate ? '9' : '5').repeat(32)}`, + commitOid: (alternate ? '9' : '5').repeat(40), + treeOid: '2'.repeat(40), + treeDeltaDigest: `sha256:${'6'.repeat(64)}`, + changedFileCount: 7, + deletedFileCount: 0, + }, + }; +} + +function baseEvent(id: string, ts: number): RuntimeEvent { + return { + id, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts, + partial: false, + role: 'system', + author: 'system', + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/abed5e43741f705d2b6e42abe8bc0eaea3d873f6cc4f8df19b59f40c3a0be2ad.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/abed5e43741f705d2b6e42abe8bc0eaea3d873f6cc4f8df19b59f40c3a0be2ad.source new file mode 100644 index 0000000000..7620d2ff6a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/abed5e43741f705d2b6e42abe8bc0eaea3d873f6cc4f8df19b59f40c3a0be2ad.source @@ -0,0 +1,405 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; +import { decodeRuntimeEvent, type RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; + +/** + * SQL counterpart of isTerminalRuntimeEvent; shared with the ledger store. + * + * The `json_valid` guard is what keeps this usable as a partial index: SQLite + * evaluates the index predicate over every row while building it, and + * `json_extract` on a malformed payload fails the whole statement. That would + * abort the migration that creates the index, roll back its version bump, and + * leave the next open to try — and fail — again. + */ +export const TERMINAL_RUNTIME_EVENT_SQL = `( + json_valid(payload_json) + AND ( + json_extract(payload_json, '$.actions.endInvocation') = 1 + OR json_extract(payload_json, '$.status') IN ('completed', 'failed', 'aborted', 'cancelled') + ) +)`; + +/** + * One invocation's events, in ledger order, carrying the Session ordinal each + * one sits at. + * + * The transcript rows of a Turn come from projecting these together: what a + * RuntimeEvent becomes is decided by the read model alone, so nothing here + * classifies an event or decides whether it produces a row. + */ +export interface RuntimeTranscriptInvocation { + readonly invocation: RuntimeInvocationRecord; + readonly firstOrdinal: number; + readonly lastOrdinal: number; + readonly events: readonly { readonly ordinal: number; readonly event: RuntimeEvent }[]; +} + +/** An invocation start, with the prompt event a landmark is labelled by. */ +export interface RuntimeTranscriptLandmark { + readonly invocation: RuntimeInvocationRecord; + readonly firstOrdinal: number; + readonly prompt?: { readonly ordinal: number; readonly event: RuntimeEvent }; +} + +export interface RuntimeTranscriptInvocationRequest { + readonly direction: 'older' | 'newer'; + readonly throughOrdinal: number; + /** Ordinal the walk starts from, inclusive, in `direction`. */ + readonly position: number; + readonly limit: number; + /** Refused rather than truncated: half a Turn projects to a wrong transcript. */ + readonly maxEvents: number; + readonly maxBytes: number; +} + +export interface RuntimeTranscriptQueries { + readTranscriptHighWater(sessionId: string): Promise; + readTranscriptInvocations( + sessionId: string, + request: RuntimeTranscriptInvocationRequest, + ): Promise; + readTranscriptLandmarks( + sessionId: string, + throughOrdinal: number, + limit: number, + ): Promise; +} + +export class RuntimeTranscriptOversizedTurnError extends Error { + readonly name = 'RuntimeTranscriptOversizedTurnError'; +} + +/** + * A Turn the Session transcript shows: one this Session ran itself rather than + * on behalf of a subagent. + * + * This is a fact about the invocation, not about any row it produces — which + * rows it produces is the read model's question, and is not asked here. + */ +const visibleOpening = (payload: string) => ` + (${payload} IS NOT NULL + AND (json_extract(${payload}, '$.lineage.parentRunId') IS NULL + OR (json_extract(${payload}, '$.source.kind') <> 'fresh' + AND json_extract(${payload}, '$.lineage.agentId') IS NULL)))`; +/** Where an invocation ends; NULL while it is still running. */ +const endingOrdinal = (invocation: string) => ` + (SELECT MIN(o2.ordinal) FROM runtime_events t + JOIN runtime_session_event_ordinals o2 ON o2.event_id = t.event_id + WHERE t.invocation_id = ${invocation} + AND ${TERMINAL_RUNTIME_EVENT_SQL.replaceAll('payload_json', 't.payload_json')})`; +/** + * A Session migrated from run headers keeps some openings beside the ledger + * rather than in it, ordered by the anchor event each one names. + */ +const migratedOpening = ` + FROM runtime_legacy_invocation_openings legacy + JOIN runtime_session_event_ordinals o ON o.event_id = legacy.anchor_event_id + WHERE legacy.session_id = :sessionId + AND ${visibleOpening('legacy.opening_json')} + AND NOT EXISTS ( + SELECT 1 FROM runtime_events opened + WHERE opened.invocation_id = legacy.invocation_id + AND opened.event_kind = 'invocation_opened' + )`; +const ledgerOpening = ` + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + WHERE o.session_id = :sessionId + AND e.event_kind = 'invocation_opened' + AND ${visibleOpening("json_extract(e.payload_json, '$.content')")}`; +/** Either shelf's opening for one invocation, reached from an event it owns. */ +const openingOrdinal = (invocation: string) => ` + COALESCE( + (SELECT o3.ordinal FROM runtime_events op + JOIN runtime_session_event_ordinals o3 ON o3.event_id = op.event_id + WHERE op.invocation_id = ${invocation} AND op.event_kind = 'invocation_opened'), + (SELECT o3.ordinal FROM runtime_legacy_invocation_openings lg + JOIN runtime_session_event_ordinals o3 ON o3.event_id = lg.anchor_event_id + WHERE lg.invocation_id = ${invocation}))`; +const openingContent = (invocation: string) => ` + COALESCE( + (SELECT json_extract(op.payload_json, '$.content') FROM runtime_events op + WHERE op.invocation_id = ${invocation} AND op.event_kind = 'invocation_opened'), + (SELECT lg.opening_json FROM runtime_legacy_invocation_openings lg + WHERE lg.invocation_id = ${invocation}))`; + +type InvocationRow = { invocation_id: string; first: number; last: number }; + +/** Selects invocations by Session ordinal. Payloads are decoded, never classified. */ +export class RuntimeTranscriptQuery { + constructor( + private readonly db: DatabaseSync, + private readonly invocation: ( + sessionId: string, + invocationId: string, + ) => RuntimeInvocationRecord, + ) {} + + highWater(sessionId: string): number | null { + // The furthest a transcript reaches is the last ending on it. + const [row] = this.byEnding(sessionId, { + order: 'DESC', + from: 0, + throughOrdinal: Number.MAX_SAFE_INTEGER, + limit: 1, + }); + return row?.last ?? null; + } + + invocations( + sessionId: string, + request: RuntimeTranscriptInvocationRequest, + ): RuntimeTranscriptInvocation[] { + assertOrdinal(request.throughOrdinal); + assertOrdinal(request.position); + if (request.direction !== 'older' && request.direction !== 'newer') { + throw new Error('Invalid transcript direction'); + } + // An invocation is selected by where its own events sit, so a walk that + // starts inside a Turn still finds that Turn and can serve its rows. Both + // ends are the invocation's own two events — its opening and its ending — + // rather than the extremes of everything between them. + // + // Each direction walks the end of the Turn that `position` bounds, which + // is the one the ordinal index can seek to: backward that is the opening, + // forward the ending. Neither assumes Turns do not overlap, and each stops + // at the page, so a page costs the page rather than the Session. + const rows = + request.direction === 'older' + ? this.byOpening(sessionId, request) + : this.byEnding(sessionId, { + order: 'ASC', + from: request.position, + throughOrdinal: request.throughOrdinal, + limit: request.limit, + }).sort((a, b) => a.first - b.first); + return rows.map((row) => ({ + invocation: this.invocation(sessionId, row.invocation_id), + firstOrdinal: row.first, + lastOrdinal: row.last, + events: this.events(row.invocation_id, request), + })); + } + + landmarks(sessionId: string, throughOrdinal: number, limit: number): RuntimeTranscriptLandmark[] { + assertOrdinal(throughOrdinal); + if (limit < 1) return []; + // Evenly spaced Turn starts, chosen before any payload is read. + const rows = this.db + .prepare(` + WITH settled AS ( + SELECT e.invocation_id AS invocation_id, o.ordinal AS ordinal ${ledgerOpening} + AND ${endingOrdinal('e.invocation_id')} <= :throughOrdinal + UNION ALL + SELECT legacy.invocation_id, o.ordinal ${migratedOpening} + AND ${endingOrdinal('legacy.invocation_id')} <= :throughOrdinal + ), candidates AS ( + SELECT invocation_id, ordinal, + ROW_NUMBER() OVER (ORDER BY ordinal) - 1 AS rank, COUNT(*) OVER () AS total + FROM settled + ), samples(n) AS ( + SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit + ) + SELECT DISTINCT invocation_id, ordinal FROM candidates + JOIN samples ON rank = CASE WHEN :limit = 1 THEN total - 1 + ELSE CAST(n * (total - 1) / (:limit - 1) AS INTEGER) END + ORDER BY ordinal + `) + .all({ sessionId, throughOrdinal, limit }) as Array<{ + invocation_id: string; + ordinal: number; + }>; + return rows.map((row) => { + // The prompt is the Turn's first user text event, which is what the read + // model projects a user message from. Only that one event is loaded: a + // landmark is a label, and projecting whole Turns to build a scrollbar + // would read most of the Session. + const prompt = this.db + .prepare(` + SELECT o.ordinal, e.event_id FROM runtime_events e + JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = ? AND o.ordinal <= ? + AND e.event_kind = 'text' AND json_extract(e.payload_json, '$.role') = 'user' + ORDER BY e.event_seq LIMIT 1 + `) + .get(row.invocation_id, throughOrdinal) as + | { ordinal: number; event_id: string } + | undefined; + return { + invocation: this.invocation(sessionId, row.invocation_id), + firstOrdinal: row.ordinal, + ...(prompt + ? { prompt: { ordinal: prompt.ordinal, event: this.event(prompt.event_id) } } + : {}), + }; + }); + } + + /** + * The page of settled visible invocations that opened at or before + * `position`, newest first. + * + * The two shelves are read as separate statements and merged rather than + * unioned, so each keeps its own index walk and stops at the page — and the + * common Session pays nothing for a table its history never wrote to. + */ + private byOpening( + sessionId: string, + request: RuntimeTranscriptInvocationRequest, + ): InvocationRow[] { + const bind = { + sessionId, + position: request.position, + throughOrdinal: request.throughOrdinal, + limit: request.limit, + }; + const ledger = this.db + .prepare(` + SELECT e.invocation_id AS invocation_id, o.ordinal AS first, + ${endingOrdinal('e.invocation_id')} AS last + ${ledgerOpening} + AND o.ordinal <= :position + AND ${endingOrdinal('e.invocation_id')} <= :throughOrdinal + ORDER BY o.ordinal DESC + LIMIT :limit + `) + .all(bind) as InvocationRow[]; + const migrated = this.db + .prepare(` + SELECT legacy.invocation_id AS invocation_id, o.ordinal AS first, + ${endingOrdinal('legacy.invocation_id')} AS last + ${migratedOpening} + AND o.ordinal <= :position + AND ${endingOrdinal('legacy.invocation_id')} <= :throughOrdinal + ORDER BY o.ordinal DESC + LIMIT :limit + `) + .all(bind) as InvocationRow[]; + if (migrated.length === 0) return ledger; + return [...ledger, ...migrated].sort((a, b) => b.first - a.first).slice(0, request.limit); + } + + /** + * The page of settled visible invocations whose ending sits between `from` + * and `throughOrdinal`, in `order` of that ending. + * + * An ending is an event of the invocation like any other, so this walks the + * same ordinal index — one statement, because the ending is on the ledger + * whichever shelf the opening came from. + */ + private byEnding( + sessionId: string, + bounds: { order: 'ASC' | 'DESC'; from: number; throughOrdinal: number; limit: number }, + ): InvocationRow[] { + return this.db + .prepare(` + SELECT ending.invocation_id AS invocation_id, + ${openingOrdinal('ending.invocation_id')} AS first, + o.ordinal AS last + FROM runtime_session_event_ordinals o + JOIN runtime_events ending ON ending.event_id = o.event_id + WHERE o.session_id = :sessionId + AND o.ordinal BETWEEN :from AND :throughOrdinal + AND ${TERMINAL_RUNTIME_EVENT_SQL.replaceAll('payload_json', 'ending.payload_json')} + AND o.ordinal = ${endingOrdinal('ending.invocation_id')} + AND ${visibleOpening(openingContent('ending.invocation_id'))} + ORDER BY o.ordinal ${bounds.order} + LIMIT :limit + `) + .all({ + sessionId, + from: bounds.from, + throughOrdinal: bounds.throughOrdinal, + limit: bounds.limit, + }) as InvocationRow[]; + } + + private events( + invocationId: string, + limits: { maxEvents: number; maxBytes: number }, + ): RuntimeTranscriptInvocation['events'] { + // Walked row by row: the limits cap what one Turn may pull into memory, so + // a check after `.all()` has already paid the cost it was meant to refuse. + const cursor = this.db + .prepare(` + SELECT o.ordinal, e.event_id, e.session_id, e.invocation_id, e.run_id, e.turn_id, e.payload_json + FROM runtime_events e JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = ? ORDER BY e.event_seq + `) + .iterate(invocationId) as Iterable; + const events: Array = []; + let bytes = 0; + for (const row of cursor) { + if (events.length === limits.maxEvents) { + throw new RuntimeTranscriptOversizedTurnError( + `Turn ${invocationId} holds more RuntimeEvents than a transcript page may read`, + ); + } + bytes += Buffer.byteLength(row.payload_json); + if (bytes > limits.maxBytes) { + throw new RuntimeTranscriptOversizedTurnError( + `Turn ${invocationId} holds more RuntimeEvent bytes than a transcript page may read`, + ); + } + events.push({ ordinal: row.ordinal, event: decodeStoredEvent(row) }); + } + return events; + } + + private event(id: string): RuntimeEvent { + const row = this.db + .prepare( + 'SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE event_id = ?', + ) + .get(id) as StoredEventRow | undefined; + if (!row) throw new Error(`Transcript RuntimeEvent ${id} is missing`); + return decodeStoredEvent(row); + } +} + +type StoredEventRow = { + event_id: string; + session_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + payload_json: string; +}; + +function decodeStoredEvent(row: StoredEventRow): RuntimeEvent { + const event = decodeRuntimeEvent(JSON.parse(row.payload_json)); + if ( + event.id !== row.event_id || + event.sessionId !== row.session_id || + event.invocationId !== row.invocation_id || + event.runId !== row.run_id || + event.turnId !== row.turn_id + ) { + throw new Error(`Transcript RuntimeEvent ${row.event_id} has inconsistent storage identity`); + } + return event; +} + +function assertOrdinal(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error('Invalid transcript event ordinal'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ac82d9fa974c255922bf29cfca4c560518c1c2e145a5c9bd690d6bf8052921fb.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ac82d9fa974c255922bf29cfca4c560518c1c2e145a5c9bd690d6bf8052921fb.source new file mode 100644 index 0000000000..7f3620069a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ac82d9fa974c255922bf29cfca4c560518c1c2e145a5c9bd690d6bf8052921fb.source @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + SessionBundleFileError, + type SessionBundleManifestV1, +} from '../session-bundle-contract.js'; +import { + decodeSessionBundleManifestV1, + encodeSessionBundleManifestV1, +} from '../session-bundle-manifest.js'; + +const manifest: SessionBundleManifestV1 = { + schemaVersion: 1, + codec: { + name: 'maka-session-bundle', + version: 1, + canonicalizationVersion: 1, + archive: 'ustar', + compression: 'zstd', + compressionLevel: 3, + }, + envelope: { + sessionId: 'cloud-session-α', + lastCommittedActivationId: 'activation-7', + }, + stateIdentity: { + path: 'state-identity.json', + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + }, + payload: { + statePath: 'state/', + workspacePath: 'workspace/', + treeDigest: `sha256:${'ab'.repeat(32)}`, + payloadBytes: 37, + entryCount: 5, + }, +}; + +const canonicalManifestText = + `{"codec":{"archive":"ustar","canonicalizationVersion":1,"compression":"zstd",` + + `"compressionLevel":3,"name":"maka-session-bundle","version":1},` + + `"envelope":{"lastCommittedActivationId":"activation-7","sessionId":"cloud-session-α"},` + + `"payload":{"entryCount":5,"payloadBytes":37,"statePath":"state/",` + + `"treeDigest":"sha256:${'ab'.repeat(32)}","workspacePath":"workspace/"},` + + `"schemaVersion":1,"stateIdentity":{"mediaType":` + + `"application/vnd.maka.session-state-identity+json;version=1","path":"state-identity.json"}}`; + +test('pins canonical Manifest V1 UTF-8 bytes and round-trips them', () => { + const encoded = encodeSessionBundleManifestV1(manifest); + assert.equal(new TextDecoder().decode(encoded), canonicalManifestText); + assert.equal(encoded[0], '{'.charCodeAt(0)); + assert.equal(encoded.at(-1), '}'.charCodeAt(0)); + assert.deepEqual(decodeSessionBundleManifestV1(encoded), manifest); +}); + +test('omits optional Activation provenance instead of inventing authority', () => { + const withoutActivation: SessionBundleManifestV1 = { + ...manifest, + envelope: { sessionId: manifest.envelope.sessionId }, + }; + const text = new TextDecoder().decode(encodeSessionBundleManifestV1(withoutActivation)); + assert.match(text, /"envelope":\{"sessionId":"cloud-session-α"\}/); + assert.doesNotMatch(text, /lastCommittedActivationId/); +}); + +test('does not serialize inherited Activation provenance', () => { + const envelope = Object.assign( + Object.create({ lastCommittedActivationId: 'inherited-activation' }) as object, + { sessionId: manifest.envelope.sessionId }, + ) as SessionBundleManifestV1['envelope']; + + const encoded = encodeSessionBundleManifestV1({ ...manifest, envelope }); + const text = new TextDecoder().decode(encoded); + assert.match(text, /"envelope":\{"sessionId":"cloud-session-α"\}/); + assert.doesNotMatch(text, /inherited-activation|lastCommittedActivationId/); + assert.deepEqual(decodeSessionBundleManifestV1(encoded).envelope, { + sessionId: manifest.envelope.sessionId, + }); +}); + +test('rejects parseable but non-canonical manifest bytes', () => { + const nonCanonical = new TextEncoder().encode(JSON.stringify(manifest)); + assertBundleError(() => decodeSessionBundleManifestV1(nonCanonical), 'invalid_manifest'); + assertBundleError( + () => decodeSessionBundleManifestV1(new TextEncoder().encode(`${canonicalManifestText}\n`)), + 'invalid_manifest', + ); + assertBundleError( + () => + decodeSessionBundleManifestV1( + Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(canonicalManifestText)]), + ), + 'invalid_manifest', + ); + assertBundleError( + () => + decodeSessionBundleManifestV1( + new TextEncoder().encode( + canonicalManifestText.replace('"schemaVersion":1', '"schemaVersion":1,"schemaVersion":1'), + ), + ), + 'invalid_manifest', + ); +}); + +test('does not expose attacker-controlled parser diagnostics', () => { + assert.throws( + () => + decodeSessionBundleManifestV1( + new TextEncoder().encode('SECRET_SESSION_TOKEN is not a JSON manifest'), + ), + (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, 'invalid_manifest'); + assert.equal(error.cause, undefined); + assert.doesNotMatch(error.message, /SECRET_SESSION_TOKEN/); + return true; + }, + ); +}); + +test('rejects control-plane revision and fork authority fields', () => { + for (const extra of [ + { revision: 'r7' }, + { head: 'r7' }, + { forkedFrom: { sessionId: 'source', revision: 'r6' } }, + ]) { + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + ...extra, + } as SessionBundleManifestV1), + 'invalid_manifest', + ); + } +}); + +test('distinguishes unsupported schema and codec versions from malformed manifests', () => { + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + schemaVersion: 2, + } as unknown as SessionBundleManifestV1), + 'unsupported_schema', + ); + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + codec: { ...manifest.codec, version: 2 }, + } as unknown as SessionBundleManifestV1), + 'unsupported_codec', + ); + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + payload: { + ...manifest.payload, + treeDigest: `sha256:${'AB'.repeat(32)}`, + }, + } as SessionBundleManifestV1), + 'invalid_manifest', + ); +}); + +test('rejects unknown nested fields, invalid Unicode, and incomplete layouts', () => { + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + payload: { ...manifest.payload, revision: 'r7' }, + } as SessionBundleManifestV1), + 'invalid_manifest', + ); + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + envelope: { sessionId: '\ud800' }, + }), + 'invalid_manifest', + ); + assertBundleError( + () => + encodeSessionBundleManifestV1({ + ...manifest, + payload: { ...manifest.payload, entryCount: 2 }, + }), + 'invalid_manifest', + ); +}); + +function assertBundleError(action: () => unknown, code: SessionBundleFileError['code']): void { + assert.throws(action, (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, code); + return true; + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/acc59811923252bc07f5b90d2d006895b4863295874e2ecc3d67620a21793cc3.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/acc59811923252bc07f5b90d2d006895b4863295874e2ecc3d67620a21793cc3.source new file mode 100644 index 0000000000..cfa7c50ded --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/acc59811923252bc07f5b90d2d006895b4863295874e2ecc3d67620a21793cc3.source @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parentPort, workerData } from 'node:worker_threads'; + +import type { SandboxBoundarySettlement } from '@maka/core/sandbox-boundary'; + +import { createSqliteSessionMetadataStore } from '../../sqlite-session-metadata-store.js'; + +interface WorkerInput { + path: string; + requestId: string; + startSettlement: SharedArrayBuffer; + holdAfterBoundaryWrite?: SharedArrayBuffer; +} + +const input = workerData as WorkerInput; +const startSettlement = new Int32Array(input.startSettlement); +const release = input.holdAfterBoundaryWrite + ? new Int32Array(input.holdAfterBoundaryWrite) + : undefined; +const store = createSqliteSessionMetadataStore(input.path, { + ...(release + ? { + failpoint: (point) => { + if (point !== 'after_sandbox_boundary_write') return; + parentPort?.postMessage({ type: 'holding' }); + Atomics.wait(release, 0, 0); + }, + } + : {}), +}); + +try { + parentPort?.postMessage({ type: 'ready' }); + Atomics.wait(startSettlement, 0, 0); + parentPort?.postMessage({ type: 'attempting' }); + const settlement: SandboxBoundarySettlement = await store.settleSandboxBoundaryRequest({ + sessionId: 'session-1', + requestId: input.requestId, + decision: 'allow', + }); + parentPort?.postMessage({ type: 'settled', settlement }); +} catch (error) { + parentPort?.postMessage({ + type: 'failed', + message: error instanceof Error ? error.message : String(error), + }); +} finally { + store.close(); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ada1fbf873d37be538d04d62aa92925ee4bf2fb917b0b39c5768bedf83138ffd.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ada1fbf873d37be538d04d62aa92925ee4bf2fb917b0b39c5768bedf83138ffd.source new file mode 100644 index 0000000000..f74d0386bb --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ada1fbf873d37be538d04d62aa92925ee4bf2fb917b0b39c5768bedf83138ffd.source @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import type { SessionExternalOrigin, SessionHeader, StoredMessage } from '@maka/core/session'; +import { + ExternalSessionAdapterRegistry, + type ExternalSessionAdapter, +} from '@maka/core/external-session'; +import { + ExternalSessionImporter, + type ExternalSessionImportTarget, +} from '../external-session-importer.js'; +import { createSessionStore } from '../session-store.js'; + +describe('ExternalSessionImporter', () => { + test('forwards the exact external Session origin to imported persistence', async () => { + const calls: SessionExternalOrigin[] = []; + const adapter = fakeAdapter({ + metadata: { name: 'Imported parser work', cwd: '/external/repo' }, + messages: [], + }); + const importer = new ExternalSessionImporter(new ExternalSessionAdapterRegistry([adapter]), { + createImportedSession: async (_input, _messages, externalOrigin) => { + calls.push(externalOrigin); + return {} as SessionHeader; + }, + }); + + await importer.import({ + adapterId: 'fake', + sourceSessionId: 'source-1', + target: target(), + }); + + assert.deepEqual(calls, [{ adapterId: 'fake', sourceSessionId: 'source-1' }]); + }); + + test('persists adapter output as native Maka StoredMessages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-external-session-import-')); + const sessions = createSessionStore(root); + const messages: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'fix the parser' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 20, + text: 'done', + modelId: 'external-model', + }, + ]; + const adapter = fakeAdapter({ + metadata: { name: 'Imported parser work', cwd: '/external/repo' }, + messages, + }); + const importer = new ExternalSessionImporter( + new ExternalSessionAdapterRegistry([adapter]), + sessions, + ); + + try { + const header = await importer.import({ + adapterId: 'fake', + sourceSessionId: 'source-1', + target: target(), + }); + + assert.equal(header.name, 'Imported parser work'); + assert.equal(header.cwd, '/external/repo'); + assert.equal(header.model, 'maka-model'); + assert.equal(header.connectionLocked, true); + assert.deepEqual(header.externalOrigin, { + adapterId: 'fake', + sourceSessionId: 'source-1', + }); + assert.deepEqual(await sessions.readMessages(header.id), messages); + + await sessions.close?.(); + const reopened = createSessionStore(root); + try { + assert.deepEqual((await reopened.readHeaderSnapshot(header.id)).externalOrigin, { + adapterId: 'fake', + sourceSessionId: 'source-1', + }); + assert.deepEqual(await reopened.readMessages(header.id), messages); + } finally { + await reopened.close?.(); + } + } finally { + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('allows the Maka target to override imported name and cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-external-session-target-')); + const sessions = createSessionStore(root); + const importer = new ExternalSessionImporter( + new ExternalSessionAdapterRegistry([ + fakeAdapter({ metadata: { name: 'Source name', cwd: '/source' }, messages: [] }), + ]), + sessions, + ); + + try { + const header = await importer.import({ + adapterId: 'fake', + sourceSessionId: 'source-1', + target: target({ name: 'Maka name', cwd: '/target' }), + }); + + assert.equal(header.name, 'Maka name'); + assert.equal(header.cwd, '/target'); + } finally { + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects invalid adapter messages without exposing a partial Session', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-external-session-invalid-')); + const sessions = createSessionStore(root); + const adapter = fakeAdapter({ + metadata: { name: 'Invalid import', cwd: '/repo' }, + messages: [{ type: 'assistant' } as unknown as StoredMessage], + }); + const importer = new ExternalSessionImporter( + new ExternalSessionAdapterRegistry([adapter]), + sessions, + ); + + try { + await assert.rejects( + importer.import({ + adapterId: 'fake', + sourceSessionId: 'source-1', + target: target(), + }), + /Invalid stored message schema/, + ); + assert.deepEqual(await sessions.listHeaders(), []); + } finally { + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function target(overrides: Partial = {}): ExternalSessionImportTarget { + return { + llmConnectionSlug: 'fake', + model: 'maka-model', + permissionMode: 'ask', + ...overrides, + }; +} + +function fakeAdapter( + session: Pick< + Awaited>, + 'metadata' | 'messages' + >, +): ExternalSessionAdapter { + return { + id: 'fake', + detect: async () => true, + listSessions: async () => [], + readSession: async (sourceSessionId) => ({ sourceSessionId, ...session }), + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae55cbd3694d60eaa600883c201945bad237fcaad0a9c35e96adb81f85d42359.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae55cbd3694d60eaa600883c201945bad237fcaad0a9c35e96adb81f85d42359.source new file mode 100644 index 0000000000..f4cf3b6fd2 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae55cbd3694d60eaa600883c201945bad237fcaad0a9c35e96adb81f85d42359.source @@ -0,0 +1,370 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + DEFAULT_DAILY_REVIEW_CONFIG, + dailyReviewArchiveId, + dailyReviewArchiveToSummary, + normalizeDailyReviewArchive, + normalizeDailyReviewConfig, + parseDailyReviewArchiveId, + type DailyReviewArchive, + type DailyReviewArchiveSummary, + type DailyReviewConfig, +} from '@maka/core/daily-review'; +import { acquireOperationalStateDatabase } from './operational-state-store.js'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; + +const writerBrand: unique symbol = Symbol('InteractiveDailyReviewAuthorityWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); + +export interface DailyReviewAuthoritySnapshot { + readonly revision: number; + readonly config: DailyReviewConfig; +} + +export interface DailyReviewArchivePage { + readonly archives: readonly DailyReviewArchiveSummary[]; + readonly nextBeforeArchiveId: string | null; +} + +export type DailyReviewConfigMutationResult = + | { + readonly kind: 'committed' | 'unchanged'; + readonly snapshot: DailyReviewAuthoritySnapshot; + } + | { + readonly kind: 'revision_conflict'; + readonly expectedRevision: number; + readonly actualRevision: number; + }; + +export interface InteractiveDailyReviewAuthorityWriter { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + readConfig(): Promise; + updateConfig( + expectedRevision: number, + config: DailyReviewConfig, + ): Promise; + publishArchive(archive: DailyReviewArchive, maxArchives: number): Promise; + listArchivePage(beforeArchiveId: string | null, limit: number): Promise; + getArchive(archiveId: string): Promise; + deleteArchive(archiveId: string): Promise; + close(): void; +} + +export function authenticateInteractiveDailyReviewAuthorityWriter( + writer: InteractiveDailyReviewAuthorityWriter, +): InteractiveDailyReviewAuthorityWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive Daily Review authority writer', + ); + } + return writer; +} + +export async function openInteractiveDailyReviewAuthorityForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + + const writer = createWriterFacade(lease); + await writer.readConfig(); + await assertStorageRootLease(lease, 'interactive', 'write'); + const raced = writerByLease.get(lease); + if (raced) { + writer.close(); + return raced; + } + writers.add(writer); + writerByLease.set(lease, writer); + return writer; +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, +): InteractiveDailyReviewAuthorityWriter { + let closed = false; + const run = (operation: (root: string) => T): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Daily Review authority writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', async (root) => operation(root)); + }; + const withDatabase = ( + root: string, + mode: 'read' | 'write', + operation: (database: import('node:sqlite').DatabaseSync) => T, + ): T => { + const database = acquireOperationalStateDatabase(root); + try { + return database.transaction(mode, () => operation(database.database)); + } finally { + database.close(); + } + }; + + const writer: InteractiveDailyReviewAuthorityWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + readConfig: () => + run((root) => withDatabase(root, 'read', (database) => readConfigSnapshot(database))), + updateConfig: (expectedRevision, config) => + run((root) => + withDatabase(root, 'write', (database) => { + const current = readConfigSnapshot(database); + if (current.revision !== expectedRevision) { + return { + kind: 'revision_conflict' as const, + expectedRevision, + actualRevision: current.revision, + }; + } + const next = normalizeDailyReviewConfig(config); + if (sameConfig(current.config, next)) { + return { kind: 'unchanged' as const, snapshot: current }; + } + const revision = current.revision + 1; + database + .prepare( + ` + INSERT INTO workflow_daily_review_state(singleton, config_json) + VALUES (1, ?) + ON CONFLICT(singleton) DO UPDATE SET config_json = excluded.config_json + `, + ) + .run(JSON.stringify(next)); + database + .prepare( + ` + INSERT INTO workflow_daily_review_authority_state(singleton, revision) + VALUES (1, ?) + ON CONFLICT(singleton) DO UPDATE SET revision = excluded.revision + `, + ) + .run(revision); + return { + kind: 'committed' as const, + snapshot: { revision, config: next }, + }; + }), + ), + publishArchive: (archive, maxArchives) => + run((root) => + withDatabase(root, 'write', (database) => { + assertArchiveId(archive.id); + const limit = requireArchiveLimit(maxArchives); + const normalized = normalizeDailyReviewArchive(archive); + if (normalized.id !== archive.id) { + throw new Error(`Daily Review archive id mismatch: ${archive.id}`); + } + if (normalized.id !== dailyReviewArchiveId(normalized.day, normalized.range)) { + throw new Error(`Daily Review archive day mismatch: ${archive.id}`); + } + database + .prepare( + ` + INSERT INTO workflow_daily_review_archives( + archive_id, generated_at, day_from_ms, record_json + ) VALUES (?, ?, ?, ?) + ON CONFLICT(archive_id) DO UPDATE SET + generated_at = excluded.generated_at, + day_from_ms = excluded.day_from_ms, + record_json = excluded.record_json + `, + ) + .run( + normalized.id, + normalized.generatedAt, + normalized.day.fromMs, + JSON.stringify(normalized), + ); + database + .prepare( + ` + DELETE FROM workflow_daily_review_archives + WHERE archive_id IN ( + SELECT archive_id + FROM workflow_daily_review_archives + WHERE archive_id <> ? + ORDER BY generated_at DESC, day_from_ms DESC, archive_id + LIMIT -1 OFFSET ? + ) + `, + ) + .run(normalized.id, limit - 1); + return normalized; + }), + ), + listArchivePage: (beforeArchiveId, limit) => + run((root) => + withDatabase(root, 'read', (database) => { + if (beforeArchiveId !== null) assertArchiveId(beforeArchiveId); + const pageLimit = requirePageLimit(limit); + const rows = ( + beforeArchiveId === null + ? database + .prepare( + ` + SELECT archive_id AS archiveId, record_json AS recordJson + FROM workflow_daily_review_archives + ORDER BY archive_id DESC + LIMIT ? + `, + ) + .all(pageLimit + 1) + : database + .prepare( + ` + SELECT archive_id AS archiveId, record_json AS recordJson + FROM workflow_daily_review_archives + WHERE archive_id < ? + ORDER BY archive_id DESC + LIMIT ? + `, + ) + .all(beforeArchiveId, pageLimit + 1) + ) as Array<{ + archiveId: string; + recordJson: string; + }>; + const archives = rows + .slice(0, pageLimit) + .map((row) => + dailyReviewArchiveToSummary(decodeArchive(row.archiveId, row.recordJson)), + ); + return { + archives, + nextBeforeArchiveId: rows.length > pageLimit ? (archives.at(-1)?.id ?? null) : null, + }; + }), + ), + getArchive: (archiveId) => + run((root) => + withDatabase(root, 'read', (database) => { + assertArchiveId(archiveId); + const row = database + .prepare( + ` + SELECT record_json AS recordJson + FROM workflow_daily_review_archives + WHERE archive_id = ? + `, + ) + .get(archiveId) as { recordJson?: unknown } | undefined; + return typeof row?.recordJson === 'string' + ? decodeArchive(archiveId, row.recordJson) + : null; + }), + ), + deleteArchive: (archiveId) => + run((root) => + withDatabase(root, 'write', (database) => { + assertArchiveId(archiveId); + return ( + database + .prepare('DELETE FROM workflow_daily_review_archives WHERE archive_id = ?') + .run(archiveId).changes > 0 + ); + }), + ), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + }, + }; + return Object.freeze(writer); +} + +function readConfigSnapshot( + database: import('node:sqlite').DatabaseSync, +): DailyReviewAuthoritySnapshot { + const configRow = database + .prepare( + 'SELECT config_json AS configJson FROM workflow_daily_review_state WHERE singleton = 1', + ) + .get() as { configJson?: unknown } | undefined; + const revisionRow = database + .prepare('SELECT revision FROM workflow_daily_review_authority_state WHERE singleton = 1') + .get() as { revision?: unknown } | undefined; + const revision = + typeof revisionRow?.revision === 'number' && + Number.isSafeInteger(revisionRow.revision) && + revisionRow.revision >= 0 + ? revisionRow.revision + : 0; + const config = + typeof configRow?.configJson === 'string' + ? normalizeDailyReviewConfig(JSON.parse(configRow.configJson) as Partial) + : DEFAULT_DAILY_REVIEW_CONFIG; + return { revision, config }; +} + +function decodeArchive(archiveId: string, recordJson: string): DailyReviewArchive { + const archive = normalizeDailyReviewArchive(JSON.parse(recordJson)); + if (archive.id !== archiveId) { + throw new Error(`Daily Review archive id mismatch: ${archiveId}`); + } + return archive; +} + +function assertArchiveId(archiveId: string): void { + if (!parseDailyReviewArchiveId(archiveId)) { + throw new Error(`Invalid Daily Review archive id: ${archiveId}`); + } +} + +function requireArchiveLimit(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid Daily Review archive limit: ${value}`); + } + return value; +} + +function requirePageLimit(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid Daily Review page limit: ${value}`); + } + return value; +} + +function sameConfig(left: DailyReviewConfig, right: DailyReviewConfig): boolean { + return ( + left.enabled === right.enabled && + left.executeTime === right.executeTime && + left.modelKey === right.modelKey + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae8183ef725fca11f60c5d3b3ec047505373c734673e96112e570f0d58adc0ad.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae8183ef725fca11f60c5d3b3ec047505373c734673e96112e570f0d58adc0ad.source new file mode 100644 index 0000000000..d284a04ebb --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ae8183ef725fca11f60c5d3b3ec047505373c734673e96112e570f0d58adc0ad.source @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isDeepStrictEqual } from 'node:util'; +import { + decodeMessageContent, + normalizeMessageContent, + type MessageContent, +} from '@maka/core/events'; +import { + decodeSkillInvocationResult, + type SkillInvocationResult, +} from '@maka/core/skill-invocation'; +import { + normalizeSubmittedTurnIntent, + submittedTurnIntentsEqual, + type SubmittedTurnIntent, +} from './submitted-turn-intent.js'; + +const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +export interface PendingMessageAdmission { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly messageId: string; + readonly content: MessageContent; + readonly submittedContentDigest: `sha256:${string}`; + readonly submittedPlacement: 'current_turn' | 'next_turn'; + readonly placement: 'current_turn' | 'next_turn'; + readonly disposition: 'steering' | 'followup'; + /** + * What this Message asked of its Turn beyond the words, when it asked for + * anything. Recovery re-opens the Turn from this record and answers retries + * against it, so the whole intent lives here: without it a crash between + * this commit and the root admission silently downgrades an explicit graph + * or swarm request to the Session default, and a later retry of the very + * same submit reads as a different one. + */ + readonly submittedIntent?: SubmittedTurnIntent; + /** The Skill resolution answer returned for this admitted Message. */ + readonly skillInvocation: SkillInvocationResult; + readonly admittedAt: number; +} + +export interface ProvenRootMessageHandoff { + readonly messageId: string; + readonly content: MessageContent; + readonly admittedAt: number; +} + +/** Immutable proof that an admission was delivered as steering by a later execution owner. */ +export interface ProvenSteeringMessageHandoff { + readonly messageId: string; + readonly admissionTurnId: string; + readonly admissionRunId: string; + readonly executionTurnId: string; + readonly eventId: string; + readonly eventTs: number; + readonly content: MessageContent; + readonly admittedAt: number; +} + +export interface MarkMessagesHandedOffInput { + readonly sessionId: string; + readonly messageIds: readonly string[]; + readonly turnId: string; + readonly provenRootMessages?: readonly ProvenRootMessageHandoff[]; + readonly provenSteeringMessages?: readonly ProvenSteeringMessageHandoff[]; +} + +export type MessageAdmissionCancellationClaimOutcome = + | 'cancelled_by_claim' + | 'same_claim' + | 'already_cancelled'; + +export interface MessageAdmissionStore { + commitMessageAdmission(admission: PendingMessageAdmission): Promise; + readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise; + /** + * Whether this Message identity carries a cancellation tombstone. That a + * Message was cancelled is the whole fact callers need — the tombstone's + * own columns never leave this layer. + */ + hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise; + claimMessageAdmissionCancellation( + sessionId: string, + messageId: string, + claimId: string, + ): Promise; + listMessageAdmissions(sessionId: string): Promise; + markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise; + updateMessageAdmission(admission: PendingMessageAdmission): Promise; + reorderMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise; + cancelMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise; +} + +export function normalizePendingMessageAdmission( + admission: PendingMessageAdmission, +): PendingMessageAdmission { + for (const [name, value] of [ + ['Session', admission.sessionId], + ['Turn', admission.turnId], + ['Run', admission.runId], + ['Message', admission.messageId], + ] as const) { + assertSafeId(value, `Invalid ${name} identity`); + } + if ( + (admission.submittedPlacement !== 'current_turn' && + admission.submittedPlacement !== 'next_turn') || + (admission.placement !== 'current_turn' && admission.placement !== 'next_turn') || + (admission.disposition !== 'steering' && admission.disposition !== 'followup') || + (admission.placement === 'current_turn') !== (admission.disposition === 'steering') + ) { + throw new Error('Invalid pending Message placement'); + } + if (!Number.isSafeInteger(admission.admittedAt) || admission.admittedAt < 0) { + throw new Error('Invalid message admission timestamp'); + } + const normalized = Object.freeze({ + ...admission, + content: normalizeMessageContent(admission.content), + ...(admission.submittedIntent + ? { submittedIntent: normalizeSubmittedTurnIntent(admission.submittedIntent) } + : {}), + skillInvocation: decodeSkillInvocationResult(admission.skillInvocation), + }); + if (!/^sha256:[a-f0-9]{64}$/u.test(normalized.submittedContentDigest)) { + throw new Error('Invalid pending Message submitted content digest'); + } + return normalized; +} + +export function normalizeProvenRootMessageHandoff( + handoff: ProvenRootMessageHandoff, +): ProvenRootMessageHandoff { + assertSafeId(handoff.messageId, 'Invalid proven Root Message identity'); + if (!Number.isSafeInteger(handoff.admittedAt) || handoff.admittedAt < 0) { + throw new Error('Invalid proven Root Message timestamp'); + } + return Object.freeze({ + ...handoff, + content: decodeMessageContent(handoff.content), + }); +} + +export function normalizeProvenSteeringMessageHandoff( + handoff: ProvenSteeringMessageHandoff, +): ProvenSteeringMessageHandoff { + assertSafeId(handoff.messageId, 'Invalid proven steering Message identity'); + assertSafeId(handoff.admissionTurnId, 'Invalid proven steering admission Turn'); + assertSafeId(handoff.admissionRunId, 'Invalid proven steering admission Run'); + assertSafeId(handoff.executionTurnId, 'Invalid proven steering execution Turn'); + assertSafeId(handoff.eventId, 'Invalid proven steering RuntimeEvent identity'); + if (!Number.isSafeInteger(handoff.eventTs) || handoff.eventTs < 0) { + throw new Error('Invalid proven steering RuntimeEvent timestamp'); + } + if (!Number.isSafeInteger(handoff.admittedAt) || handoff.admittedAt < 0) { + throw new Error('Invalid proven steering Message timestamp'); + } + return Object.freeze({ ...handoff, content: decodeMessageContent(handoff.content) }); +} + +export function samePendingMessageAdmission( + left: PendingMessageAdmission, + right: PendingMessageAdmission, +): boolean { + const a = normalizePendingMessageAdmission(left); + const b = normalizePendingMessageAdmission(right); + return ( + a.sessionId === b.sessionId && + a.turnId === b.turnId && + a.runId === b.runId && + a.messageId === b.messageId && + a.submittedContentDigest === b.submittedContentDigest && + a.submittedPlacement === b.submittedPlacement && + a.placement === b.placement && + a.disposition === b.disposition && + a.admittedAt === b.admittedAt && + submittedTurnIntentsEqual(a.submittedIntent, b.submittedIntent) && + isDeepStrictEqual(a.skillInvocation, b.skillInvocation) && + isDeepStrictEqual(a.content, b.content) + ); +} + +function assertSafeId(value: string, message: string): void { + if (!SAFE_ID_PATTERN.test(value)) throw new Error(message); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aee92dfc4cbced6cbb5db32c1bb80a7d3cd4e1576d268fde522d93e15bc26f18.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aee92dfc4cbced6cbb5db32c1bb80a7d3cd4e1576d268fde522d93e15bc26f18.source new file mode 100644 index 0000000000..f7873130a6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aee92dfc4cbced6cbb5db32c1bb80a7d3cd4e1576d268fde522d93e15bc26f18.source @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const [archivePath, archiveDigest, limitsJson, destinationRoot] = process.argv.slice(2); +if ( + archivePath === undefined || + archiveDigest === undefined || + limitsJson === undefined || + destinationRoot === undefined +) { + process.exit(2); +} + +const originalOpen = fs.promises.open.bind(fs.promises); +let targeted = false; +fs.promises.open = async (...args) => { + const handle = await originalOpen(...args); + if (targeted || !args[0].toString().endsWith('.owner.json')) return handle; + targeted = true; + const originalWrite = handle.write.bind(handle); + handle.write = (async ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => { + if (position === null) return originalWrite(buffer, offset, length, position); + await originalWrite(buffer, offset, Math.min(length, 1), position); + process.stdout.write('binding-partial\n'); + return new Promise(() => {}); + }) as typeof handle.write; + return handle; +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +// Keep the deliberately wedged top-level await alive until the parent delivers +// SIGKILL. Without a referenced handle, Node exits with code 13 as soon as the +// event loop is empty and races the crash-state assertions in the parent test. +const keepAlive = setInterval(() => undefined, 60_000); +try { + await createSessionBundleFileService().hydrate({ + source: { + path: archivePath, + expectedArchiveDigest: archiveDigest as `sha256:${string}`, + }, + limits: JSON.parse(limitsJson), + expectedSessionId: 'cloud-session-1', + destinationRoot, + }); +} finally { + clearInterval(keepAlive); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef16fd4c00f6758c3a632e1e1bd2557829e04c03441d2563169b3be99e1e4ee.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef16fd4c00f6758c3a632e1e1bd2557829e04c03441d2563169b3be99e1e4ee.source new file mode 100644 index 0000000000..3075811057 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef16fd4c00f6758c3a632e1e1bd2557829e04c03441d2563169b3be99e1e4ee.source @@ -0,0 +1,511 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { + isDeepResearchEvent, + isDeepResearchScopeLevel, + normalizeDeepResearchObjective, + projectDeepResearchEvents, + type DeepResearchArtifactRef, + type DeepResearchChecklistItem, + type DeepResearchChangedEvent, + type DeepResearchCheckpoint, + type DeepResearchEvent, + type DeepResearchEventRefs, + type DeepResearchHandoff, + type DeepResearchMutationContext, + type DeepResearchRun, + type DeepResearchScopeLevel, + type DeepResearchStep, + type DeepResearchStore, +} from '@maka/core/deep-research-run'; +import { assertSafeSessionId } from './session-store.js'; +import { chainWrite } from './write-queue.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; + +export type { DeepResearchStore } from '@maka/core/deep-research-run'; + +export interface CreateDeepResearchStoreOptions { + newId?: () => string; + now?: () => number; +} + +export interface SqliteDeepResearchStore extends DeepResearchStore { + ready(): Promise; + purgeSessionState(sessionId: string): Promise; + close(): void; +} + +export type CreateSqliteDeepResearchStoreOptions = CreateDeepResearchStoreOptions; + +export function createSqliteDeepResearchStore( + workspaceRoot: string, + options: CreateSqliteDeepResearchStoreOptions = {}, +): SqliteDeepResearchStore { + return new SqliteDeepResearchStoreImpl( + workspaceRoot, + options.newId ?? randomUUID, + options.now ?? Date.now, + ); +} + +class SqliteDeepResearchStoreImpl implements SqliteDeepResearchStore { + readonly #lease: OperationalStateDatabaseLease; + private readonly writeQueues = new Map>(); + private readonly subscribers = new Set<(event: DeepResearchChangedEvent) => void>(); + + constructor( + workspaceRoot: string, + private readonly newId: () => string, + private readonly now: () => number, + ) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + } + + ready(): Promise { + return Promise.resolve(); + } + + close(): void { + this.#lease.close(); + } + + async read(sessionId: string): Promise { + const events = await this.readEvents(sessionId); + return this.project(events); + } + + async readEvents(sessionId: string): Promise { + assertSafeSessionId(sessionId); + return readSqliteDeepResearchEvents(this.#lease.database, sessionId); + } + + async purgeSessionState(sessionId: string): Promise { + assertSafeSessionId(sessionId); + await chainWrite(this.writeQueues, sessionId, async () => { + this.#lease.transaction('write', () => { + this.#lease.database + .prepare('DELETE FROM workflow_deep_research_events WHERE session_id = ?') + .run(sessionId); + }); + }); + } + + subscribe(listener: (event: DeepResearchChangedEvent) => void): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + async start( + sessionId: string, + objective: string, + scopeLevel: DeepResearchScopeLevel, + context: DeepResearchMutationContext = {}, + ): Promise { + const normalized = normalizeDeepResearchObjective(objective); + if (!normalized) throw new Error('Deep Research objective must be a non-empty bounded string'); + if (!isDeepResearchScopeLevel(scopeLevel)) throw new Error('Invalid Deep Research scope level'); + return this.mutate( + sessionId, + 'research_started', + context, + (events) => { + if (events.length > 0) throw new Error('Deep Research workspace is already initialized'); + const ts = this.now(); + return { + eventId: this.newId(), + type: 'research_started', + sessionId, + ts, + objective: normalized, + scopeLevel, + ...refsFromContext(context), + }; + }, + (event) => + event.type === 'research_started' && + event.objective === normalized && + event.scopeLevel === scopeLevel, + ); + } + + async recordArtifact( + sessionId: string, + artifact: DeepResearchArtifactRef, + context: DeepResearchMutationContext = {}, + ): Promise { + return this.mutate( + sessionId, + 'research_artifact_recorded', + context, + () => ({ + eventId: this.newId(), + type: 'research_artifact_recorded', + sessionId, + ts: this.now(), + artifact: { + ...artifact, + sourceArtifactIds: [...artifact.sourceArtifactIds], + }, + ...refsFromContext(context), + }), + (event) => + event.type === 'research_artifact_recorded' && sameArtifact(event.artifact, artifact), + ); + } + + async updateChecklist( + sessionId: string, + item: Omit, + context: DeepResearchMutationContext = {}, + ): Promise { + return this.mutate( + sessionId, + 'research_checklist_updated', + context, + (events) => { + const run = this.project(events); + const current = run?.checklist.find((candidate) => candidate.itemId === item.itemId); + if (!current) throw new Error(`Unknown Deep Research checklist item ${item.itemId}`); + return { + eventId: this.newId(), + type: 'research_checklist_updated', + sessionId, + ts: this.now(), + item: { + ...item, + title: current.title, + evidenceArtifactIds: [...item.evidenceArtifactIds], + updatedAt: this.now(), + }, + ...refsFromContext(context), + }; + }, + (event) => + event.type === 'research_checklist_updated' && + event.item.itemId === item.itemId && + event.item.status === item.status && + event.item.blockedReason === item.blockedReason && + sameStrings(event.item.evidenceArtifactIds, item.evidenceArtifactIds), + ); + } + + async recordStep( + sessionId: string, + step: Omit, + context: DeepResearchMutationContext = {}, + ): Promise { + return this.mutate( + sessionId, + 'research_step_recorded', + context, + () => ({ + eventId: this.newId(), + type: 'research_step_recorded', + sessionId, + ts: this.now(), + step: { + ...step, + stepId: this.newId(), + roots: [...step.roots], + keywords: [...step.keywords], + ignoredPaths: [...step.ignoredPaths], + evidenceArtifactIds: [...step.evidenceArtifactIds], + inspectedRefs: step.inspectedRefs.map((ref) => ({ ...ref })), + workerRunIds: [...step.workerRunIds], + createdAt: this.now(), + }, + ...refsFromContext(context), + }), + (event) => event.type === 'research_step_recorded' && sameStep(event.step, step), + ); + } + + async recordCheckpoint( + sessionId: string, + checkpoint: Omit, + context: DeepResearchMutationContext = {}, + ): Promise { + return this.mutate( + sessionId, + 'research_checkpoint_recorded', + context, + () => ({ + eventId: this.newId(), + type: 'research_checkpoint_recorded', + sessionId, + ts: this.now(), + checkpoint: { + ...checkpoint, + checkpointId: this.newId(), + createdAt: this.now(), + openQuestions: [...checkpoint.openQuestions], + nextSteps: [...checkpoint.nextSteps], + taskIds: [...checkpoint.taskIds], + artifactIds: [...checkpoint.artifactIds], + }, + ...refsFromContext(context), + }), + (event) => + event.type === 'research_checkpoint_recorded' && + sameCheckpoint(event.checkpoint, checkpoint), + ); + } + + async complete( + sessionId: string, + reportArtifactId: string, + handoff: DeepResearchHandoff, + context: DeepResearchMutationContext = {}, + ): Promise { + return this.mutate( + sessionId, + 'research_completed', + context, + () => ({ + eventId: this.newId(), + type: 'research_completed', + sessionId, + ts: this.now(), + reportArtifactId, + handoff: { + ...handoff, + implementationTasks: [...handoff.implementationTasks], + recommendedIssues: [...handoff.recommendedIssues], + recommendedPullRequests: [...handoff.recommendedPullRequests], + verificationCommands: [...handoff.verificationCommands], + }, + ...refsFromContext(context), + }), + (event) => + event.type === 'research_completed' && + event.reportArtifactId === reportArtifactId && + sameHandoff(event.handoff, handoff), + ); + } + + private async mutate( + sessionId: string, + expectedType: DeepResearchEvent['type'], + context: DeepResearchMutationContext, + buildEvent: (events: readonly DeepResearchEvent[]) => DeepResearchEvent, + replayMatches?: (event: DeepResearchEvent) => boolean, + ): Promise { + assertSafeSessionId(sessionId); + let nextRun: DeepResearchRun | undefined; + await chainWrite(this.writeQueues, sessionId, async () => { + const current = await this.readEvents(sessionId); + if (context.toolCallId) { + const replay = current.find((event) => event.refs?.toolCallId === context.toolCallId); + if (replay) { + if (replay.type !== expectedType) { + throw new Error( + `Deep Research tool call ${context.toolCallId} was already used for ${replay.type}`, + ); + } + if (replayMatches && !replayMatches(replay)) { + throw new Error( + `Deep Research tool call ${context.toolCallId} was retried with different input`, + ); + } + nextRun = this.project(current); + return; + } + } + const event = buildEvent(current); + if (!isDeepResearchEvent(event)) { + throw new Error('Invalid Deep Research mutation event'); + } + const next = projectDeepResearchEvents([...current, event]); + if (next.diagnostics.length > 0 || !next.run) { + throw new Error( + `Deep Research mutation rejected: ${next.diagnostics.join('; ') || 'missing run projection'}`, + ); + } + await this.appendEvent(sessionId, event); + nextRun = next.run; + const changed = { sessionId, ts: event.ts }; + for (const subscriber of this.subscribers) { + try { + subscriber(changed); + } catch { + // Durable mutation success must not depend on a best-effort UI subscriber. + } + } + }); + if (!nextRun) throw new Error('Deep Research mutation did not produce a run'); + return nextRun; + } + + private project(events: readonly DeepResearchEvent[]): DeepResearchRun | undefined { + const projection = projectDeepResearchEvents(events); + if (projection.diagnostics.length > 0) { + throw new Error( + `Deep Research ledger projection failed: ${projection.diagnostics.join('; ')}`, + ); + } + return projection.run; + } + + private async appendEvent(sessionId: string, event: DeepResearchEvent): Promise { + this.#lease.transaction('write', () => { + insertDeepResearchEvent(this.#lease.database, sessionId, event); + }); + } +} + +function readSqliteDeepResearchEvents( + database: DatabaseSync, + sessionId: string, +): DeepResearchEvent[] { + assertSafeSessionId(sessionId); + const rows = database + .prepare(` + SELECT record_json + FROM workflow_deep_research_events + WHERE session_id = ? + ORDER BY sequence + `) + .all(sessionId) as Array<{ record_json?: unknown }>; + return rows.map((row, index) => { + if (typeof row.record_json !== 'string') { + throw new Error(`Invalid SQLite Deep Research event at sequence ${index}`); + } + const parsed = JSON.parse(row.record_json); + if (!isDeepResearchEvent(parsed) || parsed.sessionId !== sessionId) { + throw new Error(`Invalid SQLite Deep Research event at sequence ${index}`); + } + return parsed; + }); +} + +function insertDeepResearchEvent( + database: DatabaseSync, + sessionId: string, + event: DeepResearchEvent, +): void { + const row = database + .prepare(` + SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence + FROM workflow_deep_research_events + WHERE session_id = ? + `) + .get(sessionId) as { sequence?: unknown }; + if (typeof row.sequence !== 'number' || !Number.isSafeInteger(row.sequence)) { + throw new Error('Invalid next Deep Research event sequence'); + } + database + .prepare(` + INSERT INTO workflow_deep_research_events( + session_id, sequence, event_id, record_json + ) VALUES (?, ?, ?, ?) + `) + .run(sessionId, row.sequence, event.eventId, JSON.stringify(event)); +} + +function refsFromContext(context: DeepResearchMutationContext): { refs?: DeepResearchEventRefs } { + const refs: DeepResearchEventRefs = { + ...(context.runId ? { runId: context.runId } : {}), + ...(context.turnId ? { turnId: context.turnId } : {}), + ...(context.toolCallId ? { toolCallId: context.toolCallId } : {}), + }; + return Object.keys(refs).length > 0 ? { refs } : {}; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sameArtifact(left: DeepResearchArtifactRef, right: DeepResearchArtifactRef): boolean { + return ( + left.artifactId === right.artifactId && + left.role === right.role && + left.name === right.name && + left.summary === right.summary && + left.createdAt === right.createdAt && + left.locator === right.locator && + left.contentHash === right.contentHash && + left.reportSectionKey === right.reportSectionKey && + left.reportSectionStatus === right.reportSectionStatus && + sameStrings(left.sourceArtifactIds, right.sourceArtifactIds) + ); +} + +function sameStep( + left: DeepResearchStep, + right: Omit, +): boolean { + return ( + left.kind === right.kind && + left.status === right.status && + left.objective === right.objective && + left.summary === right.summary && + left.stoppingCondition === right.stoppingCondition && + left.expectedEvidence === right.expectedEvidence && + left.blockedReason === right.blockedReason && + sameStrings(left.roots, right.roots) && + sameStrings(left.keywords, right.keywords) && + sameStrings(left.ignoredPaths, right.ignoredPaths) && + sameStrings(left.evidenceArtifactIds, right.evidenceArtifactIds) && + sameStrings(left.workerRunIds, right.workerRunIds) && + left.inspectedRefs.length === right.inspectedRefs.length && + left.inspectedRefs.every((ref, index) => { + const candidate = right.inspectedRefs[index]; + return ( + candidate !== undefined && + ref.kind === candidate.kind && + ref.locator === candidate.locator && + ref.label === candidate.label && + ref.sourceArtifactId === candidate.sourceArtifactId + ); + }) + ); +} + +function sameCheckpoint( + left: DeepResearchCheckpoint, + right: Omit, +): boolean { + return ( + left.round === right.round && + left.stage === right.stage && + left.status === right.status && + left.summary === right.summary && + sameStrings(left.openQuestions, right.openQuestions) && + sameStrings(left.nextSteps, right.nextSteps) && + sameStrings(left.taskIds, right.taskIds) && + sameStrings(left.artifactIds, right.artifactIds) + ); +} + +function sameHandoff(left: DeepResearchHandoff, right: DeepResearchHandoff): boolean { + return ( + left.artifactId === right.artifactId && + sameStrings(left.implementationTasks, right.implementationTasks) && + sameStrings(left.recommendedIssues, right.recommendedIssues) && + sameStrings(left.recommendedPullRequests, right.recommendedPullRequests) && + sameStrings(left.verificationCommands, right.verificationCommands) + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef819bb8ffa5f04af46467ed4de04a283b60c5c62e310874fd19a89abcbf078.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef819bb8ffa5f04af46467ed4de04a283b60c5c62e310874fd19a89abcbf078.source new file mode 100644 index 0000000000..b1f433c1e8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/aef819bb8ffa5f04af46467ed4de04a283b60c5c62e310874fd19a89abcbf078.source @@ -0,0 +1,813 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + MemoryBundleBackupRevisionConflictError, + MemoryBundleRevisionConflictError, + MemoryBundleBackupNotFoundError, + MemoryBundleStoreError, + authenticateInteractiveMemoryBundleStoreReader, + authenticateInteractiveMemoryBundleStoreWriter, + openInteractiveMemoryBundleStoreForRead, + openInteractiveMemoryBundleStoreForWrite, + type MemoryBundleSnapshot, + type MemoryDocumentSnapshot, + type MemoryRevision, +} from '../memory-bundle-store.js'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + tryAcquireInteractiveRootReader, + type InteractiveRootOwner, + type StorageRootCapability, +} from '../root-authority.js'; +import { removeControlDirectory } from './fixtures/control-directory-hygiene.js'; + +const MEMORY_DIRECTORY = 'memory'; +const TRANSACTION_DIRECTORY = '.memory-bundle-transaction'; + +describe('interactive Memory bundle storage authority', () => { + test('reader and writer recovery are non-mutating when no transaction exists', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + assert.strictEqual(authenticateInteractiveMemoryBundleStoreReader(reader), reader); + const snapshot = await reader.read(); + assert.equal(snapshot.memory.kind, 'missing'); + assert.equal(snapshot.pending.kind, 'missing'); + await assert.rejects(lstat(memoryDirectory(root)), { code: 'ENOENT' }); + } finally { + await readerOwner.close(); + } + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const writer = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + assert.strictEqual(authenticateInteractiveMemoryBundleStoreWriter(writer), writer); + const snapshot = await writer.read(); + assert.equal(snapshot.memory.kind, 'missing'); + assert.equal(snapshot.pending.kind, 'missing'); + await assert.rejects(lstat(memoryDirectory(root)), { code: 'ENOENT' }); + await assert.rejects(lstat(pendingPath(root)), { code: 'ENOENT' }); + } finally { + await owner.close(); + } + }); + }); + + test('commits MEMORY.md and PENDING.md under one exact bundle CAS', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const initial = await store.read(); + const memory = Buffer.from('# Maka Memory\n\n## Preference\nUse concise answers.\n'); + const pending = Buffer.from('# Maka Pending Memory\n\n## Candidate\nReview this.\n'); + const committed = await store.commit({ + expectedRevision: initial.revision, + memory, + pending, + }); + + assert.equal(committed.changed, true); + assert.deepEqual(await readFile(memoryPath(root)), memory); + assert.deepEqual(await readFile(pendingPath(root)), pending); + assert.equal(committed.snapshot.memory.kind, 'document'); + assert.equal(committed.snapshot.pending.kind, 'document'); + + const unchanged = await store.commit({ + expectedRevision: committed.snapshot.revision, + memory, + pending, + }); + assert.equal(unchanged.changed, false); + + await assert.rejects( + store.commit({ + expectedRevision: initial.revision, + memory: Buffer.from('# Stale overwrite\n'), + pending, + }), + (error: unknown) => + error instanceof MemoryBundleRevisionConflictError && + error.actual.revision === committed.snapshot.revision, + ); + }); + }); + + test('detects direct external edits before publishing a mutation', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const basis = await store.read(); + const external = Buffer.from('# External edit\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), external); + + await assert.rejects( + store.commit({ + expectedRevision: basis.revision, + memory: Buffer.from('# Lost update\n'), + pending: null, + }), + (error: unknown) => + error instanceof MemoryBundleRevisionConflictError && + error.actual.memory.kind === 'document' && + error.actual.memory.revision === revision(external), + ); + assert.deepEqual(await readFile(memoryPath(root)), external); + }); + }); + + test('restores a selected backup, preserves PENDING.md, and rotates restore undo history', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const initial = await store.read(); + const first = Buffer.from('# First memory\n'); + const second = Buffer.from('# Second memory\n'); + const pending = Buffer.from('# Pending remains\n'); + const firstCommit = await store.commit({ + expectedRevision: initial.revision, + memory: first, + pending, + }); + const secondCommit = await store.commit({ + expectedRevision: firstCommit.snapshot.revision, + memory: second, + pending, + backup: 'save', + }); + const saveBackup = (await store.listBackups()).find((backup) => backup.kind === 'save'); + assert.ok(saveBackup); + + const restoredFirst = await store.restoreBackup({ + expectedRevision: secondCommit.snapshot.revision, + expectedBackupRevision: saveBackup.revision, + kind: 'save', + }); + assert.deepEqual(await readFile(memoryPath(root)), first); + assert.deepEqual(await readFile(pendingPath(root)), pending); + assert.deepEqual( + await readFile(join(memoryDirectory(root), 'MEMORY.md.restore.bak')), + second, + ); + const restoreBackup = (await store.listBackups()).find((backup) => backup.kind === 'restore'); + assert.ok(restoreBackup); + + const restoredSecond = await store.restoreBackup({ + expectedRevision: restoredFirst.snapshot.revision, + expectedBackupRevision: restoreBackup.revision, + kind: 'restore', + }); + assert.deepEqual(await readFile(memoryPath(root)), second); + assert.deepEqual(await readFile(join(memoryDirectory(root), 'MEMORY.md.restore.bak')), first); + assert.deepEqual( + await readFile(join(memoryDirectory(root), 'MEMORY.md.restore.1.bak')), + second, + ); + assert.equal(restoredSecond.changed, true); + + await assert.rejects( + store.restoreBackup({ + expectedRevision: restoredSecond.snapshot.revision, + expectedBackupRevision: revision(Buffer.from('missing reset backup')), + kind: 'reset', + }), + (error: unknown) => + error instanceof MemoryBundleBackupNotFoundError && error.kind === 'reset', + ); + }); + }); + + test('can restore a bounded safe-mode backup for manual recovery', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const initial = await store.read(); + const current = await store.commit({ + expectedRevision: initial.revision, + memory: Buffer.from('# Current memory\n'), + pending: null, + }); + const oversized = Buffer.alloc(128 * 1024 + 1, 0x61); + await writeFile(join(memoryDirectory(root), 'MEMORY.md.bak'), oversized); + const saveBackup = (await store.listBackups()).find((backup) => backup.kind === 'save'); + assert.ok(saveBackup); + + const restored = await store.restoreBackup({ + expectedRevision: current.snapshot.revision, + expectedBackupRevision: saveBackup.revision, + kind: 'save', + }); + assert.equal(restored.snapshot.memory.kind, 'safe_mode'); + if (restored.snapshot.memory.kind === 'safe_mode') { + assert.equal(restored.snapshot.memory.reason, 'oversize'); + assert.equal(restored.snapshot.memory.byteLength, oversized.byteLength); + } + assert.deepEqual(await readFile(memoryPath(root)), oversized); + }); + }); + + test('rejects a stale backup candidate even when the bundle revision is unchanged', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const initial = await store.read(); + const first = Buffer.from('# First memory\n'); + const firstCommit = await store.commit({ + expectedRevision: initial.revision, + memory: first, + pending: null, + }); + const second = Buffer.from('# Second memory\n'); + const secondCommit = await store.commit({ + expectedRevision: firstCommit.snapshot.revision, + memory: second, + pending: null, + backup: 'save', + }); + const selected = (await store.listBackups()).find((backup) => backup.kind === 'save'); + assert.ok(selected); + assert.equal(selected.revision, revision(first)); + + const unchanged = await store.commit({ + expectedRevision: secondCommit.snapshot.revision, + memory: second, + pending: null, + backup: 'save', + }); + assert.equal(unchanged.changed, false); + assert.equal(unchanged.snapshot.revision, secondCommit.snapshot.revision); + const replaced = (await store.listBackups()).find((backup) => backup.kind === 'save'); + assert.ok(replaced); + assert.equal(replaced.revision, revision(second)); + + await assert.rejects( + store.restoreBackup({ + expectedRevision: secondCommit.snapshot.revision, + expectedBackupRevision: selected.revision, + kind: 'save', + }), + (error: unknown) => + error instanceof MemoryBundleBackupRevisionConflictError && + error.kind === 'save' && + error.expectedRevision === selected.revision && + error.actualRevision === replaced.revision, + ); + assert.deepEqual(await readFile(memoryPath(root)), second); + }); + }); + + test('rolls a durable two-document decision forward after partial materialization', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const targetMemory = Buffer.from('# Target memory\n'); + const oldPending = Buffer.from('# Old pending\n'); + const targetPending = Buffer.from('# Target pending\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), targetMemory); + await writeFile(pendingPath(root), oldPending); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + let target: MemoryBundleSnapshot; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + basis = await reader.read(); + await writeFile(pendingPath(root), targetPending); + target = await reader.read(); + } finally { + await readerOwner.close(); + } + + await writeFile(pendingPath(root), oldPending); + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), targetMemory, { mode: 0o600 }); + await writeFile(join(transaction, 'PENDING.md.next'), targetPending, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: transactionBundleDecision(target), + })}\n`, + { mode: 0o600 }, + ); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const recovered = await store.read(); + assert.equal(recovered.revision, target.revision); + assert.deepEqual(await readFile(memoryPath(root)), targetMemory); + assert.deepEqual(await readFile(pendingPath(root)), targetPending); + await assert.rejects(lstat(transaction), { code: 'ENOENT' }); + } finally { + await owner.close(); + } + }); + }); + + test('preserves external edits made after a durable decision', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const basisMemory = Buffer.from('# Basis memory\n'); + const basisPending = Buffer.from('# Basis pending\n'); + const targetMemory = Buffer.from('# Target memory\n'); + const targetPending = Buffer.from('# Target pending\n'); + const externalPending = Buffer.from('# External edit after commit\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), basisMemory); + await writeFile(pendingPath(root), basisPending); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + let target: MemoryBundleSnapshot; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + basis = await reader.read(); + await writeFile(memoryPath(root), targetMemory); + await writeFile(pendingPath(root), targetPending); + target = await reader.read(); + } finally { + await readerOwner.close(); + } + + await writeFile(pendingPath(root), externalPending); + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), targetMemory, { mode: 0o600 }); + await writeFile(join(transaction, 'PENDING.md.next'), targetPending, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: transactionBundleDecision(target), + })}\n`, + { mode: 0o600 }, + ); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'recovery_conflict', + ); + assert.deepEqual(await readFile(memoryPath(root)), targetMemory); + assert.deepEqual(await readFile(pendingPath(root)), externalPending); + assert.equal((await lstat(transaction)).isDirectory(), true); + } finally { + await owner.close(); + } + }); + }); + + test('does not replace an external save published after basis displacement', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const basisMemory = Buffer.from('# Basis memory\n'); + const targetMemory = Buffer.from('# Target memory\n'); + const externalMemory = Buffer.from('# External replacement\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), basisMemory); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + let target: MemoryBundleSnapshot; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + basis = await reader.read(); + await writeFile(memoryPath(root), targetMemory); + target = await reader.read(); + } finally { + await readerOwner.close(); + } + + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), targetMemory, { mode: 0o600 }); + await writeFile(join(transaction, 'MEMORY.md.displaced'), basisMemory, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: transactionBundleDecision(target), + })}\n`, + { mode: 0o600 }, + ); + await writeFile(memoryPath(root), externalMemory); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'recovery_conflict', + ); + assert.deepEqual(await readFile(memoryPath(root)), externalMemory); + assert.deepEqual(await readFile(join(transaction, 'MEMORY.md.displaced')), basisMemory); + assert.deepEqual(await readFile(join(transaction, 'MEMORY.md.next')), targetMemory); + assert.equal((await lstat(transaction)).isDirectory(), true); + } finally { + await owner.close(); + } + }); + }); + + test('restores an external save captured while displacing the basis', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const basisMemory = Buffer.from('# Basis memory\n'); + const targetMemory = Buffer.from('# Target memory\n'); + const externalMemory = Buffer.from('# External in-place save\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), basisMemory); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + let target: MemoryBundleSnapshot; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + basis = await reader.read(); + await writeFile(memoryPath(root), targetMemory); + target = await reader.read(); + } finally { + await readerOwner.close(); + } + + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), targetMemory, { mode: 0o600 }); + await writeFile(join(transaction, 'MEMORY.md.displaced'), externalMemory, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: transactionBundleDecision(target), + })}\n`, + { mode: 0o600 }, + ); + await rm(memoryPath(root)); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'recovery_conflict', + ); + assert.deepEqual(await readFile(memoryPath(root)), externalMemory); + assert.deepEqual(await readFile(join(transaction, 'MEMORY.md.displaced')), externalMemory); + assert.deepEqual(await readFile(join(transaction, 'MEMORY.md.next')), targetMemory); + assert.equal((await lstat(transaction)).isDirectory(), true); + } finally { + await owner.close(); + } + }); + }); + + test('keeps staged targets immutable after partial materialization', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const basisMemory = Buffer.from('# Basis memory\n'); + const basisPending = Buffer.from('# Basis pending\n'); + const targetMemory = Buffer.from('# Target memory\n'); + const targetPending = Buffer.from('# Target pending\n'); + const externalMemory = Buffer.from('# External in-place edit\n'); + const externalPending = Buffer.from('# External pending edit\n'); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), basisMemory); + await writeFile(pendingPath(root), basisPending); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + let target: MemoryBundleSnapshot; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + basis = await reader.read(); + await writeFile(memoryPath(root), targetMemory); + await writeFile(pendingPath(root), targetPending); + target = await reader.read(); + } finally { + await readerOwner.close(); + } + + await writeFile(memoryPath(root), basisMemory); + await writeFile(pendingPath(root), externalPending); + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), targetMemory, { mode: 0o600 }); + await writeFile(join(transaction, 'PENDING.md.next'), targetPending, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: transactionBundleDecision(target), + })}\n`, + { mode: 0o600 }, + ); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'recovery_conflict', + ); + assert.deepEqual(await readFile(memoryPath(root)), targetMemory); + assert.deepEqual(await readFile(pendingPath(root)), externalPending); + + await writeFile(memoryPath(root), externalMemory); + assert.deepEqual(await readFile(memoryPath(root)), externalMemory); + assert.deepEqual(await readFile(join(transaction, 'MEMORY.md.next')), targetMemory); + assert.equal( + (await readdir(transaction)).some((entry) => entry.endsWith('.publish')), + false, + ); + } finally { + await owner.close(); + } + }); + }); + + test('rejects a durable decision whose document state disagrees with staged bytes', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const current = Buffer.from('# Current memory\n'); + const currentRevision = revision(current); + await mkdir(memoryDirectory(root), { mode: 0o700 }); + await writeFile(memoryPath(root), current); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + let basis: MemoryBundleSnapshot; + try { + basis = await (await openInteractiveMemoryBundleStoreForRead(readerOwner.lease)).read(); + } finally { + await readerOwner.close(); + } + + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), current, { mode: 0o600 }); + await writeFile( + join(transaction, 'decision.json'), + `${JSON.stringify({ + schemaVersion: 1, + basis: transactionBundleDecision(basis), + target: { + revision: decisionBundleRevision('safe_mode', current.byteLength, currentRevision), + memory: { + kind: 'safe_mode', + byteLength: current.byteLength, + revision: currentRevision, + reason: 'invalid_utf8', + }, + pending: { + kind: 'missing', + byteLength: 0, + revision: null, + reason: null, + }, + }, + })}\n`, + { mode: 0o600 }, + ); + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'invalid_document', + ); + assert.deepEqual(await readFile(memoryPath(root)), current); + assert.equal((await lstat(transaction)).isDirectory(), true); + } finally { + await owner.close(); + } + }); + }); + + test('discards an undecided transaction without touching user documents', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + await mkdir(memoryDirectory(root), { mode: 0o700 }); + const current = Buffer.from('# Current\n'); + await writeFile(memoryPath(root), current); + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), '# Uncommitted\n'); + + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + assert.deepEqual(await readFile(memoryPath(root)), current); + await assert.rejects(lstat(transaction), { code: 'ENOENT' }); + assert.equal((await store.read()).memory.kind, 'document'); + }); + }); + + test('read-only inspection fails closed until a writer recovers a transaction', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + await mkdir(memoryDirectory(root), { mode: 0o700 }); + const current = Buffer.from('# Current\n'); + await writeFile(memoryPath(root), current); + const transaction = join(memoryDirectory(root), TRANSACTION_DIRECTORY); + await mkdir(transaction, { mode: 0o700 }); + await writeFile(join(transaction, 'MEMORY.md.next'), '# Uncommitted\n'); + + const readerOwner = await tryAcquireInteractiveRootReader(capability); + assert.ok(readerOwner); + if (!readerOwner) return; + try { + const reader = await openInteractiveMemoryBundleStoreForRead(readerOwner.lease); + await assert.rejects( + reader.read(), + (error: unknown) => error instanceof MemoryBundleStoreError && error.code === 'io_failed', + ); + } finally { + await readerOwner.close(); + } + + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + assert.equal((await store.read()).memory.kind, 'document'); + assert.deepEqual(await readFile(memoryPath(root)), current); + await assert.rejects(lstat(transaction), { code: 'ENOENT' }); + } finally { + await owner.close(); + } + }); + }); + + test('rejects a symbolic-link Memory parent without reading or writing outside the root', async () => { + await withInteractiveOwner(async ({ root, owner }) => { + const outside = await mkdtemp(join(tmpdir(), 'maka-memory-outside-')); + try { + const outsideMemory = join(outside, 'MEMORY.md'); + await writeFile(outsideMemory, '# Outside\n'); + await symlink( + outside, + memoryDirectory(root), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await assert.rejects( + openInteractiveMemoryBundleStoreForWrite(owner.lease), + (error: unknown) => + error instanceof MemoryBundleStoreError && error.code === 'invalid_document', + ); + assert.equal(await readFile(outsideMemory, 'utf8'), '# Outside\n'); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + }); + + test('root owner close drains mutations admitted before close', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const store = await openInteractiveMemoryBundleStoreForWrite(owner.lease); + const initial = await store.read(); + const commit = store.commit({ + expectedRevision: initial.revision, + memory: Buffer.alloc(128 * 1024, 0x61), + pending: null, + }); + const close = owner.close(); + + const committed = await commit; + assert.equal(committed.changed, true); + await close; + await assert.rejects(store.read(), { code: 'invalid_lease' }); + }); + }); +}); + +async function withInteractiveOwner( + run: (input: { root: string; owner: InteractiveRootOwner }) => Promise, +): Promise { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + await run({ root, owner }); + } finally { + if (!owner.closed) await owner.close(); + } + }); +} + +async function withInteractiveRoot( + run: (input: { root: string; capability: StorageRootCapability<'interactive'> }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-memory-bundle-store-')); + try { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + try { + await run({ root, capability }); + } finally { + await removeControlDirectory(capability.rootId); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function memoryDirectory(root: string): string { + return join(root, MEMORY_DIRECTORY); +} + +function memoryPath(root: string): string { + return join(memoryDirectory(root), 'MEMORY.md'); +} + +function pendingPath(root: string): string { + return join(memoryDirectory(root), 'PENDING.md'); +} + +function revision(bytes: Uint8Array): MemoryRevision { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +} + +function decisionBundleRevision( + memoryKind: 'document' | 'safe_mode', + memoryByteLength: number, + memoryRevision: MemoryRevision, +): MemoryRevision { + const hash = createHash('sha256'); + hash.update('maka-memory-bundle-v1\0'); + hash.update(`memory\0${memoryKind}\0${memoryByteLength}\0`); + hash.update(memoryRevision); + hash.update('\0'); + hash.update('pending\0missing\0'); + hash.update('0'); + hash.update('\0missing\0'); + return `sha256:${hash.digest('hex')}`; +} + +function transactionBundleDecision(snapshot: MemoryBundleSnapshot) { + return { + revision: snapshot.revision, + memory: transactionDocumentDecision(snapshot.memory), + pending: transactionDocumentDecision(snapshot.pending), + }; +} + +function transactionDocumentDecision(snapshot: MemoryDocumentSnapshot) { + return { + kind: snapshot.kind, + byteLength: snapshot.byteLength, + revision: snapshot.revision, + reason: snapshot.kind === 'safe_mode' ? snapshot.reason : null, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b03ada6aca801b4466bffc0b996bc7fcadfd13cc3172f96d4760dee34e2737dd.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b03ada6aca801b4466bffc0b996bc7fcadfd13cc3172f96d4760dee34e2737dd.source new file mode 100644 index 0000000000..0cf9a14dd5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b03ada6aca801b4466bffc0b996bc7fcadfd13cc3172f96d4760dee34e2737dd.source @@ -0,0 +1,972 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { + CONNECTION_CATALOG_MAX_CONNECTIONS, + decodeCanonicalConnectionCatalogEntry, + decodeConnectionName, + decodeConnectionSlug, + decodeConnectionTarget, + decodeConnectionTestSummary, + decodeConnectionVersionBasis, + decodeProviderType, + decodeRuntimePolicyEntityId, + normalizeConnectionCatalogEntryUpdateForProvider, + normalizeConnectionModelDiscoveryResult, + normalizeCreateCatalogConnectionInput, + normalizeRemoveCatalogConnectionInput, + normalizeSetDefaultConnectionTargetInput, + normalizeUpdateCatalogConnectionInput, + type ConnectionCatalogEntry, + type ConnectionCatalogMutationResult, + type ConnectionCatalogSnapshot, + type ConnectionModelDiscoveryResult, + type ConnectionTarget, + type ConnectionTestSummary, + type ConnectionVersionBasis, + type CreateCatalogConnectionInput, + type RemoveCatalogConnectionInput, + type SetDefaultConnectionTargetInput, + type MigrateSystemSeedInput, + type UpdateCatalogConnectionInput, +} from '@maka/core/runtime-policy'; +import { PROVIDER_REGISTRY, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; +import { + modelIdAliasesForProvider, + providerReportsCompleteModelCatalog, +} from '@maka/core/model-metadata'; +import { isRetiredProvider } from '@maka/core/provider-registry'; +import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; +import { deepFreeze, nextRevision, record, revision, unique } from './codec.js'; +import { + codecError, + decodeConnectionInput, + decodePersistedDomain, + RuntimePolicyStoreError, +} from './errors.js'; +import { + CATALOG_DOCUMENT_MAX_BYTES, + readBoundedJsonDocument, + serializeJsonDocument, + writeJsonDocument, +} from './document-io.js'; + +const FILE = 'connection-catalog.json'; +const SCHEMA_VERSION = 1 as const; + +export interface ConnectionCatalogDocument { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly revision: number; + readonly defaultTarget: ConnectionTarget | null; + readonly connections: readonly ConnectionCatalogEntry[]; +} + +export interface ConnectionTestModelBasis { + readonly enabledModelIds: readonly string[]; + readonly modelSource: ConnectionCatalogEntry['modelSource']; + readonly models: readonly { + readonly id: string; + readonly apiProtocol: ConnectionCatalogEntry['models'][number]['apiProtocol']; + }[]; +} + +interface PreparedOnboardingResult { + readonly kind: 'ready'; + readonly document: ConnectionCatalogDocument; + readonly changed: boolean; +} + +export class ConnectionCatalogDocumentOwner { + async read(root: string): Promise { + const value = await readBoundedJsonDocument(root, FILE, CATALOG_DOCUMENT_MAX_BYTES); + if (value === undefined) { + return { schemaVersion: SCHEMA_VERSION, revision: 0, defaultTarget: null, connections: [] }; + } + const raw = record(value, FILE, 'invalid_document', [ + 'schemaVersion', + 'revision', + 'defaultTarget', + 'connections', + ]); + if (raw.schemaVersion !== SCHEMA_VERSION) { + throw codecError('invalid_document', `${FILE} has an unsupported schema version`); + } + if ( + !Array.isArray(raw.connections) || + raw.connections.length > CONNECTION_CATALOG_MAX_CONNECTIONS + ) { + throw codecError('invalid_document', `${FILE}.connections must be a bounded array`); + } + // Releases before #3054 could persist the non-executable Gemini account + // preview. Keep the raw file recoverable on read, but omit retired entries + // from the active catalog; the next catalog mutation writes the canonical + // supported set and completes the migration. + const retiredConnections: Array<{ + readonly connectionId: ConnectionCatalogEntry['connectionId']; + readonly slug: ConnectionCatalogEntry['slug']; + }> = []; + const maintainedConnections = raw.connections.filter((item) => { + if (!isRetiredGeminiCliConnection(item)) return true; + retiredConnections.push({ + connectionId: decodePersistedDomain(() => decodeRuntimePolicyEntityId(item.connectionId)), + slug: decodePersistedDomain(() => decodeConnectionSlug(item.slug)), + }); + return false; + }); + const connections = maintainedConnections.map((item) => + decodePersistedDomain(() => decodeCanonicalConnectionCatalogEntry(item)), + ); + const catalogIdentities = [...retiredConnections, ...connections]; + unique( + catalogIdentities.map((item) => item.slug), + `${FILE} connection slugs`, + 'invalid_document', + ); + unique( + catalogIdentities.map((item) => item.connectionId), + `${FILE} connection ids`, + 'invalid_document', + ); + const retiredConnectionIds = new Set([ + ...retiredConnections.map((item) => item.connectionId), + // A retired provider whose connection is *kept* — kept so the user can + // still see it and delete it to clear the credential. Keeping the row + // must not also keep it as the target new Sessions default to: it cannot + // execute, and the settings row has no control to move the default off a + // connection that can no longer be one. + ...connections + .filter((item) => isRetiredProvider(item.providerType)) + .map((item) => item.connectionId), + ]); + const decodedDefaultTarget = + raw.defaultTarget === null + ? null + : decodePersistedDomain(() => decodeConnectionTarget(raw.defaultTarget)); + const defaultTarget = + decodedDefaultTarget && retiredConnectionIds.has(decodedDefaultTarget.connectionId) + ? null + : decodedDefaultTarget; + if (defaultTarget && !isValidTarget(defaultTarget, connections)) { + throw codecError('invalid_document', `${FILE} contains an invalid default target`); + } + return { + schemaVersion: SCHEMA_VERSION, + revision: revision(raw.revision, `${FILE}.revision`, 'invalid_document'), + defaultTarget, + connections, + }; + } + + async create( + root: string, + rawInput: CreateCatalogConnectionInput, + ): Promise { + const input = decodeConnectionInput(() => normalizeCreateCatalogConnectionInput(rawInput)); + // A retired provider stays a valid `ProviderType` so existing rows keep + // decoding, but nothing may author a new one: it could never execute, and + // reading the catalog back would immediately release it as a default. + // Decoding and deleting are the deliberate exceptions to that. + if (isRetiredProvider(input.connection.providerType)) { + throw codecError( + 'invalid_connection_input', + `"${input.connection.providerType}" is retired and cannot be added`, + ); + } + const current = await this.read(root); + if (current.revision !== input.expectedCatalogRevision) { + return revisionConflict(input.expectedCatalogRevision, current.revision); + } + if (current.connections.some((item) => item.slug === input.connection.slug)) { + return deepFreeze({ kind: 'connection_exists', slug: input.connection.slug }); + } + if (current.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + throw codecError( + 'invalid_connection_input', + `Connection catalog cannot exceed ${CONNECTION_CATALOG_MAX_CONNECTIONS} entries`, + ); + } + const next = this.nextDocument(current, [ + ...current.connections, + { + ...input.connection, + connectionId: randomUUID(), + revision: 1, + // A provider with no model-list endpoint ships its inventory in the + // registry, and the resolver prepends it to whatever the connection + // stores. Copying it in here as well persisted a build-time constant + // as if it were connection state: a second authority for the same + // fact, frozen at the moment the row was written, that a build + // shipping a new model could no longer correct. + models: [], + }, + ]); + await this.write(root, next); + return committed(next); + } + + async update( + root: string, + rawInput: UpdateCatalogConnectionInput, + ): Promise { + const input = decodeConnectionInput(() => normalizeUpdateCatalogConnectionInput(rawInput)); + const current = await this.read(root); + const index = findConnectionIndex(current, input.expected); + const previous = index < 0 ? undefined : current.connections[index]; + if (!previous || previous.revision !== input.expected.revision) { + return connectionStale(input.expected, previous ? connectionBasis(previous) : null); + } + const changes = decodeConnectionInput(() => + normalizeConnectionCatalogEntryUpdateForProvider(input.changes, previous.providerType), + ); + // A retired row may be read and deleted, and it may stay exactly as it is + // — but it may not be edited back toward usable. Re-enabling is the one + // that matters (a disabled retired connection would become a default + // candidate again), and refusing the whole update rather than that single + // field keeps this a boundary rather than a field-by-field allow list: a + // retired connection has no edit worth committing. + if (isRetiredProvider(previous.providerType)) { + throw codecError( + 'invalid_connection_input', + `"${previous.providerType}" is retired and its connections cannot be edited`, + ); + } + const endpointChanged = previous.baseUrl !== changes.baseUrl; + const testBasisChanged = + endpointChanged || + previous.enabled !== changes.enabled || + !sameStringArray(previous.enabledModelIds, changes.enabledModelIds) || + (changes.requestBodyOverlay !== undefined && + !isDeepStrictEqual(previous.requestBodyOverlay, changes.requestBodyOverlay ?? undefined)); + const connections = [...current.connections]; + connections[index] = { + connectionId: previous.connectionId, + revision: nextRevision(previous.revision), + slug: previous.slug, + name: changes.name, + providerType: previous.providerType, + ...(changes.baseUrl === undefined ? {} : { baseUrl: changes.baseUrl }), + enabled: changes.enabled, + enabledModelIds: changes.enabledModelIds, + // Profile-table semantics, in order: + // - the store invariant: profiles exist only for enabled models, so a + // selection change prunes whatever no longer qualifies (disabling a + // model deletes its profile); + // - a table replaces wholesale — it wins even over an endpoint change + // in the same update, because a writer submitting a new endpoint and + // a new table declares that the table belongs to the new endpoint + // (config import does exactly this); + // - null clears; + // - absent leaves the stored table alone, except that an endpoint + // change retires it: declarations are endpoint-keyed like the model + // inventory, and the old table must not outlive the relay it + // described. + ...(changes.relayModelProfiles === undefined + ? endpointChanged || previous.relayModelProfiles === undefined + ? {} + : { + relayModelProfiles: pruneRelayModelProfiles( + previous.relayModelProfiles, + changes.enabledModelIds, + ), + } + : changes.relayModelProfiles === null + ? {} + : { relayModelProfiles: changes.relayModelProfiles }), + ...(changes.requestBodyOverlay === undefined + ? previous.requestBodyOverlay === undefined + ? {} + : { requestBodyOverlay: previous.requestBodyOverlay } + : changes.requestBodyOverlay === null + ? {} + : { requestBodyOverlay: changes.requestBodyOverlay }), + models: endpointChanged ? [] : previous.models, + ...(endpointChanged || previous.modelSource === undefined + ? {} + : { modelSource: previous.modelSource }), + ...(endpointChanged || previous.modelsFetchedAt === undefined + ? {} + : { modelsFetchedAt: previous.modelsFetchedAt }), + ...(testBasisChanged || previous.lastTest === undefined + ? {} + : { lastTest: previous.lastTest }), + }; + const next = this.nextDocument(current, connections); + await this.write(root, next); + return committed(next); + } + + async remove( + root: string, + rawInput: RemoveCatalogConnectionInput, + ): Promise { + const input = decodeConnectionInput(() => normalizeRemoveCatalogConnectionInput(rawInput)); + const current = await this.read(root); + const index = findConnectionIndex(current, input.expected); + const previous = index < 0 ? undefined : current.connections[index]; + if (!previous || previous.revision !== input.expected.revision) { + return connectionStale(input.expected, previous ? connectionBasis(previous) : null); + } + const next = this.nextDocument( + current, + current.connections.filter((_item, candidate) => candidate !== index), + ); + await this.write(root, next); + return committed(next); + } + + /** + * Built-in seed evolution as ONE atomic catalog mutation. A row whose + * `enabledModelIds` still exactly match a historical system seed is provably + * system-owned: it follows the current seed, its static inventory is + * re-derived from the current build, and a default target the migration + * removes is retargeted inside the same document write — so no restart can + * observe enabled ids without their inventory, or a nulled default awaiting + * a second write. Any other inventory (including a reordering) is a user + * selection and is never touched; an already-null default stays null. + */ + async migrateSystemSeed( + root: string, + input: MigrateSystemSeedInput, + ): Promise { + if (!input.enabledModelIds.includes(input.defaultModelId)) { + throw codecError('invalid_connection_input', 'Seed default must be in the seed selection'); + } + const current = await this.read(root); + const index = current.connections.findIndex( + (item) => item.slug === input.slug && item.providerType === input.providerType, + ); + const previous = current.connections[index]; + const retired = new Set(input.retiredModelIds); + const sameIds = (left: readonly string[], right: readonly string[]) => + left.length === right.length && left.every((id, position) => id === right[position]); + const isLegacySeed = previous + ? input.legacyEnabledModelIds.some((seed) => sameIds(previous.enabledModelIds, seed)) + : false; + const hasRetiredModels = previous + ? previous.enabledModelIds.some((id) => retired.has(id)) || + previous.models.some((model) => retired.has(model.id)) + : false; + if (!previous || (!isLegacySeed && !hasRetiredModels)) { + return committed(current); + } + // A legacy seed's stored inventory was the registry's shipped list copied + // in at write time. Clearing it is the migration: the resolver prepends + // that list from the current build, so the row stops carrying a stale + // second copy of it. Any other row keeps its own inventory, minus the + // retired ids. + const models = isLegacySeed ? [] : previous.models.filter((model) => !retired.has(model.id)); + const { + lastTest: _lastTest, + modelSource: _modelSource, + modelsFetchedAt: _modelsFetchedAt, + ...retained + } = previous; + const migratedEnabledModelIds = isLegacySeed + ? [...input.enabledModelIds] + : previous.enabledModelIds.filter((id) => !retired.has(id)); + const connections = [...current.connections]; + const relayModelProfiles = pruneRelayModelProfiles( + previous.relayModelProfiles, + migratedEnabledModelIds, + ); + connections[index] = { + ...retained, + revision: nextRevision(previous.revision), + enabledModelIds: migratedEnabledModelIds, + models, + ...(isLegacySeed || previous.modelSource === undefined + ? {} + : { modelSource: previous.modelSource, modelsFetchedAt: previous.modelsFetchedAt }), + ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + }; + const target = current.defaultTarget; + const defaultTarget = + target !== null && + target.connectionId === previous.connectionId && + !migratedEnabledModelIds.includes(target.modelId) + ? { connectionId: previous.connectionId, modelId: input.defaultModelId } + : target; + const next = this.nextDocument(current, connections, defaultTarget); + await this.write(root, next); + return committed(next); + } + + async setDefaultTarget( + root: string, + rawInput: SetDefaultConnectionTargetInput, + ): Promise { + const input = decodeConnectionInput(() => normalizeSetDefaultConnectionTargetInput(rawInput)); + const current = await this.read(root); + if (current.revision !== input.expectedCatalogRevision) { + return revisionConflict(input.expectedCatalogRevision, current.revision); + } + // The one call that states a target, so the one place an unusable one is + // the caller's error rather than a consequence to release. + if (input.target && !isValidTarget(input.target, current.connections)) { + return deepFreeze({ kind: 'invalid_default_target', target: input.target }); + } + // Refused rather than accepted-then-released: committing it would succeed + // and the next read would silently rewrite it to null, which reads to the + // caller as the write having been lost. + if ( + input.target && + current.connections.some( + (item) => + item.connectionId === input.target?.connectionId && isRetiredProvider(item.providerType), + ) + ) { + return deepFreeze({ kind: 'invalid_default_target', target: input.target }); + } + const next = this.nextDocument(current, current.connections, input.target); + await this.write(root, next); + return committed(next); + } + + async writeModelFetchResult( + root: string, + current: ConnectionCatalogDocument, + expected: ConnectionVersionBasis, + rawResult: ConnectionModelDiscoveryResult, + ): Promise { + const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); + if (result.models.length === 0) { + throw codecError('invalid_connection_input', 'Model discovery result must not be empty'); + } + const index = findConnectionIndex(current, expected); + const previous = current.connections[index]; + if (!previous || previous.revision !== expected.revision) { + throw codecError('invalid_document', 'Coordinator admitted a stale model discovery result'); + } + const currentDefaultTarget = + current.defaultTarget?.connectionId === previous.connectionId + ? current.defaultTarget + : undefined; + const reconciled = reconcileConnectionAfterModelFetch( + { + defaultModel: currentDefaultTarget?.modelId ?? previous.enabledModelIds[0], + enabledModelIds: previous.enabledModelIds, + // An entry always carries a `models` array, so "has an inventory" has + // to be read off its contents: empty means this connection has never + // had a list to pick from and discovery may seed one. A non-empty one + // means an empty selection is the user's answer. + hasModelInventory: previous.models.length > 0, + }, + result.models, + { + aliases: modelIdAliasesForProvider(previous.providerType), + authoritative: providerReportsCompleteModelCatalog(previous.providerType), + }, + ); + // Discovery MOVES a target: a provider's model rename carries the default + // across by alias. A default outside the selection the reconciler just + // decided is its own bug — fail closed where it is still attributable. + const defaultTarget = currentDefaultTarget + ? reconciled.defaultModel + ? { connectionId: previous.connectionId, modelId: reconciled.defaultModel } + : null + : current.defaultTarget; + if ( + currentDefaultTarget && + reconciled.defaultModel && + !reconciled.enabledModelIds.includes(reconciled.defaultModel) + ) { + throw codecError( + 'invalid_document', + 'Model discovery reconciled a default outside its own selection', + ); + } + // A refresh only ever migrates a renamed id now, but that rename still + // rekeys the selection, and this write path bypasses the canonical + // decoder — so prune here or the persisted document is un-loadable on + // next read. + const relayModelProfiles = pruneRelayModelProfiles( + previous.relayModelProfiles, + reconciled.enabledModelIds, + ); + const { relayModelProfiles: _staleProfiles, ...previousWithoutProfiles } = previous; + const discovered: ConnectionCatalogEntry = { + ...previousWithoutProfiles, + ...(relayModelProfiles ? { relayModelProfiles } : {}), + revision: nextRevision(previous.revision), + enabledModelIds: reconciled.enabledModelIds, + models: result.models, + modelSource: result.source, + modelsFetchedAt: result.fetchedAt, + }; + const testBasisChanged = !sameConnectionTestModelBasis( + connectionTestModelBasis(previous), + connectionTestModelBasis(discovered), + ); + const { lastTest: _lastTest, ...discoveredWithoutLastTest } = discovered; + return this.writePatchedResult( + root, + current, + index, + testBasisChanged ? discoveredWithoutLastTest : discovered, + defaultTarget, + ); + } + + prepareOnboardingUpsert( + current: ConnectionCatalogDocument, + rawConnectionId: string, + rawSlug: string, + rawProviderType: unknown, + rawName: string | null, + rawBaseUrl: string | null, + rawEnabledModelIds: readonly string[], + rawResult: ConnectionModelDiscoveryResult, + invalidateLastTest: boolean, + ): + | PreparedOnboardingResult + | { readonly kind: 'slug_conflict' } + | { readonly kind: 'catalog_full' } { + const connectionId = decodeConnectionInput(() => decodeRuntimePolicyEntityId(rawConnectionId)); + const slug = decodeConnectionInput(() => decodeConnectionSlug(rawSlug)); + const providerType = decodeConnectionInput(() => decodeProviderType(rawProviderType)); + const requestedName = + rawName === null ? null : decodeConnectionInput(() => decodeConnectionName(rawName)); + const definition = PROVIDER_REGISTRY[providerType]; + // Identity first: the intent's connectionId names the connection being + // edited, whatever slug it lives under — a relay created in Desktop under + // a custom slug is updated in place, never duplicated at the canonical + // slug. Only a genuinely new connection lands at the derived slug. + const index = current.connections.findIndex( + (connection) => connection.connectionId === connectionId, + ); + const previous = current.connections[index]; + if (previous && previous.providerType !== providerType) { + return { kind: 'slug_conflict' }; + } + if (previous && previous.slug !== slug) { + throw codecError('invalid_document', 'Onboarding intent conflicts with the connection slug'); + } + if (!previous && current.connections.some((connection) => connection.slug === slug)) { + return { kind: 'slug_conflict' }; + } + if (!previous && current.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return { kind: 'catalog_full' }; + } + const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); + // Non-empty is the requirement; `source` is write provenance, not a + // quality bar. A provider without a model-list endpoint runs discovery by + // replaying the array this build shipped, and that inventory onboards a + // connection exactly as well (#1584). + if (result.models.length === 0) { + throw codecError( + 'invalid_connection_input', + 'Onboarding requires a non-empty model inventory', + ); + } + // A supplied endpoint replaces the previous one; null preserves the + // existing override or the registry default (blank-reuse, like the key). + const effectiveBaseUrl = rawBaseUrl ?? previous?.baseUrl ?? definition.baseUrl; + const changes = decodeConnectionInput(() => + normalizeConnectionCatalogEntryUpdateForProvider( + { + // An edit keeps the stored name; a create takes the caller's choice + // when the wizard collected one, else the provider label. + name: previous?.name ?? requestedName ?? definition.label, + ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}), + enabled: true, + enabledModelIds: rawEnabledModelIds, + }, + providerType, + ), + ); + if (changes.enabledModelIds.length === 0) { + throw codecError('invalid_connection_input', 'Onboarding must enable a model'); + } + // Onboarding offers what discovery returned, so a model the user declared + // by hand is one the wizard never showed. Not being re-picked in a list it + // was absent from is not a decision to drop it (#1584). + const offered = new Set(result.models.map(({ id }) => id)); + const undisplayed = (previous?.enabledModelIds ?? []).filter( + (modelId) => !offered.has(modelId) && !changes.enabledModelIds.includes(modelId), + ); + const enabledModelIds = [...changes.enabledModelIds, ...undisplayed]; + // The endpoint keys the profile table and the last test the same way it + // does on the update path: declarations describe the relay that made + // them, so a swapped URL must not inherit either. + const endpointChanged = previous !== undefined && previous.baseUrl !== changes.baseUrl; + // Onboarding installs a new enabledModelIds authority, so a profile keyed + // by a model it dropped would violate the subset invariant. Like the + // refresh path above, this one bypasses the canonical decoder, so pruning + // has to happen here or the document is un-loadable on next read. + const relayModelProfiles = + previous && !endpointChanged + ? pruneRelayModelProfiles(previous.relayModelProfiles, enabledModelIds) + : undefined; + const base: ConnectionCatalogEntry = previous ?? { + connectionId, + revision: 0, + slug, + name: definition.label, + providerType, + enabled: false, + enabledModelIds: [], + models: [], + }; + const { relayModelProfiles: _staleProfiles, ...baseWithoutProfiles } = base; + const finalized: ConnectionCatalogEntry = { + ...baseWithoutProfiles, + // `changes.name` carries the edit-preserving / create-requested / provider + // -label resolution — `base` alone would keep the provider label forever. + name: changes.name, + ...(relayModelProfiles ? { relayModelProfiles } : {}), + ...(changes.baseUrl !== undefined ? { baseUrl: changes.baseUrl } : {}), + revision: previous ? nextRevision(previous.revision) : 1, + enabled: true, + enabledModelIds, + models: result.models, + modelSource: result.source, + modelsFetchedAt: result.fetchedAt, + }; + // Onboarding only seeds the first default. + const defaultTarget = current.defaultTarget ?? { + connectionId, + modelId: changes.enabledModelIds[0]!, + }; + if ( + previous?.enabled && + (changes.baseUrl === undefined || changes.baseUrl === previous.baseUrl) && + sameStringArray(previous.enabledModelIds, enabledModelIds) && + isDeepStrictEqual(previous.models, result.models) && + previous.modelSource === result.source && + previous.modelsFetchedAt === result.fetchedAt && + isDeepStrictEqual(current.defaultTarget, defaultTarget) && + (!invalidateLastTest || previous.lastTest === undefined) + ) { + return { kind: 'ready', document: current, changed: false }; + } + const testBasisChanged = previous + ? endpointChanged || + !sameConnectionTestModelBasis( + connectionTestModelBasis(previous), + connectionTestModelBasis(finalized), + ) + : true; + const { lastTest: _lastTest, ...finalizedWithoutLastTest } = finalized; + const connections = [...current.connections]; + const entry = testBasisChanged || invalidateLastTest ? finalizedWithoutLastTest : finalized; + if (previous) connections[index] = entry; + else connections.push(entry); + const next = this.nextDocument(current, connections, defaultTarget); + this.assertDocumentSize(next); + return { kind: 'ready', document: next, changed: true }; + } + + prepareOAuthEnrollmentUpsert( + current: ConnectionCatalogDocument, + connectionBefore: ConnectionCatalogEntry | null, + rawConnectionAfter: ConnectionCatalogEntry, + ): + | PreparedOnboardingResult + | { readonly kind: 'connection_conflict' } + | { readonly kind: 'catalog_full' } { + const connectionAfter = decodeConnectionInput(() => + decodeCanonicalConnectionCatalogEntry(rawConnectionAfter), + ); + const idIndex = current.connections.findIndex( + (connection) => connection.connectionId === connectionAfter.connectionId, + ); + const slugIndex = current.connections.findIndex( + (connection) => connection.slug === connectionAfter.slug, + ); + if (connectionBefore === null) { + if (idIndex >= 0 || slugIndex >= 0) { + const exact = + idIndex >= 0 && + idIndex === slugIndex && + isDeepStrictEqual(current.connections[idIndex], connectionAfter); + return exact + ? { kind: 'ready', document: current, changed: false } + : { kind: 'connection_conflict' }; + } + if (current.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return { kind: 'catalog_full' }; + } + const next = this.nextDocument(current, [...current.connections, connectionAfter]); + this.assertDocumentSize(next); + return { kind: 'ready', document: next, changed: true }; + } + if (idIndex < 0 || (slugIndex >= 0 && slugIndex !== idIndex)) { + return { kind: 'connection_conflict' }; + } + const actual = current.connections[idIndex]; + if (isDeepStrictEqual(actual, connectionAfter)) { + return { kind: 'ready', document: current, changed: false }; + } + if (!isDeepStrictEqual(actual, connectionBefore)) { + return { kind: 'connection_conflict' }; + } + const connections = [...current.connections]; + connections[idIndex] = connectionAfter; + const next = this.nextDocument(current, connections); + this.assertDocumentSize(next); + return { kind: 'ready', document: next, changed: true }; + } + + async commitPreparedOnboarding( + root: string, + prepared: PreparedOnboardingResult, + ): Promise { + if (prepared.changed) await this.write(root, prepared.document); + return catalogSnapshot(prepared.document); + } + + async writeConnectionTestResult( + root: string, + current: ConnectionCatalogDocument, + expected: ConnectionVersionBasis, + rawResult: ConnectionTestSummary, + modelFactsFingerprint: string, + ): Promise { + const result = decodeConnectionInput(() => decodeConnectionTestSummary(rawResult)); + const index = findConnectionIndex(current, expected); + const previous = current.connections[index]; + if (!previous || previous.revision !== expected.revision) { + throw codecError('invalid_document', 'Coordinator admitted a stale connection test result'); + } + return this.writePatchedResult(root, current, index, { + ...previous, + revision: nextRevision(previous.revision), + lastTest: result, + lastTestModelFactsFingerprint: modelFactsFingerprint, + }); + } + + async clearConnectionLastTest( + root: string, + current: ConnectionCatalogDocument, + connectionId: string, + ): Promise { + const index = findConnectionIndex(current, { connectionId }); + const previous = current.connections[index]; + if (!previous) { + throw codecError('invalid_document', 'Coordinator admitted an unknown connection'); + } + // Same tombstone rule the global sweep follows, reached one connection at + // a time: deleting a retained retired credential is a write the row must + // still accept, and invalidating its verification on the way out would + // bump the revision of a row nothing may rewrite — enough to make a + // deletion started elsewhere fail as stale. Its `lastTest` describes a + // provider that can no longer be tested, so there is nothing to + // invalidate. + if (previous.lastTest === undefined || isRetiredProvider(previous.providerType)) return false; + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = previous; + await this.writePatchedResult(root, current, index, { + ...withoutLastTest, + revision: nextRevision(previous.revision), + }); + return true; + } + + async clearAllConnectionLastTests( + root: string, + current: ConnectionCatalogDocument, + ): Promise { + // A retired row is a tombstone: byte-stable until it is deleted. Global + // invalidation is the indirect way back in — a user editing the network + // proxy would otherwise bump its revision, which is enough to make a + // deletion they started elsewhere fail as stale. Its `lastTest` describes + // a provider that can no longer be tested anyway, so there is nothing to + // invalidate. + const invalidates = (connection: ConnectionCatalogEntry): boolean => + connection.lastTest !== undefined && !isRetiredProvider(connection.providerType); + if (!current.connections.some(invalidates)) return false; + const connections = current.connections.map((connection) => { + if (!invalidates(connection)) return connection; + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = connection; + return { + ...withoutLastTest, + revision: nextRevision(connection.revision), + }; + }); + await this.write(root, this.nextDocument(current, connections)); + return true; + } + + private async writePatchedResult( + root: string, + current: ConnectionCatalogDocument, + index: number, + patched: ConnectionCatalogEntry, + defaultTarget: ConnectionTarget | null = current.defaultTarget, + ): Promise { + const connections = [...current.connections]; + connections[index] = patched; + const next = this.nextDocument(current, connections, defaultTarget); + await this.write(root, next); + return catalogSnapshot(next); + } + + // The catalog's only next-version constructor: callers state the target they + // want kept, never the one they have to police. + private nextDocument( + current: ConnectionCatalogDocument, + connections: readonly ConnectionCatalogEntry[], + defaultTarget: ConnectionTarget | null = current.defaultTarget, + ): ConnectionCatalogDocument { + return { + ...current, + revision: nextRevision(current.revision), + defaultTarget: retainedDefaultTarget(defaultTarget, connections), + connections, + }; + } + + private async write(root: string, document: ConnectionCatalogDocument): Promise { + this.assertDocumentSize(document); + await writeJsonDocument(root, FILE, document, CATALOG_DOCUMENT_MAX_BYTES); + } + + private assertDocumentSize(document: ConnectionCatalogDocument): void { + if (serializeJsonDocument(document).length > CATALOG_DOCUMENT_MAX_BYTES) { + throw new RuntimePolicyStoreError( + 'invalid_connection_input', + `connection catalog exceeds its ${CATALOG_DOCUMENT_MAX_BYTES} byte limit`, + ); + } + } +} + +export function catalogSnapshot(document: ConnectionCatalogDocument): ConnectionCatalogSnapshot { + return deepFreeze({ + revision: document.revision, + defaultTarget: structuredClone(document.defaultTarget), + connections: structuredClone(document.connections), + }); +} + +export function connectionBasis(connection: ConnectionCatalogEntry): ConnectionVersionBasis { + return { + connectionId: connection.connectionId, + revision: connection.revision, + }; +} + +export function findConnection( + document: ConnectionCatalogDocument, + identity: Pick, +): ConnectionCatalogEntry | undefined { + return document.connections.find((item) => sameConnectionIdentity(item, identity)); +} + +export function connectionTestModelBasis( + connection: ConnectionCatalogEntry, +): ConnectionTestModelBasis { + return { + enabledModelIds: [...connection.enabledModelIds], + modelSource: connection.modelSource, + models: connection.models.map((model) => ({ + id: model.id, + apiProtocol: model.apiProtocol, + })), + }; +} + +export function sameConnectionTestModelBasis( + actual: ConnectionTestModelBasis, + expected: ConnectionTestModelBasis, +): boolean { + return ( + sameStringArray(actual.enabledModelIds, expected.enabledModelIds) && + actual.modelSource === expected.modelSource && + actual.models.length === expected.models.length && + actual.models.every( + (model, index) => + model.id === expected.models[index]?.id && + model.apiProtocol === expected.models[index]?.apiProtocol, + ) + ); +} + +function findConnectionIndex( + document: ConnectionCatalogDocument, + identity: Pick, +): number { + return document.connections.findIndex((item) => sameConnectionIdentity(item, identity)); +} + +function sameConnectionIdentity( + left: Pick, + right: Pick, +): boolean { + return left.connectionId === right.connectionId; +} + +function isValidTarget( + target: ConnectionTarget, + connections: readonly ConnectionCatalogEntry[], +): boolean { + const connection = connections.find((item) => sameConnectionIdentity(item, target)); + return Boolean(connection?.enabled && connection.enabledModelIds.includes(target.modelId)); +} + +// `reconcileConnectionAfterEnabledModelsChange`'s rule, at catalog scope: a +// mutation that drops what the target names drops the target with it. Nothing +// here picks a replacement — see that function for why. +function retainedDefaultTarget( + target: ConnectionTarget | null, + connections: readonly ConnectionCatalogEntry[], +): ConnectionTarget | null { + return target && isValidTarget(target, connections) ? target : null; +} + +function sameStringArray(actual: readonly string[], expected: readonly string[]): boolean { + return ( + actual.length === expected.length && actual.every((value, index) => value === expected[index]) + ); +} + +function revisionConflict(expectedRevision: number, actualRevision: number) { + return deepFreeze({ kind: 'revision_conflict' as const, expectedRevision, actualRevision }); +} + +function connectionStale(expected: ConnectionVersionBasis, actual: ConnectionVersionBasis | null) { + return deepFreeze({ kind: 'connection_stale' as const, expected, actual }); +} + +function isRetiredGeminiCliConnection(value: unknown): value is { + readonly connectionId?: unknown; + readonly providerType: 'gemini-cli'; + readonly slug?: unknown; +} { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Reflect.get(value, 'providerType') === 'gemini-cli' + ); +} + +function committed(document: ConnectionCatalogDocument): ConnectionCatalogMutationResult { + return deepFreeze({ kind: 'committed', snapshot: catalogSnapshot(document) }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b0867788fb85f4d84c3e0e90bf0d5f9432ba92e93a03f678dafbc4a4b0f3fb3f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b0867788fb85f4d84c3e0e90bf0d5f9432ba92e93a03f678dafbc4a4b0f3fb3f.source new file mode 100644 index 0000000000..df6b7ab520 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b0867788fb85f4d84c3e0e90bf0d5f9432ba92e93a03f678dafbc4a4b0f3fb3f.source @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type SerializedOperationExecutor = ( + operation: (context: Context) => Promise, +) => Promise; + +/** + * Admits an operation into its owner before waiting for earlier operations. + * This lets owner shutdown observe and drain every accepted reservation. + */ +export class SerializedOperationLane { + readonly #execute: SerializedOperationExecutor; + #tail: Promise = Promise.resolve(); + + constructor(execute: SerializedOperationExecutor) { + this.#execute = execute; + } + + run(operation: (context: Context) => Promise): Promise { + const ready = this.#tail; + let releaseTail!: () => void; + this.#tail = new Promise((resolve) => { + releaseTail = resolve; + }); + let released = false; + const release = () => { + if (released) return; + released = true; + releaseTail(); + }; + let entered = false; + let execution: Promise; + try { + execution = this.#execute(async (context) => { + entered = true; + await ready; + try { + return await operation(context); + } finally { + release(); + } + }); + } catch (error) { + release(); + throw error; + } + return execution.finally(() => { + if (!entered) release(); + }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b19149e12ee8cc016b900bfd626927864d428b950a5b903d83bfc00e08bfb7be.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b19149e12ee8cc016b900bfd626927864d428b950a5b903d83bfc00e08bfb7be.source new file mode 100644 index 0000000000..39e2d55c70 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b19149e12ee8cc016b900bfd626927864d428b950a5b903d83bfc00e08bfb7be.source @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tryAcquireFileLifetimeOwner } from '../../file-lifetime-owner.js'; + +const path = process.argv[2]; +if (!path) throw new Error('Missing file lifetime owner path'); +const owner = await tryAcquireFileLifetimeOwner(path); +if (!owner) throw new Error('File lifetime owner is already active'); +process.send?.('owned'); +setInterval(() => undefined, 1_000).unref(); +await new Promise((resolve) => process.once('disconnect', resolve)); +await owner.close(); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1cc31cf7c96f025d523f115ec69f70ca16063d0855349518de5dffee7a9c033.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1cc31cf7c96f025d523f115ec69f70ca16063d0855349518de5dffee7a9c033.source new file mode 100644 index 0000000000..0780f8c34d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1cc31cf7c96f025d523f115ec69f70ca16063d0855349518de5dffee7a9c033.source @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { createSqliteSessionTodoStore } from '../session-todo-store.js'; + +const SESSION_ID = 'session-todo'; + +describe('SQLite SessionTodo store', () => { + test('persists an initialized-empty document on the first read', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { items: [] }); + todos.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', + ) + .get(SESSION_ID)!.count, + 1, + ); + } finally { + database.close(); + } + }); + }); + + test('serializes a first explicit replacement ahead of a following bootstrap read', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + const [written, read] = await Promise.all([ + todos.replaceAll(SESSION_ID, [{ content: 'explicit work', status: 'in_progress' }]), + todos.readOrBootstrap(SESSION_ID), + ]); + assert.deepEqual(written, { + items: [{ content: 'explicit work', status: 'in_progress' }], + }); + assert.deepEqual(read, written); + todos.close(); + }); + }); + + test('persists replacement order across reopen and purge restores uninitialized state', async () => { + await withRoot(async (root) => { + const first = createSqliteSessionTodoStore(root); + await first.replaceAll(SESSION_ID, [ + { content: 'second', status: 'in_progress' }, + { content: 'first', status: 'pending' }, + ]); + first.close(); + + const reopened = createSqliteSessionTodoStore(root); + assert.deepEqual(await reopened.readOrBootstrap(SESSION_ID), { + items: [ + { content: 'second', status: 'in_progress' }, + { content: 'first', status: 'pending' }, + ], + }); + await reopened.purgeSessionState(SESSION_ID); + assert.deepEqual(await reopened.readOrBootstrap(SESSION_ID), { items: [] }); + reopened.close(); + }); + }); + + test('fails closed on a corrupt current document but permits explicit replacement recovery', async () => { + await withRoot(async (root) => { + const initialized = createSqliteSessionTodoStore(root); + await initialized.replaceAll(SESSION_ID, []); + initialized.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database + .prepare( + 'UPDATE workflow_session_todo_documents SET record_json = ? WHERE session_id = ?', + ) + .run('{not-json', SESSION_ID); + } finally { + database.close(); + } + + const todos = createSqliteSessionTodoStore(root); + await assert.rejects(() => todos.readOrBootstrap(SESSION_ID), /Invalid SessionTodo document/); + assert.deepEqual( + await todos.replaceAll(SESSION_ID, [{ content: 'recovered', status: 'pending' }]), + { items: [{ content: 'recovered', status: 'pending' }] }, + ); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { + items: [{ content: 'recovered', status: 'pending' }], + }); + todos.close(); + }); + }); + + test('initializes latest copies atomically and accepts only an identical retry', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + await todos.replaceAll('source', [{ content: 'current work', status: 'in_progress' }]); + const input = { sourceSessionId: 'source', targetSessionId: 'target', copyCurrent: true }; + const expected = { items: [{ content: 'current work', status: 'in_progress' as const }] }; + assert.deepEqual(await todos.initializeCopy(input), expected); + assert.deepEqual(await todos.initializeCopy(input), expected); + await todos.replaceAll('target', [{ content: 'different', status: 'pending' }]); + await assert.rejects(() => todos.initializeCopy(input), /different state/); + todos.close(); + }); + }); + + test('fails closed when a copy source or target document is corrupt', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + await todos.replaceAll('source', [{ content: 'current work', status: 'pending' }]); + await todos.replaceAll('corrupt-target', []); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + database + .prepare( + 'UPDATE workflow_session_todo_documents SET record_json = ? WHERE session_id = ?', + ) + .run('{not-json', 'corrupt-target'); + } finally { + database.close(); + } + + await assert.rejects( + () => + todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'corrupt-target', + copyCurrent: true, + }), + /Invalid SessionTodo document JSON/, + ); + + const corruptSource = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + corruptSource + .prepare( + 'UPDATE workflow_session_todo_documents SET record_json = ? WHERE session_id = ?', + ) + .run('{not-json', 'source'); + } finally { + corruptSource.close(); + } + await assert.rejects( + () => + todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'new-target', + copyCurrent: true, + }), + /Invalid SessionTodo document JSON/, + ); + + const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + verified + .prepare( + 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', + ) + .get('new-target')!.count, + 0, + ); + } finally { + verified.close(); + } + todos.close(); + }); + }); + + test('writes an explicit empty copy marker when the copy skips current state', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + assert.deepEqual( + await todos.initializeCopy({ + sourceSessionId: 'source', + targetSessionId: 'historical-target', + copyCurrent: false, + }), + { items: [] }, + ); + todos.close(); + + // The copy must persist the marker itself: reading it back would return an + // empty document either way, so only the stored row separates "wrote an + // explicit empty copy" from "wrote nothing at all". + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + assert.equal( + database + .prepare( + 'SELECT COUNT(*) AS count FROM workflow_session_todo_documents WHERE session_id = ?', + ) + .get('historical-target')!.count, + 1, + ); + } finally { + database.close(); + } + }); + }); + + test('copies an uninitialized source as empty without initializing the source', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + const input = { sourceSessionId: 'source', targetSessionId: 'target', copyCurrent: true }; + assert.deepEqual(await todos.initializeCopy(input), { items: [] }); + assert.deepEqual(await todos.initializeCopy(input), { items: [] }); + todos.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + const rows = database + .prepare('SELECT session_id FROM workflow_session_todo_documents ORDER BY session_id') + .all() as Array<{ session_id: string }>; + assert.deepEqual( + rows.map((row) => row.session_id), + ['target'], + ); + } finally { + database.close(); + } + }); + }); + + test('linearizes concurrent whole-document replacements', async () => { + await withRoot(async (root) => { + const todos = createSqliteSessionTodoStore(root); + const writes = Array.from({ length: 128 }, (_, index) => + todos.replaceAll(SESSION_ID, [{ content: `write ${index}`, status: 'pending' }]), + ); + await Promise.all(writes); + assert.deepEqual(await todos.readOrBootstrap(SESSION_ID), { + items: [{ content: 'write 127', status: 'pending' }], + }); + todos.close(); + }); + }); +}); + +async function withRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-session-todo-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1e5fe0faa5605e6186d04c3618f48e6dff6d1ae50fe4832330eca0c2ebef913.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1e5fe0faa5605e6186d04c3618f48e6dff6d1ae50fe4832330eca0c2ebef913.source new file mode 100644 index 0000000000..baee5be92a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1e5fe0faa5605e6186d04c3618f48e6dff6d1ae50fe4832330eca0c2ebef913.source @@ -0,0 +1,882 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { appendFile, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, mock, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { decodeCanonicalMessage } from '@maka/core/session'; +import { CodexSessionAdapter } from '../codex-session-adapter.js'; +import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; + +const CURRENT_FIXTURE = fixturePath('codex-rollout-v0.144.jsonl'); +const ITEM_COMPLETED_FIXTURE = fixturePath('codex-rollout-v0.149-item-completed.jsonl'); + +describe('CodexSessionAdapter', () => { + test('lists active and archived root Sessions from the newest Codex state database', async () => { + await withCodexHome(async (codexHome) => { + const activePath = await seedFixtureRollout(codexHome, 'codex-session-1', false); + const archivedPath = await seedMinimalRollout( + codexHome, + 'codex-session-archived', + true, + '/workspace/archive', + 'Archived task', + ); + await seedStateDatabase(codexHome, [ + { + id: 'codex-session-1', + rolloutPath: activePath, + cwd: '/workspace/project', + name: 'Named Codex thread', + createdAtMs: 1000, + updatedAtMs: 3000, + archived: false, + source: 'cli', + }, + { + id: 'codex-session-archived', + rolloutPath: archivedPath, + cwd: '/workspace/archive', + name: 'Archived Codex thread', + createdAtMs: 1500, + updatedAtMs: 2000, + archived: true, + source: 'vscode', + }, + { + id: 'codex-subagent', + rolloutPath: activePath, + cwd: '/workspace/project', + name: 'Internal child', + createdAtMs: 2000, + updatedAtMs: 4000, + archived: false, + source: '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}', + }, + ]); + + const adapter = new CodexSessionAdapter({ codexHome }); + assert.equal(await adapter.detect(), true); + assert.deepEqual(await adapter.listSessions(), [ + { + id: 'codex-session-1', + name: 'Named Codex thread', + cwd: '/workspace/project', + createdAt: 1_000_000, + updatedAt: 3_000_000, + archived: false, + }, + ]); + assert.deepEqual( + (await adapter.listSessions({ includeArchived: true })).map((session) => session.id), + ['codex-session-1', 'codex-session-archived'], + ); + + // The same text query the Claude Code adapter honours. A catalog filter + // that silently worked for one source and not the other would be worse + // than none — the user cannot see which source dropped their term. + assert.deepEqual( + (await adapter.listSessions({ text: 'named' })).map((session) => session.id), + ['codex-session-1'], + ); + assert.deepEqual( + (await adapter.listSessions({ text: '/workspace/project' })).map((session) => session.id), + ['codex-session-1'], + ); + assert.equal((await adapter.listSessions({ text: 'kubernetes' })).length, 0); + // A blank box selects nothing, so it must not filter. + assert.equal((await adapter.listSessions({ text: ' ' })).length, 1); + // Text does not override the archived gate. + assert.equal((await adapter.listSessions({ text: 'archived' })).length, 0); + assert.deepEqual( + (await adapter.listSessions({ includeArchived: true, text: 'archived' })).map( + (session) => session.id, + ), + ['codex-session-archived'], + ); + assert.deepEqual( + await adapter.listSessions({ includeArchived: true, cwd: '/workspace/archive/' }), + [ + { + id: 'codex-session-archived', + name: 'Archived Codex thread', + cwd: '/workspace/archive', + createdAt: 1_500_000, + updatedAt: 2_000_000, + archived: true, + }, + ], + ); + }); + }); + + test('lists every thread source the foreign-session scanner accepts (#3693)', async () => { + // The adapter owned its own token set, so bare `atlas`/`chatgpt` and a + // wrapped `{"custom":"cli"}` were dropped here while the scanner in + // `@maka/core/foreign-session` listed them. Both gates now share one + // authority, so the catalog and the scan agree on every shape. + await withCodexHome(async (codexHome) => { + const sources = ['cli', 'exec', 'vscode', 'atlas', 'chatgpt'] as const; + const rows: StateRow[] = []; + for (const [index, source] of sources.entries()) { + const bareId = `codex-bare-${source}`; + const wrappedId = `codex-wrapped-${source}`; + rows.push({ + id: bareId, + rolloutPath: await seedMinimalRollout(codexHome, bareId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `bare ${source}`, + createdAtMs: 1000 + index, + updatedAtMs: 3000 + index, + archived: false, + source, + }); + rows.push({ + id: wrappedId, + rolloutPath: await seedMinimalRollout(codexHome, wrappedId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `wrapped ${source}`, + createdAtMs: 1100 + index, + updatedAtMs: 3100 + index, + archived: false, + source: JSON.stringify({ custom: source }), + }); + } + const subagentId = 'codex-subagent-drop'; + rows.push({ + id: subagentId, + rolloutPath: await seedMinimalRollout(codexHome, subagentId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: 'internal child', + createdAtMs: 2000, + updatedAtMs: 4000, + archived: false, + source: '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}', + }); + await seedStateDatabase(codexHome, rows); + + const listed = new Set( + (await new CodexSessionAdapter({ codexHome }).listSessions()).map((session) => session.id), + ); + for (const source of sources) { + assert.ok(listed.has(`codex-bare-${source}`), `bare ${source} was dropped`); + assert.ok(listed.has(`codex-wrapped-${source}`), `wrapped ${source} was dropped`); + } + // Internal subagent threads stay out of the catalog. + assert.equal(listed.has(subagentId), false); + assert.equal(listed.size, sources.length * 2); + }); + }); + + test('a Windows path spelling reaches the matcher instead of being lost in SQL', async () => { + // The SQL used to prefilter with `cwd IN ()`, and + // SQLite compares those exactly — a row stored `C:\\Repo\\App` was + // discarded before the shared matcher could see that `c:/repo/app` names + // the same project. This drives the real state-database path, not the + // matcher in isolation, because that is where the row was being dropped. + await withCodexHome(async (codexHome) => { + const rolloutPath = await seedMinimalRollout( + codexHome, + 'codex-win', + false, + 'C:\\Repo\\App', + 'hello', + ); + await seedStateDatabase(codexHome, [ + { + id: 'codex-win', + rolloutPath, + cwd: 'C:\\Repo\\App', + name: 'Windows-shaped path', + createdAtMs: 1_000, + updatedAtMs: 2_000, + archived: false, + source: 'cli', + }, + ]); + const adapter = new CodexSessionAdapter({ codexHome }); + for (const cwd of ['C:\\Repo\\App', 'C:/Repo/App', 'c:/repo/app', 'c:\\repo\\app\\']) { + assert.deepEqual( + (await adapter.listSessions({ cwd })).map((session) => session.id), + ['codex-win'], + `cwd=${cwd}`, + ); + } + // A genuinely different project is still excluded. + assert.equal((await adapter.listSessions({ cwd: 'C:/Repo/Other' })).length, 0); + }); + }); + + test('converts Codex presentation events and raw tool items without duplicates', async () => { + await withCodexHome(async (codexHome) => { + await seedFixtureRollout(codexHome, 'codex-session-1', false); + const adapter = new CodexSessionAdapter({ codexHome }); + + assert.deepEqual(await adapter.listSessions(), [ + { + id: 'codex-session-1', + name: 'Fix the parser', + cwd: '/workspace/project', + createdAt: Date.parse('2026-08-08T00:00:00.000Z'), + updatedAt: await rolloutMtime(codexHome, 'codex-session-1', false), + archived: false, + }, + ]); + + const session = await adapter.readSession('codex-session-1'); + assert.deepEqual(session.metadata, { + name: 'Fix the parser', + cwd: '/workspace/project', + }); + assert.equal(session.messages.length, 9); + for (const message of session.messages) { + assert.deepEqual(decodeCanonicalMessage(message), message); + } + + assert.deepEqual(session.messages[0], { + type: 'user', + id: 'codex-user-1', + turnId: 'codex-turn-1', + ts: Date.parse('2026-08-08T00:00:02.000Z'), + text: 'Fix the parser', + }); + assert.deepEqual(session.messages[1], { + type: 'assistant', + id: 'codex-codex-session-1-reasoning-7', + turnId: 'codex-turn-1', + ts: Date.parse('2026-08-08T00:00:03.000Z'), + text: '', + thinking: { text: 'Inspect the failing path.' }, + contentOrder: ['thinking'], + modelId: 'gpt-codex-test', + }); + assert.equal(session.messages[2]?.type, 'assistant'); + assert.equal(session.messages[2]?.text, 'I found the issue.'); + assert.deepEqual( + session.messages[2]?.type === 'assistant' ? session.messages[2].providerOptions : undefined, + { openai: { phase: 'commentary' } }, + ); + assert.deepEqual(session.messages[3], { + type: 'tool_call', + id: 'call-wait-1', + turnId: 'codex-turn-1', + ts: Date.parse('2026-08-08T00:00:05.000Z'), + toolName: 'wait', + args: { milliseconds: 25 }, + }); + assert.deepEqual(session.messages[4], { + type: 'tool_result', + id: 'function-output-1', + turnId: 'codex-turn-1', + ts: Date.parse('2026-08-08T00:00:06.000Z'), + toolUseId: 'call-wait-1', + isError: false, + content: { kind: 'text', text: 'waited' }, + }); + assert.equal(session.messages[5]?.type, 'tool_call'); + assert.equal(session.messages[5]?.args, '*** Begin Patch'); + assert.deepEqual( + session.messages[6]?.type === 'tool_result' ? session.messages[6].content : undefined, + { kind: 'text', text: 'Done\n1 file changed' }, + ); + assert.equal(session.messages[7]?.type, 'system_note'); + assert.equal(session.messages[8]?.type, 'turn_state'); + assert.equal(session.messages[8]?.status, 'completed'); + }); + }); + + test('converts Codex Desktop completed items without importing response mirrors', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-item-completed'; + await seedRawRollout(codexHome, sessionId, await readFile(ITEM_COMPLETED_FIXTURE, 'utf8')); + + const adapter = new CodexSessionAdapter({ codexHome }); + assert.deepEqual( + (await adapter.listSessions()).map(({ id, name }) => ({ id, name })), + [{ id: sessionId, name: 'Analyze the image. Use OpenCV.js.' }], + ); + const session = await adapter.readSession(sessionId); + + assert.deepEqual(session.metadata, { + name: 'Analyze the image. Use OpenCV.js.', + cwd: '/workspace/opencv', + }); + assert.equal(session.messages.length, 4); + assert.deepEqual( + session.messages.map((message) => message.type), + ['user', 'assistant', 'assistant', 'turn_state'], + ); + for (const message of session.messages) { + assert.deepEqual(decodeCanonicalMessage(message), message); + } + + assert.deepEqual(session.messages[0], { + type: 'user', + id: 'user-client-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:02.100Z'), + text: 'Analyze the image. Use OpenCV.js.', + }); + assert.deepEqual(session.messages[1], { + type: 'assistant', + id: 'reasoning-item-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:03.000Z'), + text: '', + thinking: { text: 'Inspect the pixels.\nDraft the solution.' }, + contentOrder: ['thinking'], + modelId: 'gpt-codex-item-test', + }); + assert.deepEqual(session.messages[2], { + type: 'assistant', + id: 'assistant-item-1', + turnId: 'codex-turn-item-completed', + ts: Date.parse('2026-08-22T00:00:04.000Z'), + text: 'Use canvas. Then process the pixels.', + providerOptions: { + openai: { + phase: 'final_answer', + }, + }, + modelId: 'gpt-codex-item-test', + contentOrder: ['text'], + }); + assert.equal(session.messages[3]?.type, 'turn_state'); + assert.equal(session.messages[3]?.status, 'completed'); + }); + }); + + test('imports terminal errors as failed without failing turns on non-terminal errors', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-error-semantics'; + await seedRawRollout(codexHome, sessionId, errorSemanticsRollout(sessionId)); + + const session = await new CodexSessionAdapter({ codexHome }).readSession(sessionId); + assert.deepEqual( + session.messages + .filter((message) => message.type === 'turn_state') + .map(({ turnId, status, errorClass }) => ({ turnId, status, errorClass })), + [ + { turnId: 'turn-terminal', status: 'failed', errorClass: 'codex_error' }, + { turnId: 'turn-rollback', status: 'completed', errorClass: undefined }, + { turnId: 'turn-not-steerable', status: 'completed', errorClass: undefined }, + ], + ); + }); + }); + + test('filesystem fallback excludes internal subagent rollouts', async () => { + await withCodexHome(async (codexHome) => { + await seedMinimalRollout( + codexHome, + 'codex-root-fallback', + false, + '/workspace/root', + 'Root task', + ); + const subagentId = 'codex-subagent-fallback'; + await seedRawRollout( + codexHome, + subagentId, + minimalRollout(subagentId, '/workspace/root', 'Internal task', { + subagent: { + thread_spawn: { parent_thread_id: 'parent', depth: 1 }, + }, + }), + ); + + const adapter = new CodexSessionAdapter({ codexHome }); + assert.deepEqual( + (await adapter.listSessions()).map((session) => session.id), + ['codex-root-fallback'], + ); + await assert.rejects(adapter.readSession(subagentId), /not found/); + }); + }); + + test('rejects corrupt interior records, tolerates a torn tail, and bounds scanned bytes', async () => { + await withCodexHome(async (codexHome) => { + const fixture = await readFile(CURRENT_FIXTURE, 'utf8'); + const corruptId = 'codex-corrupt'; + await seedRawRollout( + codexHome, + corruptId, + fixture + .replaceAll('codex-session-1', corruptId) + .replace( + '\n{"timestamp":"2026-08-08T00:00:01.000Z"', + '\nnot-json\n{"timestamp":"2026-08-08T00:00:01.000Z"', + ), + ); + const tornId = 'codex-torn'; + await seedRawRollout( + codexHome, + tornId, + `${fixture.replaceAll('codex-session-1', tornId)}{"timestamp"`, + ); + + const adapter = new CodexSessionAdapter({ codexHome }); + await assert.rejects(adapter.readSession(corruptId), /Invalid Codex rollout.*line 2/); + assert.equal((await adapter.readSession(tornId)).messages.length, 9); + + const bounded = new CodexSessionAdapter({ codexHome, maxRolloutBytes: 100 }); + await assert.rejects(bounded.readSession(tornId), /exceeds 100 bytes/); + }); + }); + + test('parses a UTF-8 JSONL record split across read buffers', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-cross-buffer-utf8'; + const meta = `${JSON.stringify({ + timestamp: '2026-08-08T00:00:00.000Z', + type: 'session_meta', + payload: { + session_id: sessionId, + id: sessionId, + cwd: '/workspace/utf8', + source: 'cli', + }, + })}\n`; + const prefixBytes = Buffer.byteLength(meta, 'utf8'); + const eventTemplate = JSON.stringify({ + timestamp: '2026-08-08T00:00:01.000Z', + type: 'event_msg', + payload: { type: 'user_message', message: '__MESSAGE__' }, + }); + const [eventPrefix, eventSuffix] = eventTemplate.split('__MESSAGE__'); + assert.ok(eventPrefix !== undefined && eventSuffix !== undefined); + const paddingBytes = 64 * 1024 - prefixBytes - Buffer.byteLength(eventPrefix, 'utf8') - 1; + assert.ok(paddingBytes > 0); + const content = `${meta}${eventPrefix}${'x'.repeat(paddingBytes)}你${eventSuffix}\n`; + await seedRawRollout(codexHome, sessionId, content); + + const session = await new CodexSessionAdapter({ codexHome }).readSession(sessionId); + assert.equal(session.messages[0]?.type, 'user'); + assert.equal( + session.messages[0]?.type === 'user' ? session.messages[0].text : undefined, + `${'x'.repeat(paddingBytes)}你`, + ); + }); + }); + + test('rejects a short read before the fixed rollout snapshot is complete', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-truncated-during-read'; + const rolloutPath = await seedRawRollout( + codexHome, + sessionId, + `${minimalRollout(sessionId, '/workspace', 'Keep this message')}${JSON.stringify({ + timestamp: '2026-08-08T00:00:02.000Z', + type: 'world_state', + payload: { padding: 'x'.repeat(128 * 1024) }, + })}\n`, + ); + await seedStateDatabase(codexHome, [ + { + id: sessionId, + rolloutPath, + cwd: '/workspace', + name: 'Truncated during read', + createdAtMs: 1_000, + updatedAtMs: 2_000, + archived: false, + source: 'cli', + }, + ]); + + let readCalls = 0; + await withFileReadMock( + rolloutPath, + async (readOriginal, buffer) => { + readCalls += 1; + return readCalls === 2 ? { bytesRead: 0, buffer } : readOriginal(); + }, + () => + assert.rejects( + new CodexSessionAdapter({ codexHome }).readSession(sessionId), + /changed while being read/, + ), + ); + }); + }); + + test('does not follow records appended after the rollout snapshot is opened', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-appended-during-read'; + const rolloutPath = await seedRawRollout( + codexHome, + sessionId, + minimalRollout(sessionId, '/workspace', 'Keep this message'), + ); + await seedStateDatabase(codexHome, [ + { + id: sessionId, + rolloutPath, + cwd: '/workspace', + name: 'Appended during read', + createdAtMs: 1_000, + updatedAtMs: 2_000, + archived: false, + source: 'cli', + }, + ]); + + let appended = false; + await withFileReadMock( + rolloutPath, + async (readOriginal) => { + const result = await readOriginal(); + if (!appended) { + appended = true; + await appendFile(rolloutPath, 'not-json\n'); + } + return result; + }, + async () => { + const session = await new CodexSessionAdapter({ codexHome }).readSession(sessionId); + assert.equal(session.messages[0]?.type, 'user'); + assert.equal( + session.messages[0]?.type === 'user' ? session.messages[0].text : undefined, + 'Keep this message', + ); + }, + ); + }); + }); + + test('rejects an oversized JSONL record without buffering the complete rollout', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-record-limit'; + await seedMinimalRollout(codexHome, sessionId, false, '/workspace', 'hello'); + const adapter = new CodexSessionAdapter({ codexHome, maxRecordBytes: 100 }); + + await assert.rejects(adapter.readSession(sessionId), /record at line 1 exceeds 100 bytes/); + }); + }); + + test('rejects converted histories that exceed message count or byte budgets', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-converted-limits'; + await seedRawRollout( + codexHome, + sessionId, + `${minimalRollout(sessionId, '/workspace', 'hello')}${JSON.stringify({ + timestamp: '2026-08-08T00:00:02.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: 'world' }, + })}\n`, + ); + + await assert.rejects( + new CodexSessionAdapter({ codexHome, maxMessages: 1 }).readSession(sessionId), + /more than 1 messages/, + ); + await assert.rejects( + new CodexSessionAdapter({ codexHome, maxConvertedBytes: 10 }).readSession(sessionId), + /more than 10 bytes/, + ); + }); + }); + + test('streams valid rollouts larger than the legacy 64 MiB whole-file limit', async () => { + await withCodexHome(async (codexHome) => { + const sessionId = 'codex-large-streamed'; + const rolloutPath = await seedMinimalRollout( + codexHome, + sessionId, + false, + '/workspace/large', + 'Keep this message', + ); + const ignoredRecord = `${JSON.stringify({ + timestamp: '2026-08-08T00:00:02.000Z', + type: 'world_state', + payload: { padding: 'x'.repeat(1024 * 1024) }, + })}\n`; + const handle = await open(rolloutPath, 'a'); + try { + for (let index = 0; index < 65; index += 1) await handle.write(ignoredRecord); + } finally { + await handle.close(); + } + assert.ok((await stat(rolloutPath)).size > 64 * 1024 * 1024); + + const session = await new CodexSessionAdapter({ codexHome }).readSession(sessionId); + assert.deepEqual(session.messages, [ + { + type: 'user', + id: `codex-${sessionId}-user-2`, + turnId: `codex-${sessionId}-turn-2`, + ts: Date.parse('2026-08-08T00:00:01.000Z'), + text: 'Keep this message', + }, + ]); + }); + }); + + test('never follows a state database rollout path outside CODEX_HOME', async () => { + const outside = await mkdtemp(join(tmpdir(), 'maka-codex-outside-')); + try { + await withCodexHome(async (codexHome) => { + const id = 'codex-escaped'; + const escapedPath = join(outside, `rollout-2026-08-08T00-00-00-${id}.jsonl`); + await writeFile(escapedPath, minimalRollout(id, '/outside', 'outside')); + await seedStateDatabase(codexHome, [ + { + id, + rolloutPath: escapedPath, + cwd: '/outside', + name: 'Escaped', + createdAtMs: 1000, + updatedAtMs: 2000, + archived: false, + source: 'cli', + }, + ]); + + const adapter = new CodexSessionAdapter({ codexHome }); + assert.deepEqual(await adapter.listSessions(), []); + await assert.rejects(adapter.readSession(id), /not found/); + }); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + test('is registered by the internal default registry', async () => { + await withCodexHome(async (codexHome) => { + const registry = createExternalSessionAdapterRegistry({ codex: { codexHome } }); + assert.equal(registry.require('codex').id, 'codex'); + }); + }); +}); + +function fixturePath(name: string): string { + return fileURLToPath(new URL(`../../src/__tests__/fixtures/${name}`, import.meta.url)); +} + +async function withCodexHome(run: (codexHome: string) => Promise): Promise { + const codexHome = await mkdtemp(join(tmpdir(), 'maka-codex-adapter-')); + try { + await run(codexHome); + } finally { + await rm(codexHome, { recursive: true, force: true }); + } +} + +type PositionalRead = ( + buffer: Buffer, + offset: number, + length: number, + position: number, +) => Promise<{ bytesRead: number; buffer: Buffer }>; + +async function withFileReadMock( + path: string, + read: ( + readOriginal: () => ReturnType, + buffer: Buffer, + ) => ReturnType, + run: () => Promise, +): Promise { + const probe = await open(path, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { read: PositionalRead }; + const originalRead = fileHandlePrototype.read; + await probe.close(); + const readMock = mock.method( + fileHandlePrototype, + 'read', + async function ( + this: typeof probe, + buffer: Buffer, + offset: number, + length: number, + position: number, + ) { + return read(() => originalRead.call(this, buffer, offset, length, position), buffer); + }, + ); + try { + await run(); + } finally { + readMock.mock.restore(); + } +} + +async function seedFixtureRollout( + codexHome: string, + sessionId: string, + archived: boolean, +): Promise { + const fixture = (await readFile(CURRENT_FIXTURE, 'utf8')).replaceAll( + 'codex-session-1', + sessionId, + ); + return seedRawRollout(codexHome, sessionId, fixture, archived); +} + +async function seedMinimalRollout( + codexHome: string, + sessionId: string, + archived: boolean, + cwd: string, + userText: string, +): Promise { + return seedRawRollout(codexHome, sessionId, minimalRollout(sessionId, cwd, userText), archived); +} + +async function seedRawRollout( + codexHome: string, + sessionId: string, + content: string, + archived = false, +): Promise { + const directory = archived + ? join(codexHome, 'archived_sessions') + : join(codexHome, 'sessions', '2026', '08', '08'); + await mkdir(directory, { recursive: true }); + const path = join(directory, `rollout-2026-08-08T00-00-00-${sessionId}.jsonl`); + await writeFile(path, content); + return path; +} + +function minimalRollout( + sessionId: string, + cwd: string, + userText: string, + source: unknown = 'cli', +): string { + return [ + JSON.stringify({ + timestamp: '2026-08-08T00:00:00.000Z', + type: 'session_meta', + payload: { session_id: sessionId, id: sessionId, cwd, source }, + }), + JSON.stringify({ + timestamp: '2026-08-08T00:00:01.000Z', + type: 'event_msg', + payload: { type: 'user_message', message: userText }, + }), + '', + ].join('\n'); +} + +function errorSemanticsRollout(sessionId: string): string { + const event = (second: number, payload: Record): string => + JSON.stringify({ + timestamp: `2026-08-08T00:00:${String(second).padStart(2, '0')}.000Z`, + type: 'event_msg', + payload, + }); + return [ + JSON.stringify({ + timestamp: '2026-08-08T00:00:00.000Z', + type: 'session_meta', + payload: { session_id: sessionId, id: sessionId, cwd: '/workspace', source: 'cli' }, + }), + event(1, { type: 'task_started', turn_id: 'turn-terminal' }), + event(2, { type: 'user_message', message: 'Fail terminally' }), + event(3, { + type: 'task_complete', + turn_id: 'turn-terminal', + error: { message: 'capacity', codex_error_info: 'server_overloaded' }, + }), + event(4, { type: 'task_started', turn_id: 'turn-rollback' }), + event(5, { type: 'user_message', message: 'Rollback warning' }), + event(6, { + type: 'error', + message: 'rollback failed', + codex_error_info: 'thread_rollback_failed', + }), + event(7, { type: 'task_complete', turn_id: 'turn-rollback' }), + event(8, { type: 'task_started', turn_id: 'turn-not-steerable' }), + event(9, { type: 'user_message', message: 'Steer review' }), + event(10, { + type: 'error', + message: 'cannot steer review', + codex_error_info: { active_turn_not_steerable: { turn_kind: 'review' } }, + }), + event(11, { type: 'task_complete', turn_id: 'turn-not-steerable' }), + '', + ].join('\n'); +} + +interface StateRow { + id: string; + rolloutPath: string; + cwd: string; + name: string; + createdAtMs: number; + updatedAtMs: number; + archived: boolean; + source: string; +} + +async function seedStateDatabase(codexHome: string, rows: readonly StateRow[]): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const database = new DatabaseSync(join(codexHome, 'state_5.sqlite')); + try { + database.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + cwd TEXT, + name TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + archived INTEGER, + source TEXT + ) + `); + const insert = database.prepare(` + INSERT INTO threads ( + id, rollout_path, cwd, name, created_at_ms, updated_at_ms, archived, source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + insert.run( + row.id, + row.rolloutPath, + row.cwd, + row.name, + row.createdAtMs, + row.updatedAtMs, + row.archived ? 1 : 0, + row.source, + ); + } + } finally { + database.close(); + } +} + +async function rolloutMtime( + codexHome: string, + sessionId: string, + archived: boolean, +): Promise { + const { stat } = await import('node:fs/promises'); + const directory = archived + ? join(codexHome, 'archived_sessions') + : join(codexHome, 'sessions', '2026', '08', '08'); + return (await stat(join(directory, `rollout-2026-08-08T00-00-00-${sessionId}.jsonl`))).mtimeMs; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1f568e9e8cc04815be9e2086b34c0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1f568e9e8cc04815be9e2086b34c0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source new file mode 100644 index 0000000000..1f1a34ff10 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b1f568e9e8cc04815be9e2086b34c0cd7a1db093650b6ae9d32d1dcd8d8d48ad.source @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { createSqliteArtifactStoreWriteAuthority } from '../artifact-store.js'; +import { createProjectCatalog } from '../project-catalog.js'; +import { createSessionStore } from '../session-store.js'; +import { + createOperationalStateBackup, + OperationalBackupError, + restoreOperationalStateBackup, + validateOperationalStateBackup, +} from '../operational-state-backup.js'; + +test('backs up and restores runtime.sqlite plus artifact bytes', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-operational-backup-')); + const stateRoot = join(base, 'state'); + const backupRoot = join(base, 'backup'); + const restoreRoot = join(base, 'restore'); + const projectPath = join(base, 'project'); + await mkdir(projectPath); + const sessions = createSessionStore(stateRoot); + try { + // The project catalog decides how every session is grouped, and its name, + // relink aliases and archive state exist nowhere else. Restoring sessions + // without it would silently reorganize the user's whole sidebar. + const catalog = createProjectCatalog(stateRoot, { now: () => 5 }); + const project = await catalog.register(projectPath); + await catalog.rename(project.id, 'Renamed Project'); + await catalog.archive(project.id); + catalog.close(); + + const session = await sessions.create({ + projectId: project.id, + cwd: '/tmp/cwd', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Backup', + labels: [], + }); + const message = { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'durable'.repeat(12_000), + } as const; + await sessions.appendMessage(session.id, message); + await sessions.close?.(); + const artifactAuthority = createSqliteArtifactStoreWriteAuthority(stateRoot); + const artifacts = artifactAuthority.store; + const artifact = await artifacts.create({ + id: 'artifact-1', + sessionId: session.id, + turnId: 'turn-1', + name: 'note.txt', + kind: 'file', + content: 'artifact', + source: 'tool_result', + now: 2, + }); + artifactAuthority.close(); + + await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 }); + assert.equal((await validateOperationalStateBackup(backupRoot)).createdAt, 10); + await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot }); + + const restored = createSessionStore(restoreRoot); + const restoredCatalog = createProjectCatalog(restoreRoot); + try { + assert.deepEqual(await restored.readMessages(session.id), [message]); + assert.equal( + await readFile(join(restoreRoot, 'artifacts', artifact.relativePath), 'utf8'), + 'artifact', + ); + assert.equal( + (await restored.readHeaderSnapshot(session.id)).projectId, + project.id, + 'a restored session still belongs to the project it was grouped under', + ); + assert.deepEqual(await restoredCatalog.list(), [ + { + id: project.id, + name: 'Renamed Project', + locations: [{ path: await realpath(projectPath), isWorktree: false }], + archivedAt: 5, + available: true, + preferredPath: await realpath(projectPath), + }, + ]); + } finally { + await restored.close?.(); + restoredCatalog.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('rejects a backup whose SQLite Artifact metadata has no matching payload', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-operational-backup-artifact-')); + const stateRoot = join(base, 'state'); + try { + const artifactAuthority = createSqliteArtifactStoreWriteAuthority(stateRoot); + const artifacts = artifactAuthority.store; + const artifact = await artifacts.create({ + id: 'artifact-1', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'note.txt', + kind: 'file', + content: 'artifact', + source: 'tool_result', + now: 2, + }); + artifactAuthority.close(); + await rm(join(stateRoot, 'artifacts', artifact.relativePath)); + + await assert.rejects( + createOperationalStateBackup({ + stateRoot, + destinationRoot: join(base, 'backup'), + now: () => 10, + }), + (error: unknown) => + error instanceof OperationalBackupError && + error.code === 'corrupt_backup' && + /artifact payload/i.test(error.message), + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('rejects a backup whose native Runtime version contradicts its registry', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-operational-backup-version-')); + const stateRoot = join(base, 'state'); + const backupRoot = join(base, 'backup'); + try { + const sessions = createSessionStore(stateRoot); + await sessions.close?.(); + await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 }); + + const databasePath = join(backupRoot, 'runtime.sqlite'); + const database = new DatabaseSync(databasePath); + database.exec('PRAGMA user_version = 999'); + database.close(); + const bytes = await readFile(databasePath); + const manifestPath = join(backupRoot, 'operational-backup.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + files: Array<{ path: string; size: number; sha256: string }>; + }; + const entry = manifest.files.find((file) => file.path === 'runtime.sqlite'); + assert.ok(entry); + entry.size = bytes.byteLength; + entry.sha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}`; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + + await assert.rejects(validateOperationalStateBackup(backupRoot), /newer than supported/); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('continues to validate and restore version 3 backups without context refs', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-backup-v3-')); + try { + const stateRoot = join(base, 'state'); + const sessions = createSessionStore(stateRoot); + await sessions.close?.(); + const backupRoot = join(base, 'backup'); + await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot }); + const path = join(backupRoot, 'operational-backup.json'); + const manifest = JSON.parse(await readFile(path, 'utf8')); + manifest.schemaVersion = 3; + await writeFile(path, JSON.stringify(manifest)); + assert.equal((await validateOperationalStateBackup(backupRoot)).schemaVersion, 3); + assert.equal( + (await restoreOperationalStateBackup({ backupRoot, destinationRoot: join(base, 'restored') })) + .schemaVersion, + 3, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b2a6946d892bf8a0f15dbe3ebac7ed41053454581aa7e626c2c89394b7a150a2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b2a6946d892bf8a0f15dbe3ebac7ed41053454581aa7e626c2c89394b7a150a2.source new file mode 100644 index 0000000000..8c94a0490e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b2a6946d892bf8a0f15dbe3ebac7ed41053454581aa7e626c2c89394b7a150a2.source @@ -0,0 +1,605 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test, type TestContext } from 'node:test'; +import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from '../operational-state-store.js'; +import { + openInteractiveScheduledTaskStoreForWrite, + ScheduledTaskStoreError, + type InteractiveScheduledTaskStoreWriter, +} from '../scheduled-task-store.js'; +import { + resolveStorageRoot, + runWithStorageRootLease, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +const NOW = 1_000_000; +const EXECUTION = { + sessionId: 'session-target', + turnId: 'turn-target', + runId: 'run-target', + userMessageId: 'message-target', +}; + +test('ScheduledTask point operations do not materialize or rewrite unrelated rows', async (t) => { + for (const unrelatedCount of [0, 32, 256]) { + await t.test(`${unrelatedCount} unrelated tasks and pending fires`, async (t) => { + await withStore(t, async ({ store, probe }) => { + // Setup goes through the actual owner and public store, outside measurement. + // Each unrelated task has a pending claim, so scanning either table fails. + for (let index = 0; index < unrelatedCount; index += 1) { + const other = await store.create(notifyInput(`Unrelated ${index}`), NOW); + await store.claimNow(other.id, NOW); + } + const target = await store.create(agentInput(), NOW); + const unchanged = probe.snapshotExcluding(target.id); + + const read = await probe.measure(() => store.get(target.id)); + assert.equal(read.value?.id, target.id); + assertPointCost(read.cost, { rows: 1, payloadRows: 1, changes: 0 }); + assert.ok( + read.cost.reads.every((read) => !read.sql.includes('workflow_scheduled_task_fires')), + ); + + const snoozed = await probe.measure(() => store.snooze(target.id, 1_000, NOW)); + assert.equal(snoozed.value.nextFireAt, target.nextFireAt! + 1_000); + assertPointCost(snoozed.cost, { rows: 2, payloadRows: 1, changes: 1 }); + + const paused = await probe.measure(() => store.pause(target.id, NOW + 1)); + assert.equal(paused.value.status, 'paused'); + assertPointCost(paused.cost, { rows: 2, payloadRows: 1, changes: 1 }); + await store.resume(target.id, NOW + 2); + + const claimed = await probe.measure(() => store.claimNow(target.id, NOW + 3)); + assert.equal(claimed.value.taskId, target.id); + assertPointCost(claimed.cost, { rows: 2, payloadRows: 1, changes: 1 }); + + const bound = await probe.measure(() => + store.bindFireExecution(claimed.value.id, EXECUTION), + ); + assert.deepEqual(bound.value.execution, EXECUTION); + assertPointCost(bound.cost, { rows: 1, payloadRows: 1, changes: 1 }); + + const settled = await probe.measure(() => + store.settleFire(claimed.value.id, { + id: 'run-receipt-target', + at: NOW + 4, + outcome: 'ok', + message: 'done', + sessionId: EXECUTION.sessionId, + runId: EXECUTION.runId, + }), + ); + assert.equal(settled.value.fireCount, 1); + assertPointCost(settled.cost, { rows: 2, payloadRows: 2, changes: 2 }); + assert.deepEqual(probe.snapshotExcluding(target.id), unchanged); + }); + }); + } +}); + +test('ScheduledTask polls without new due or expired tasks perform no DML', async (t) => { + await withStore(t, async ({ store, probe }) => { + for (let index = 0; index < 32; index += 1) { + await store.create(notifyInput(`Future ${index}`), NOW); + } + const due = await store.create( + { ...notifyInput('Already claimed'), schedule: { kind: 'once', runAt: NOW + 1 } }, + NOW, + ); + await store.claimNow(due.id, NOW); + const before = probe.snapshotExcluding(); + + for (let index = 0; index < 3; index += 1) { + const poll = await probe.measure(() => store.claimNextDue(NOW + 2)); + assert.deepEqual(poll.value, { claim: null, expired: [] }); + assert.equal(poll.cost.writes.length, 0, 'an unchanged poll must issue no DML'); + assert.equal(poll.cost.totalChanges, 0); + } + assert.deepEqual(probe.snapshotExcluding(), before); + }); +}); + +test('ScheduledTask expiry updates only newly expired tasks, including a pending task', async (t) => { + await withStore(t, async ({ store, probe }) => { + const expiringInput = { + ...notifyInput('Expired with pending fire'), + schedule: { kind: 'interval', everySeconds: 60, startAt: NOW + 1_000 }, + expiresAt: NOW + 2_000, + }; + const pending = await store.create(expiringInput, NOW); + const pendingClaim = await store.claimNow(pending.id, NOW); + const otherExpired = await store.create({ ...expiringInput, title: 'Other expired task' }, NOW); + const due = await store.create( + { ...notifyInput('Due'), schedule: { kind: 'once', runAt: NOW + 1_500 } }, + NOW, + ); + for (let index = 0; index < 32; index += 1) { + await store.create(notifyInput(`Unchanged ${index}`), NOW); + } + + const first = await probe.measure(() => store.claimNextDue(NOW + 2_000)); + assert.deepEqual( + first.value.expired.map((task) => task.id).sort(), + [pending.id, otherExpired.id].sort(), + ); + assert.equal(first.value.claim?.taskId, due.id); + assert.equal(first.cost.totalChanges, 3, 'two task updates and one new claim'); + assert.equal( + first.cost.writes.reduce((count, write) => count + write.changes, 0), + 3, + ); + assert.ok((await store.listPendingFires()).some((claim) => claim.id === pendingClaim.id)); + + const second = await probe.measure(() => store.claimNextDue(NOW + 2_000)); + assert.deepEqual(second.value, { claim: null, expired: [] }); + assert.equal(second.cost.writes.length, 0); + assert.equal(second.cost.totalChanges, 0); + + // Even though pause(expired) otherwise does nothing, a pending fire still + // takes precedence and must reject the mutation. + const rejectedPause = await probe.measure(() => + assert.rejects(() => store.pause(pending.id, NOW + 3_000), isOperationConflict), + ); + assertNoDml(rejectedPause.cost); + }); +}); + +test('ScheduledTask native delivery allows waiting cancellation but cannot undo admission', async (t) => { + await withStore(t, async ({ store, probe }) => { + const task = await store.create(notifyInput('Native notification'), NOW); + const waiting = await store.claimNow(task.id, NOW); + await store.setFireNativeState(waiting.id, 'waiting_for_provider'); + const cancelled = await probe.measure(() => store.cancelWaitingNativeFire(task.id)); + assert.equal(cancelled.value, true); + assert.equal(cancelled.cost.totalChanges, 1); + assert.deepEqual(await store.listPendingFires(), []); + assert.equal((await store.get(task.id))?.fireCount, 0); + + const invoking = await store.claimNow(task.id, NOW + 1); + await store.setFireNativeState(invoking.id, 'waiting_for_provider'); + await store.setFireNativeState(invoking.id, 'invoking'); + const admitted = probe.snapshotExcluding(); + const rejections = await probe.measure(async () => { + await assert.rejects(() => store.cancelWaitingNativeFire(task.id), isOperationConflict); + await assert.rejects( + () => store.setFireNativeState(invoking.id, 'waiting_for_provider'), + isOperationConflict, + ); + await store.setFireNativeState(invoking.id, 'invoking'); + }); + assertNoDml(rejections.cost); + assert.deepEqual(probe.snapshotExcluding(), admitted); + + // The same writer queue must remain usable after both rejected operations. + const settled = await store.settleFire(invoking.id, { + at: NOW + 2, + outcome: 'failed', + message: 'Delivery outcome was not observed.', + }); + assert.equal(settled.fireCount, 1); + assert.deepEqual(await store.listPendingFires(), []); + }); +}); + +test('ScheduledTask execution binding is idempotent and does not retain caller-owned objects', async (t) => { + await withStore(t, async ({ store, probe }) => { + const task = await store.create(agentInput(), NOW); + const claim = await store.claimNow(task.id, NOW); + const input = { ...EXECUTION }; + const bound = await store.bindFireExecution(claim.id, input); + const persisted = probe.snapshotExcluding(); + input.runId = 'mutated-input-run'; + assert.ok(bound.execution); + bound.execution.userMessageId = 'mutated-return-message'; + bound.task.title = 'Mutated returned task'; + assert.deepEqual(probe.snapshotExcluding(), persisted); + + const repeated = await probe.measure(() => store.bindFireExecution(claim.id, EXECUTION)); + assert.deepEqual(repeated.value.execution, EXECUTION); + assert.equal(repeated.value.task.title, task.title); + assertNoDml(repeated.cost); + const conflict = await probe.measure(() => + assert.rejects( + () => store.bindFireExecution(claim.id, { ...EXECUTION, runId: 'another-run' }), + isOperationConflict, + ), + ); + assertNoDml(conflict.cost); + assert.deepEqual(probe.snapshotExcluding(), persisted); + }); +}); + +test('ScheduledTask metadata updates keep schedule and expired-trigger semantics', async (t) => { + await withStore(t, async ({ store, probe }) => { + const task = await store.create(notifyInput('Original title'), NOW); + assert.equal(task.nextFireAt, NOW + 60_000); + const updated = await store.update(task.id, { title: 'New title' }, NOW + 61_000); + assert.equal(updated.title, 'New title'); + assert.equal(updated.nextFireAt, NOW + 120_000); + + const paused = await store.pause(task.id, NOW + 61_001); + const repeatedPause = await probe.measure(() => store.pause(task.id, NOW + 61_002)); + assert.deepEqual(repeatedPause.value, paused); + assertNoDml(repeatedPause.cost); + + const expiring = await store.create( + { + ...notifyInput('Expired trigger'), + schedule: { kind: 'interval', everySeconds: 60, startAt: NOW + 1_000 }, + expiresAt: NOW + 2_000, + }, + NOW, + ); + const before = probe.snapshotExcluding(); + const rejected = await probe.measure(() => + assert.rejects(() => store.claimNow(expiring.id, NOW + 2_000), isOperationConflict), + ); + assertNoDml(rejected.cost); + assert.deepEqual(probe.snapshotExcluding(), before); + assert.equal((await store.get(expiring.id))?.status, 'active'); + }); +}); + +test('ScheduledTask due discovery rejects a damaged task identity before changing another task', async (t) => { + await withStore(t, async ({ store, probe }) => { + const expiring = await store.create( + { + ...notifyInput('Expiring task'), + schedule: { kind: 'interval', everySeconds: 60, startAt: NOW + 1_000 }, + expiresAt: NOW + 2_000, + }, + NOW, + ); + const future = await store.create(notifyInput('Unrelated future task'), NOW); + // Both records came from the public API. This single-field corruption is a + // fault injection: the expiry write must not follow a damaged JSON identity. + probe.damageTaskIdentity(expiring.id, future.id); + const damaged = probe.snapshotExcluding(); + const rejected = await probe.measure(() => + assert.rejects(() => store.claimNextDue(NOW + 2_000), /Invalid scheduled task identity/), + ); + assertNoDml(rejected.cost); + assert.deepEqual(probe.snapshotExcluding(), damaged); + assert.equal((await store.get(future.id))?.status, 'active'); + }); +}); + +test('ScheduledTask settlement rolls back both rows and the queue accepts a retry', async (t) => { + await withStore(t, async ({ store, probe }) => { + const task = await store.create(agentInput(), NOW); + const claim = await store.claimNow(task.id, NOW); + await store.bindFireExecution(claim.id, EXECUTION); + const before = probe.snapshotExcluding(); + const run = { + id: 'rollback-receipt', + at: NOW + 1, + outcome: 'ok' as const, + message: 'durable settlement', + }; + + // Execute the task mutation normally, then fail before deleting the claim. + // This exercises SQLite rollback, not an early rejection in the store facade. + probe.failNextClaimDelete(); + await assert.rejects(() => store.settleFire(claim.id, run), /injected claim-delete failure/); + assert.equal(probe.failedAfterTaskWrite, true); + assert.deepEqual(probe.snapshotExcluding(), before); + + const retried = await store.settleFire(claim.id, run); + assert.equal(retried.fireCount, 1); + assert.equal(retried.runs.filter((item) => item.id === run.id).length, 1); + assert.equal((await store.listPendingFires()).length, 0); + }); +}); + +test('ScheduledTask execution identity survives closing and reacquiring the root owner', async (t) => { + await withStore(t, async (fixture) => { + const task = await fixture.store.create(agentInput(), NOW); + const claim = await fixture.store.claimNow(task.id, NOW); + await fixture.store.bindFireExecution(claim.id, EXECUTION); + const oldWriter = fixture.store; + + await fixture.reopen(); + await assert.rejects(() => oldWriter.get(task.id), /writer is closed/); + assert.notEqual(fixture.store, oldWriter); + assert.deepEqual((await fixture.store.listPendingFires())[0]?.execution, EXECUTION); + assert.equal((await fixture.store.listPendingFires())[0]?.id, claim.id); + await fixture.store.settleFire(claim.id, { + at: NOW + 1, + outcome: 'ok', + message: 'after reopening', + sessionId: EXECUTION.sessionId, + runId: EXECUTION.runId, + }); + + await fixture.reopen(); + assert.deepEqual(await fixture.store.listPendingFires(), []); + const settled = await fixture.store.get(task.id); + assert.equal(settled?.fireCount, 1); + assert.equal(settled?.runs[0]?.runId, EXECUTION.runId); + }); +}); + +function notifyInput(title: string) { + return { + title, + intentBody: '', + schedule: { kind: 'interval', everySeconds: 60, startAt: NOW + 60_000 }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }; +} + +function agentInput() { + return { + ...notifyInput('Target task'), + intentBody: 'Perform the scheduled work.', + effect: { + kind: 'agent_run', + execution: { + cwd: '/workspace', + llmConnectionId: 'connection-default', + llmConnectionSlug: 'default', + model: 'test-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + }; +} + +interface QueryRead { + sql: string; + parameters: SQLInputValue[]; + rows: number; + payloadRows: number; + payloadBytes: number; + plan: string[]; +} + +interface OperationCost { + reads: QueryRead[]; + writes: Array<{ sql: string; changes: number }>; + totalChanges: number; +} + +function isOperationConflict(error: unknown): boolean { + return error instanceof ScheduledTaskStoreError && error.code === 'operation_conflict'; +} + +function assertNoDml(cost: OperationCost): void { + assert.equal(cost.writes.length, 0); + assert.equal(cost.totalChanges, 0); +} + +function assertPointCost( + cost: OperationCost, + expected: { rows: number; payloadRows: number; changes: number }, +): void { + const returnedRows = cost.reads.reduce((count, read) => count + read.rows, 0); + const payloadRows = cost.reads.reduce((count, read) => count + read.payloadRows, 0); + const payloadBytes = cost.reads.reduce((count, read) => count + read.payloadBytes, 0); + assert.ok(returnedRows <= expected.rows, JSON.stringify({ returnedRows, cost })); + assert.ok(payloadRows <= expected.payloadRows, JSON.stringify({ payloadRows, cost })); + assert.ok(payloadBytes < 8 * 1024, JSON.stringify({ payloadBytes, cost })); + assert.equal(cost.totalChanges, expected.changes, JSON.stringify(cost)); + assert.equal( + cost.writes.reduce((count, write) => count + write.changes, 0), + expected.changes, + JSON.stringify(cost), + ); + assert.ok(cost.reads.length > 0, 'the probe must observe the actual point read'); + for (const read of cost.reads) { + assert.ok( + read.plan.some((line) => /SEARCH .*USING .*INDEX/u.test(line)), + JSON.stringify(read), + ); + assert.ok( + read.plan.every((line) => !/\bSCAN\b/u.test(line)), + JSON.stringify(read), + ); + } +} + +class SqlProbe { + readonly #prepare: DatabaseSync['prepare']; + #cost: OperationCost | undefined; + #failDelete = false; + #taskWritten = false; + failedAfterTaskWrite = false; + + constructor(t: TestContext, database: DatabaseSync) { + this.#prepare = database.prepare.bind(database); + t.mock.method(database, 'prepare', (sql: string) => { + const statement = this.#prepare(sql); + const relevant = /\bworkflow_scheduled_task(?:s|_fires)\b/u.test(sql); + if (!relevant) return statement; + return new Proxy(statement, { + get: (target, key) => { + const value: unknown = Reflect.get(target, key, target); + if (typeof value !== 'function') return value; + if (!['all', 'get', 'iterate', 'run'].includes(String(key))) return value.bind(target); + return (...parameters: SQLInputValue[]) => { + const isWrite = /^\s*(?:INSERT|UPDATE|DELETE)\b/iu.test(sql); + if ( + this.#failDelete && + /^\s*DELETE\s+FROM\s+workflow_scheduled_task_fires\b/iu.test(sql) + ) { + this.#failDelete = false; + this.failedAfterTaskWrite = this.#taskWritten; + throw new Error('injected claim-delete failure'); + } + const result: unknown = Reflect.apply(value, target, parameters); + if (isWrite && key === 'run') { + const changes = Number((result as { changes: number | bigint }).changes); + if (/\bworkflow_scheduled_tasks\b/u.test(sql) && changes > 0) { + this.#taskWritten = true; + } + this.#cost?.writes.push({ sql, changes }); + } else if (this.#cost && /^\s*SELECT\b/iu.test(sql)) { + const read: QueryRead = { + sql, + parameters, + rows: 0, + payloadRows: 0, + payloadBytes: 0, + plan: [], + }; + this.#cost.reads.push(read); + const record = (row: unknown) => { + if (row === undefined) return; + read.rows += 1; + const json = (row as { record_json?: unknown }).record_json; + if (typeof json === 'string') { + read.payloadRows += 1; + read.payloadBytes += Buffer.byteLength(json, 'utf8'); + } + }; + if (key === 'iterate') { + return (function* () { + for (const row of result as Iterable) { + record(row); + yield row; + } + })(); + } + if (key === 'all') { + for (const row of result as unknown[]) record(row); + } else if (key === 'get') record(result); + } + return result; + }; + }, + }); + }); + } + + async measure(operation: () => Promise): Promise<{ value: T; cost: OperationCost }> { + const before = this.#totalChanges(); + const cost: OperationCost = { reads: [], writes: [], totalChanges: 0 }; + this.#cost = cost; + try { + const value = await operation(); + cost.totalChanges = this.#totalChanges() - before; + for (const read of cost.reads) { + read.plan = this.#prepare(`EXPLAIN QUERY PLAN ${read.sql}`) + .all(...read.parameters) + .map((row) => String(row.detail)); + } + return { value, cost }; + } finally { + this.#cost = undefined; + } + } + + failNextClaimDelete(): void { + this.#taskWritten = false; + this.#failDelete = true; + this.failedAfterTaskWrite = false; + } + + damageTaskIdentity(taskId: string, replacementId: string): void { + const result = this.#prepare( + "UPDATE workflow_scheduled_tasks SET record_json = json_set(record_json, '$.id', ?) WHERE task_id = ?", + ).run(replacementId, taskId); + assert.equal(result.changes, 1); + } + + snapshotExcluding(taskId = ''): unknown { + return { + tasks: this.#prepare( + 'SELECT * FROM workflow_scheduled_tasks WHERE task_id <> ? ORDER BY task_id', + ).all(taskId), + claims: this.#prepare( + 'SELECT * FROM workflow_scheduled_task_fires WHERE task_id <> ? ORDER BY claim_id', + ).all(taskId), + }; + } + + #totalChanges(): number { + return Number(this.#prepare('SELECT total_changes() AS count').get()?.count); + } +} + +interface Fixture { + store: InteractiveScheduledTaskStoreWriter; + probe: SqlProbe; + reopen(): Promise; +} + +async function withStore(t: TestContext, run: (fixture: Fixture) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-scheduled-task-rows-')); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + let owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + let lease: OperationalStateDatabaseLease = await runWithStorageRootLease( + owner.lease, + 'interactive', + 'write', + async (canonicalRoot) => acquireOperationalStateDatabase(canonicalRoot), + ); + const probe = new SqlProbe(t, lease.database); + let store = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + const fixture: Fixture = { + store, + probe, + async reopen() { + store.close(); + lease.close(); + await owner!.close(); + owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + lease = await runWithStorageRootLease( + owner.lease, + 'interactive', + 'write', + async (canonicalRoot) => acquireOperationalStateDatabase(canonicalRoot), + ); + fixture.probe = new SqlProbe(t, lease.database); + store = await openInteractiveScheduledTaskStoreForWrite(owner.lease); + fixture.store = store; + }, + }; + try { + await run(fixture); + } finally { + store.close(); + lease.close(); + await owner?.close(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b3824836d5e0e3213efe069016a5cb1c3740fa80267090c595280f0e141b1d40.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b3824836d5e0e3213efe069016a5cb1c3740fa80267090c595280f0e141b1d40.source new file mode 100644 index 0000000000..5259ffd941 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b3824836d5e0e3213efe069016a5cb1c3740fa80267090c595280f0e141b1d40.source @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolveStorageRoot, StorageRootAuthorityError } from '../../root-authority.js'; + +const [root] = process.argv.slice(2); +if (!root || !process.send) throw new Error('usage: root-resolver '); + +try { + await resolveStorageRoot({ path: root, kind: 'interactive' }); + await send({ type: 'resolved' }); +} catch (error) { + await send({ + type: 'error', + code: error instanceof StorageRootAuthorityError ? error.code : 'unexpected', + }); +} +process.disconnect?.(); + +function send(message: object): Promise { + return new Promise((resolve, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38ae9dd2e02544fac2d2f16c379d43c813897e0d1a6265ac8bcccb1abbb2b5a.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38ae9dd2e02544fac2d2f16c379d43c813897e0d1a6265ac8bcccb1abbb2b5a.source new file mode 100644 index 0000000000..8188f50b8b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38ae9dd2e02544fac2d2f16c379d43c813897e0d1a6265ac8bcccb1abbb2b5a.source @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { StoredMessage, UserMessage } from '@maka/core/session'; +import type { SessionTurnContribution } from './session-store.js'; + +export function projectSessionCatalogMessages(messages: readonly StoredMessage[]): { + readonly lastMessageAt?: number; + readonly lastMessagePreview?: string; +} { + const lastMessageAt = latestVisibleMessageAt(messages); + const lastMessagePreview = lastMessagePreviewForMessages(messages); + return { + ...(lastMessageAt === undefined ? {} : { lastMessageAt }), + ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + }; +} + +export function catalogPreviewForUserMessage(message: UserMessage): string | undefined { + const text = normalizePreviewText(message.displayText ?? message.text); + if (text) return truncatePreview(text); + return message.attachments && message.attachments.length > 0 ? '附件' : undefined; +} + +export function latestVisibleMessageAt(messages: readonly StoredMessage[]): number | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if ( + message.type === 'user' || + message.type === 'assistant' || + message.type === 'workhub_coordination' + ) { + return message.ts; + } + } + return undefined; +} + +export function isVisibleSessionMessage( + message: StoredMessage, +): message is Extract { + return message.type === 'user' || message.type === 'assistant'; +} + +export function lastMessagePreviewForMessages( + messages: readonly StoredMessage[], +): string | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.type === 'user') { + const preview = catalogPreviewForUserMessage(message); + if (preview !== undefined) return preview; + } + if (message.type === 'assistant') { + const text = normalizePreviewText(message.text); + if (text) return truncatePreview(text); + } + if (message.type === 'workhub_coordination') { + const text = 'userText' in message ? normalizePreviewText(message.userText) : ''; + if (text) return truncatePreview(text); + } + } + return undefined; +} + +function normalizePreviewText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function truncatePreview(text: string, maxLength = 96): string { + const chars = Array.from(text); + if (chars.length <= maxLength) return text; + return `${chars.slice(0, maxLength - 1).join('')}…`; +} + +/** One Turn's summary, folded message by message in transcript order. */ +export function foldTurnContribution( + current: SessionTurnContribution | undefined, + turnId: string, + sequence: number, + message: StoredMessage, +): SessionTurnContribution { + const contribution = current ?? { + turnId, + firstSequence: sequence, + latestState: null, + userPromptPreview: null, + }; + const userPrompt = message.type === 'user' ? (message.displayText ?? message.text).trim() : ''; + return { + ...contribution, + latestState: message.type === 'turn_state' ? { sequence, message } : contribution.latestState, + userPromptPreview: contribution.userPromptPreview ?? (userPrompt || null), + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38f5ac8a354528bf7849f0d436f2ea7c74eecbdecea11fb4f88b5dc0d693433.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38f5ac8a354528bf7849f0d436f2ea7c74eecbdecea11fb4f88b5dc0d693433.source new file mode 100644 index 0000000000..8cbc6a5a77 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b38f5ac8a354528bf7849f0d436f2ea7c74eecbdecea11fb4f88b5dc0d693433.source @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { test } from 'node:test'; +import { SessionBundleFileError } from '../session-bundle-contract.js'; +import { + decodeSessionBundleUstarHeaderV1, + encodeSessionBundleUstarHeaderV1, +} from '../session-bundle-ustar.js'; + +test('pins the exact canonical USTAR V1 header', () => { + const header = Buffer.from( + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: 'workspace/run.sh', + mode: 0o755, + size: 18, + }), + ); + + assert.equal(header.byteLength, 512); + assert.equal(header.subarray(0, 16).toString('utf8'), 'workspace/run.sh'); + assert.equal(header.subarray(100, 108).toString('ascii'), '0000755\0'); + assert.equal(header.subarray(124, 136).toString('ascii'), '00000000022\0'); + assert.equal(header.subarray(257, 265).toString('hex'), '7573746172003030'); + assert.equal( + createHash('sha256').update(header).digest('hex'), + '1d69bf3bf7dcb40b103d0d405c1d21ebc49a22c0ae3771b07dfd17eeeb4b6c4d', + ); + assert.deepEqual(decodeSessionBundleUstarHeaderV1(header), { + kind: 'file', + path: 'workspace/run.sh', + mode: 0o755, + size: 18, + }); +}); + +test('uses the rightmost representable USTAR prefix split', () => { + const path = `workspace/${'a'.repeat(90)}/${'b'.repeat(90)}`; + const header = Buffer.from( + encodeSessionBundleUstarHeaderV1({ kind: 'file', path, mode: 0o644, size: 0 }), + ); + assert.equal(header.subarray(0, 100).toString('utf8').replaceAll('\0', ''), 'b'.repeat(90)); + assert.equal( + header.subarray(345, 500).toString('utf8').replaceAll('\0', ''), + `workspace/${'a'.repeat(90)}`, + ); + assert.equal(decodeSessionBundleUstarHeaderV1(header).path, path); +}); + +test('accepts exact USTAR field boundaries and non-BMP paths', () => { + const name100 = 'n'.repeat(100); + const nameHeader = Buffer.from( + encodeSessionBundleUstarHeaderV1({ kind: 'file', path: name100, mode: 0o644, size: 0 }), + ); + assert.equal(nameHeader.subarray(0, 100).toString('utf8'), name100); + assert.equal(decodeSessionBundleUstarHeaderV1(nameHeader).path, name100); + + const prefix155 = 'p'.repeat(155); + const total256 = `${prefix155}/${name100}`; + assert.equal(Buffer.byteLength(total256), 256); + const splitHeader = Buffer.from( + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: total256, + mode: 0o644, + size: 0o77_777_777_777, + }), + ); + assert.equal(splitHeader.subarray(0, 100).toString('utf8'), name100); + assert.equal(splitHeader.subarray(345, 500).toString('utf8'), prefix155); + assert.deepEqual(decodeSessionBundleUstarHeaderV1(splitHeader), { + kind: 'file', + path: total256, + mode: 0o644, + size: 0o77_777_777_777, + }); + + const nonBmpPath = 'workspace/😀.txt'; + const nonBmpHeader = encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: nonBmpPath, + mode: 0o644, + size: 0, + }); + assert.equal(decodeSessionBundleUstarHeaderV1(nonBmpHeader).path, nonBmpPath); + + assertBundleError( + () => + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: `${'p'.repeat(156)}/n`, + mode: 0o644, + size: 0, + }), + 'unsafe_path', + ); +}); + +test('rejects noncanonical metadata and unrepresentable paths', () => { + for (const path of [ + '/absolute', + '../outside', + 'state//file', + 'C:/drive', + 'workspace/file:stream', + 'workspace/CON', + 'workspace/con.txt', + 'workspace/LPT9.log', + 'workspace/trailing.', + 'workspace/trailing ', + 'workspace/question?', + ]) { + assertBundleError( + () => encodeSessionBundleUstarHeaderV1({ kind: 'file', path, mode: 0o644, size: 0 }), + 'unsafe_path', + ); + } + assertBundleError( + () => + encodeSessionBundleUstarHeaderV1({ + kind: 'file', + path: `${'a'.repeat(101)}`, + mode: 0o644, + size: 0, + }), + 'unsafe_path', + ); + + const header = Buffer.from( + encodeSessionBundleUstarHeaderV1({ + kind: 'directory', + path: 'state/', + mode: 0o755, + size: 0, + }), + ); + header[135] = '1'.charCodeAt(0); + assertBundleError(() => decodeSessionBundleUstarHeaderV1(header), 'integrity_mismatch'); +}); + +function assertBundleError(action: () => unknown, code: SessionBundleFileError['code']): void { + assert.throws(action, (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, code); + return true; + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b47ffc2aa43cf43c9787166f0abf486069f663244ef71334f4530fca16f31e14.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b47ffc2aa43cf43c9787166f0abf486069f663244ef71334f4530fca16f31e14.source new file mode 100644 index 0000000000..94f549e310 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b47ffc2aa43cf43c9787166f0abf486069f663244ef71334f4530fca16f31e14.source @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; +import type { OnboardingMilestone, OnboardingMilestoneId } from '@maka/core/onboarding'; +import { createDefaultSettings, mergeSettings, normalizeSettings } from '@maka/core/settings'; +import { sanitizeOnboardingMilestones } from '@maka/core/onboarding'; +import { writeAtomicFile } from './atomic-file-write.js'; + +/** + * A conditional write's patch, either fixed or derived from the state the + * predicate just accepted. + * + * The function form exists so a caller that has to touch several fields, but + * only the ones that actually matched, can still do it in ONE queued write. + * Splitting that into two conditional updates makes a failure of the second + * leave the first committed — a partial write that the caller then reports as + * an error, with the persisted state and the live state disagreeing. + */ +export type ConditionalSettingsPatch = + | UpdateAppSettingsInput + | ((current: AppSettings) => UpdateAppSettingsInput); + +export interface SettingsStore { + get(): Promise; + update(patch: UpdateAppSettingsInput): Promise; + updateIf( + predicate: (current: AppSettings) => boolean, + patch: ConditionalSettingsPatch, + ): Promise<{ applied: boolean; settings: AppSettings }>; + /** + * PR110b: upsert a single onboarding milestone. Caller passes the + * desired terminal status; the store stamps `Date.now()` so the + * renderer cannot tamper with timestamps. Returns the freshly + * sanitized milestone list. Last-valid-entry-wins dedup applies. + * + * @throws if `id` is not in `OnboardingMilestoneId` or status is + * not 'completed' | 'skipped'. + */ + upsertOnboardingMilestone( + id: OnboardingMilestoneId, + status: 'completed' | 'skipped', + ): Promise; + /** + * Remove one milestone entry without disturbing the rest. Used for + * reversible first-run suggestion dismissal; it still flows through + * the closed enum so arbitrary renderer strings cannot reshape the + * onboarding settings section. + */ + clearOnboardingMilestone(id: OnboardingMilestoneId): Promise; +} + +export function createSettingsStore(workspaceRoot: string): SettingsStore { + return new FileSettingsStore(workspaceRoot); +} + +class FileSettingsStore implements SettingsStore { + private readonly settingsPath: string; + private queue: Promise = Promise.resolve(); + + constructor(workspaceRoot: string) { + this.settingsPath = join(workspaceRoot, 'settings.json'); + } + + async get(): Promise { + let settings: AppSettings | undefined; + await this.withQueue(async () => { + settings = await this.readOrCreate(); + }); + if (!settings) throw new Error('Failed to read settings'); + return settings; + } + + private async readOrCreate(): Promise { + try { + const text = await readFile(this.settingsPath, 'utf8'); + const persisted: unknown = JSON.parse(text); + const settings = normalizeSettings(persisted); + if (hasLegacyProxyCredentialFields(persisted)) { + await this.write(settings); + } + return settings; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const settings = createDefaultSettings(); + await this.write(settings); + return settings; + } + } + + async update(patch: UpdateAppSettingsInput): Promise { + let next: AppSettings | undefined; + await this.withQueue(async () => { + const current = await this.readOrCreate(); + next = mergeSettings(current, patch); + await this.write(next); + }); + if (!next) throw new Error('Failed to update settings'); + return next; + } + + async updateIf( + predicate: (current: AppSettings) => boolean, + patch: ConditionalSettingsPatch, + ): Promise<{ applied: boolean; settings: AppSettings }> { + let result: { applied: boolean; settings: AppSettings } | undefined; + await this.withQueue(async () => { + const current = await this.readOrCreate(); + if (!predicate(current)) { + result = { applied: false, settings: current }; + return; + } + const next = mergeSettings(current, typeof patch === 'function' ? patch(current) : patch); + await this.write(next); + result = { applied: true, settings: next }; + }); + if (!result) throw new Error('Failed to conditionally update settings'); + return result; + } + + async upsertOnboardingMilestone( + id: OnboardingMilestoneId, + status: 'completed' | 'skipped', + ): Promise { + if (status !== 'completed' && status !== 'skipped') { + throw new Error(`invalid onboarding milestone status: ${String(status)}`); + } + const timestamp = Date.now(); + const next: OnboardingMilestone = + status === 'completed' ? { id, completedAt: timestamp } : { id, skippedAt: timestamp }; + let result: OnboardingMilestone[] | undefined; + await this.withQueue(async () => { + const current = await this.readOrCreate(); + // Append the new entry; sanitize() applies last-valid-entry-wins + // dedup with stable first-seen position. ID validity is enforced + // by the sanitizer (closed enum). + const sanitized = sanitizeOnboardingMilestones([...current.onboarding.milestones, next]); + if (!sanitized.some((entry) => entry.id === id)) { + // ID was rejected by the validator — propagate so the IPC + // handler can reject the caller's input. + throw new Error(`invalid onboarding milestone id: ${String(id)}`); + } + const merged: AppSettings = { + ...current, + onboarding: { milestones: sanitized }, + }; + await this.write(merged); + result = sanitized; + }); + if (!result) throw new Error('Failed to upsert onboarding milestone'); + return result; + } + + async clearOnboardingMilestone(id: OnboardingMilestoneId): Promise { + let result: OnboardingMilestone[] | undefined; + await this.withQueue(async () => { + const current = await this.readOrCreate(); + const knownId = sanitizeOnboardingMilestones([{ id }]).some((entry) => entry.id === id); + if (!knownId) { + throw new Error(`invalid onboarding milestone id: ${String(id)}`); + } + const milestones = current.onboarding.milestones.filter((entry) => entry.id !== id); + const merged: AppSettings = { + ...current, + onboarding: { milestones }, + }; + await this.write(merged); + result = milestones; + }); + if (!result) throw new Error('Failed to clear onboarding milestone'); + return result; + } + + private async write(settings: AppSettings): Promise { + // SettingsStore does not own the workspace directory's permission policy: + // sibling stores such as MCP config may independently harden the same root. + // Keep both directory creation and the historical umask-derived file mode. + await mkdir(dirname(this.settingsPath), { recursive: true }); + await writeAtomicFile(this.settingsPath, JSON.stringify(settings, null, 2) + '\n', { + fileMode: 0o666 & ~process.umask(), + }); + } + + private withQueue(operation: () => Promise): Promise { + const next = this.queue.then(operation, operation); + this.queue = next.catch(() => {}); + return next; + } +} + +function hasLegacyProxyCredentialFields(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.network) || !isRecord(value.network.proxy)) { + return false; + } + const proxy = value.network.proxy; + return ['password', 'passwordConfigured', 'credential'].some((key) => + Object.prototype.hasOwnProperty.call(proxy, key), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7079dbad7ddbf492932972e73b860b51df5f2971cbb176b1fc246ecc51451fe.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7079dbad7ddbf492932972e73b860b51df5f2971cbb176b1fc246ecc51451fe.source new file mode 100644 index 0000000000..6119b08a40 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7079dbad7ddbf492932972e73b860b51df5f2971cbb176b1fc246ecc51451fe.source @@ -0,0 +1,1509 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import fsPromises from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { createHash } from 'node:crypto'; +import { + link, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { describe, test, type TestContext } from 'node:test'; +import { + ARTIFACT_ENTITY_ID_MAX_CHARS, + ARTIFACT_TURN_KEY_MAX_CHARS, + type ArtifactRecord, +} from '@maka/core/artifacts'; +import { + ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES, + type ArtifactAuthorityStore, + type ArtifactStoreWriteAuthority, + type CreateArtifactInput, + createSqliteArtifactStoreWriteAuthority, + isSafeRelativeArtifactPath, + resolveArtifactPath, + sanitizeArtifactName, +} from '../artifact-store.js'; +import { withArtifactWriterLock } from '../artifact-writer-lock.js'; +import { createSqliteArtifactMetadataRepository } from '../sqlite-artifact-metadata.js'; + +const artifactStoreClosersByRoot = new Map void>>(); + +function trackArtifactStoreCloser(root: string, close: () => void): void { + const closers = artifactStoreClosersByRoot.get(root) ?? new Set<() => void>(); + closers.add(close); + artifactStoreClosersByRoot.set(root, closers); +} + +function createArtifactStore(root: string): ArtifactAuthorityStore { + const authority = createSqliteArtifactStoreWriteAuthority(root); + trackArtifactStoreCloser(root, () => authority.close()); + return authority.store; +} + +async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) { + return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records; +} + +async function getArtifact( + store: ArtifactAuthorityStore, + artifactId: string, + sessionId = 'session-1', +) { + return (await store.getInSession(sessionId, artifactId)).record; +} + +function readArtifactText( + store: ArtifactAuthorityStore, + artifactId: string, + sessionId = 'session-1', +) { + return store.readTextInSession(sessionId, artifactId); +} + +function readArtifactBinary( + store: ArtifactAuthorityStore, + artifactId: string, + sessionId = 'session-1', +) { + return store.readBinaryInSession(sessionId, artifactId); +} + +function createArtifactStoreWriteAuthority(root: string): ArtifactStoreWriteAuthority { + const authority = createSqliteArtifactStoreWriteAuthority(root); + trackArtifactStoreCloser(root, () => authority.close()); + return authority; +} + +function closeArtifactStores(root: string): void { + const closers = artifactStoreClosersByRoot.get(root); + artifactStoreClosersByRoot.delete(root); + if (!closers) return; + for (const close of [...closers].reverse()) close(); +} + +describe('SQLite Artifact store', () => { + test('creates a missing workspace root before acquiring the writer lock', async () => { + const parent = await mkdtemp(join(tmpdir(), 'maka-artifact-missing-root-')); + const root = join(parent, 'nested', 'workspace'); + try { + const created = await createArtifactStore(root).create( + artifactInput('missing-root', 'created', 1), + ); + + assert.equal((await stat(root)).isDirectory(), true); + assert.equal(created.id, 'missing-root'); + assert.deepEqual( + (await listArtifacts(createArtifactStore(root), 'session-1')).map((record) => record.id), + ['missing-root'], + ); + } finally { + closeArtifactStores(root); + await rm(parent, { recursive: true, force: true }); + } + }); + + test('publishes stable identities and persists canonical records', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + const first = await store.create(artifactInput('artifact-1', '# Notes', 100)); + const second = await store.create({ + ...artifactInput('artifact-2', 'diff --git a/a b/a', 200), + name: 'patch.diff', + kind: 'diff', + source: 'tool_result', + }); + + assert.equal(first.relativePath, 'session-1/artifact-1-artifact-1.txt'); + assert.equal(first.sizeBytes, 7); + assert.deepEqual( + (await listArtifacts(store, 'session-1')).map((record) => record.id), + ['artifact-2', 'artifact-1'], + ); + assert.deepEqual(await readArtifactText(store, first.id), { ok: true, text: '# Notes' }); + + const reopened = createArtifactStore(root); + assert.deepEqual(await getArtifact(reopened, second.id), second); + await assert.rejects( + () => reopened.create(artifactInput('artifact-1', 'replacement', 300)), + /Artifact artifact-1 already exists/, + ); + assert.deepEqual(await readArtifactText(reopened, 'artifact-1'), { + ok: true, + text: '# Notes', + }); + }); + }); + + test('lists only live Artifacts committed by one exact Turn', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + await store.create({ ...artifactInput('turn-a-new', 'new', 30), turnId: 'turn-a' }); + await store.create({ ...artifactInput('turn-b', 'other turn', 20), turnId: 'turn-b' }); + await store.create({ + ...artifactInput('turn-a-old', 'old', 10), + turnId: 'turn-a', + }); + await store.create({ + ...artifactInput('other-session', 'other session', 40), + sessionId: 'session-2', + turnId: 'turn-a', + }); + + assert.deepEqual( + (await store.listTurnArtifacts('session-1', 'turn-a')).map((record) => record.id), + ['turn-a-new', 'turn-a-old'], + ); + await store.deleteUserArtifactInSession('session-1', 'turn-a-new'); + assert.deepEqual( + (await store.listTurnArtifacts('session-1', 'turn-a')).map((record) => record.id), + ['turn-a-old'], + ); + }); + }); + + test('persists and reopens bounded synthetic turn keys', async () => { + await withWorkspace(async (root) => { + for (const [id, turnId] of [ + ['history-artifact', 'history-compact:42'], + ['synthesis-artifact', 'synthesis-cache:43'], + ] as const) { + const input = { ...artifactInput(id, 'compacted', 1), turnId }; + const created = await createArtifactStore(root).create(input); + assert.equal(created.turnId, input.turnId); + assert.equal( + (await getArtifact(createArtifactStore(root), created.id))?.turnId, + input.turnId, + ); + } + + for (const turnId of [ + '', + 'history-compact:\n1', + 'synthesis-cache:\u007f1', + 'x'.repeat(ARTIFACT_TURN_KEY_MAX_CHARS + 1), + ]) { + await assert.rejects( + () => + createArtifactStore(root).create({ + ...artifactInput(`invalid-${turnId.length}`, 'invalid', 2), + turnId, + }), + /bounded opaque turn key/, + ); + } + }); + }); + + test('owns stable session revisions across paging, reopen, mutations, and no-ops', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + const empty = await store.listPage('session-1', { offset: 0, limit: 2 }); + assert.equal(empty.total, 0); + assert.deepEqual(empty.records, []); + assert.equal((await store.getInSession('session-1', 'missing')).revision, empty.revision); + assert.equal( + (await store.listPage('another-empty-session', { offset: 0, limit: 1 })).revision, + empty.revision, + ); + + const firstInput = artifactInput('first', 'first', 10); + await store.create(firstInput); + await store.create(artifactInput('second', 'second', 20)); + const created = await store.listPage('session-1', { offset: 0, limit: 1 }); + assert.equal(created.total, 2); + assert.deepEqual( + created.records.map((record) => record.id), + ['second'], + ); + assert.equal((await store.getInSession('session-1', 'first')).revision, created.revision); + + const reopenedAuthority = createArtifactStoreWriteAuthority(root); + const reopenedPage = await reopenedAuthority.store.listPage('session-1', { + offset: 0, + limit: 2, + }); + assert.equal(reopenedPage.revision, created.revision); + assert.deepEqual( + reopenedPage.records.map((record) => record.id), + ['second', 'first'], + ); + + await store.create({ + ...artifactInput('other-session', 'other', 30), + sessionId: 'session-2', + }); + assert.equal( + (await store.listPage('session-1', { offset: 0, limit: 2 })).revision, + created.revision, + ); + await store.create(firstInput); + assert.equal( + (await store.listPage('session-1', { offset: 0, limit: 2 })).revision, + created.revision, + ); + + await store.create(artifactInput('revision-race', 'race', 25)); + const deletedResult = await store.deleteUserArtifactInSession('session-1', 'first'); + assert.equal(deletedResult.kind, 'deleted'); + const deleted = await store.listPage('session-1', { offset: 0, limit: 3 }); + assert.notEqual(deleted.revision, created.revision); + assert.equal( + deleted.records.find((record) => record.id === 'first'), + undefined, + ); + assert.equal( + (await store.deleteUserArtifactInSession('session-1', 'first')).kind, + 'not_found', + ); + assert.equal( + (await store.listPage('session-1', { offset: 0, limit: 3 })).revision, + deleted.revision, + ); + + await store.create(firstInput); + const revived = await store.listPage('session-1', { offset: 0, limit: 3 }); + assert.notEqual(revived.revision, deleted.revision); + assert.equal(revived.records.find((record) => record.id === 'first')?.id, 'first'); + + await store.purgeSessionArtifacts('session-1'); + const purged = await store.listPage('session-1', { offset: 0, limit: 2 }); + assert.equal(purged.revision, empty.revision); + assert.equal(purged.total, 0); + }); + }); + + test('copies an exact turn-scoped Artifact snapshot and purges only the target Session', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + const retained = await store.create({ + ...artifactInput('retained-artifact', 'retained', 10), + turnId: 'turn-retained', + mimeType: 'text/plain', + }); + const deleted = await store.create({ + ...artifactInput('deleted-artifact', 'deleted', 11), + turnId: 'turn-retained', + }); + await store.deleteUserArtifactInSession('session-1', deleted.id); + await store.create({ + ...artifactInput('later-artifact', 'later', 20), + turnId: 'turn-later', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + }); + const copiedId = copied.artifactIds.get(retained.id); + const copiedDeletedId = copied.artifactIds.get(deleted.id); + assert.ok(copiedId); + assert.equal(copiedDeletedId, undefined); + assert.notEqual(copiedId, retained.id); + const target = await listArtifacts(store, 'session-copy'); + assert.equal(target.length, 1); + assert.equal(target[0]?.id, copiedId); + assert.equal(target[0]?.turnId, retained.turnId); + assert.equal(copied.relativePaths.get(retained.relativePath), target[0]?.relativePath); + assert.deepEqual(await readArtifactText(store, copiedId!, 'session-copy'), { + ok: true, + text: 'retained', + }); + assert.equal(copied.relativePaths.get(deleted.relativePath), undefined); + + await store.purgeSessionArtifacts('session-copy'); + assert.deepEqual(await listArtifacts(store, 'session-copy'), []); + assert.deepEqual( + (await listArtifacts(store, 'session-1')).map((record) => record.id).sort(), + ['later-artifact', 'retained-artifact'], + ); + }); + }); + + test('excludes selected Artifacts from a conversation snapshot', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + await store.create({ + ...artifactInput('retained-artifact', 'retained', 10), + turnId: 'turn-retained', + }); + await store.create({ + ...artifactInput('excluded-archive', 'archived child result', 11), + turnId: 'turn-retained', + source: 'tool_result_archive', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + excludeArtifactIds: ['excluded-archive'], + }); + + assert.equal(copied.artifactIds.has('excluded-archive'), false); + assert.deepEqual( + (await listArtifacts(store, 'session-copy')).map((record) => record.name), + ['retained-artifact.txt'], + ); + assert.equal((await getArtifact(store, 'excluded-archive'))?.sessionId, 'session-1'); + }); + }); + + test('copies explicit linked child Artifacts into a conversation snapshot', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + await store.create({ + ...artifactInput('child-artifact', 'child result', 10), + sessionId: 'child-session', + turnId: 'child-turn', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + linkedArtifacts: [{ sessionId: 'child-session', artifactIds: ['child-artifact'] }], + }); + + const copiedId = copied.artifactIds.get('child-artifact'); + assert.ok(copiedId); + assert.deepEqual(await readArtifactText(store, copiedId, 'session-copy'), { + ok: true, + text: 'child result', + }); + assert.equal((await getArtifact(store, copiedId, 'session-copy'))?.sessionId, 'session-copy'); + assert.equal( + (await getArtifact(store, 'child-artifact', 'child-session'))?.sessionId, + 'child-session', + ); + }); + }); + + test('includes explicit same-Session Artifacts outside the copied turns', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + await store.create({ + ...artifactInput('retained-artifact', 'retained', 10), + turnId: 'turn-retained', + }); + // A user upload carries the uploadId sentinel as its turnId, so it is + // never a member of the copied conversation turns. + const upload = await store.create({ + ...artifactInput('attachment-upload', 'uploaded bytes', 11), + turnId: 'upload-sentinel', + source: 'user_upload', + }); + + const withoutInclude = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + }); + assert.equal(withoutInclude.artifactIds.has(upload.id), false); + + const withInclude = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy-2', + turnIds: ['turn-retained'], + includeArtifactIds: [upload.id], + }); + const copiedUploadId = withInclude.artifactIds.get(upload.id); + assert.ok(copiedUploadId); + assert.deepEqual(await readArtifactText(store, copiedUploadId, 'session-copy-2'), { + ok: true, + text: 'uploaded bytes', + }); + assert.equal( + (await getArtifact(store, copiedUploadId, 'session-copy-2'))?.sessionId, + 'session-copy-2', + ); + // Unknown include ids are a no-op, not an error. + const withUnknown = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy-3', + turnIds: ['turn-retained'], + includeArtifactIds: ['does-not-exist'], + }); + assert.equal(withUnknown.artifactIds.has('does-not-exist'), false); + }); + }); + + test('user deletion respects the current artifact source while owner cleanup remains possible', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + const input = artifactInput('current-policy', 'replaceable', 1); + await store.create(input); + await store.deleteUserArtifactInSession(input.sessionId, input.id); + await store.create({ + ...input, + content: 'protected replacement', + source: 'deep_research', + }); + + assert.equal( + (await store.deleteUserArtifactInSession(input.sessionId, input.id)).kind, + 'protected', + ); + assert.equal((await getArtifact(store, input.id))?.source, 'deep_research'); + assert.deepEqual(await store.readTextInSession(input.sessionId, input.id), { + ok: true, + text: 'protected replacement', + }); + assert.deepEqual(await store.deleteUserArtifactInSession('different-session', input.id), { + kind: 'not_found', + }); + + await store.purgeSessionArtifacts(input.sessionId); + assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), { + kind: 'not_found', + }); + }); + }); + + test('sanitizes adversarial names idempotently across reopen and stable-id retry', async () => { + const names = [ + '-.gitignore', + ' . a', + `${'a'.repeat(119)}- trailing`, + `${'b'.repeat(119)}. trailing`, + `${'c'.repeat(119)} trailing`, + `${'.-'.repeat(80)}report.txt`, + `${'d'.repeat(120)}---`, + ]; + for (const name of names) { + const sanitized = sanitizeArtifactName(name); + assert.equal(sanitizeArtifactName(sanitized), sanitized, name); + assert.ok(sanitized.length > 0 && sanitized.length <= 120, name); + assert.doesNotMatch(sanitized, /^[ .-]|[ .-]$/, name); + } + assert.equal(sanitizeArtifactName('-.gitignore'), 'gitignore'); + assert.equal(sanitizeArtifactName(' . a'), 'a'); + + await withWorkspace(async (root) => { + for (const [index, name] of names.entries()) { + const input = { + ...artifactInput(`adversarial-${index}`, `payload-${index}`, index + 1), + name, + }; + const created = await createArtifactStore(root).create(input); + const reopened = createArtifactStore(root); + + assert.deepEqual(await listArtifacts(reopened, input.sessionId), [ + created, + ...(await listArtifacts(reopened, input.sessionId)).filter( + (record) => record.id !== created.id, + ), + ]); + assert.deepEqual(await getArtifact(reopened, created.id), created); + assert.deepEqual(await readArtifactText(reopened, created.id), { + ok: true, + text: input.content, + }); + assert.deepEqual(await reopened.create(input), created); + } + }); + }); + + test('ignores metadata names that neither writer could have produced', async () => { + for (const name of ['short ', 'embedded\ttab', 'embedded\nnewline']) { + await withWorkspace(async (root) => { + await writeArtifactMetadata(root, [ + canonicalRecord({ id: 'invalid-name', sessionId: 'session-1', name, sizeBytes: 0 }), + ]); + const store = createArtifactStore(root); + assert.deepEqual(await listArtifacts(store, 'session-1'), []); + assert.equal( + (await store.create(artifactInput('replacement', 'kept', 1))).id, + 'replacement', + ); + }); + } + }); + + test('does not split a surrogate pair at the persisted name boundary', async () => { + await withWorkspace(async (root) => { + const input = { + ...artifactInput('unicode-boundary', 'boundary', 1), + name: `${'a'.repeat(119)}😀tail`, + }; + assert.equal(sanitizeArtifactName(input.name), 'a'.repeat(119)); + + const created = await createArtifactStore(root).create(input); + assert.equal(created.name, 'a'.repeat(119)); + const authority = createArtifactStoreWriteAuthority(root); + const reopened = authority.store; + assert.deepEqual(await reopened.create(input), created); + assert.deepEqual(await readArtifactText(reopened, created.id), { + ok: true, + text: input.content, + }); + }); + }); + + test('persists complete canonical deep-research and archived tool-result records', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + const report = await store.create({ + id: 'research-report', + sessionId: 'session-1', + turnId: 'turn-report', + name: 'report.html', + kind: 'html', + content: '

Research

', + mimeType: 'text/html', + source: 'deep_research', + summary: 'Canonical research report', + deepResearchRole: 'report', + now: 100, + }); + const archive = await store.create({ + id: 'tool-archive', + sessionId: 'session-1', + turnId: 'turn-tool', + name: 'tool-result.json', + kind: 'file', + content: '{"ok":true}', + mimeType: 'application/json', + source: 'tool_result_archive', + summary: 'Archived tool result', + now: 200, + }); + + const reopened = createArtifactStore(root); + assert.deepEqual(await getArtifact(reopened, report.id), report); + assert.deepEqual(await getArtifact(reopened, archive.id), archive); + assert.deepEqual(await readArtifactText(reopened, report.id), { + ok: true, + text: '

Research

', + }); + assert.deepEqual(await readArtifactText(reopened, archive.id), { + ok: true, + text: '{"ok":true}', + }); + }); + }); + + test('exact live replay returns the canonical record without rewriting or accepting conflicts', async () => { + await withWorkspace(async (root) => { + const input = deepResearchArtifactInput('stable-replay', '# Durable result'); + const first = await createArtifactStore(root).create(input); + const metadataPath = join(root, 'artifacts', 'metadata.jsonl'); + await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' }); + + const replayed = await createArtifactStore(root).create(input); + assert.deepEqual(replayed, first); + await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' }); + assert.deepEqual(await readArtifactText(createArtifactStore(root), first.id), { + ok: true, + text: '# Durable result', + }); + + await assert.rejects( + () => + createArtifactStore(root).create({ + ...input, + content: '# Mutated result', + }), + /already exists with different metadata or content/, + ); + assert.equal(Buffer.byteLength(input.content), Buffer.byteLength('# Mutated result')); + await assert.rejects( + () => + createArtifactStore(root).create({ + ...input, + summary: 'Different summary', + }), + /already exists with different metadata or content/, + ); + await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' }); + }); + }); + + test('a stable id can be created again after physical deletion', async () => { + await withWorkspace(async (root) => { + const input = deepResearchArtifactInput('stable-revive', '# Revivable'); + const store = createArtifactStore(root); + const first = await store.create(input); + await store.deleteOwnedArtifactInSession(input.sessionId, first.id, input.source); + assert.equal(await getArtifact(store, first.id), null); + + const recreated = await createArtifactStore(root).create(input); + assert.deepEqual({ ...recreated, createdAt: first.createdAt }, first); + assert.deepEqual(await readArtifactText(createArtifactStore(root), first.id), { + ok: true, + text: '# Revivable', + }); + }); + }); + + test('serializes concurrent creates without dropping metadata', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + const ids = Array.from({ length: 12 }, (_, index) => `artifact-${index}`); + await Promise.all(ids.map((id, index) => store.create(artifactInput(id, id, index + 1)))); + + const reopened = createArtifactStore(root); + const rows = await listArtifacts(reopened, 'session-1'); + assert.deepEqual(rows.map((record) => record.id).sort(), ids.sort()); + await assert.rejects(() => stat(join(root, 'artifacts', 'metadata.jsonl')), { + code: 'ENOENT', + }); + }); + }); + + test('serializes independent SQLite stores without dropping metadata', async () => { + await withWorkspace(async (root) => { + const first = createArtifactStore(root); + const second = createArtifactStore(root); + await Promise.all([ + first.create(artifactInput('independent-first', 'first', 1)), + second.create(artifactInput('independent-second', 'second', 2)), + ]); + + const rows = await listArtifacts(createArtifactStore(root), 'session-1'); + assert.deepEqual(rows.map((record) => record.id).sort(), [ + 'independent-first', + 'independent-second', + ]); + }); + }); + + test('snapshots mutable create input and bytes before waiting for the writer lock', async () => { + await withWorkspace(async (root) => { + let releaseLock!: () => void; + let lockAcquired!: () => void; + const acquired = new Promise((resolve) => { + lockAcquired = resolve; + }); + const release = new Promise((resolve) => { + releaseLock = resolve; + }); + const holder = withArtifactWriterLock(root, async () => { + lockAcquired(); + await release; + }); + await acquired; + + const bytes = Uint8Array.from([0x73, 0x61, 0x66, 0x65]); + const input: CreateArtifactInput = { + id: 'accepted-id', + sessionId: 'session-1', + turnId: 'turn-1', + name: 'accepted.bin', + kind: 'file', + content: bytes, + mimeType: 'application/octet-stream', + source: 'tool_result', + summary: 'accepted summary', + now: 7, + }; + const accepted = createArtifactStore(root).create(input); + + input.id = 'mutated-id'; + input.sessionId = 'mutated-session'; + input.turnId = 'mutated-turn'; + input.name = 'mutated.txt'; + input.kind = 'diff'; + input.content = 'mutated content'; + input.mimeType = 'text/plain'; + input.source = 'tool_result'; + input.summary = 'mutated summary'; + input.now = 99; + bytes.fill(0x78); + releaseLock(); + await holder; + + const record = await accepted; + assert.deepEqual(record, { + id: 'accepted-id', + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 7, + name: 'accepted.bin', + kind: 'file', + relativePath: 'session-1/accepted-id-accepted.bin', + sizeBytes: 4, + mimeType: 'application/octet-stream', + source: 'tool_result', + summary: 'accepted summary', + }); + assert.deepEqual( + await readFile(join(root, 'artifacts', record.relativePath)), + Buffer.from('safe'), + ); + const reopened = createArtifactStore(root); + assert.deepEqual(await getArtifact(reopened, record.id), record); + assert.deepEqual(await listArtifacts(reopened, 'session-1'), [record]); + assert.equal(await getArtifact(reopened, 'mutated-id'), null); + }); + }); + + test('refreshes a loaded SQLite store after another instance commits metadata', async () => { + await withWorkspace(async (root) => { + const stale = createArtifactStore(root); + const writer = createArtifactStore(root); + await stale.create(artifactInput('first', 'first', 1)); + assert.deepEqual( + (await listArtifacts(stale, 'session-1')).map((record) => record.id), + ['first'], + ); + + await writer.create(artifactInput('second', 'second', 2)); + + assert.deepEqual( + (await listArtifacts(stale, 'session-1')).map((record) => record.id), + ['second', 'first'], + ); + assert.equal((await getArtifact(stale, 'second'))?.id, 'second'); + assert.deepEqual(await readArtifactText(stale, 'second'), { ok: true, text: 'second' }); + }); + }); + + test('does not publish a payload after the SQLite metadata repository closes', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + await store.create(artifactInput('published', 'kept', 1)); + store.close(); + + await assert.rejects( + () => store.create(artifactInput('rejected', 'not durable', 2)), + /Artifact metadata repository is closed/, + ); + await assert.rejects( + () => readFile(join(root, 'artifacts', 'session-1', 'rejected-rejected.txt')), + { code: 'ENOENT' }, + ); + const reopened = createArtifactStore(root); + assert.equal(await getArtifact(reopened, 'rejected'), null); + }); + }); + + test('legacy publication residue cannot block stable-id creation', async () => { + await withWorkspace(async (root) => { + const residue = await createPublicationResidue(root, 'stable-retry', 'retry.txt', 'old'); + const authority = createArtifactStoreWriteAuthority(root); + const { store } = authority; + const retried = await store.create({ + ...artifactInput('stable-retry', 'new', 1), + name: 'retry.txt', + }); + assert.equal(retried.id, 'stable-retry'); + assert.deepEqual(await readArtifactText(store, retried.id), { ok: true, text: 'new' }); + assert.equal(await readFile(residue.stagingPath, 'utf8'), 'old'); + }); + }); + + test('stable-id creation replaces an untracked payload', async () => { + await withWorkspace(async (root) => { + const input = { + ...artifactInput('target-orphan', 'orphan bytes', 1), + name: 'report.txt', + }; + const orphanPath = join(root, 'artifacts', 'session-1', 'target-orphan-report.txt'); + await mkdir(dirname(orphanPath), { recursive: true }); + await writeFile(orphanPath, input.content, { flag: 'wx' }); + + const bare = createArtifactStore(root); + const adopted = await bare.create(input); + assert.equal(adopted.name, 'report.txt'); + assert.equal(adopted.relativePath, 'session-1/target-orphan-report.txt'); + assert.deepEqual(await readArtifactText(createArtifactStore(root), adopted.id), { + ok: true, + text: input.content, + }); + }); + }); + + test('invalid path identities and turn keys cannot block later artifact writes', async () => { + const invalidIdentities = [ + { field: 'id', value: 'a'.repeat(ARTIFACT_ENTITY_ID_MAX_CHARS + 1) }, + { field: 'id', value: '.' }, + { field: 'sessionId', value: 'session/bad' }, + { field: 'turnId', value: 'turn\nid' }, + { field: 'turnId', value: 'x'.repeat(ARTIFACT_TURN_KEY_MAX_CHARS + 1) }, + ] as const; + + for (const { field, value } of invalidIdentities) { + await withWorkspace(async (root) => { + await writeArtifactMetadata(root, [recordWithIdentity(field, value)]); + const store = createArtifactStore(root); + assert.deepEqual(await listArtifacts(store, 'session-1'), []); + assert.equal( + (await store.create(artifactInput('replacement', 'kept', 1))).id, + 'replacement', + ); + }); + } + }); + + test('legacy purge-intent residue cannot block later writes', async () => { + await withWorkspace(async (root) => { + const intentPath = join(root, 'artifacts', '.artifact-purge-intent.json'); + await mkdir(dirname(intentPath), { recursive: true }); + await writeFile(intentPath, 'not valid json', 'utf8'); + + const authority = createArtifactStoreWriteAuthority(root); + const record = await authority.store.create(artifactInput('after-retired-purge', 'kept', 1)); + + assert.equal(record.id, 'after-retired-purge'); + assert.equal(await readFile(intentPath, 'utf8'), 'not valid json'); + }); + }); + + test('user delete physically removes metadata and bytes idempotently', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + const record = await store.create(artifactInput('artifact-1', '

Report

', 1)); + + await store.deleteUserArtifactInSession(record.sessionId, record.id); + await store.deleteUserArtifactInSession(record.sessionId, record.id); + assert.deepEqual(await listArtifacts(store, 'session-1'), []); + assert.equal(await getArtifact(store, record.id), null); + await assert.rejects(() => stat(join(root, 'artifacts', record.relativePath)), { + code: 'ENOENT', + }); + }); + }); + + test('a partial purge retains its cleanup obligations across reopen', async (t) => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + const first = await authority.store.create(artifactInput('purge-first', 'first', 1)); + const second = await authority.store.create(artifactInput('purge-second', 'second', 2)); + const target = await fsPromises.realpath(join(root, 'artifacts', second.relativePath)); + const originalRm = fsPromises.rm; + const injected = t.mock.method( + fsPromises, + 'rm', + async (...[path, options]: Parameters) => { + if (path === target) + throw Object.assign(new Error('injected unlink failure'), { code: 'EIO' }); + return originalRm(path, options); + }, + ); + syncBuiltinESMExports(); + try { + await assert.rejects(authority.store.purgeSessionArtifacts(first.sessionId), { + code: 'EIO', + }); + } finally { + injected.mock.restore(); + syncBuiltinESMExports(); + } + authority.close(); + const reopened = createArtifactStore(root); + assert.equal((await listArtifacts(reopened, first.sessionId)).length, 2); + if (process.platform !== 'win32') { + const originalOpen = fsPromises.open; + const syncFailure = t.mock.method( + fsPromises, + 'open', + async (...args: Parameters) => { + if (args[0] === dirname(target)) { + throw Object.assign(new Error('injected directory sync failure'), { code: 'EIO' }); + } + return originalOpen(...args); + }, + ); + syncBuiltinESMExports(); + try { + // The second attempt sees no payloads, but still owes the directory sync. + for (let attempt = 0; attempt < 2; attempt++) { + await assert.rejects(reopened.purgeSessionArtifacts(first.sessionId), { code: 'EIO' }); + assert.equal((await listArtifacts(reopened, first.sessionId)).length, 2); + } + } finally { + syncFailure.mock.restore(); + syncBuiltinESMExports(); + } + } + await reopened.purgeSessionArtifacts(first.sessionId); + assert.deepEqual(await listArtifacts(reopened, first.sessionId), []); + for (const record of [first, second]) { + await assert.rejects(stat(join(root, 'artifacts', record.relativePath)), { + code: 'ENOENT', + }); + } + }); + }); + + test('purge rejects a symlink escape without deleting external bytes or metadata', async (t) => { + const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-outside-')); + try { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const safeRecord = canonicalRecord({ + id: 'safe', + sessionId: 'session-1', + name: 'safe.txt', + sizeBytes: 4, + }); + const escapedRecord = canonicalRecord({ + id: 'escaped', + sessionId: 'linked', + name: 'victim.txt', + sizeBytes: 8, + }); + await mkdir(join(artifactRoot, safeRecord.sessionId), { recursive: true }); + await writeFile(join(artifactRoot, safeRecord.relativePath), 'safe', 'utf8'); + const externalPath = join(outsideRoot, basename(escapedRecord.relativePath)); + await writeFile(externalPath, 'external', 'utf8'); + if (!(await createSymlinkOrSkip(t, outsideRoot, join(artifactRoot, 'linked'), 'dir'))) + return; + const metadataPath = await writeArtifactMetadata(root, [safeRecord, escapedRecord]); + const metadataBefore = await readFile(metadataPath, 'utf8'); + + const store = createArtifactStore(root); + await assert.rejects( + () => store.deleteUserArtifactInSession(escapedRecord.sessionId, escapedRecord.id), + /outside the artifact root/, + ); + + assert.equal(await readFile(externalPath, 'utf8'), 'external'); + assert.equal(await readFile(join(artifactRoot, safeRecord.relativePath), 'utf8'), 'safe'); + assert.equal(await readFile(metadataPath, 'utf8'), metadataBefore); + assert.equal((await getArtifact(store, safeRecord.id))?.id, safeRecord.id); + assert.equal( + (await getArtifact(store, escapedRecord.id, escapedRecord.sessionId))?.id, + escapedRecord.id, + ); + }); + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + }); + + test('purge unlinks a final symlink without deleting its in-root target', async (t) => { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const record = canonicalRecord({ + id: 'linked', + sessionId: 'session-1', + name: 'linked.txt', + sizeBytes: 11, + }); + const sessionRoot = join(artifactRoot, record.sessionId); + await mkdir(sessionRoot, { recursive: true }); + const targetPath = join(sessionRoot, 'target.txt'); + const linkPath = join(artifactRoot, record.relativePath); + await writeFile(targetPath, 'keep target', 'utf8'); + if (!(await createSymlinkOrSkip(t, 'target.txt', linkPath, 'file'))) return; + await writeArtifactMetadata(root, [record]); + + const store = createArtifactStore(root); + await store.deleteUserArtifactInSession(record.sessionId, record.id); + + assert.equal(await readFile(targetPath, 'utf8'), 'keep target'); + await assert.rejects(() => stat(linkPath), { code: 'ENOENT' }); + assert.equal(await getArtifact(store, record.id), null); + }); + }); + + test('purge preserves a payload inode still referenced by a non-target canonical record', async () => { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const first = canonicalRecord({ + id: 'first', + sessionId: 'session-1', + name: 'first.txt', + sizeBytes: 12, + }); + const second = canonicalRecord({ + id: 'second', + sessionId: 'session-1', + name: 'second.txt', + sizeBytes: 12, + }); + const firstPath = join(artifactRoot, first.relativePath); + const secondPath = join(artifactRoot, second.relativePath); + await mkdir(dirname(firstPath), { recursive: true }); + await writeFile(firstPath, 'shared bytes', 'utf8'); + await link(firstPath, secondPath); + await writeArtifactMetadata(root, [first, second]); + const firstStat = await stat(firstPath); + const secondStat = await stat(secondPath); + assert.equal(firstStat.ino, secondStat.ino); + + const store = createArtifactStore(root); + await store.deleteUserArtifactInSession(first.sessionId, first.id); + + await assert.rejects(() => stat(firstPath), { code: 'ENOENT' }); + assert.equal(await readFile(secondPath, 'utf8'), 'shared bytes'); + assert.equal((await getArtifact(store, second.id))?.id, second.id); + assert.deepEqual(await readArtifactText(store, second.id), { + ok: true, + text: 'shared bytes', + }); + }); + }); + + test('purge rejects case aliases that resolve to another live record', async (t) => { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const lower = canonicalRecord({ + id: 'case', + sessionId: 'session-1', + name: 'file.txt', + sizeBytes: 12, + }); + const upper = canonicalRecord({ + id: 'CASE', + sessionId: 'session-1', + name: 'file.txt', + sizeBytes: 12, + }); + const lowerPath = join(artifactRoot, lower.relativePath); + const upperPath = join(artifactRoot, upper.relativePath); + await mkdir(dirname(lowerPath), { recursive: true }); + await writeFile(lowerPath, 'shared bytes', 'utf8'); + if (!(await stat(upperPath).catch(() => null))) { + t.skip('filesystem is case-sensitive'); + return; + } + await writeArtifactMetadata(root, [lower, upper]); + + const store = createArtifactStore(root); + await assert.rejects( + () => store.deleteUserArtifactInSession(lower.sessionId, lower.id), + /path is still referenced/, + ); + assert.equal(await readFile(upperPath, 'utf8'), 'shared bytes'); + assert.equal((await getArtifact(store, upper.id))?.id, upper.id); + }); + }); + + test('purge rejects case aliases of the same final symlink without unlinking it', async (t) => { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const lower = canonicalRecord({ + id: 'case', + sessionId: 'session-1', + name: 'file.txt', + sizeBytes: 12, + }); + const upper = canonicalRecord({ + id: 'CASE', + sessionId: 'session-1', + name: 'file.txt', + sizeBytes: 12, + }); + const lowerPath = join(artifactRoot, lower.relativePath); + const upperPath = join(artifactRoot, upper.relativePath); + const targetPath = join(dirname(lowerPath), 'target.txt'); + await mkdir(dirname(lowerPath), { recursive: true }); + await writeFile(targetPath, 'shared bytes', 'utf8'); + if (!(await createSymlinkOrSkip(t, 'target.txt', lowerPath, 'file'))) return; + const upperEntry = await lstat(upperPath).catch(() => null); + if (!upperEntry?.isSymbolicLink()) { + t.skip('filesystem is case-sensitive'); + return; + } + await writeArtifactMetadata(root, [lower, upper]); + + const store = createArtifactStore(root); + await assert.rejects( + () => store.deleteUserArtifactInSession(lower.sessionId, lower.id), + /path is still referenced/, + ); + + assert.deepEqual(await readArtifactText(store, upper.id), { ok: true, text: 'shared bytes' }); + assert.equal((await lstat(lowerPath)).isSymbolicLink(), true); + assert.equal((await lstat(upperPath)).isSymbolicLink(), true); + assert.equal(await readFile(targetPath, 'utf8'), 'shared bytes'); + }); + }); + + test('durable attachment reads report physically deleted bytes as missing', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + await store.create({ ...artifactInput('image', png, 1), name: 'image.png', kind: 'image' }); + await store.deleteUserArtifactInSession('session-1', 'image'); + + assert.deepEqual(await readArtifactBinary(store, 'image'), { + ok: false, + reason: 'not_found', + }); + assert.deepEqual( + await store.readDurableAttachmentBinary({ + artifactId: 'image', + sessionId: 'other-session', + }), + { ok: false, reason: 'not_found' }, + ); + assert.deepEqual( + await store.readDurableAttachmentBinary({ + artifactId: 'image', + sessionId: 'session-1', + }), + { ok: false, reason: 'not_found' }, + ); + }); + }); + + test('enforces preview limits and sniffed binary MIME types', async () => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + await store.create( + artifactInput('large', 'x'.repeat(ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES + 1), 1), + ); + await store.create({ + ...artifactInput('unknown', Uint8Array.from([0, 1, 2, 3]), 2), + name: 'unknown.bin', + }); + + assert.deepEqual(await readArtifactText(store, 'large'), { ok: false, reason: 'too_large' }); + assert.deepEqual(await readArtifactBinary(store, 'unknown'), { + ok: false, + reason: 'unsupported_mime', + }); + }); + }); + + test('persists canonical entity identities at the shared 128-character boundary', async () => { + await withWorkspace(async (root) => { + const boundaryId = 'a'.repeat(ARTIFACT_ENTITY_ID_MAX_CHARS); + const store = createArtifactStore(root); + const record = await store.create({ + id: boundaryId, + sessionId: boundaryId, + turnId: boundaryId, + name: 'boundary.txt', + kind: 'file', + content: 'boundary', + source: 'tool_result', + now: 1, + }); + + assert.equal(record.id.length, ARTIFACT_ENTITY_ID_MAX_CHARS); + assert.deepEqual( + await getArtifact(createArtifactStore(root), boundaryId, boundaryId), + record, + ); + assert.deepEqual(await listArtifacts(createArtifactStore(root), boundaryId), [record]); + }); + }); + + test('create rejects invalid path identities and bounded turn keys', async () => { + const invalidInputs = [ + { + expected: /Artifact id must be a canonical entity ID/, + input: { ...artifactInput('a'.repeat(ARTIFACT_ENTITY_ID_MAX_CHARS + 1), 'no', 1) }, + }, + { + expected: /Artifact id must be a canonical entity ID/, + input: { ...artifactInput('.', 'no', 1) }, + }, + { + expected: /Artifact sessionId must be a canonical entity ID/, + input: { ...artifactInput('valid', 'no', 1), sessionId: 'session/bad' }, + }, + { + expected: /Artifact turnId must be a bounded opaque turn key/, + input: { ...artifactInput('valid', 'no', 1), turnId: '' }, + }, + { + expected: /Artifact turnId must be a bounded opaque turn key/, + input: { ...artifactInput('valid', 'no', 1), turnId: 'turn\nid' }, + }, + { + expected: /Artifact turnId must be a bounded opaque turn key/, + input: { + ...artifactInput('valid', 'no', 1), + turnId: 'x'.repeat(ARTIFACT_TURN_KEY_MAX_CHARS + 1), + }, + }, + ] as const; + + for (const { expected, input } of invalidInputs) { + await withWorkspace(async (root) => { + await assert.rejects(() => createArtifactStore(root).create(input), expected); + await assert.rejects(() => stat(join(root, 'artifacts')), { code: 'ENOENT' }); + }); + } + }); + + test('keeps relative path resolution inside the artifact root', async () => { + assert.equal(isSafeRelativeArtifactPath('session-1/artifact.txt'), true); + for (const value of ['', '/tmp/file', '../file', 'session/../file', 'file:///tmp/a']) { + assert.equal(isSafeRelativeArtifactPath(value), false, value); + } + assert.equal(sanitizeArtifactName(' ../unsafe:name?.txt '), 'unsafe-name-.txt'); + + await withWorkspace(async (root) => { + assert.deepEqual( + await resolveArtifactPath({ + artifactRoot: join(root, 'artifacts'), + relativePath: '../outside', + }), + { ok: false, reason: 'not_allowed' }, + ); + }); + }); + + test('resolve and read reject a canonical payload that escapes through a symlink', async (t) => { + const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-read-outside-')); + try { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + const record = canonicalRecord({ + id: 'escaped', + sessionId: 'session-1', + name: 'secret.txt', + sizeBytes: 6, + }); + await mkdir(join(artifactRoot, record.sessionId), { recursive: true }); + const outsidePath = join(outsideRoot, 'secret.txt'); + await writeFile(outsidePath, 'secret', 'utf8'); + if ( + !(await createSymlinkOrSkip( + t, + outsidePath, + join(artifactRoot, record.relativePath), + 'file', + )) + ) { + return; + } + await writeArtifactMetadata(root, [record]); + + assert.deepEqual( + await resolveArtifactPath({ + artifactRoot, + relativePath: record.relativePath, + }), + { ok: false, reason: 'not_allowed' }, + ); + assert.deepEqual(await readArtifactText(createArtifactStore(root), record.id), { + ok: false, + reason: 'not_allowed', + }); + }); + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + }); + + test('create path and identity failures leave no payload or metadata', async (t) => { + await withWorkspace(async (root) => { + const store = createArtifactStore(root); + await assert.rejects( + () => store.create({ ...artifactInput('bad/id', 'no', 1) }), + /Artifact id must be a canonical entity ID/, + ); + await assert.rejects( + () => + store.create({ + ...artifactInput('invalid-role', 'no', 1), + deepResearchRole: 'invalid' as never, + }), + /Invalid Artifact deep-research role/, + ); + await assert.rejects(() => stat(join(root, 'artifacts')), { code: 'ENOENT' }); + }); + + const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-create-outside-')); + try { + await withWorkspace(async (root) => { + const artifactRoot = join(root, 'artifacts'); + await mkdir(artifactRoot, { recursive: true }); + if (!(await createSymlinkOrSkip(t, outsideRoot, join(artifactRoot, 'session-1'), 'dir'))) { + return; + } + + await assert.rejects( + () => createArtifactStore(root).create(artifactInput('escaped', 'must not write', 1)), + /target directory resolves outside the artifact root/, + ); + assert.deepEqual(await readdir(outsideRoot), []); + await assert.rejects(() => stat(join(artifactRoot, 'metadata.jsonl')), { + code: 'ENOENT', + }); + assert.deepEqual((await readdir(artifactRoot)).sort(), ['session-1']); + }); + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + }); +}); + +function artifactInput(id: string, content: string | Uint8Array, now: number) { + return { + id, + sessionId: 'session-1', + turnId: 'turn-1', + name: `${id}.txt`, + kind: 'file' as const, + content, + source: 'tool_result' as const, + now, + }; +} + +function deepResearchArtifactInput(id: string, content: string) { + return { + id, + sessionId: 'session-1', + turnId: 'turn-1', + name: 'research.md', + kind: 'file' as const, + content, + mimeType: 'text/markdown', + source: 'deep_research' as const, + summary: 'Stable research artifact', + deepResearchRole: 'source' as const, + }; +} + +function canonicalRecord(input: { + id: string; + sessionId: string; + name: string; + sizeBytes: number; +}): ArtifactRecord { + return { + id: input.id, + sessionId: input.sessionId, + turnId: 'turn-1', + createdAt: 1, + name: input.name, + kind: 'file', + relativePath: `${input.sessionId}/${input.id}-${input.name}`, + sizeBytes: input.sizeBytes, + source: 'tool_result', + }; +} + +function recordWithIdentity(field: 'id' | 'sessionId' | 'turnId', value: string): ArtifactRecord { + const record = canonicalRecord({ + id: 'artifact-1', + sessionId: 'session-1', + name: 'artifact.txt', + sizeBytes: 0, + }); + const mutated = { ...record, [field]: value }; + return { + ...mutated, + relativePath: `${mutated.sessionId}/${mutated.id}-${mutated.name}`, + }; +} + +async function writeArtifactMetadata( + root: string, + records: readonly ArtifactRecord[], +): Promise { + const repository = createSqliteArtifactMetadataRepository(root); + try { + repository.applyChanges({ upserts: records }); + } finally { + repository.close(); + } + return join(root, 'runtime.sqlite'); +} + +async function createSymlinkOrSkip( + t: TestContext, + target: string, + path: string, + type: 'file' | 'dir', +): Promise { + try { + await symlink(target, path, type); + return true; + } catch (error) { + const code = (error as { code?: unknown }).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + t.skip('Windows symlink creation requires elevated privileges or Developer Mode'); + return false; + } + throw error; + } +} + +async function createPublicationResidue( + root: string, + id: string, + name: string, + content: string, + sessionId = 'session-1', +): Promise<{ stagingPath: string; targetPath: string }> { + const sessionDirectory = join(root, 'artifacts', sessionId); + await mkdir(sessionDirectory, { recursive: true }); + const targetPath = join(sessionDirectory, `${id}-${name}`); + const stagingPath = publicationStagingPath(targetPath); + await writeFile(stagingPath, content, { flag: 'wx' }); + await link(stagingPath, targetPath); + return { stagingPath, targetPath }; +} + +async function holdArtifactWriterLock( + root: string, +): Promise<{ release: () => void; finished: Promise }> { + let release!: () => void; + let acquired!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + acquired = resolve; + }); + const finished = withArtifactWriterLock(root, async () => { + acquired(); + await gate; + }); + await entered; + return { release, finished }; +} + +function publicationStagingPath(targetPath: string): string { + const hash = createHash('sha256').update(basename(targetPath)).digest('hex'); + return join( + dirname(targetPath), + `.artifact-publish.${hash}.00000000-0000-4000-8000-000000000000.tmp`, + ); +} + +async function withWorkspace(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-store-')); + try { + await run(root); + } finally { + closeArtifactStores(root); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7af208b4ba7a033bc4fa94cae80cc2c6c75b7d7f1a11e353fd5f2a9d299a59e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7af208b4ba7a033bc4fa94cae80cc2c6c75b7d7f1a11e353fd5f2a9d299a59e.source new file mode 100644 index 0000000000..aa891235d5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b7af208b4ba7a033bc4fa94cae80cc2c6c75b7d7f1a11e353fd5f2a9d299a59e.source @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { + createStorageRootLeaseIdentityGuard, + runWithStorageRootLease, + type StorageRootKind, + type StorageRootLease, +} from './root-authority.js'; +import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js'; + +export const STATE_ROOT_COMPOSITION_FILE = '.maka-host-composition.json'; +export const STATE_ROOT_COMPOSITION_SCHEMA_VERSION = 1 as const; +const MAX_STATE_ROOT_COMPOSITION_BYTES = 1_024; +const COMPOSITION_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; + +interface StateRootCompositionRecord { + readonly schemaVersion: typeof STATE_ROOT_COMPOSITION_SCHEMA_VERSION; + readonly compositionId: string; +} + +export interface StateRootCompositionBinding { + readonly compositionId: string; +} + +export class StateRootCompositionError extends Error { + constructor( + readonly code: 'invalid_composition' | 'composition_mismatch' | 'composition_io_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'StateRootCompositionError'; + } +} + +export async function bindStateRootComposition( + lease: StorageRootLease, + compositionId: string, +): Promise { + requireCompositionId(compositionId); + const assertCurrentRoot = createStorageRootLeaseIdentityGuard(lease, lease.kind, 'write'); + await runWithStorageRootLease(lease, lease.kind, 'write', async (root) => { + const existing = await readStateRootCompositionIfPresent(root); + if (existing) { + assertMatchingComposition(existing, compositionId); + return; + } + const record: StateRootCompositionRecord = { + schemaVersion: STATE_ROOT_COMPOSITION_SCHEMA_VERSION, + compositionId, + }; + const publication = await withCompositionIoFailure(() => + publishMarkerFile({ + root, + markerFile: STATE_ROOT_COMPOSITION_FILE, + contents: `${JSON.stringify(record)}\n`, + maxBytes: MAX_STATE_ROOT_COMPOSITION_BYTES, + publication: 'create', + beforePublish: assertCurrentRoot, + invalidFile, + }), + ); + if (publication === 'published') return; + const winner = await readStateRootComposition(root); + assertMatchingComposition(winner, compositionId); + }); +} + +export async function readStateRootCompositionBinding( + root: string, +): Promise { + const record = await readStateRootCompositionIfPresent(root); + return record ? Object.freeze({ compositionId: record.compositionId }) : undefined; +} + +async function readStateRootCompositionIfPresent( + root: string, +): Promise { + try { + return await readStateRootComposition(root); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function readStateRootComposition(root: string): Promise { + return withCompositionIoFailure(async () => { + const contents = await readBoundedMarkerFile({ + path: join(root, STATE_ROOT_COMPOSITION_FILE), + maxBytes: MAX_STATE_ROOT_COMPOSITION_BYTES, + invalidFile, + }); + let value: unknown; + try { + value = JSON.parse(contents) as unknown; + } catch (error) { + throw invalidFile(error); + } + if (!isExactCompositionRecord(value)) throw invalidFile(); + requireCompositionId(value.compositionId); + return value; + }); +} + +function assertMatchingComposition( + record: StateRootCompositionRecord, + compositionId: string, +): void { + if (record.compositionId === compositionId) return; + throw new StateRootCompositionError( + 'composition_mismatch', + `State Root requires Runtime Host composition ${record.compositionId}`, + ); +} + +function requireCompositionId(value: string): void { + if (!COMPOSITION_ID_PATTERN.test(value) || value.length > 128) { + throw new StateRootCompositionError( + 'invalid_composition', + 'Runtime Host composition id is invalid', + ); + } +} + +function isExactCompositionRecord(value: unknown): value is StateRootCompositionRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const keys = Object.keys(record); + return ( + keys.length === 2 && + Object.hasOwn(record, 'schemaVersion') && + Object.hasOwn(record, 'compositionId') && + record.schemaVersion === STATE_ROOT_COMPOSITION_SCHEMA_VERSION && + typeof record.compositionId === 'string' + ); +} + +function invalidFile(cause?: unknown): StateRootCompositionError { + return new StateRootCompositionError( + 'invalid_composition', + 'State Root composition record is invalid', + cause === undefined ? undefined : { cause }, + ); +} + +async function withCompositionIoFailure(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof StateRootCompositionError || isNodeError(error, 'ENOENT')) throw error; + throw new StateRootCompositionError( + 'composition_io_failed', + 'Unable to access the State Root composition record', + { cause: error }, + ); + } +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9225538e7a736ee5d7415380154496ee8741a528e052dbe307d4cf6d03c4e8b.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9225538e7a736ee5d7415380154496ee8741a528e052dbe307d4cf6d03c4e8b.source new file mode 100644 index 0000000000..73de404977 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9225538e7a736ee5d7415380154496ee8741a528e052dbe307d4cf6d03c4e8b.source @@ -0,0 +1,744 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Claude Code transcripts as Maka Sessions. +// +// Transcripts live at `~/.claude/projects//.jsonl`, one +// JSON object per line, discriminated by `type`. The parsing primitives are +// shared with the CLI's foreign-session handoff (`@maka/core/foreign-session`) +// rather than reimplemented: a scanner and an importer that disagreed about +// "what did the user actually say" would be a real defect, not a cosmetic one. +// +// The directory name cannot answer which session belongs to which project — +// it encodes the cwd by replacing separators, so `-Users-a-b` is ambiguous +// between `/Users/a/b` and `/Users/a-b`. Every record carries its own `cwd`, +// and that is what a project-scoped query reads. +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + claudeAssistantText, + claudeUserAuthoredText, + isSyntheticClaudeUserText, + pickClaudeTitle, + sanitizeForeignTitle, +} from '@maka/core/foreign-session'; +import { externalSessionMatchesQuery } from '@maka/core/external-session'; +import type { + ExternalMakaSession, + ExternalSessionAdapter, + ExternalSessionQuery, + ExternalSessionSummary, +} from '@maka/core/external-session'; +import type { StoredMessage } from '@maka/core/session'; +import { + resolveTranscriptLineage, + type TranscriptRecord, +} from './claude-code-transcript-lineage.js'; + +export const CLAUDE_CODE_SESSION_ADAPTER_ID = 'claude-code'; + +/** A transcript larger than this is not read. Bounded for the same reason the + * Codex rollout cap exists: a single hostile or runaway file must not be able + * to exhaust the Host's memory during an import the user asked for. */ +export const CLAUDE_TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024; + +/** Session ids are the transcript's filename stem, and reach the filesystem. + * A uuid is what Claude Code writes; anything else is refused rather than + * joined onto a path. */ +const SESSION_ID_PATTERN = /^[0-9a-fA-F-]{1,128}$/u; + +export interface ClaudeCodeSessionAdapterOptions { + /** Overrides `~/.claude`. */ + claudeHome?: string; + maxTranscriptBytes?: number; +} + +interface ParsedTranscript { + readonly records: readonly TranscriptRecord[]; + readonly cwd: string; + readonly title: string; + readonly createdAt?: number; + readonly updatedAt?: number; + readonly isSidechain: boolean; +} + +export class ClaudeCodeSessionAdapter implements ExternalSessionAdapter { + readonly id = CLAUDE_CODE_SESSION_ADAPTER_ID; + readonly #home: string; + readonly #maxBytes: number; + /** + * Summaries already derived from a transcript, keyed by path and invalidated + * by the file's own mtime and size. + * + * Listing reads and parses every transcript: 1128 of them take about a + * second here, and the catalog is listed once per search term. Without this, + * a pause in typing starts another full parse of files that have not changed + * since the last one — the cost is paid again for an answer already known. + * + * Keyed on what the filesystem reports rather than a timer: a transcript + * that Claude Code appended to must be re-read, and one that did not change + * cannot have a different summary. + */ + readonly #summaries = new Map< + string, + { mtimeMs: number; size: number; summary?: ExternalSessionSummary } + >(); + + constructor(options: ClaudeCodeSessionAdapterOptions = {}) { + this.#home = options.claudeHome ?? join(homedir(), '.claude'); + this.#maxBytes = options.maxTranscriptBytes ?? CLAUDE_TRANSCRIPT_MAX_BYTES; + } + + async detect(): Promise { + return existsSync(this.#projectsRoot()); + } + + async listSessions(query?: ExternalSessionQuery): Promise { + const summaries: ExternalSessionSummary[] = []; + const live = new Set(); + for (const file of await this.#transcriptFiles()) { + live.add(file.path); + const summary = await this.#summaryOf(file.path, file.sessionId); + if (!summary) continue; + // The shared matcher, not a local cwd comparison: filtering happens here + // rather than after paging, and every source has to answer a query the + // same way or the catalog lies about which one dropped the term. + if (!externalSessionMatchesQuery(summary, query)) continue; + summaries.push(summary); + } + // A transcript the source no longer lists must not keep its entry alive, + // or a long-lived Host grows one per deleted session. + for (const path of this.#summaries.keys()) { + if (!live.has(path)) this.#summaries.delete(path); + } + summaries.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); + return summaries; + } + + /** + * The summary for one transcript, parsed only when the file has changed. + * + * `undefined` is cached too: a sidechain transcript or an unreadable one is + * a stable answer, and re-deriving it every list would defeat the point. + */ + async #summaryOf(path: string, sessionId: string): Promise { + let mtimeMs: number; + let size: number; + try { + const info = await stat(path); + mtimeMs = info.mtimeMs; + size = info.size; + } catch { + this.#summaries.delete(path); + return undefined; + } + const cached = this.#summaries.get(path); + if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.summary; + + const parsed = await this.#parse(path, sessionId); + // Sub-agent transcripts are whole files, never records interleaved into a + // parent — so exclusion is per file. Importing one would present a + // fragment of a conversation as a conversation. + const summary = + parsed && !parsed.isSidechain + ? { + id: sessionId, + name: parsed.title || sessionId, + cwd: parsed.cwd, + ...(parsed.createdAt !== undefined ? { createdAt: parsed.createdAt } : {}), + ...(parsed.updatedAt !== undefined ? { updatedAt: parsed.updatedAt } : {}), + } + : undefined; + this.#summaries.set(path, { mtimeMs, size, ...(summary ? { summary } : {}) }); + return summary; + } + + async readSession(sessionId: string): Promise { + assertSafeSessionId(sessionId); + const file = (await this.#transcriptFiles()).find( + (candidate) => candidate.sessionId === sessionId, + ); + if (!file) throw new Error(`Claude Code transcript not found: ${sessionId}`); + const parsed = await this.#parse(file.path, sessionId); + if (!parsed) throw new Error(`Claude Code transcript could not be read: ${sessionId}`); + if (parsed.isSidechain) { + throw new Error(`Claude Code transcript is a sub-agent sidechain: ${sessionId}`); + } + return { + sourceSessionId: sessionId, + metadata: { name: parsed.title || sessionId, cwd: parsed.cwd }, + messages: convertTranscript(sessionId, parsed.records), + }; + } + + #projectsRoot(): string { + return join(this.#home, 'projects'); + } + + async #transcriptFiles(): Promise> { + const root = this.#projectsRoot(); + let projects: string[]; + try { + projects = (await readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return []; + } + // Keyed by session id: the same id can legitimately exist under more than + // one project directory after a workspace move or a resumed session. Two + // files with one id are two candidates for the same source session, and + // list and read must pick the same one or a user selects one summary and + // imports the other. + const bySessionId = new Map(); + for (const project of projects) { + let entries: string[]; + try { + entries = (await readdir(join(root, project), { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => entry.name); + } catch { + continue; + } + for (const name of entries) { + const sessionId = name.slice(0, -'.jsonl'.length); + if (!SESSION_ID_PATTERN.test(sessionId)) continue; + const path = join(root, project, name); + // The id reaches a path join, so the resolved file must still be under + // the projects root — a crafted id must not read outside it. + if (!resolve(path).startsWith(resolve(root))) continue; + let mtimeMs: number; + try { + mtimeMs = (await stat(path)).mtimeMs; + } catch { + continue; + } + const existing = bySessionId.get(sessionId); + // Newest wins, and the path breaks a tie so the choice does not depend + // on directory iteration order. A resumed session's continuation is + // the copy a user means when they pick that id. + if ( + !existing || + mtimeMs > existing.mtimeMs || + (mtimeMs === existing.mtimeMs && path < existing.path) + ) { + bySessionId.set(sessionId, { path, sessionId, mtimeMs }); + } + } + } + return [...bySessionId.values()].map(({ path, sessionId }) => ({ path, sessionId })); + } + + async #parse(path: string, sessionId: string): Promise { + try { + const info = await stat(path); + if (info.size > this.#maxBytes) return undefined; + } catch { + return undefined; + } + + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch { + return undefined; + } + + const records: TranscriptRecord[] = []; + let cwd = ''; + let isSidechain = false; + let createdAt: number | undefined; + let updatedAt: number | undefined; + const titles: { + customTitle?: string; + aiTitle?: string; + summary?: string; + lastPrompt?: string; + firstUserMessage?: string; + } = {}; + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + let record: unknown; + try { + record = JSON.parse(trimmed); + } catch { + // A torn final line is what an interrupted write leaves behind, and a + // corrupt interior line is not worth failing an otherwise readable + // transcript over. Skipping is what the scanner already does. + continue; + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) continue; + const typed = record as TranscriptRecord; + records.push(typed); + + if (typed.isSidechain === true) isSidechain = true; + if (typeof typed.cwd === 'string' && typed.cwd && !cwd) cwd = typed.cwd; + const ts = timestampMs(typed); + if (ts !== undefined) { + createdAt ??= ts; + updatedAt = ts; + } + collectTitle(typed, titles); + if (titles.firstUserMessage === undefined && typed.type === 'user') { + const text = claudeUserAuthoredText(typed); + if (text) titles.firstUserMessage = text; + } + } + + if (records.length === 0) return undefined; + return { + records, + cwd, + title: pickClaudeTitle(titles), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + isSidechain, + }; + } +} + +function assertSafeSessionId(sessionId: string): void { + if (!SESSION_ID_PATTERN.test(sessionId)) { + throw new Error('Claude Code session id is not a transcript name'); + } +} + +function collectTitle( + record: TranscriptRecord, + titles: { customTitle?: string; aiTitle?: string; summary?: string; lastPrompt?: string }, +): void { + const take = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? sanitizeForeignTitle(value) : undefined; + switch (record.type) { + case 'ai-title': + titles.aiTitle = take(record.aiTitle ?? record.title) ?? titles.aiTitle; + return; + case 'last-prompt': + titles.lastPrompt = take(record.lastPrompt ?? record.prompt) ?? titles.lastPrompt; + return; + case 'summary': + titles.summary = take(record.summary) ?? titles.summary; + return; + default: + return; + } +} + +function timestampMs(record: TranscriptRecord): number | undefined { + const value = record.timestamp; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +/* ------------------------------------------------------------------ * + * Transcript -> StoredMessage[] + * ------------------------------------------------------------------ */ + +/** `stop_reason` values that mean the model finished what it was saying. This + * is the recorded evidence a terminal `turn_state` needs: the Ledger refuses a + * reconstructed terminal that no record corroborates + * (`runtime-ledger-repair.ts`), and rightly so — a transcript killed + * mid-answer must not import as one that completed. + * + * `max_tokens` is deliberately absent. It does report that generation stopped, + * but it stopped because the answer hit the output limit — the turn was cut + * off mid-sentence, which is the opposite of completed. It is rare (1 + * occurrence across 1130 local transcripts) and imports with no terminal + * state, the same as any other turn whose end nothing vouches for. */ +const TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence']); + +/** Names the cutoff for a turn the transcript simply stops inside, so a reader + * can tell an imported snapshot's edge from a user's Stop or a provider abort. */ +const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot'; + +interface TurnAccumulator { + turnId: string; + lastTs: number; + /** Set when a terminal `stop_reason` is seen; the turn ends `completed`. */ + terminalStop?: string; + /** Set by `isApiErrorMessage`; the turn ends `failed`. */ + failed?: boolean; + /** Set by an interrupt notice; the turn ends `aborted`. */ + aborted?: boolean; +} + +/** + * Which records are the conversation, decided before any of them becomes a + * message. See `claude-code-transcript-lineage` for what the transcript's own + * fields say about rewind branches, compaction boundaries, and fragmented + * responses; this file only converts what that returns. + */ +export function convertTranscript( + sessionId: string, + rawRecords: readonly TranscriptRecord[], +): readonly StoredMessage[] { + const records = resolveTranscriptLineage(rawRecords).records; + // Every fragment of one assistant response, keyed by `message.id`. A + // response is emitted once, from all of its fragments, at the position of + // the first — so a later fragment's text is part of the reply rather than + // something the first fragment's absence of text can suppress. + const responseFragments = new Map(); + for (const record of records) { + if (record.type !== 'assistant') continue; + const responseId = stringOf(asMessageRecord(record)?.id); + if (responseId === undefined) continue; + const existing = responseFragments.get(responseId); + if (existing) existing.push(record); + else responseFragments.set(responseId, [record]); + } + const emittedResponses = new Set(); + const messages: StoredMessage[] = []; + // A boundary can precede the first turn — a transcript that opens straight + // after a compaction. A system note needs a turn to hang from, so the fact + // waits for one rather than being dropped for arriving early. + let pendingCompactBoundaryTs: number | undefined; + let turn: TurnAccumulator | undefined; + let sequence = 0; + const id = (kind: string): string => `claude-code:${sessionId}:${kind}:${sequence++}`; + // Turn ids count separately from message ids. Sharing one counter made turn + // ids skip (`turn:0`, `turn:3`) for no reason, and left them one edit away + // from colliding with a message id if the emission order ever changed. + let turnSequence = 0; + const nextTurnId = (): string => `claude-code:${sessionId}:turn:${turnSequence++}`; + + const closeTurn = (): void => { + if (!turn) return; + // Every turn gets a terminal state, and which one depends on what the + // transcript actually says. + // + // Leaving one out is not the same as preserving "unfinished". Without a + // `turn_state`, `deriveTurnRecords` falls back to `inferLegacyTurnStatus`, + // which answers `completed` for any turn holding an assistant message + // (`session.ts:1250`) and marks it `inferred`. The Ledger then refuses + // that uncorroborated terminal and the repair path persists + // `failed / missing_terminal_event` — an internal-corruption verdict on a + // transcript that was merely cut short. Measured: 13.9% of turns across + // 1130 local transcripts end with no assistant reply or at a `tool_use` + // whose result never arrived. + // + // So an unfinished turn is recorded as what it is: a snapshot that ended + // mid-turn, with an `abortSource` naming the import rather than a user or + // a provider. `end_turn`, interrupt notices and API errors keep their own + // evidence and are unaffected. + if (turn.aborted) { + messages.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: 'claude-code.interrupt', + }); + } else if (turn.failed) { + messages.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'failed', + errorClass: 'claude_code_api_error', + }); + } else if (turn.terminalStop) { + messages.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'completed', + }); + } else { + messages.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE, + }); + } + turn = undefined; + }; + + for (const record of records) { + const ts = timestampMs(record) ?? turn?.lastTs ?? 0; + if (turn) turn.lastTs = ts; + const type = record.type; + + if (type === 'user') { + const message = asMessageRecord(record); + const toolResults = toolResultBlocks(message); + if (toolResults.length > 0) { + // Tool results arrive as `user` records — the harness replying to the + // model, not the human. Importing them as user Turns would put the + // model's own tool output in the user's mouth. + for (const block of toolResults) { + if (!turn) continue; + const toolUseId = stringOf(block.tool_use_id); + // A result with no `tool_use_id` cannot be matched to its call. + // Minting one produces a result that is guaranteed not to pair with + // anything — a detached row in the transcript view, which is worse + // than the row being absent. + if (!toolUseId) continue; + messages.push({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId, + isError: block.is_error === true, + content: { kind: 'text', text: toolResultText(block.content) }, + }); + } + continue; + } + + const text = claudeUserAuthoredText(record); + if (text === undefined) { + // Synthetic user text: interrupt notices and command wrappers. The + // interrupt notice is one of the few terminal facts a transcript + // carries, so it is read for status even though it is not a message. + const raw = rawUserText(message); + if ( + raw && + isSyntheticClaudeUserText(raw) && + raw.trimStart().startsWith('[Request interrupted') + ) { + if (turn) turn.aborted = true; + } + continue; + } + + // A human-authored user record opens a new turn. + closeTurn(); + turn = { turnId: nextTurnId(), lastTs: ts }; + if (pendingCompactBoundaryTs !== undefined) { + messages.push({ + type: 'system_note', + id: id('compact'), + turnId: turn.turnId, + ts: pendingCompactBoundaryTs, + kind: 'context_compacted', + }); + pendingCompactBoundaryTs = undefined; + } + messages.push({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text }); + continue; + } + + if (type === 'assistant') { + if (!turn) { + // A transcript can open with an assistant record when the session was + // resumed. Give it a turn rather than dropping the content. + turn = { turnId: nextTurnId(), lastTs: ts }; + if (pendingCompactBoundaryTs !== undefined) { + messages.push({ + type: 'system_note', + id: id('compact'), + turnId: turn.turnId, + ts: pendingCompactBoundaryTs, + kind: 'context_compacted', + }); + pendingCompactBoundaryTs = undefined; + } + } + if (record.isApiErrorMessage === true) turn.failed = true; + const message = asMessageRecord(record); + const responseId = stringOf(message?.id); + // A response is emitted once, at its first fragment, assembled from all + // of them. A later fragment reached here is that same response still + // being written — its content is already in what was emitted, and + // emitting again would repeat the reply. + if (responseId !== undefined) { + if (emittedResponses.has(responseId)) continue; + emittedResponses.add(responseId); + } + // A fragment with no id stands alone; it is the only fragment of itself. + const fragments = (responseId === undefined + ? undefined + : responseFragments.get(responseId)) ?? [record]; + + // Status evidence is read from every fragment, not just the first: the + // `stop_reason` lands on whichever fragment the response finished on. + for (const fragment of fragments) { + if (fragment.isApiErrorMessage === true) turn.failed = true; + const stop = stringOf(asMessageRecord(fragment)?.stop_reason); + if (stop && TERMINAL_STOP_REASONS.has(stop)) turn.terminalStop = stop; + } + + // The transcript names the model that produced each step. Carrying the + // real value keeps an imported turn attributable; a placeholder would + // put a model the user never ran onto their history. + const modelId = stringOf(message?.model) ?? 'claude-code'; + + // Concatenated in fragment order, which is the order the response was + // streamed. Joining rather than picking one: every delta is content the + // model produced, and choosing between them would be choosing which + // half of a reply to keep. + const thinking = fragments + .map((fragment) => thinkingText(asMessageRecord(fragment))) + .filter((part) => part.length > 0) + .join('\n\n'); + if (thinking) { + messages.push({ + type: 'assistant', + id: id('thinking'), + turnId: turn.turnId, + ts, + text: '', + thinking: { text: thinking }, + contentOrder: ['thinking'], + modelId, + }); + } + const text = fragments + .map((fragment) => claudeAssistantText(fragment)) + .filter((part): part is string => part !== undefined && part.length > 0) + .join('\n\n'); + if (text) { + messages.push({ + type: 'assistant', + id: id('assistant'), + turnId: turn.turnId, + ts, + text, + contentOrder: ['text'], + modelId, + }); + } + // Every call the response made, before any of their results. Calls + // sharing a `message.id` came from one API response, so they were + // issued together however the log interleaved them with the results + // arriving; a call written after its sibling's result did not follow it. + for (const fragment of fragments) { + for (const block of toolUseBlocks(asMessageRecord(fragment))) { + messages.push({ + type: 'tool_call', + // The id must equal the tool_use id so the result can match it. + id: stringOf(block.id) ?? id('tool-call'), + turnId: turn.turnId, + ts, + toolName: stringOf(block.name) ?? 'unknown', + args: block.input ?? {}, + }); + } + } + continue; + } + + // The compaction boundary, keyed on the record that states it. + // + // It used to be keyed on `isCompactSummary`, which belongs to the summary + // *user* record — and that record is consumed by the `user` branch above + // and never reaches here, so the note was never emitted. The import then + // carried the pre-boundary history flat with nothing saying a compaction + // had happened, while `claudeUserAuthoredText` dropped the summary itself + // for being `isCompactSummary`: both halves of the event lost at once. + // + // Pre-boundary records stay. They are the conversation that actually + // happened — 24,695 of them across the 5 compacted transcripts here — and + // the boundary marks where the model's context restarted, which is the + // part a reader cannot reconstruct from the messages themselves. + if (record.subtype === 'compact_boundary') { + if (!turn) { + pendingCompactBoundaryTs = ts; + continue; + } + messages.push({ + type: 'system_note', + id: id('compact'), + turnId: turn.turnId, + ts, + kind: 'context_compacted', + }); + } + } + + closeTurn(); + return messages; +} + +function asMessageRecord(record: TranscriptRecord): Record | undefined { + const message = record.message; + return typeof message === 'object' && message !== null && !Array.isArray(message) + ? (message as Record) + : undefined; +} + +function contentBlocks(message: Record | undefined): Record[] { + const content = message?.content; + if (!Array.isArray(content)) return []; + return content.filter( + (block): block is Record => + typeof block === 'object' && block !== null && !Array.isArray(block), + ); +} + +function toolUseBlocks(message: Record | undefined): Record[] { + return contentBlocks(message).filter((block) => block.type === 'tool_use'); +} + +function toolResultBlocks(message: Record | undefined): Record[] { + return contentBlocks(message).filter((block) => block.type === 'tool_result'); +} + +function thinkingText(message: Record | undefined): string { + return contentBlocks(message) + .filter((block) => block.type === 'thinking') + .map((block) => stringOf(block.thinking) ?? '') + .filter(Boolean) + .join('\n\n'); +} + +function rawUserText(message: Record | undefined): string | undefined { + const content = message?.content; + if (typeof content === 'string') return content; + const texts = contentBlocks(message) + .filter((block) => block.type === 'text') + .map((block) => stringOf(block.text) ?? ''); + return texts.join('\n').trim() || undefined; +} + +function toolResultText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((block) => + typeof block === 'object' && block !== null && !Array.isArray(block) + ? (stringOf((block as Record).text) ?? '') + : '', + ) + .filter(Boolean) + .join('\n'); + } + return ''; +} + +function stringOf(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b950bcebf7ddd53748505fdb4d9d6a857edf519f7d191e697eab95d79890dd87.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b950bcebf7ddd53748505fdb4d9d6a857edf519f7d191e697eab95d79890dd87.source new file mode 100644 index 0000000000..b2cec3194b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b950bcebf7ddd53748505fdb4d9d6a857edf519f7d191e697eab95d79890dd87.source @@ -0,0 +1,329 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { test } from 'node:test'; +import { + compareSessionBundleCanonicalPaths, + computeSessionBundleCanonicalTreeDigest, + encodeSessionBundleCanonicalTree, + SessionBundleCanonicalTreeDigestBuilder, + type SessionBundleCanonicalTreeEntry, +} from '../session-bundle-canonical-tree.js'; +import { SessionBundleFileError, type Sha256Digest } from '../session-bundle-contract.js'; + +const identityDigest: Sha256Digest = + 'sha256:0e9561cfb83d50990a103b3896fe249a11fe27fa28985448187f93ec12116d72'; +const executableDigest: Sha256Digest = + 'sha256:b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c'; +const emptyDigest = `sha256:${createHash('sha256').update('').digest('hex')}` as Sha256Digest; + +const canonicalEntries: readonly SessionBundleCanonicalTreeEntry[] = [ + { + kind: 'file', + path: 'state-identity.json', + mode: 0o644, + size: 19, + contentDigest: identityDigest, + }, + { kind: 'directory', path: 'state/' }, + { kind: 'directory', path: 'state/empty/' }, + { kind: 'directory', path: 'workspace/' }, + { + kind: 'file', + path: 'workspace/run.sh', + mode: 0o755, + size: 18, + contentDigest: executableDigest, + }, +]; + +const unsortedEntries: readonly SessionBundleCanonicalTreeEntry[] = [ + canonicalEntries[3], + canonicalEntries[0], + canonicalEntries[2], + canonicalEntries[4], + canonicalEntries[1], +]; + +const expectedTreeHex = [ + '4d414b415f53455353494f4e5f42554e444c455f5452454500', + '00000001', + '0000000000000005', + '02', + '00000013', + '73746174652d6964656e746974792e6a736f6e', + '01a4', + '0000000000000013', + '0e9561cfb83d50990a103b3896fe249a11fe27fa28985448187f93ec12116d72', + '01', + '00000006', + '73746174652f', + '01ed', + '0000000000000000', + '0000000000000000000000000000000000000000000000000000000000000000', + '01', + '0000000c', + '73746174652f656d7074792f', + '01ed', + '0000000000000000', + '0000000000000000000000000000000000000000000000000000000000000000', + '01', + '0000000a', + '776f726b73706163652f', + '01ed', + '0000000000000000', + '0000000000000000000000000000000000000000000000000000000000000000', + '02', + '00000010', + '776f726b73706163652f72756e2e7368', + '01ed', + '0000000000000012', + 'b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c', +].join(''); + +const expectedTreeDigest = + 'sha256:429f57cded2f9a22620adc7da0ad9d33eb1c690f8ded102042e7c6904c70d0df'; + +test('pins exact canonical tree-record bytes and SHA-256 digest', () => { + const encoded = encodeSessionBundleCanonicalTree(unsortedEntries); + assert.equal(Buffer.from(encoded).toString('hex'), expectedTreeHex); + assert.equal(encoded.byteLength, 335); + assert.equal(`sha256:${createHash('sha256').update(encoded).digest('hex')}`, expectedTreeDigest); + assert.deepEqual(computeSessionBundleCanonicalTreeDigest(unsortedEntries), { + treeDigest: expectedTreeDigest, + payloadBytes: 37, + entryCount: 5, + }); +}); + +test('incrementally hashes the same stream without retaining file bytes', () => { + const builder = new SessionBundleCanonicalTreeDigestBuilder(canonicalEntries.length); + for (const entry of canonicalEntries) builder.add(entry); + assert.deepEqual(builder.finish(), { + treeDigest: expectedTreeDigest, + payloadBytes: 37, + entryCount: 5, + }); + assert.deepEqual(builder.finish(), { + treeDigest: expectedTreeDigest, + payloadBytes: 37, + entryCount: 5, + }); + assertBundleError(() => builder.add(canonicalEntries[0]), 'integrity_mismatch'); + + const incomplete = new SessionBundleCanonicalTreeDigestBuilder(canonicalEntries.length + 1); + for (const entry of canonicalEntries) incomplete.add(entry); + assertBundleError(() => incomplete.finish(), 'integrity_mismatch'); +}); + +test('preserves raw UTF-8 path bytes without Unicode normalization', () => { + const decomposed = 'state/e\u0301.txt'; + const composed = 'state/é.txt'; + assert.ok(compareSessionBundleCanonicalPaths(decomposed, composed) < 0); + + const result = computeSessionBundleCanonicalTreeDigest([ + ...canonicalEntries.filter((entry) => entry.path !== 'state/empty/'), + { + kind: 'file', + path: composed, + mode: 0o644, + size: 0, + contentDigest: emptyDigest, + }, + { + kind: 'file', + path: decomposed, + mode: 0o644, + size: 0, + contentDigest: emptyDigest, + }, + ]); + assert.equal(result.entryCount, 6); + assert.notEqual(result.treeDigest, expectedTreeDigest); +}); + +test('makes empty directories and executable semantics digest-significant', () => { + const withoutEmptyDirectory = canonicalEntries.filter((entry) => entry.path !== 'state/empty/'); + const nonExecutable = canonicalEntries.map((entry) => + entry.kind === 'file' && entry.path === 'workspace/run.sh' + ? { ...entry, mode: 0o644 as const } + : entry, + ); + assert.notEqual( + computeSessionBundleCanonicalTreeDigest(withoutEmptyDirectory).treeDigest, + expectedTreeDigest, + ); + assert.notEqual( + computeSessionBundleCanonicalTreeDigest(nonExecutable).treeDigest, + expectedTreeDigest, + ); +}); + +test('rejects traversal, host path syntax, invalid Unicode, and paths outside V1 roots', () => { + for (const path of [ + '/state/file', + 'C:/state/file', + 'state\\file', + 'state/../secret', + 'state/./file', + 'state//file', + 'state/\ud800', + 'other/file', + ]) { + assertBundleError( + () => + computeSessionBundleCanonicalTreeDigest([ + ...canonicalEntries, + { + kind: 'file', + path, + mode: 0o644, + size: 0, + contentDigest: emptyDigest, + }, + ]), + 'unsafe_path', + ); + } +}); + +test('rejects duplicate, conflicting, orphaned, and incomplete payload trees', () => { + assertBundleError( + () => computeSessionBundleCanonicalTreeDigest([...canonicalEntries, canonicalEntries[1]]), + 'unsafe_path', + ); + assertBundleError( + () => + computeSessionBundleCanonicalTreeDigest([ + ...canonicalEntries, + { + kind: 'file', + path: 'state/empty', + mode: 0o644, + size: 0, + contentDigest: emptyDigest, + }, + ]), + 'unsafe_path', + ); + assertBundleError( + () => + computeSessionBundleCanonicalTreeDigest([ + ...canonicalEntries, + { + kind: 'file', + path: 'state/missing/file', + mode: 0o644, + size: 0, + contentDigest: emptyDigest, + }, + ]), + 'unsafe_path', + ); + assertBundleError( + () => + computeSessionBundleCanonicalTreeDigest( + canonicalEntries.filter((entry) => entry.path !== 'workspace/'), + ), + 'unsafe_path', + ); +}); + +test('rejects non-normalized file metadata and an out-of-order streaming source', () => { + for (const entry of [ + { + kind: 'file', + path: 'state/private', + mode: 0o600, + size: 0, + contentDigest: emptyDigest, + }, + { + kind: 'file', + path: 'state/bad-digest', + mode: 0o644, + size: 0, + contentDigest: `sha256:${'AB'.repeat(32)}`, + }, + { + kind: 'file', + path: 'state-identity.json', + mode: 0o755, + size: 19, + contentDigest: identityDigest, + }, + ]) { + assertBundleError( + () => + computeSessionBundleCanonicalTreeDigest([ + ...canonicalEntries.filter( + (candidate) => + candidate.path !== (entry.path === 'state-identity.json' ? entry.path : ''), + ), + entry as unknown as SessionBundleCanonicalTreeEntry, + ]), + 'unsupported_entry', + ); + } + + const builder = new SessionBundleCanonicalTreeDigestBuilder(canonicalEntries.length); + builder.add(canonicalEntries[1]); + assertBundleError(() => builder.add(canonicalEntries[0]), 'unsafe_path'); + assertBundleError(() => builder.finish(), 'integrity_mismatch'); +}); + +test('fails closed after aggregate payload size overflows', () => { + const builder = new SessionBundleCanonicalTreeDigestBuilder(4); + builder.add({ + kind: 'file', + path: 'state-identity.json', + mode: 0o644, + size: Number.MAX_SAFE_INTEGER, + contentDigest: identityDigest, + }); + builder.add({ kind: 'directory', path: 'state/' }); + + assertBundleError( + () => + builder.add({ + kind: 'file', + path: 'state/file', + mode: 0o644, + size: 1, + contentDigest: emptyDigest, + }), + 'unsupported_entry', + ); + + assertBundleError( + () => builder.add({ kind: 'directory', path: 'workspace/' }), + 'integrity_mismatch', + ); + assertBundleError(() => builder.finish(), 'integrity_mismatch'); +}); + +function assertBundleError(action: () => unknown, code: SessionBundleFileError['code']): void { + assert.throws(action, (error) => { + assert.ok(error instanceof SessionBundleFileError); + assert.equal(error.code, code); + return true; + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9644fc799b3504a8845835638e29700b08e0b1b83f6f0861ca70ec22a6f9379.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9644fc799b3504a8845835638e29700b08e0b1b83f6f0861ca70ec22a6f9379.source new file mode 100644 index 0000000000..a5920ee0b9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/b9644fc799b3504a8845835638e29700b08e0b1b83f6f0861ca70ec22a6f9379.source @@ -0,0 +1,655 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { DatabaseSync } from 'node:sqlite'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test, type TestContext } from 'node:test'; +import { + authenticateInteractiveArtifactStoreWriter, + openInteractiveArtifactStoreForWrite, + type InteractiveArtifactStoreWriter, +} from '../artifact-stores.js'; +import { ARTIFACT_WRITER_LOCK_FILE } from '../artifact-writer-lock.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + type StorageRootLease, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +describe('interactive artifact store authority', () => { + for (const unrelated of [0, 1_000, 12_000]) { + test(`upgrade cleanup addresses one page without decoding ${unrelated} unrelated records`, async (t) => { + await withInteractiveOwner(async (owner, root, track) => { + const store = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + db.prepare(`WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < ? + ) INSERT INTO artifact_records + SELECT 'other-' || n, 'other', 0, 'other/' || n, '{}' FROM numbers WHERE n <= ?`).run( + unrelated, + unrelated, + ); + db.exec(`WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 12000 + ) INSERT INTO artifact_upgrade_orphan_paths + SELECT printf('removed/%05d', n) FROM numbers`); + const queries: string[] = []; + const prepare = DatabaseSync.prototype.prepare; + const spy = t.mock.method( + DatabaseSync.prototype, + 'prepare', + function (this: DatabaseSync, sql: string) { + queries.push(sql); + assert.doesNotMatch(sql, /SELECT record_json\s+FROM artifact_records/); + return prepare.call(this, sql); + }, + ); + const result = await store.reclaimUpgradeResidue({ maxPaths: 3 }); + spy.mock.restore(); + assert.deepEqual(result, { + nextAfter: 'removed/00003', + processedPaths: 3, + failedPaths: 0, + }); + assert.equal( + queries.filter((sql) => sql.includes('SELECT 1 FROM artifact_records')).length, + 3, + ); + assert.equal( + db.prepare('SELECT count(*) AS n FROM artifact_upgrade_orphan_paths').get()?.n, + 11997, + ); + const plan = db + .prepare(`EXPLAIN QUERY PLAN SELECT relative_path FROM artifact_upgrade_orphan_paths + WHERE relative_path > ? ORDER BY relative_path LIMIT ?`) + .all('', 4); + assert.match(JSON.stringify(plan), /SEARCH.*INDEX/); + const claimedPlan = db + .prepare('EXPLAIN QUERY PLAN SELECT 1 FROM artifact_records WHERE relative_path = ?') + .all('other/1'); + assert.match(JSON.stringify(claimedPlan), /artifact_records_relative_path/); + } finally { + db.close(); + } + }); + }); + } + + test('reads retained v1 payloads after upgrade without reviving retired rows', async () => { + await withInteractiveOwner(async (owner, root, track) => { + const initial = await openInteractiveArtifactStoreForWrite(owner.lease); + initial.close(); + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + db.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0), + status TEXT NOT NULL CHECK(status IN ('live', 'deleted')), + relative_path TEXT NOT NULL, record_json TEXT NOT NULL + ); + CREATE INDEX artifact_records_session_order ON artifact_records(session_id, created_at, storage_key); + CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path); + UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact'; + `); + const retained = [ + 'tool_result', + 'tool_result_projection', + 'tool_result_archive', + 'subagent_writeback', + 'deep_research', + 'user_upload', + 'session_effect', + ]; + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1sAAAAASUVORK5CYII=', + 'base64', + ); + await mkdir(join(root, 'artifacts', 'session-1'), { recursive: true }); + for (const source of [...retained, 'fixture', 'deleted', 'malformed']) { + const path = `session-1/${source}-result.txt`; + const content = + source === 'tool_result_projection' || source === 'user_upload' + ? image + : `original ${source}`; + const record = { + id: source, + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 1, + name: 'result.txt', + kind: 'file', + sizeBytes: Buffer.byteLength(content), + relativePath: path, + source: source === 'deleted' ? 'tool_result' : source, + status: source === 'deleted' ? 'deleted' : 'live', + }; + await writeFile(join(root, 'artifacts', path), content); + db.prepare('INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?, ?, ?)').run( + source, + source, + 'session-1', + 1, + record.status, + path, + source === 'malformed' ? '{' : JSON.stringify(record), + ); + } + db.close(); + for (let reopen = 0; reopen < 2; reopen += 1) { + const store = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + assert.deepEqual( + (await store.listTurnArtifacts('session-1', 'turn-1')).map((r) => r.id).sort(), + [...retained].sort(), + ); + for (const source of retained) { + if (source === 'tool_result_projection' || source === 'user_upload') { + assert.deepEqual( + await store.readDurableAttachmentBinary({ + sessionId: 'session-1', + artifactId: source, + }), + { ok: true, base64: image.toString('base64'), mimeType: 'image/png' }, + ); + continue; + } + const result = await store.readTextInSession('session-1', source); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.text, `original ${source}`); + } + for (const id of ['fixture', 'deleted', 'malformed']) { + assert.equal((await store.getInSession('session-1', id)).record, null); + } + store.close(); + } + }); + }); + + test('reclaims the bytes the v1 upgrade orphaned, including a user-deleted upload', async () => { + await withInteractiveOwner(async (owner, root, track) => { + const initial = await openInteractiveArtifactStoreForWrite(owner.lease); + initial.close(); + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + db.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0), + status TEXT NOT NULL CHECK(status IN ('live', 'deleted')), + relative_path TEXT NOT NULL, record_json TEXT NOT NULL + ); + CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path); + UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact'; + `); + // One row per reason the upgrade drops one, each with bytes on disk. + const rows = [ + { id: 'live', name: 'quarterly numbers.csv', status: 'live', source: 'user_upload' }, + { id: 'erased', name: 'passport scan.pdf', status: 'deleted', source: 'user_upload' }, + { + id: 'retired', + name: 'provider-request-step-4-cap.json', + status: 'live', + source: 'provider_request_capture', + }, + { id: 'sourceless', name: 'recap-request.json', status: 'live', source: undefined }, + { id: 'broken', name: 'unreadable.txt', status: 'live', source: 'tool_result' }, + { id: 'mismatched', name: 'inconsistent.txt', status: 'live', source: 'tool_result' }, + // Sorts first, and a directory cannot be unlinked, so it stands in for + // any leftover the store cannot remove. + { id: 'aborted', name: 'stuck', status: 'live', source: 'provider_request_capture' }, + ]; + await mkdir(join(root, 'artifacts', 'session-1'), { recursive: true }); + for (const row of rows) { + const relativePath = `session-1/${row.id}-${row.name}`; + if (row.id === 'aborted') await mkdir(join(root, 'artifacts', relativePath)); + else await writeFile(join(root, 'artifacts', relativePath), `bytes of ${row.id}`); + db.prepare('INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?, ?, ?)').run( + row.id, + row.id, + 'session-1', + 1, + row.status, + relativePath, + row.id === 'broken' + ? '{' + : JSON.stringify({ + id: row.id === 'mismatched' ? 'other-id' : row.id, + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 1, + name: row.name, + kind: 'file', + sizeBytes: `bytes of ${row.id}`.length, + relativePath, + ...(row.source ? { source: row.source } : {}), + status: row.status, + }), + ); + } + db.close(); + + const store = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + // Re-creating a dropped record's exact path before the reclamation runs + // must keep the new bytes, not honour the note. + await store.create({ + id: 'erased', + sessionId: 'session-1', + turnId: 'turn-2', + name: 'passport scan.pdf', + kind: 'file', + content: 'uploaded again', + source: 'user_upload', + }); + let after: string | undefined; + do { + const batch = await store.reclaimUpgradeResidue({ after, maxPaths: 2 }); + assert.ok(batch.processedPaths <= 2); + after = batch.nextAfter ?? undefined; + } while (after); + const retry = await store.reclaimUpgradeResidue({ maxPaths: 2 }); + assert.equal(retry.failedPaths, 1); + assert.equal(retry.nextAfter, null); + + const path = (id: string) => + join(root, 'artifacts', `session-1/${id}-${rows.find((row) => row.id === id)!.name}`); + for (const id of ['retired', 'sourceless', 'broken', 'mismatched']) { + await assert.rejects(() => stat(path(id)), { code: 'ENOENT' }); + } + assert.deepEqual(await store.readTextInSession('session-1', 'live'), { + ok: true, + text: 'bytes of live', + }); + assert.deepEqual(await store.readTextInSession('session-1', 'erased'), { + ok: true, + text: 'uploaded again', + }); + assert.equal((await stat(path('aborted'))).isDirectory(), true); + store.close(); + + // Everything behind the one that would not go was still reclaimed, and + // only its own note survives for a later attempt. + const remaining = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + assert.deepEqual( + remaining + .prepare('SELECT relative_path FROM artifact_upgrade_orphan_paths') + .all() + .map((row) => (row as { relative_path: string }).relative_path), + ['session-1/aborted-stuck'], + ); + remaining.close(); + }); + }); + + test('does not follow a replaced parent directory while reclaiming upgrade residue', async (t) => { + const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-upgrade-outside-')); + try { + await withInteractiveOwner(async (owner, root, track) => { + const initial = await openInteractiveArtifactStoreForWrite(owner.lease); + initial.close(); + const relativePath = 'session-1/retired-payload.txt'; + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + db.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0), + status TEXT NOT NULL CHECK(status IN ('live', 'deleted')), + relative_path TEXT NOT NULL, record_json TEXT NOT NULL + ); + CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path); + UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact'; + `); + db.prepare('INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?, ?, ?)').run( + 'retired', + 'retired', + 'session-1', + 1, + 'live', + relativePath, + JSON.stringify({ + id: 'retired', + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 1, + name: 'payload.txt', + kind: 'file', + sizeBytes: 8, + relativePath, + source: 'provider_request_capture', + status: 'live', + }), + ); + db.close(); + + const artifactRoot = join(root, 'artifacts'); + const sessionRoot = join(artifactRoot, 'session-1'); + await mkdir(sessionRoot, { recursive: true }); + await writeFile(join(artifactRoot, relativePath), 'original', 'utf8'); + const store = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + const displacedSessionRoot = join(artifactRoot, 'displaced-session-1'); + await rename(sessionRoot, displacedSessionRoot); + const outsidePath = join(outsideRoot, 'retired-payload.txt'); + await writeFile(outsidePath, 'external', 'utf8'); + if (!(await createSymlinkOrSkip(t, outsideRoot, sessionRoot))) return; + + assert.deepEqual(await store.reclaimUpgradeResidue({ maxPaths: 64 }), { + nextAfter: null, + processedPaths: 1, + failedPaths: 1, + }); + + assert.equal(await readFile(outsidePath, 'utf8'), 'external'); + assert.equal( + await readFile(join(displacedSessionRoot, 'retired-payload.txt'), 'utf8'), + 'original', + ); + assert.deepEqual(readUpgradeOrphanPaths(root), [relativePath]); + }); + } finally { + await rm(outsideRoot, { recursive: true, force: true }); + } + }); + + test('does not reclaim an upgrade orphan path that aliases a live artifact', async (t) => { + await withInteractiveOwner(async (owner, root, track) => { + if (!(await isCaseInsensitiveFilesystem(root))) { + t.skip('requires a case-insensitive filesystem'); + return; + } + + const initial = await openInteractiveArtifactStoreForWrite(owner.lease); + initial.close(); + const liveRelativePath = 'session-1/SHARED-Payload.txt'; + const orphanRelativePath = 'session-1/shared-payload.txt'; + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + db.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0), + status TEXT NOT NULL CHECK(status IN ('live', 'deleted')), + relative_path TEXT NOT NULL, record_json TEXT NOT NULL + ); + CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path); + UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact'; + `); + for (const [storageKey, artifactId, status, relativePath, name] of [ + ['live', 'SHARED', 'live', liveRelativePath, 'Payload.txt'], + ['retired', 'shared', 'deleted', orphanRelativePath, 'payload.txt'], + ] as const) { + db.prepare('INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?, ?, ?)').run( + storageKey, + artifactId, + 'session-1', + 1, + status, + relativePath, + JSON.stringify({ + id: artifactId, + sessionId: 'session-1', + turnId: 'turn-1', + createdAt: 1, + name, + kind: 'file', + sizeBytes: 10, + relativePath, + source: 'user_upload', + status, + }), + ); + } + db.close(); + + await mkdir(join(root, 'artifacts', 'session-1'), { recursive: true }); + await writeFile(join(root, 'artifacts', liveRelativePath), 'live bytes', 'utf8'); + const store = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + assert.deepEqual(readUpgradeOrphanPaths(root), [orphanRelativePath]); + + assert.deepEqual(await store.reclaimUpgradeResidue({ maxPaths: 64 }), { + nextAfter: null, + processedPaths: 1, + failedPaths: 0, + }); + + assert.deepEqual(await store.readTextInSession('session-1', 'SHARED'), { + ok: true, + text: 'live bytes', + }); + assert.deepEqual(readUpgradeOrphanPaths(root), []); + }); + }); + + test('requires authentic leases and writer facades', async () => { + await assert.rejects( + () => + openInteractiveArtifactStoreForWrite( + {} as unknown as StorageRootLease<'interactive', 'write'>, + ), + invalidLease, + ); + + assert.throws( + () => + authenticateInteractiveArtifactStoreWriter({} as unknown as InteractiveArtifactStoreWriter), + invalidLease, + ); + }); + + test('returns one authenticated writer per lease and preserves mutation operations', async () => { + await withInteractiveOwner(async (owner, root, track) => { + const [first, second] = await Promise.all([ + openInteractiveArtifactStoreForWrite(owner.lease), + openInteractiveArtifactStoreForWrite(owner.lease), + ]); + track(first); + track(second); + + assert.strictEqual(first, second); + assert.strictEqual(authenticateInteractiveArtifactStoreWriter(first), first); + await first.create(artifactInput('deleted', 'delete me')); + const deleted = await first.deleteUserArtifactInSession('session-1', 'deleted'); + + assert.strictEqual(await openInteractiveArtifactStoreForWrite(owner.lease), first); + assert.equal(deleted.kind, 'deleted'); + const page = await first.listPage('session-1', { offset: 0, limit: 1 }); + assert.equal(page.total, 0); + assert.deepEqual(await first.getInSession('session-1', 'deleted'), { + revision: page.revision, + record: null, + }); + assert.deepEqual(await first.readTextInSession('session-1', 'deleted'), { + ok: false, + reason: 'not_found', + }); + assert.deepEqual(await first.readTextInSession('other-session', 'deleted'), { + ok: false, + reason: 'not_found', + }); + await assert.rejects(() => stat(join(root, ARTIFACT_WRITER_LOCK_FILE)), { code: 'ENOENT' }); + + first.close(); + const reopened = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + assert.notStrictEqual(reopened, first); + assert.equal((await reopened.getInSession('session-1', 'deleted')).record, null); + }); + }); + + test('root close revokes new facade operations after draining an in-flight write', async () => { + await withTemporaryRoot('interactive', async (root, track) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const writer = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + const accepted = writer.create( + artifactInput('accepted', new Uint8Array(8 * 1024 * 1024).fill(0x62)), + ); + + await owner.close(); + assert.equal((await accepted).id, 'accepted'); + await assert.rejects( + () => writer.listPage('session-1', { offset: 0, limit: 1 }), + invalidLease, + ); + }); + }); + + test('snapshots create inputs and makes user deletion idempotent', async () => { + await withInteractiveOwner(async (owner, _root, track) => { + const writer = track(await openInteractiveArtifactStoreForWrite(owner.lease)); + const bytes = Uint8Array.from([0x73, 0x61, 0x66, 0x65]); + const createInput = artifactInput('accepted', bytes); + const created = writer.create(createInput); + createInput.id = 'mutated'; + createInput.sessionId = 'mutated-session'; + createInput.content = 'mutated'; + bytes.fill(0x78); + + const record = await created; + assert.equal(record.id, 'accepted'); + assert.deepEqual(await writer.readTextInSession('session-1', 'accepted'), { + ok: true, + text: 'safe', + }); + + const deleted = writer.deleteUserArtifactInSession('session-1', record.id); + assert.equal((await deleted).kind, 'deleted'); + assert.equal( + (await writer.deleteUserArtifactInSession('session-1', record.id)).kind, + 'not_found', + ); + assert.equal((await writer.getInSession('session-1', 'accepted')).record, null); + }); + }); +}); + +function artifactInput(id: string, content: string | Uint8Array) { + return { + id, + sessionId: 'session-1', + turnId: 'turn-1', + name: `${id}.txt`, + kind: 'file' as const, + content, + source: 'tool_result' as const, + now: 1, + }; +} + +function invalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} + +async function withInteractiveOwner( + run: ( + owner: NonNullable>>, + root: string, + track: TrackArtifactWriter, + ) => Promise, +): Promise { + await withTemporaryRoot('interactive', async (root, track) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + await run(owner, root, track); + } finally { + await owner.close(); + } + }); +} + +async function withTemporaryRoot( + kind: 'interactive', + run: (root: string, track: TrackArtifactWriter) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), `maka-artifact-${kind}-`)); + const writers = new Set<{ close(): void }>(); + const track: TrackArtifactWriter = (writer) => { + writers.add(writer); + return writer; + }; + try { + await run(root, track); + } finally { + for (const writer of [...writers].reverse()) writer.close(); + await rm(root, { recursive: true, force: true }); + } +} + +type TrackArtifactWriter = (writer: T) => T; + +async function createSymlinkOrSkip(t: TestContext, target: string, path: string): Promise { + try { + await symlink(target, path, process.platform === 'win32' ? 'junction' : 'dir'); + return true; + } catch (error) { + const code = (error as { code?: unknown }).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + t.skip('Windows symlink creation requires elevated privileges or Developer Mode'); + return false; + } + throw error; + } +} + +function readUpgradeOrphanPaths(root: string): string[] { + const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + return database + .prepare('SELECT relative_path FROM artifact_upgrade_orphan_paths ORDER BY relative_path') + .all() + .map((row) => (row as { relative_path: string }).relative_path); + } finally { + database.close(); + } +} + +async function isCaseInsensitiveFilesystem(directory: string): Promise { + const probe = join(directory, '.maka-case-sensitivity-probe'); + const alias = join(directory, '.MAKA-CASE-SENSITIVITY-PROBE'); + await writeFile(probe, 'probe', { flag: 'wx' }); + try { + return await stat(alias).then( + () => true, + (error: unknown) => { + if ((error as { code?: unknown }).code === 'ENOENT') return false; + throw error; + }, + ); + } finally { + await rm(probe, { force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bb9183a1575e5d423129bf70d496ea6fbb356e0d9be056fe89de9ecca32f05b1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bb9183a1575e5d423129bf70d496ea6fbb356e0d9be056fe89de9ecca32f05b1.source new file mode 100644 index 0000000000..d5e19aa834 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bb9183a1575e5d423129bf70d496ea6fbb356e0d9be056fe89de9ecca32f05b1.source @@ -0,0 +1,415 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import type { ArtifactRecord } from '@maka/core/artifacts'; +import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js'; +import { withArtifactWriterLock } from './artifact-writer-lock.js'; +import { + withOfflineContextSnapshot, + copyContextSnapshot, + validateContextSnapshot, + planContextSnapshotFiles, +} from './context-offload-snapshot.js'; +import { + CONTEXT_OFFLOAD_DATABASE_NAME, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, +} from './sqlite-context-offload-store.js'; +import { + acquireOperationalStateDatabase, + OPERATIONAL_STATE_DATABASE_NAME, +} from './operational-state-store.js'; +import { isSafeStorageId } from './storage-id.js'; + +export const SESSION_BUNDLE_STATE_ENTRIES = [ + 'artifacts', + OPERATIONAL_STATE_DATABASE_NAME, + CONTEXT_OFFLOAD_DATABASE_NAME, + CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME, +] as const; +export const SESSION_BUNDLE_PROTECTED_ENTRIES = [] as const; + +export type SessionBundleExportErrorCode = + | 'invalid_root' + | 'overlapping_roots' + | 'symlink' + | 'path_escape' + | 'unknown_entry' + | 'unsupported_entry' + | 'destination_not_empty'; + +export class SessionBundleExportError extends Error { + constructor( + readonly code: SessionBundleExportErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'SessionBundleExportError'; + } +} + +export interface SessionBundleRootLayoutInput { + stateRoot: string; + configRoot: string; + allowShared?: boolean; +} + +export interface SessionBundleExportPlanEntry { + relativePath: string; + kind: 'file' | 'directory'; + source: 'copy' | 'filtered_runtime_sqlite' | 'context_snapshot'; +} + +export interface SessionBundleExportPlan { + stateRoot: string; + configRoot: string; + destinationRoot: string; + sessionId: string; + includedEntries: string[]; + excludedEntries: string[]; + entries: SessionBundleExportPlanEntry[]; +} + +export interface SessionBundleExportInput extends SessionBundleRootLayoutInput { + destinationRoot: string; + sessionId: string; +} + +export async function assertSessionBundleRootLayout( + input: SessionBundleRootLayoutInput, +): Promise { + const stateRoot = await canonicalRoot(input.stateRoot, 'state'); + const configRoot = await canonicalRoot(input.configRoot, 'config', true); + assertRootsSeparate(stateRoot, configRoot, input.allowShared === true); +} + +export async function planSessionBundleExport( + input: SessionBundleExportInput, +): Promise { + assertSafeSessionId(input.sessionId); + const stateRoot = await canonicalRoot(input.stateRoot, 'state'); + const configRoot = await canonicalRoot(input.configRoot, 'config', true); + const destinationRoot = resolve(input.destinationRoot); + assertRootsSeparate(stateRoot, configRoot, input.allowShared === true); + assertRootsSeparate(stateRoot, destinationRoot, false); + assertRootsSeparate(configRoot, destinationRoot, false); + + const databasePath = resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME); + await assertRegularFile(databasePath, OPERATIONAL_STATE_DATABASE_NAME); + const database = new DatabaseSync(databasePath, { readOnly: true }); + let artifacts: ArtifactRecord[]; + try { + const session = database + .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?') + .get(input.sessionId); + if (!session) { + throw new SessionBundleExportError( + 'invalid_root', + `Session bundle session does not exist: ${input.sessionId}`, + ); + } + const rows = database + .prepare( + 'SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, artifact_id', + ) + .all(input.sessionId) as Array<{ record_json?: unknown }>; + artifacts = decodeArtifactRecordJsons(rows.map((row) => row.record_json)); + } finally { + database.close(); + } + + const entries: SessionBundleExportPlanEntry[] = [ + { + relativePath: OPERATIONAL_STATE_DATABASE_NAME, + kind: 'file', + source: 'filtered_runtime_sqlite', + }, + ]; + const includedEntries = [OPERATIONAL_STATE_DATABASE_NAME]; + if (artifacts.length > 0) { + entries.push({ relativePath: 'artifacts', kind: 'directory', source: 'copy' }); + for (const artifact of artifacts) { + if (!isArtifactPathForSession(artifact.relativePath, input.sessionId)) { + throw new SessionBundleExportError( + 'path_escape', + `Artifact path does not belong to session ${input.sessionId}: ${artifact.relativePath}`, + ); + } + const relativePath = `artifacts/${artifact.relativePath}`; + await assertRegularFile(resolve(stateRoot, relativePath), relativePath); + entries.push({ relativePath, kind: 'file', source: 'copy' }); + } + includedEntries.push('artifacts'); + } + const contextFiles = await planContextSnapshotFiles(stateRoot, input.sessionId); + for (const relativePath of contextFiles) { + entries.push({ relativePath, kind: 'file', source: 'context_snapshot' }); + } + if (contextFiles.length > 0) includedEntries.push(CONTEXT_OFFLOAD_DATABASE_NAME); + if (contextFiles.length > 1) includedEntries.push(CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME); + const allowed = new Set([...SESSION_BUNDLE_STATE_ENTRIES]); + const excludedEntries = (await readdir(stateRoot)).filter((entry) => !allowed.has(entry)).sort(); + return { + stateRoot, + configRoot, + destinationRoot, + sessionId: input.sessionId, + includedEntries, + excludedEntries, + entries, + }; +} + +export async function exportSessionBundleState( + input: SessionBundleExportInput, +): Promise { + return withOfflineContextSnapshot(input.stateRoot, (contextLocked) => + withArtifactWriterLock(input.stateRoot, async (stateRoot) => { + const plan = await planSessionBundleExport({ ...input, stateRoot }); + await assertDestinationMissing(plan.destinationRoot); + const stagingRoot = `${plan.destinationRoot}.${process.pid}.${randomUUID()}.tmp`; + try { + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + for (const entry of plan.entries) { + if (entry.source === 'context_snapshot') continue; + const destination = resolveInside(stagingRoot, entry.relativePath); + if (entry.kind === 'directory') { + await mkdir(destination, { recursive: true }); + continue; + } + await mkdir(dirname(destination), { recursive: true }); + if (entry.source === 'copy') { + await copyFile(resolveInside(plan.stateRoot, entry.relativePath), destination); + } else { + await exportFilteredDatabase(plan.stateRoot, destination, plan.sessionId); + } + } + await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionId); + await validateContextSnapshot(stagingRoot); + await mkdir(dirname(plan.destinationRoot), { recursive: true }); + await rename(stagingRoot, plan.destinationRoot); + return plan; + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } + }), + ); +} + +async function exportFilteredDatabase( + stateRoot: string, + destinationPath: string, + sessionId: string, +): Promise { + const lease = acquireOperationalStateDatabase(stateRoot); + try { + await lease.backup(destinationPath); + } finally { + lease.close(); + } + const database = new DatabaseSync(destinationPath); + try { + database.exec('PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE'); + const tables = database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .all() as Array<{ name?: unknown }>; + for (const row of tables) { + if (typeof row.name !== 'string' || PORTABLE_GLOBAL_TABLES.has(row.name)) continue; + const columns = database + .prepare(`PRAGMA table_info(${quoteIdentifier(row.name)})`) + .all() as Array<{ name?: unknown }>; + const names = new Set( + columns + .map((column) => column.name) + .filter((name): name is string => typeof name === 'string'), + ); + const sessionColumns = ['session_id', 'source_session_id', 'target_session_id'].filter( + (name) => names.has(name), + ); + if (sessionColumns.length > 0) { + const predicate = sessionColumns + .map((name) => `${quoteIdentifier(name)} <> ?`) + .join(' OR '); + database + .prepare(`DELETE FROM ${quoteIdentifier(row.name)} WHERE ${predicate}`) + .run(...sessionColumns.map(() => sessionId)); + } else if (!PORTABLE_DERIVED_TABLES.has(row.name)) { + database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); + } + } + database + .prepare(` + DELETE FROM tool_journal_events + WHERE NOT EXISTS ( + SELECT 1 FROM runtime_events + WHERE runtime_events.invocation_id = tool_journal_events.invocation_id + ) + `) + .run(); + database + .prepare(` + DELETE FROM runtime_partial_segments + WHERE NOT EXISTS ( + SELECT 1 FROM runtime_partial_snapshots + WHERE runtime_partial_snapshots.stream_key = runtime_partial_segments.stream_key + ) + `) + .run(); + database + .prepare(` + DELETE FROM tool_operations + WHERE NOT EXISTS ( + SELECT 1 FROM runtime_events + WHERE runtime_events.invocation_id = tool_operations.invocation_id + ) + `) + .run(); + database + .prepare(` + DELETE FROM core_interaction_outcomes + WHERE NOT EXISTS ( + SELECT 1 FROM core_interaction_requests + WHERE core_interaction_requests.request_id = core_interaction_outcomes.request_id + ) + `) + .run(); + database.exec('COMMIT'); + const foreignKeyViolation = database.prepare('PRAGMA foreign_key_check').get(); + if (foreignKeyViolation) throw new Error('Filtered session database has dangling references'); + const session = database + .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?') + .get(sessionId); + if (!session) throw new Error(`Filtered session is missing: ${sessionId}`); + database.exec('PRAGMA journal_mode = DELETE'); + } catch (error) { + try { + database.exec('ROLLBACK'); + } catch {} + throw error; + } finally { + database.close(); + } +} + +const PORTABLE_GLOBAL_TABLES = new Set([ + 'operational_schema_migrations', + 'session_metadata_schema', + 'runtime_capabilities', + 'session_catalog_state', +]); + +const PORTABLE_DERIVED_TABLES = new Set([ + 'tool_journal_events', + 'tool_operations', + 'runtime_partial_segments', + 'core_interaction_outcomes', +]); + +export function isArtifactPathForSession(relativePath: string, sessionId: string): boolean { + const parts = relativePath.split(/[\\/]+/); + return ( + parts.length >= 2 && + parts[0] === sessionId && + parts.every((part) => part.length > 0 && part !== '.' && part !== '..') + ); +} + +async function canonicalRoot(path: string, role: string, allowMissing = false): Promise { + const requested = resolve(path); + try { + const metadata = await lstat(requested); + if (metadata.isSymbolicLink()) { + throw new SessionBundleExportError('symlink', `${role} root cannot be a symlink`); + } + if (!metadata.isDirectory()) { + throw new SessionBundleExportError('invalid_root', `${role} root is not a directory`); + } + return realpath(requested); + } catch (error) { + if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return requested; + if (error instanceof SessionBundleExportError) throw error; + throw new SessionBundleExportError('invalid_root', `${role} root does not exist`, { + cause: error, + }); + } +} + +async function assertRegularFile(path: string, label: string): Promise { + const metadata = await lstat(path).catch((error) => { + throw new SessionBundleExportError('invalid_root', `Missing ${label}`, { cause: error }); + }); + if (metadata.isSymbolicLink()) { + throw new SessionBundleExportError('symlink', `${label} cannot be a symlink`); + } + if (!metadata.isFile()) { + throw new SessionBundleExportError('unsupported_entry', `${label} is not a regular file`); + } +} + +async function assertDestinationMissing(path: string): Promise { + try { + await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + throw new SessionBundleExportError( + 'destination_not_empty', + `Session bundle destination already exists: ${path}`, + ); +} + +function resolveInside(root: string, path: string): string { + const candidate = resolve(root, path); + const rel = relative(root, candidate); + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new SessionBundleExportError('path_escape', `Path escapes bundle root: ${path}`); + } + return candidate; +} + +function assertRootsSeparate(left: string, right: string, allowSame: boolean): void { + if (left === right) { + if (allowSame) return; + throw new SessionBundleExportError('overlapping_roots', 'Session bundle roots overlap'); + } + const leftToRight = relative(left, right); + const rightToLeft = relative(right, left); + if ( + (!leftToRight.startsWith('..') && !isAbsolute(leftToRight)) || + (!rightToLeft.startsWith('..') && !isAbsolute(rightToLeft)) + ) { + throw new SessionBundleExportError('overlapping_roots', 'Session bundle roots overlap'); + } +} + +function assertSafeSessionId(sessionId: string): void { + if (!isSafeStorageId(sessionId)) { + throw new SessionBundleExportError('invalid_root', `Invalid session id: ${sessionId}`); + } +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bca376a532c9cb05a7abc816eef2e44ab5c92e2be7770593d92b7a8ca8862193.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bca376a532c9cb05a7abc816eef2e44ab5c92e2be7770593d92b7a8ca8862193.source new file mode 100644 index 0000000000..b3b0a9353c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bca376a532c9cb05a7abc816eef2e44ab5c92e2be7770593d92b7a8ca8862193.source @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { + normalizeSessionTodoItems, + type SessionTodoItem, + type SessionTodoSnapshot, +} from '@maka/core/session-todo'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { assertSafeSessionId } from './session-store.js'; +import { chainWrite } from './write-queue.js'; + +const SESSION_TODO_DOCUMENT_SCHEMA_VERSION = 1; + +interface StoredSessionTodoDocument { + schemaVersion: typeof SESSION_TODO_DOCUMENT_SCHEMA_VERSION; + items: SessionTodoItem[]; +} + +export interface SessionTodoStore { + /** + * Return the initialized current document, persisting an empty one on the + * first read so later reads and copies see the same row. + */ + readOrBootstrap(sessionId: string): Promise; + /** Replace the complete document. */ + replaceAll(sessionId: string, items: unknown): Promise; + /** Initialize one conversation-copy target without overwriting conflicting state. */ + initializeCopy(input: { + sourceSessionId: string; + targetSessionId: string; + copyCurrent: boolean; + }): Promise; + /** Purge current state. */ + purgeSessionState(sessionId: string): Promise; +} + +export interface SqliteSessionTodoStore extends SessionTodoStore { + ready(): Promise; + close(): void; +} + +export function createSqliteSessionTodoStore(workspaceRoot: string): SqliteSessionTodoStore { + return new SqliteSessionTodoStoreImpl(workspaceRoot); +} + +class SqliteSessionTodoStoreImpl implements SqliteSessionTodoStore { + readonly #lease: OperationalStateDatabaseLease; + private readonly writeQueues = new Map>(); + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + } + + ready(): Promise { + return Promise.resolve(); + } + + close(): void { + this.#lease.close(); + } + + async readOrBootstrap(sessionId: string): Promise { + assertSafeSessionId(sessionId); + let snapshot: SessionTodoSnapshot | undefined; + await this.#write(async () => { + snapshot = this.#lease.transaction('write', () => { + const existing = readStoredDocument(this.#lease.database, sessionId); + if (existing) return snapshotFromDocument(existing); + + const initial = emptyDocument(); + insertDocument(this.#lease.database, sessionId, initial); + return snapshotFromDocument(initial); + }); + }); + return snapshot!; + } + + async replaceAll(sessionId: string, items: unknown): Promise { + assertSafeSessionId(sessionId); + const normalized = normalizeSessionTodoItems(items); + if (!normalized.ok) throw new Error(normalized.message); + const document: StoredSessionTodoDocument = { + schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, + items: normalized.value.items, + }; + await this.#write(async () => { + this.#lease.transaction('write', () => + upsertDocument(this.#lease.database, sessionId, document), + ); + }); + return snapshotFromDocument(document); + } + + async initializeCopy(input: { + sourceSessionId: string; + targetSessionId: string; + copyCurrent: boolean; + }): Promise { + assertSafeSessionId(input.sourceSessionId); + assertSafeSessionId(input.targetSessionId); + if (input.sourceSessionId === input.targetSessionId) { + throw new Error('SessionTodo copy source and target must differ'); + } + let snapshot: SessionTodoSnapshot | undefined; + await this.#write(async () => { + snapshot = this.#lease.transaction('write', () => { + const source = + (input.copyCurrent + ? readStoredDocument(this.#lease.database, input.sourceSessionId) + : undefined) ?? emptyDocument(); + const existing = readStoredDocument(this.#lease.database, input.targetSessionId); + if (existing) { + if (!sameDocument(existing, source)) { + throw new Error('SessionTodo copy target already has different state'); + } + return snapshotFromDocument(existing); + } + insertDocument(this.#lease.database, input.targetSessionId, source); + return snapshotFromDocument(source); + }); + }); + return snapshot!; + } + + async purgeSessionState(sessionId: string): Promise { + assertSafeSessionId(sessionId); + await this.#write(async () => { + this.#lease.transaction('write', () => { + this.#lease.database + .prepare('DELETE FROM workflow_session_todo_documents WHERE session_id = ?') + .run(sessionId); + }); + }); + } + + #write(operation: () => Promise): Promise { + // Todo documents are small and infrequently mutated. One queue makes + // cross-Session initialization linearizable without lock ordering. + return chainWrite(this.writeQueues, 'session-todo', operation); + } +} + +function emptyDocument(): StoredSessionTodoDocument { + return { schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, items: [] }; +} + +function sameDocument(left: StoredSessionTodoDocument, right: StoredSessionTodoDocument): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function readStoredDocument( + database: DatabaseSync, + sessionId: string, +): StoredSessionTodoDocument | undefined { + const row = database + .prepare('SELECT record_json FROM workflow_session_todo_documents WHERE session_id = ?') + .get(sessionId) as { record_json?: unknown } | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') throw new Error('Invalid SessionTodo document record'); + let parsed: unknown; + try { + parsed = JSON.parse(row.record_json); + } catch { + throw new Error('Invalid SessionTodo document JSON'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid SessionTodo document shape'); + } + const record = parsed as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== 'items' || keys[1] !== 'schemaVersion') { + throw new Error('Invalid SessionTodo document fields'); + } + if (record.schemaVersion !== SESSION_TODO_DOCUMENT_SCHEMA_VERSION) { + throw new Error(`Unsupported SessionTodo document schema: ${String(record.schemaVersion)}`); + } + const normalized = normalizeSessionTodoItems(record.items); + if (!normalized.ok) throw new Error(`Invalid SessionTodo document: ${normalized.message}`); + return { + schemaVersion: SESSION_TODO_DOCUMENT_SCHEMA_VERSION, + items: normalized.value.items, + }; +} + +function insertDocument( + database: DatabaseSync, + sessionId: string, + document: StoredSessionTodoDocument, +): void { + database + .prepare(` + INSERT INTO workflow_session_todo_documents(session_id, record_json) + VALUES (?, ?) + `) + .run(sessionId, JSON.stringify(document)); +} + +function upsertDocument( + database: DatabaseSync, + sessionId: string, + document: StoredSessionTodoDocument, +): void { + database + .prepare(` + INSERT INTO workflow_session_todo_documents(session_id, record_json) + VALUES (?, ?) + ON CONFLICT(session_id) DO UPDATE SET record_json = excluded.record_json + `) + .run(sessionId, JSON.stringify(document)); +} + +function snapshotFromDocument(document: StoredSessionTodoDocument): SessionTodoSnapshot { + return { items: document.items.map((item) => ({ ...item })) }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcddd8d7a6e9fa0051e4a791689a04d434e1567b61dd363d3b2bf845ba73af7d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcddd8d7a6e9fa0051e4a791689a04d434e1567b61dd363d3b2bf845ba73af7d.source new file mode 100644 index 0000000000..03b059db9f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcddd8d7a6e9fa0051e4a791689a04d434e1567b61dd363d3b2bf845ba73af7d.source @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { + createSqliteSessionTodoStore, + type SessionTodoStore, + type SqliteSessionTodoStore, +} from './session-todo-store.js'; + +const writerBrand: unique symbol = Symbol('InteractiveSessionTodoWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveSessionTodoWriter extends SessionTodoStore { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + close(): void; +} + +export function authenticateInteractiveSessionTodoWriter( + writer: InteractiveSessionTodoWriter, +): InteractiveSessionTodoWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive SessionTodo writer', + ); + } + return writer; +} + +export async function openInteractiveSessionTodoStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let store: SqliteSessionTodoStore | undefined; + try { + store = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const opened = createSqliteSessionTodoStore(root); + try { + await opened.ready(); + return opened; + } catch (error) { + opened.close(); + throw error; + } + }); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + store.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, store); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + store?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + store: SqliteSessionTodoStore, +): InteractiveSessionTodoWriter { + let closed = false; + const run = (operation: () => Promise) => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'SessionTodo writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', async () => operation()); + }; + const writer: InteractiveSessionTodoWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + readOrBootstrap: (sessionId) => run(() => store.readOrBootstrap(sessionId)), + replaceAll: (sessionId, items) => run(() => store.replaceAll(sessionId, items)), + initializeCopy: (input) => run(() => store.initializeCopy(input)), + purgeSessionState: (sessionId) => run(() => store.purgeSessionState(sessionId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + store.close(); + }, + }; + Object.freeze(writer); + return writer; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcffaa56010ce6b9ef7960943ca455d04d17b1bbc1680618b2805ebb3a483117.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcffaa56010ce6b9ef7960943ca455d04d17b1bbc1680618b2805ebb3a483117.source new file mode 100644 index 0000000000..968ab76e16 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bcffaa56010ce6b9ef7960943ca455d04d17b1bbc1680618b2805ebb3a483117.source @@ -0,0 +1,315 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Filesystem-facing contract for portable Session Bundles. + * + * This boundary deliberately treats Maka state as opaque bytes and files. It + * must not acquire dependencies on Session, SQLite, JSONL, RuntimeEvent, or + * Artifact schemas. State preparation and semantic identity validation happen + * before this codec is called; state migration and re-keying happen after + * hydration in the state-owning layer. + * + * V1 archive paths intentionally use a host-independent portability subset. + * Every segment rejects ASCII control characters, `<`, `>`, `:`, `"`, `|`, + * `?`, `*`, trailing dots/spaces, and case-insensitive Windows device names + * such as `CON`, `NUL`, `COM1`, and `LPT1`, even when encoding on POSIX. + */ + +export const SESSION_BUNDLE_SCHEMA_VERSION = 1 as const; +export const SESSION_BUNDLE_CODEC_NAME = 'maka-session-bundle' as const; +export const SESSION_BUNDLE_CODEC_VERSION = 1 as const; +export const SESSION_BUNDLE_CANONICALIZATION_VERSION = 1 as const; +export const SESSION_BUNDLE_ARCHIVE_FORMAT = 'ustar' as const; +export const SESSION_BUNDLE_COMPRESSION_FORMAT = 'zstd' as const; +export const SESSION_BUNDLE_COMPRESSION_LEVEL = 3 as const; + +export const SESSION_BUNDLE_MANIFEST_PATH = 'manifest.json' as const; +export const SESSION_BUNDLE_STATE_IDENTITY_PATH = 'state-identity.json' as const; +export const SESSION_BUNDLE_STATE_PATH = 'state/' as const; +export const SESSION_BUNDLE_WORKSPACE_PATH = 'workspace/' as const; + +export type Sha256Digest = `sha256:${string}`; + +const SHA256_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +export function isSha256Digest(value: unknown): value is Sha256Digest { + return typeof value === 'string' && SHA256_DIGEST_PATTERN.test(value); +} + +/** + * State identity is produced and semantically validated by the state layer. + * The filesystem codec preserves these bytes exactly and never parses them. + */ +export interface OpaqueStateIdentityDescriptor { + mediaType: string; + bytes: Uint8Array; +} + +export function copyOpaqueStateIdentityDescriptor( + descriptor: OpaqueStateIdentityDescriptor, +): OpaqueStateIdentityDescriptor { + if (!isRecord(descriptor)) { + throw new TypeError('State identity descriptor must be an object'); + } + if (!isNonEmptyUnicodeString(descriptor.mediaType)) { + throw new TypeError('State identity mediaType must be a non-empty Unicode string'); + } + if (!(descriptor.bytes instanceof Uint8Array)) { + throw new TypeError('State identity bytes must be a Uint8Array'); + } + return { + mediaType: descriptor.mediaType, + bytes: Uint8Array.from(descriptor.bytes), + }; +} + +export interface PreparedSessionBundleSnapshot { + stateRoot: string; + workspaceRoot: string; + stateIdentity: OpaqueStateIdentityDescriptor; +} + +export interface SessionBundleSource { + path: string; + /** + * Optional for standalone inspection, but required when the source was + * resolved through SessionRepository. + */ + expectedArchiveDigest?: Sha256Digest; +} + +export interface SessionBundleLimits { + maxCompressedBytes: number; + maxDecompressedTarBytes: number; + maxPayloadBytes: number; + maxFileBytes: number; + maxEntryCount: number; + maxManifestBytes: number; + maxStateIdentityBytes: number; + maxPathBytes: number; + maxPathDepth: number; +} + +export type SessionBundleQuotaName = keyof SessionBundleLimits; + +export const SESSION_BUNDLE_LIMIT_NAMES = [ + 'maxCompressedBytes', + 'maxDecompressedTarBytes', + 'maxPayloadBytes', + 'maxFileBytes', + 'maxEntryCount', + 'maxManifestBytes', + 'maxStateIdentityBytes', + 'maxPathBytes', + 'maxPathDepth', +] as const satisfies readonly SessionBundleQuotaName[]; + +/** + * Limits are caller-owned policy and intentionally have no defaults. Zero is a + * valid fail-closed budget; negative, fractional, missing, or unsafe integers + * are programmer/configuration errors rather than errors in an input Bundle. + */ +export function assertSessionBundleLimits( + limits: SessionBundleLimits, +): asserts limits is SessionBundleLimits { + if (!isRecord(limits)) throw new TypeError('Session bundle limits must be an object'); + const actualKeys = Object.keys(limits); + if ( + actualKeys.length !== SESSION_BUNDLE_LIMIT_NAMES.length || + actualKeys.some((key) => !SESSION_BUNDLE_LIMIT_NAMES.includes(key as SessionBundleQuotaName)) + ) { + throw new TypeError('Session bundle limits must contain exactly the supported quota keys'); + } + for (const name of SESSION_BUNDLE_LIMIT_NAMES) { + const value = limits[name]; + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`Session bundle limit ${name} must be a non-negative safe integer`); + } + } +} + +export interface SessionBundleEnvelopeInput { + /** + * Cloud identity allocated by the control plane. Hydration verifies this + * binding and never silently rebinds a Bundle. + */ + sessionId: string; + /** + * Optional provenance only. Activation deduplication and terminal outcomes + * remain authoritative in the control plane's Activation store. + */ + lastCommittedActivationId?: string; +} + +export interface SessionBundlePackInput { + snapshot: PreparedSessionBundleSnapshot; + envelope: SessionBundleEnvelopeInput; + destination: string; + limits: SessionBundleLimits; +} + +export interface SessionBundleReadInput { + source: SessionBundleSource; + limits: SessionBundleLimits; +} + +export interface SessionBundleHydrateInput extends SessionBundleReadInput { + expectedSessionId: string; + destinationRoot: string; +} + +export interface SessionBundleHydrationCleanupInput { + /** Cleanup is restricted to codec-owned staging for this exact target. */ + destinationRoot: string; +} + +export interface SessionBundleManifestV1 { + schemaVersion: 1; + codec: { + name: 'maka-session-bundle'; + version: 1; + canonicalizationVersion: 1; + archive: 'ustar'; + compression: 'zstd'; + compressionLevel: 3; + }; + envelope: { + sessionId: string; + lastCommittedActivationId?: string; + }; + stateIdentity: { + path: 'state-identity.json'; + mediaType: string; + }; + payload: { + statePath: 'state/'; + workspacePath: 'workspace/'; + treeDigest: Sha256Digest; + payloadBytes: number; + entryCount: number; + }; +} + +export interface SessionBundleArtifact { + path: string; + archiveDigest: Sha256Digest; + compressedBytes: number; + decompressedTarBytes: number; + payloadBytes: number; + entryCount: number; +} + +export interface SessionBundleInspection { + manifest: SessionBundleManifestV1; + stateIdentity: OpaqueStateIdentityDescriptor; + archiveDigest: Sha256Digest; + verified: true; +} + +export interface SessionBundleHydration extends SessionBundleInspection { + destinationRoot: string; + stateRoot: string; + workspaceRoot: string; +} + +export interface SessionBundleHydrationCleanupResult { + destinationRoot: string; + removedStagingDirectories: number; + removedOwnershipRecords: number; +} + +export interface SessionBundleFileService { + pack(input: SessionBundlePackInput): Promise; + inspect(input: SessionBundleReadInput): Promise; + hydrate(input: SessionBundleHydrateInput): Promise; + cleanupHydrationStaging( + input: SessionBundleHydrationCleanupInput, + ): Promise; +} + +export type SessionBundleFileErrorCode = + | 'invalid_manifest' + | 'unsupported_schema' + | 'unsupported_codec' + | 'identity_mismatch' + | 'integrity_mismatch' + | 'quota_exceeded' + | 'unsafe_path' + | 'unsupported_entry' + | 'destination_exists' + | 'source_changed' + | 'io_failure'; + +export type SessionBundleFileOperation = 'pack' | 'inspect' | 'hydrate' | 'cleanup'; + +/** + * Bounded diagnostic facts only. Raw attacker-controlled paths and strings do + * not belong in stable error details. + */ +export interface SessionBundleFileErrorDetails { + operation?: SessionBundleFileOperation; + quota?: SessionBundleQuotaName; + limit?: number; + observed?: number; + entryIndex?: number; + pathDepth?: number; +} + +export interface SessionBundleFileErrorOptions extends ErrorOptions { + details?: SessionBundleFileErrorDetails; +} + +export class SessionBundleFileError extends Error { + readonly details?: Readonly; + + constructor( + readonly code: SessionBundleFileErrorCode, + message: string, + options: SessionBundleFileErrorOptions = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'SessionBundleFileError'; + if (options.details !== undefined) this.details = Object.freeze({ ...options.details }); + } +} + +export function isValidUnicodeString(value: unknown): value is string { + if (typeof value !== 'string') return false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + if (index + 1 >= value.length) return false; + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return false; + index += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) return false; + } + return true; +} + +export function isNonEmptyUnicodeString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && isValidUnicodeString(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/be2f232722c8aeb2da8d57e82cc308f805b3eec7c97962a8546e5bfe3c91269d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/be2f232722c8aeb2da8d57e82cc308f805b3eec7c97962a8546e5bfe3c91269d.source new file mode 100644 index 0000000000..81eda1193d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/be2f232722c8aeb2da8d57e82cc308f805b3eec7c97962a8546e5bfe3c91269d.source @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parentPort, threadId, workerData } from 'node:worker_threads'; +import { createWorkBoardStore } from '../../work-board-store.js'; + +interface WorkerInput { + workspaceRoot: string; + itemId: string; +} + +const input = workerData as WorkerInput; +const store = createWorkBoardStore(input.workspaceRoot); + +try { + const updated = await store.update( + input.itemId, + { title: `worker-${threadId}` }, + { expectedRevision: 1 }, + 200, + ); + parentPort?.postMessage({ ok: true, revision: updated.revision }); +} catch (error) { + parentPort?.postMessage({ + ok: false, + code: + error instanceof Error && 'code' in error ? (error as { code?: unknown }).code : 'unknown', + }); +} finally { + store.close(); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf012cbc99025221bd64a40ecdffeb67b7a225eecfa1083cf94c83ee6dd5c55c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf012cbc99025221bd64a40ecdffeb67b7a225eecfa1083cf94c83ee6dd5c55c.source new file mode 100644 index 0000000000..77def1c7b0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf012cbc99025221bd64a40ecdffeb67b7a225eecfa1083cf94c83ee6dd5c55c.source @@ -0,0 +1,667 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import type { DatabaseSync } from 'node:sqlite'; +import { + configureSqliteRuntimeDatabase, + configureSqliteRuntimeLockWait, + migrateSqliteRuntimeDatabase, + readUserVersion, + SQLITE_RUNTIME_SCHEMA_VERSION, +} from './sqlite-runtime-schema.js'; +import { + migrateSqliteSessionMetadataDatabase, + readSqliteSessionMetadataSchemaVersion, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, +} from './sqlite-session-metadata-schema.js'; +import { + migrateSqliteCoreExecutionDatabase, + SQLITE_CORE_EXECUTION_SCHEMA_VERSION, +} from './sqlite-core-execution-schema.js'; +import { + migrateSqliteWorkflowDatabase, + SQLITE_WORKFLOW_SCHEMA_VERSION, +} from './sqlite-workflow-schema.js'; +import { migrateSqliteUsageDatabase, SQLITE_USAGE_SCHEMA_VERSION } from './sqlite-usage-schema.js'; +import { + migrateSqliteArtifactDatabase, + SQLITE_ARTIFACT_SCHEMA_VERSION, +} from './sqlite-artifact-schema.js'; +import { + assertLegacySchedulingSchema, + insertMigratedScheduledTasks, + planLegacyScheduledTasks, +} from './sqlite-legacy-scheduling.js'; +import { + assertCurrentOperationalTargetSchema, + assertReleasedLegacyRetirementShape, + ensureOperationalSchemaRegistry, + isCurrentOperationalTargetSchema, +} from './operational-target-schema.js'; + +export const OPERATIONAL_STATE_DATABASE_NAME = 'runtime.sqlite'; +export const OPERATIONAL_STATE_SCHEMA_VERSION = 2; + +/** Resolve the authoritative on-disk path of the operational-state database. */ +export function resolveOperationalStateDatabasePath(workspaceRoot: string): string { + return resolve(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME); +} + +const OPERATIONAL_SCHEMA_VERSIONS: ReadonlyMap = new Map([ + ['runtime', SQLITE_RUNTIME_SCHEMA_VERSION], + ['session_metadata', SQLITE_SESSION_METADATA_SCHEMA_VERSION], + ['core_execution', SQLITE_CORE_EXECUTION_SCHEMA_VERSION], + ['workflow', SQLITE_WORKFLOW_SCHEMA_VERSION], + ['usage', SQLITE_USAGE_SCHEMA_VERSION], + ['artifact', SQLITE_ARTIFACT_SCHEMA_VERSION], + ['operational', OPERATIONAL_STATE_SCHEMA_VERSION], +] as const); +const REMOVED_OPERATIONAL_SCHEMA_VERSIONS: ReadonlyMap = new Map([ + ['automation', 2], +]); +/** + * Exact validation-evidence contract emitted into `cutover_journal` by the + * released cutover writers (commit 1caea265c^, removed in #1994). Each entry + * maps a released `store_name` to the precise key set its `importAndValidate` + * returned as `validation_json`: `session_metadata` reports one row count per + * copied session-metadata table, and every other store reports its own fixed + * evidence keys. A completed row whose store name is absent here, or whose + * validation keys are not *exactly* this set, is + * evidence this build never wrote — retirement fails closed and preserves it + * rather than dropping unrecognized migration state. + * + * The contract is pinned to the final writer generation (1caea265c^); a + * workspace whose cutover ran under an earlier writer whose key set differed + * fails closed here (preserved, startup still blocked) — non-regressive versus + * today and deliberately safer than dropping evidence we do not recognize. + */ +const RELEASED_CUTOVER_STORE_VALIDATION_KEYS: ReadonlyMap> = new Map([ + [ + 'agent_runs', + new Set([ + 'agent_runs', + 'agent_run_events', + 'agent_run_projections', + 'root_turn_admissions', + 'root_source_message_proofs', + ]), + ], + ['artifact_metadata', new Set(['records'])], + ['automations', new Set(['automations'])], + ['interactions', new Set(['interaction_requests', 'interaction_outcomes'])], + ['message_receipts', new Set(['host_epochs', 'message_receipts'])], + [ + 'session_metadata', + new Set([ + 'session_metadata', + 'session_metadata_labels', + 'session_metadata_import_sources', + 'session_metadata_tombstones', + 'subagent_spawns', + 'agent_graph_intent_claims', + 'agent_graph_schedule_updates', + 'agent_graph_operator_provisions', + 'agent_graph_client_projections', + 'agent_graph_client_operator_projections', + 'agent_graph_client_terminal_activity', + 'agent_graph_client_applied_records', + 'agent_graph_supervisor_wakes', + 'agent_graph_supervisor_wake_attempts', + 'sandbox_boundary_log', + ]), + ], + ['shell_runs', new Set(['shell_runs'])], + ['usage_pricing', new Set(['llm', 'tools', 'pricing'])], + ['workflow_deep_research', new Set(['sessions', 'events'])], + ['workflow_plan', new Set(['sessions', 'events'])], + ['workflow_plan_reminders', new Set(['reminders'])], + ['workflow_task_ledger', new Set(['sessions', 'events'])], +]); + +const require = createRequire(import.meta.url); +const owners = new Map(); + +export interface OperationalStateDatabaseOptions { + now?: () => number; + /** + * `migrate` is reserved for the process that owns the State Root. A + * secondary process may use `require_current` to share current tables, but + * it must never rewrite the schema underneath that owner. + */ + schemaMigration?: 'migrate' | 'require_current'; +} + +export class OperationalStateMigrationBlockedError extends Error { + readonly code = 'operational_state_migration_blocked'; + + constructor( + cause: unknown, + readonly reason: 'requires_host_migration' | 'blocked' = 'blocked', + ) { + super(cause instanceof Error ? cause.message : 'Operational state migration is blocked', { + cause, + }); + this.name = 'OperationalStateMigrationBlockedError'; + } +} + +export interface OperationalStateDatabaseLease { + readonly database: DatabaseSync; + readonly databasePath: string; + transaction(mode: 'read' | 'write', operation: () => T): T; + backup(destinationPath: string): Promise; + close(): void; +} + +/** + * Acquire the process-local owner for the operational SQLite authority. + * + * Repositories receive leases instead of opening independent connections. + * The last lease closes the connection, while transaction boundaries remain + * centralized on the owner for the lifetime of the workspace. + */ +export function acquireOperationalStateDatabase( + workspaceRoot: string, + options: OperationalStateDatabaseOptions = {}, +): OperationalStateDatabaseLease { + const databasePath = resolveOperationalStateDatabasePath(workspaceRoot); + let owner = owners.get(databasePath); + if (!owner) { + owner = new OperationalStateDatabaseOwner(databasePath, options); + owners.set(databasePath, owner); + } + return owner.acquire(); +} + +class OperationalStateDatabaseOwner { + readonly database: DatabaseSync; + private references = 0; + private closed = false; + private transactionDepth = 0; + + constructor( + readonly databasePath: string, + options: OperationalStateDatabaseOptions, + ) { + if (options.schemaMigration === 'require_current' && !existsSync(databasePath)) { + throw new OperationalStateMigrationBlockedError( + new Error('Operational state has not been initialized by its Runtime Host'), + 'requires_host_migration', + ); + } + mkdirSync(dirname(databasePath), { recursive: true }); + const Database = loadDatabaseSync(); + this.database = new Database(databasePath); + try { + configureSqliteRuntimeLockWait(this.database); + this.database.exec('PRAGMA foreign_keys = ON'); + if (options.schemaMigration === 'require_current') { + requireCurrentOperationalState(this.database); + } else { + inspectAndMigrateOperationalState(this.database, options.now ?? Date.now); + } + configureSqliteRuntimeDatabase(this.database); + } catch (error) { + this.database.close(); + this.closed = true; + throw error; + } + } + + acquire(): OperationalStateDatabaseLease { + if (this.closed) throw new Error('Operational state database is closed'); + this.references += 1; + let released = false; + return { + database: this.database, + databasePath: this.databasePath, + transaction: (mode, operation) => this.transaction(mode, operation), + backup: (destinationPath) => this.backup(destinationPath), + close: () => { + if (released) return; + released = true; + this.releaseReference(); + }, + }; + } + + private async backup(destinationPath: string): Promise { + if (this.closed) throw new Error('Operational state database is closed'); + if (!destinationPath) throw new Error('Operational state backup destination is required'); + const canonicalDestination = resolve(destinationPath); + if (canonicalDestination === this.databasePath) { + throw new Error('Operational state backup destination must differ from the source database'); + } + if (existsSync(canonicalDestination)) { + throw new Error( + `Operational state backup destination already exists: ${canonicalDestination}`, + ); + } + mkdirSync(dirname(canonicalDestination), { recursive: true }); + this.references += 1; + try { + return await loadSqliteModule().backup(this.database, canonicalDestination); + } finally { + this.releaseReference(); + } + } + + private releaseReference(): void { + this.references -= 1; + if (this.references !== 0) return; + this.closed = true; + owners.delete(this.databasePath); + this.database.close(); + } + + private transaction(mode: 'read' | 'write', operation: () => T): T { + if (this.closed) throw new Error('Operational state database is closed'); + if (this.transactionDepth > 0) return operation(); + this.database.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); + this.transactionDepth += 1; + try { + const result = operation(); + this.database.exec('COMMIT'); + return result; + } catch (error) { + rollback(this.database); + throw error; + } finally { + this.transactionDepth -= 1; + } + } +} + +function requireCurrentOperationalState(database: DatabaseSync): void { + try { + const inspection = inspectOperationalStateSchema(database); + if (inspection.status === 'current' && isCurrentOperationalTargetSchema(database)) return; + throw new OperationalStateMigrationBlockedError( + new Error('Operational state requires migration by its Runtime Host'), + 'requires_host_migration', + ); + } catch (error) { + if (isSqliteEnvironmentError(error)) throw error; + if (error instanceof OperationalStateMigrationBlockedError) throw error; + throw new OperationalStateMigrationBlockedError(error); + } +} + +function inspectAndMigrateOperationalState(database: DatabaseSync, now: () => number): void { + try { + const inspection = inspectOperationalStateSchema(database); + if (inspection.status === 'current' && isCurrentOperationalTargetSchema(database)) return; + migrateOperationalStateDatabaseInternal(database, now); + } catch (error) { + if (isSqliteEnvironmentError(error)) throw error; + throw new OperationalStateMigrationBlockedError(error); + } +} + +function isSqliteEnvironmentError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const errcode = (error as { errcode?: unknown }).errcode; + if (typeof errcode !== 'number') return false; + return [5, 6, 7, 8, 10, 13, 14].includes(errcode & 0xff); +} + +export interface OperationalStateSchemaInspection { + readonly status: 'current' | 'needs_migration'; + readonly versions: ReadonlyMap; +} + +export function inspectOperationalStateSchema( + database: DatabaseSync, +): OperationalStateSchemaInspection { + if (database.isTransaction) return inspectOperationalStateSchemaInternal(database); + database.exec('BEGIN'); + try { + const inspection = inspectOperationalStateSchemaInternal(database); + database.exec('COMMIT'); + return inspection; + } catch (error) { + rollback(database); + throw error; + } +} + +function inspectOperationalStateSchemaInternal( + database: DatabaseSync, +): OperationalStateSchemaInspection { + let needsMigration = false; + const runtimeVersion = readUserVersion(database); + const versions = new Map([['runtime', runtimeVersion]]); + assertSupportedOperationalSchemaVersion('runtime', runtimeVersion, SQLITE_RUNTIME_SCHEMA_VERSION); + needsMigration ||= runtimeVersion < SQLITE_RUNTIME_SCHEMA_VERSION; + + if (hasTable(database, 'session_metadata_schema')) { + const sessionMetadataVersion = readSqliteSessionMetadataSchemaVersion(database); + versions.set('session_metadata', sessionMetadataVersion); + assertSupportedOperationalSchemaVersion( + 'session_metadata', + sessionMetadataVersion, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, + ); + needsMigration ||= sessionMetadataVersion < SQLITE_SESSION_METADATA_SCHEMA_VERSION; + } else { + needsMigration = true; + } + + if (!hasTable(database, 'operational_schema_migrations')) { + if (runtimeVersion === 0 && !hasApplicationSchemaObjects(database)) { + return { status: 'needs_migration', versions }; + } + throw new Error( + 'Operational schema registry is missing from a nonempty database; ' + + 'Maka did not migrate or delete the database. Restore or repair this workspace before opening it.', + ); + } + const rows = database + .prepare('SELECT scope, version FROM operational_schema_migrations') + .all() as Array<{ scope?: unknown; version?: unknown }>; + const registered = new Map(); + for (const { scope, version } of rows) { + if (typeof scope !== 'string') { + throw new Error( + 'Operational schema registry has an invalid scope; ' + + 'Maka did not migrate or delete the database. Restore or repair this workspace before opening it.', + ); + } + const supportedVersion = OPERATIONAL_SCHEMA_VERSIONS.get(scope); + if (supportedVersion === undefined) { + const removedVersion = REMOVED_OPERATIONAL_SCHEMA_VERSIONS.get(scope); + if (removedVersion !== undefined) { + if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) { + throw new Error(`Operational schema ${scope} has invalid version ${String(version)}`); + } + assertSupportedOperationalSchemaVersion(scope, version, removedVersion); + versions.set(scope, version); + needsMigration = true; + continue; + } + throw new Error( + `Operational schema ${scope} is unknown to this Maka build; ` + + 'Maka did not migrate or delete the database. Upgrade Maka to open this workspace.', + ); + } + if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) { + throw new Error( + `Operational schema ${scope} has invalid version ${String(version)}; ` + + 'Maka did not migrate or delete the database. Restore or repair this workspace before opening it.', + ); + } + assertSupportedOperationalSchemaVersion(scope, version, supportedVersion); + registered.set(scope, version); + if (scope === 'workflow' || scope === 'automation') versions.set(scope, version); + } + assertLegacySchedulingSchema(database, versions); + for (const [scope, version] of OPERATIONAL_SCHEMA_VERSIONS) { + const registeredVersion = registered.get(scope); + if (registeredVersion === undefined) { + throw new Error( + `Operational schema registry is missing scope ${scope}; ` + + 'Maka did not migrate or delete the database. Restore or repair this workspace before opening it.', + ); + } + needsMigration ||= registeredVersion < version; + } + return { status: needsMigration ? 'needs_migration' : 'current', versions }; +} + +function assertSupportedOperationalSchemaVersion( + scope: string, + observedVersion: number, + supportedVersion: number, +): void { + if (observedVersion <= supportedVersion) return; + throw new Error( + `Operational schema ${scope} is newer than supported version ${supportedVersion}; ` + + 'Maka did not migrate or delete the database. Upgrade Maka to open this workspace.', + ); +} + +function hasTable(database: DatabaseSync, name: string): boolean { + const table = database + .prepare(` + SELECT 1 AS present + FROM sqlite_master + WHERE type = 'table' AND name = ? + `) + .get(name) as { present?: unknown } | undefined; + return table?.present === 1; +} + +function hasApplicationSchemaObjects(database: DatabaseSync): boolean { + const object = database + .prepare("SELECT 1 AS present FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' LIMIT 1") + .get() as { present?: unknown } | undefined; + return object?.present === 1; +} + +export function migrateOperationalStateDatabaseInternal(db: DatabaseSync, now: () => number): void { + db.exec('BEGIN IMMEDIATE'); + try { + const inspection = inspectOperationalStateSchema(db); + const legacyScheduledTasks = planLegacyScheduledTasks(db, inspection.versions); + migrateSqliteRuntimeDatabase(db, { transaction: 'caller' }); + migrateSqliteSessionMetadataDatabase(db, { transaction: 'caller' }); + migrateSqliteCoreExecutionDatabase(db); + migrateSqliteWorkflowDatabase(db); + insertMigratedScheduledTasks(db, legacyScheduledTasks); + migrateSqliteUsageDatabase(db); + migrateSqliteArtifactDatabase(db); + ensureOperationalSchemaRegistry(db); + const appliedAt = now(); + db.exec(` + DROP TABLE IF EXISTS automation_pending_fires; + DROP TABLE IF EXISTS automation_definitions; + DROP TABLE IF EXISTS automation_authority_state; + DELETE FROM operational_schema_migrations WHERE scope = 'automation'; + `); + retireCompletedLegacyMigrationMetadata(db); + assertCurrentOperationalTargetSchema(db); + for (const [scope, version] of OPERATIONAL_SCHEMA_VERSIONS) { + registerSchema(db, scope, version, appliedAt); + } + db.exec('COMMIT'); + } catch (error) { + rollback(db); + throw error; + } +} + +/** + * Retire import and cutover evidence written before SQLite became the sole + * operational authority. A table is only removed when its full released schema + * signature is recognized (columns *and* CHECK/FK constraints, with no extra + * index/trigger — see {@link assertReleasedLegacyRetirementShape}), it names only + * a released store contract, and every row is internally valid and completed. Any + * interrupted, malformed, unrecognized-shape, or unknown-store state stays + * fail-closed and the surrounding migration transaction rolls back unchanged. + */ +function retireCompletedLegacyMigrationMetadata(db: DatabaseSync): void { + if (hasTable(db, 'cutover_journal')) { + assertReleasedLegacyRetirementShape(db, 'cutover_journal'); + const rows = db + .prepare(` + SELECT + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + FROM cutover_journal + ORDER BY store_name + `) + .all() as Array>; + for (const row of rows) assertCompletedLegacyCutoverJournalRow(row); + } + if (hasTable(db, 'runtime_import_sources')) { + assertReleasedLegacyRetirementShape(db, 'runtime_import_sources'); + const rows = db + .prepare(` + SELECT source_path, fingerprint, imported_at + FROM runtime_import_sources + ORDER BY source_path + `) + .all() as Array>; + for (const row of rows) assertLegacyImportSourceRow(row); + } + if (hasTable(db, 'session_metadata_import_sources')) { + assertReleasedLegacyRetirementShape(db, 'session_metadata_import_sources'); + const rows = db + .prepare(` + SELECT source_path, fingerprint, session_id, imported_at + FROM session_metadata_import_sources + ORDER BY source_path + `) + .all() as Array>; + const sessionExists = db.prepare('SELECT 1 FROM session_metadata WHERE session_id = ?'); + for (const row of rows) { + assertLegacyImportSourceRow(row); + if ( + typeof row.session_id !== 'string' || + row.session_id.length === 0 || + !sessionExists.get(row.session_id) + ) { + throw new Error('Legacy session import source is incomplete or invalid'); + } + } + } + + db.exec(` + DROP TABLE IF EXISTS session_metadata_import_sources; + DROP TABLE IF EXISTS runtime_import_sources; + DROP TABLE IF EXISTS cutover_journal; + `); +} + +function assertCompletedLegacyCutoverJournalRow(row: Record): void { + // A store name absent from the released contract yields `undefined` here, + // which fails closed below — an empty or non-string name lands the same way. + const expectedValidationKeys = + typeof row.store_name === 'string' + ? RELEASED_CUTOVER_STORE_VALIDATION_KEYS.get(row.store_name) + : undefined; + if ( + expectedValidationKeys === undefined || + typeof row.source_path !== 'string' || + row.source_path.length === 0 || + typeof row.source_fingerprint !== 'string' || + row.source_fingerprint.length === 0 || + row.state !== 'completed' || + !isNonnegativeInteger(row.started_at) || + !isNonnegativeInteger(row.completed_at) || + typeof row.validation_json !== 'string' + ) { + throw new Error('Legacy operational cutover journal is incomplete or invalid'); + } + let validation: unknown; + try { + validation = JSON.parse(row.validation_json); + } catch { + throw new Error('Legacy operational cutover journal has invalid validation evidence'); + } + if ( + typeof validation !== 'object' || + validation === null || + Array.isArray(validation) || + !Object.values(validation).every(isNonnegativeInteger) || + // The evidence keys must be exactly the set the released writer emitted for + // this store: a missing, extra, or renamed key is a contract this build + // never produced, so the journal is preserved rather than retired. + !hasExactValidationKeys(validation as Record, expectedValidationKeys) + ) { + throw new Error('Legacy operational cutover journal has invalid validation evidence'); + } +} + +function hasExactValidationKeys( + validation: Record, + expected: ReadonlySet, +): boolean { + const keys = Object.keys(validation); + return keys.length === expected.size && keys.every((key) => expected.has(key)); +} + +function assertLegacyImportSourceRow(row: Record): void { + if ( + typeof row.source_path !== 'string' || + row.source_path.length === 0 || + typeof row.fingerprint !== 'string' || + row.fingerprint.length === 0 || + !isNonnegativeInteger(row.imported_at) + ) { + throw new Error('Legacy operational import source is incomplete or invalid'); + } +} + +function isNonnegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function registerSchema(db: DatabaseSync, scope: string, version: number, appliedAt: number): void { + db.prepare(` + INSERT INTO operational_schema_migrations(scope, version, applied_at) + VALUES (?, ?, ?) + ON CONFLICT(scope) DO UPDATE SET + version = excluded.version, + applied_at = CASE + WHEN operational_schema_migrations.version = excluded.version + THEN operational_schema_migrations.applied_at + ELSE excluded.applied_at + END + `).run(scope, version, appliedAt); +} + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + const emitWarning = process.emitWarning; + process.emitWarning = ((warning: string | Error, ...args: unknown[]) => { + const warningType = typeof args[0] === 'string' ? args[0] : undefined; + if ( + warningType === 'ExperimentalWarning' && + String(warning).startsWith('SQLite is an experimental feature') + ) { + return; + } + Reflect.apply(emitWarning, process, [warning, ...args]); + }) as typeof process.emitWarning; + try { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; + } finally { + process.emitWarning = emitWarning; + } +} + +function loadSqliteModule(): typeof import('node:sqlite') { + return require('node:sqlite') as typeof import('node:sqlite'); +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf0ec2be49569dd1ee470fc4aa8a8e8d187b7f5ecbea3036ca6edfb6eaa7e171.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf0ec2be49569dd1ee470fc4aa8a8e8d187b7f5ecbea3036ca6edfb6eaa7e171.source new file mode 100644 index 0000000000..5250d30c47 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf0ec2be49569dd1ee470fc4aa8a8e8d187b7f5ecbea3036ca6edfb6eaa7e171.source @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import { acquireProcessLifetimeOwner } from '../process-lifetime-owner.js'; +import { + createFileProductionSessionSnapshotService, + SESSION_SNAPSHOT_STATE_IDENTITY_MEDIA_TYPE, +} from '../production-session-snapshot.js'; +import { + SessionSnapshotError, + type SessionSnapshotQuiescenceAuthority, + type SessionSnapshotWorkspaceConfirmationAuthority, +} from '../quiescent-session-snapshot.js'; +import type { PackQuiescentSessionBundleInput } from '../production-session-snapshot.js'; +import type { SessionBundleFileService, SessionBundleLimits } from '../session-bundle-contract.js'; +import { createSessionBundleFileService } from '../session-bundle-file-service.js'; +import { createSessionStore } from '../session-store.js'; + +const limits: SessionBundleLimits = { + maxCompressedBytes: 4 * 1024 * 1024, + maxDecompressedTarBytes: 8 * 1024 * 1024, + maxPayloadBytes: 4 * 1024 * 1024, + maxFileBytes: 2 * 1024 * 1024, + maxEntryCount: 100, + maxManifestBytes: 256 * 1024, + maxStateIdentityBytes: 64 * 1024, + maxPathBytes: 255, + maxPathDepth: 16, +}; + +const immediateQuiescence: SessionSnapshotQuiescenceAuthority = { + async runQuiescent(_input, operation) { + return await operation(); + }, +}; + +test('prepares real Session state and a policy-filtered workspace, then packs and hydrates it', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'README.md'), 'portable workspace\n'); + await writeFile(join(fixture.workspaceRoot, 'package-lock.json'), '{"lockfileVersion":3}\n'); + await mkdir(join(fixture.workspaceRoot, 'node_modules', 'dep'), { recursive: true }); + await writeFile(join(fixture.workspaceRoot, 'node_modules', 'dep', 'index.js'), 'excluded'); + await mkdir(join(fixture.workspaceRoot, '.git'), { recursive: true }); + await writeFile(join(fixture.workspaceRoot, '.git', 'config'), 'excluded'); + await mkdir(join(fixture.workspaceRoot, '.cache'), { recursive: true }); + await writeFile(join(fixture.workspaceRoot, '.cache', 'result'), 'excluded'); + await mkdir(join(fixture.workspaceRoot, 'logs'), { recursive: true }); + await writeFile(join(fixture.workspaceRoot, 'logs', 'runtime.log'), 'excluded'); + + const service = await fixture.createService(); + const archivePath = join(fixture.root, 'bundle.tar.zst'); + const artifact = await service.pack({ destination: archivePath }); + assert.deepEqual(artifact.snapshotCleanup, { state: 'released' }); + + const hydratedRoot = join(fixture.root, 'hydrated'); + const hydrated = await createSessionBundleFileService().hydrate({ + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + expectedSessionId: fixture.cloudSessionId, + destinationRoot: hydratedRoot, + limits, + }); + assert.equal( + await readFile(join(hydrated.workspaceRoot, 'README.md'), 'utf8'), + 'portable workspace\n', + ); + assert.equal( + await readFile(join(hydrated.workspaceRoot, 'package-lock.json'), 'utf8'), + '{"lockfileVersion":3}\n', + ); + await assert.rejects(readFile(join(hydrated.workspaceRoot, 'node_modules', 'dep', 'index.js'))); + await assert.rejects(readFile(join(hydrated.workspaceRoot, '.git', 'config'))); + await assert.rejects(readFile(join(hydrated.workspaceRoot, '.cache', 'result'))); + await assert.rejects(readFile(join(hydrated.workspaceRoot, 'logs', 'runtime.log'))); + assert.equal((await readFile(join(hydrated.stateRoot, 'runtime.sqlite'))).byteLength > 0, true); + + const inspection = await createSessionBundleFileService().inspect({ + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + limits, + }); + assert.equal(inspection.stateIdentity.mediaType, SESSION_SNAPSHOT_STATE_IDENTITY_MEDIA_TYPE); + assert.deepEqual(JSON.parse(Buffer.from(inspection.stateIdentity.bytes).toString('utf8')), { + schemaVersion: 1, + makaSessionId: fixture.sessionId, + }); + assert.deepEqual( + (await readdir(fixture.stagingParent)).filter((name) => name.startsWith('.snapshot-')), + [], + ); + assert.deepEqual( + (await readdir(fixture.stagingParent)).filter((name) => name.startsWith('snapshot-')), + [], + ); + } finally { + await fixture.close(); + } +}); + +test('fails closed for a known user-authored secret and removes private staging', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, '.env'), 'TOKEN=not-portable\n'); + const service = await fixture.createService(); + await assert.rejects(service.prepare({}), (error) => { + assert.ok(error instanceof SessionSnapshotError); + assert.equal(error.code, 'policy_rejected'); + assert.deepEqual(error.details, { + phase: 'workspace', + policyCategory: 'known_secret_file', + }); + return true; + }); + assert.deepEqual( + (await readdir(fixture.stagingParent)).filter((name) => name.includes('snapshot')), + [], + ); + } finally { + await fixture.close(); + } +}); + +test('binds the configured Maka Session and Cloud envelope despite untrusted call fields', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'README.md'), 'bound workspace\n'); + const service = await fixture.createService(); + const archivePath = join(fixture.root, 'bound.tar.zst'); + const artifact = await service.pack({ + destination: archivePath, + makaSessionId: 'other-session', + sessionId: 'other-cloud-session', + } as unknown as PackQuiescentSessionBundleInput); + const inspection = await createSessionBundleFileService().inspect({ + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + limits, + }); + assert.equal(inspection.manifest.envelope.sessionId, fixture.cloudSessionId); + assert.deepEqual(JSON.parse(Buffer.from(inspection.stateIdentity.bytes).toString('utf8')), { + schemaVersion: 1, + makaSessionId: fixture.sessionId, + }); + } finally { + await fixture.close(); + } +}); + +test('rejects a production root nested under the workspace before copying', async () => { + const fixture = await createFixture(); + try { + await assert.rejects( + fixture.createService({ stagingParent: join(fixture.workspaceRoot, 'staging') }), + (error) => error instanceof SessionSnapshotError && error.code === 'unsafe_source', + ); + } finally { + await fixture.close(); + } +}); + +test('uses an authenticated confirmation authority for suspected secret directories', async () => { + const fixture = await createFixture(); + try { + await mkdir(join(fixture.workspaceRoot, 'secrets')); + await writeFile( + join(fixture.workspaceRoot, 'secrets', 'reference.txt'), + 'not a secret value\n', + ); + const confirmations: string[] = []; + const confirmationAuthority: SessionSnapshotWorkspaceConfirmationAuthority = { + async resolveConfirmation(input) { + confirmations.push(`${input.makaSessionId}:${input.confirmationPath}`); + assert.equal(input.confirmationGrantId, 'grant-1'); + return { action: 'include' }; + }, + }; + const service = await fixture.createService({ confirmationAuthority }); + const prepared = await service.prepare({ confirmationGrantId: 'grant-1' }); + try { + assert.equal( + await readFile(join(prepared.snapshot.workspaceRoot, 'secrets', 'reference.txt'), 'utf8'), + 'not a secret value\n', + ); + assert.deepEqual(confirmations, [`${fixture.sessionId}:secrets`]); + } finally { + await prepared.release(); + } + } finally { + await fixture.close(); + } +}); + +test('reserves state entries before admitting workspace entries', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'README.md'), 'would exceed bundle entry quota\n'); + const service = await fixture.createService({ + limits: { ...limits, maxEntryCount: 4 }, + }); + await assert.rejects(service.prepare({}), (error) => { + assert.ok(error instanceof SessionSnapshotError); + assert.equal(error.code, 'quota_exceeded'); + assert.deepEqual(error.details, { phase: 'workspace' }); + return true; + }); + } finally { + await fixture.close(); + } +}); + +test('returns the written Bundle and reports recoverable staging cleanup failure', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'README.md'), 'durable artifact\n'); + const codec = createSessionBundleFileService(); + const bundleFileService: SessionBundleFileService = { + async pack(input) { + const artifact = await codec.pack(input); + const snapshotRoot = dirname(input.snapshot.stateRoot); + await rename(snapshotRoot, `${snapshotRoot}.displaced`); + await mkdir(snapshotRoot, { mode: 0o700 }); + await writeFile(join(snapshotRoot, 'unrelated.txt'), 'do not remove\n'); + return artifact; + }, + inspect: codec.inspect.bind(codec), + hydrate: codec.hydrate.bind(codec), + cleanupHydrationStaging: codec.cleanupHydrationStaging.bind(codec), + }; + const service = await fixture.createService({ bundleFileService }); + const archivePath = join(fixture.root, 'bundle-with-pending-cleanup.tar.zst'); + + const artifact = await service.pack({ destination: archivePath }); + + assert.equal((await readFile(archivePath)).byteLength > 0, true); + const inspection = await codec.inspect({ + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + limits, + }); + assert.equal(inspection.verified, true); + assert.equal(artifact.snapshotCleanup.state, 'pending_recovery'); + if (artifact.snapshotCleanup.state === 'pending_recovery') { + assert.equal(artifact.snapshotCleanup.error.code, 'cleanup_failed'); + assert.deepEqual(artifact.snapshotCleanup.error.details, { phase: 'cleanup' }); + } + } finally { + await fixture.close(); + } +}); + +test('rejects a POSIX-only workspace name with a bounded portability diagnostic', { + skip: process.platform === 'win32', +}, async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'name.'), 'not portable\n'); + const service = await fixture.createService(); + await assert.rejects(service.prepare({}), (error) => { + assert.ok(error instanceof SessionSnapshotError); + assert.equal(error.code, 'unsafe_source'); + assert.deepEqual(error.details, { + phase: 'workspace', + policyCategory: 'unsupported_portable_path', + observed: 1, + }); + return true; + }); + } finally { + await fixture.close(); + } +}); + +async function createFixture(): Promise<{ + readonly root: string; + readonly stateRoot: string; + readonly configRoot: string; + readonly workspaceRoot: string; + readonly stagingParent: string; + readonly cleanupStateRoot: string; + readonly sessionId: string; + readonly cloudSessionId: string; + createService: (overrides?: { + readonly workspaceRoot?: string; + readonly stagingParent?: string; + readonly cleanupStateRoot?: string; + readonly limits?: SessionBundleLimits; + readonly confirmationAuthority?: SessionSnapshotWorkspaceConfirmationAuthority; + readonly bundleFileService?: SessionBundleFileService; + }) => ReturnType; + close(): Promise; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-production-snapshot-')); + const stateRoot = join(root, 'state'); + const configRoot = join(root, 'config'); + const workspaceRoot = join(root, 'workspace'); + const stagingParent = join(root, 'staging'); + const cleanupStateRoot = join(root, 'cleanup-state'); + await Promise.all([ + mkdir(configRoot), + mkdir(workspaceRoot), + mkdir(stagingParent, { mode: 0o700 }), + ]); + const sessions = createSessionStore(stateRoot); + const session = await sessions.create(sessionInput()); + await sessions.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'durable message', + }); + await sessions.close?.(); + const owner = await acquireProcessLifetimeOwner(cleanupStateRoot); + const cloudSessionId = 'cloud-session-1'; + return { + root, + stateRoot, + configRoot, + workspaceRoot, + stagingParent, + cleanupStateRoot, + sessionId: session.id, + cloudSessionId, + createService: (overrides = {}) => + createFileProductionSessionSnapshotService({ + session: { makaSessionId: session.id, cloudSessionId }, + stateRoot, + configRoot, + workspaceRoot: overrides.workspaceRoot ?? workspaceRoot, + stagingParent: overrides.stagingParent ?? stagingParent, + cleanupStateRoot: overrides.cleanupStateRoot ?? cleanupStateRoot, + processLifetimeOwner: owner, + quiescence: immediateQuiescence, + limits: overrides.limits ?? limits, + confirmationAuthority: overrides.confirmationAuthority, + bundleFileService: overrides.bundleFileService, + }), + async close(): Promise { + await owner.close(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +function sessionInput(): CreateSessionInput { + return { + cwd: '/tmp/workspace', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Portable Session', + labels: [], + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf3c74074f632a14c344c2e7a2b4e010ef8171c4ab1cfe25f5c1dd958d50a0e9.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf3c74074f632a14c344c2e7a2b4e010ef8171c4ab1cfe25f5c1dd958d50a0e9.source new file mode 100644 index 0000000000..c818f28e4f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/bf3c74074f632a14c344c2e7a2b4e010ef8171c4ab1cfe25f5c1dd958d50a0e9.source @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; + +for (const actionId of [undefined, 'stable-action']) { + test(`WorkHub Coordination admission preserves its bounded content identity across restart (${actionId ?? 'legacy'})`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-admission-')); + const inputDigest = `sha256:${'a'.repeat(64)}` as const; + try { + const store = createSqliteAgentRunStore(root); + const admitted = await store.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'coordination-turn', + proposedRunId: 'coordination-run', + proposedUserMessageId: 'coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest, + ...(actionId ? { operation: 'action' as const, actionId } : {}), + }, + previousRootTurnId: null, + normalizedInput: { text: 'What should happen next?' }, + sourceMessages: [], + admittedAt: 50, + }); + assert.equal(admitted.kind, 'admitted'); + store.close?.(); + + const reopened = createSqliteAgentRunStore(root); + assert.deepEqual( + await reopened.readRootTurnAdmission('coordination-session', 'coordination-turn'), + admitted.admission, + ); + await assert.rejects( + () => + reopened.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'invalid-coordination-turn', + proposedRunId: 'invalid-coordination-run', + proposedUserMessageId: 'invalid-coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest: 'sha256:not-a-digest', + } as RootExecutionDescriptor, + previousRootTurnId: 'coordination-turn', + normalizedInput: { text: 'Invalid identity' }, + sourceMessages: [], + admittedAt: 60, + }), + /Invalid root execution descriptor/u, + ); + reopened.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c17b71c2d1c9a64b48bd5f0e65ac91f78ae9a92432735bc1f2cb420af4814d06.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c17b71c2d1c9a64b48bd5f0e65ac91f78ae9a92432735bc1f2cb420af4814d06.source new file mode 100644 index 0000000000..e37ec33df1 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c17b71c2d1c9a64b48bd5f0e65ac91f78ae9a92432735bc1f2cb420af4814d06.source @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { promisify } from 'node:util'; +import { execGitBytes, execGitText } from '../git-exec.js'; + +const execFileAsync = promisify(execFile); + +test('Git execution ignores ambient repository variables for text and byte output', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-git-exec-')); + const repository = join(root, 'repository'); + await execFileAsync('git', ['init', '--quiet', repository]); + + const keys = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'] as const; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + process.env.GIT_DIR = join(root, 'wrong-git-dir'); + process.env.GIT_WORK_TREE = join(root, 'wrong-work-tree'); + process.env.GIT_COMMON_DIR = join(root, 'wrong-common-dir'); + process.env.GIT_INDEX_FILE = join(root, 'wrong-index'); + + const expected = await realpath(repository); + const text = await execGitText(repository, ['rev-parse', '--show-toplevel']); + const bytes = await execGitBytes(repository, ['rev-parse', '--show-toplevel']); + + assert.equal(text.trim(), expected); + assert.equal(new TextDecoder().decode(bytes).trim(), expected); + } finally { + for (const key of keys) { + const value = previous[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3670ed264cb7c6762d741b685d4a955c34f812692c81423ea852e2c5dda0920.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3670ed264cb7c6762d741b685d4a955c34f812692c81423ea852e2c5dda0920.source new file mode 100644 index 0000000000..98cacbc201 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3670ed264cb7c6762d741b685d4a955c34f812692c81423ea852e2c5dda0920.source @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { execFile } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export async function createGitRepositoryWithWorktree( + repository: string, + linkedWorktree: string, + branch: string, +): Promise { + await mkdir(repository); + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); + await writeFile(join(repository, 'tracked.txt'), 'tracked\n', 'utf8'); + await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repository }); + await execFileAsync( + 'git', + [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'init', + ], + { cwd: repository }, + ); + await execFileAsync('git', ['worktree', 'add', '--quiet', '-b', branch, linkedWorktree], { + cwd: repository, + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3973fb227903bf53858772876c7eabe65c00cb8bffa2506dcd52782d6b56e0e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3973fb227903bf53858772876c7eabe65c00cb8bffa2506dcd52782d6b56e0e.source new file mode 100644 index 0000000000..a17a1d1581 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3973fb227903bf53858772876c7eabe65c00cb8bffa2506dcd52782d6b56e0e.source @@ -0,0 +1,6689 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdirSync } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; +import type { DatabaseSync } from 'node:sqlite'; +import { + AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION, + AgentGraphClientProjectionConflictError, + AgentGraphClientTerminalCursorError, + type AgentGraphClientClaimAdmission, + type AgentGraphClientProjectionRecord, + type AgentGraphClientProjectionWithOperator, + type AgentGraphClientOperatorProjectionRecord, + type AgentGraphClientTerminalActivityPage, + type CommitAgentGraphClientProjectionRequest, +} from '@maka/core/agent-graph-client-projection'; +import { + assessSandboxBoundaryExpansion, + assertExecutionBoundaryCapacity, + decodeExecutionBoundary, + createGenesisExecutionBoundary, + SANDBOX_BOUNDARY_CLOSURE_REASONS, + SANDBOX_BOUNDARY_HOST_RESTART_CLOSURE_REASON, + validateSandboxBoundaryExpansion, + type CreateSandboxBoundaryRequest, + type ExecutionBoundary, + type SandboxBoundaryRequest, + type SandboxBoundarySettlement, + type SettleSandboxBoundaryRequest, +} from '@maka/core/sandbox-boundary'; +import { + AGENT_GRAPH_EPOCH_SCHEMA_VERSION, + AgentGraphEpochConflictError, + assertAdvanceAgentGraphEpochRequest, + assertResolveAgentGraphEpochRequest, + decodeAgentGraphEpochBinding, + type AdvanceAgentGraphEpochRequest, + type AgentGraphEpochBinding, + type ResolveAgentGraphEpochRequest, +} from '@maka/core/agent-graph-epoch'; +import { + assertAgentGraphScheduleUpdateRequest, + AgentGraphScheduleClosedError, + AgentGraphScheduleRevisionConflictError, + decodeAgentGraphScheduleUpdate, + type AgentGraphScheduleUpdate, + type AgentGraphScheduleUpdateRequest, + type AgentGraphScheduleUpdateResult, + type AgentGraphIntentAdmissionState, + type AgentGraphIntentAdmissionTransition, +} from '@maka/core/agent-graph-schedule'; +import { + assertAgentGraphOperatorProvisionRequest, + decodeAgentGraphOperatorProvision, + type AgentGraphOperatorProvision, + type AgentGraphOperatorProvisionRequest, + type AgentGraphOperatorProvisionResult, +} from '@maka/core/agent-graph-topology'; +import { + assertAgentGraphIntentClaimRequest, + decodeAgentGraphIntentClaim, + type AgentGraphIntentClaim, + type AgentGraphIntentClaimRequest, + type AgentGraphIntentClaimResult, +} from '@maka/core/agent-graph-control'; +import { + isSubagentSessionParent, + isSubagentSessionRuntime, + isSubagentSessionSpawn, + type SessionHeader, + type SessionHeaderPatch, + type StoredMessage, + type AssistantMessage, + type UserMessage, + type SubagentSessionParent, + type WorkHubActionClaim, + type WorkHubActionClaimOutcome, + type WorkHubActionOperation, + type WorkHubDelegationAssignedMessage, + type WorkHubDelegationSupersededMessage, + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + decodeCanonicalMessage, + decodeStoredMessage as decodePersistedStoredMessage, +} from '@maka/core/session'; +import { markPersisted } from '@maka/core/persisted-value'; +import { + normalizePendingMessageAdmission, + normalizeProvenRootMessageHandoff, + normalizeProvenSteeringMessageHandoff, + samePendingMessageAdmission, + type MarkMessagesHandedOffInput, + type MessageAdmissionCancellationClaimOutcome, + type PendingMessageAdmission, + type ProvenRootMessageHandoff, + type ProvenSteeringMessageHandoff, +} from './message-admission-store.js'; +import { normalizeSubmittedTurnIntent } from './submitted-turn-intent.js'; +import { + messageContentDigest, + messageContentsEqual, + normalizeMessageContent, +} from '@maka/core/events'; +import { + type AgentGraphIntentAdmissionSnapshot, + type AgentGraphTimelineMetadataSnapshot, +} from '@maka/core/agent-graph-timeline'; +import { + AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION, + type AgentGraphSupervisorWakeAttemptRecord, + type AgentGraphSupervisorWakeRecord, + type BeginAgentGraphSupervisorWakeAttemptRequest, + type ClaimAgentGraphSupervisorWakeRequest, + type CompleteAgentGraphSupervisorWakeAttemptRequest, + type SupersedeAgentGraphSupervisorWakesRequest, +} from '@maka/core/agent-graph-supervisor-wake'; +import { type SessionListFilter } from '@maka/core/runtime-inputs'; +import { + assertSafeSessionId, + decodePersistedSessionHeader, + normalizeSessionHeader, + SessionNotFoundError, + type ExternalSessionImportLookupResult, + type CoordinationTranscriptReference, + type CoordinationTranscriptIndexRecord, + type CoordinationTranscriptIndexState, + type SessionMessageScanPage, + type SessionMessageScanRecord, + type SessionMessageScanRequest, + type SessionTranscriptMessageLookupRequest, +} from './session-store.js'; +import { + isDiscardableConversationCopy, + isValidConversationCopyTransition, +} from './session-conversation-copy.js'; +import { projectSessionCatalogMessages } from './session-message-projection.js'; +import { + configureSqliteSessionMetadataDatabase, + migrateSqliteSessionMetadataDatabase, + readSqliteSessionMetadataSchemaVersion, + SQLITE_AGENT_GRAPH_CONTROL_TABLES, + SQLITE_SESSION_MESSAGE_CHUNK_BYTES, + SQLITE_SESSION_MESSAGE_CHUNK_MARKER, +} from './sqlite-session-metadata-schema.js'; +import type { OperationalStateDatabaseLease } from './operational-state-store.js'; +import { + buildSqliteSessionCatalogPageQuery, + type SqliteSessionCatalogCursor, +} from './sqlite-session-catalog-query.js'; +import { + sqliteOrdinarySessionRolePredicate, + sqliteRecoverableSessionRolePredicate, +} from './sqlite-session-role-scope.js'; + +export { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; + +const SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE = 256; +// Each target Session binds three parameters in the linkage query. Stay well +// inside SQLite's bound-parameter limit. +const WORKHUB_TARGET_LINKAGE_MAX_SESSIONS = 256; + +function decodeStoredMessage(value: unknown): StoredMessage { + return decodePersistedStoredMessage(markPersisted(value)); +} + +const require = createRequire(import.meta.url); +const AGENT_GRAPH_CONTROL_DELETE_TABLES = SQLITE_AGENT_GRAPH_CONTROL_TABLES.filter( + (table) => table !== 'agent_graph_epochs', +).reverse(); + +function loadSqliteModule(): typeof import('node:sqlite') { + const emitWarning = process.emitWarning; + process.emitWarning = ((warning: string | Error, ...args: unknown[]) => { + const warningType = typeof args[0] === 'string' ? args[0] : undefined; + if ( + warningType === 'ExperimentalWarning' && + String(warning).startsWith('SQLite is an experimental feature') + ) { + return; + } + Reflect.apply(emitWarning, process, [warning, ...args]); + }) as typeof process.emitWarning; + try { + return require('node:sqlite') as typeof import('node:sqlite'); + } finally { + process.emitWarning = emitWarning; + } +} + +export type SqliteSessionMetadataStoreFailpoint = + | 'after_session_row_write' + | 'after_agent_graph_intent_claim_write' + | 'after_agent_graph_schedule_update_write' + | 'after_agent_graph_operator_provision_write' + | 'after_sandbox_boundary_write'; + +/** + * Role visibility is stated at every call site on purpose: a default would let + * a new reader inherit the widest scope by omission. + */ +export type SessionMetadataRoleScope = 'all' | 'ordinary' | 'recoverable'; + +export interface SqliteSessionMetadataStoreOptions { + now?: () => number; + failpoint?: (point: SqliteSessionMetadataStoreFailpoint) => void; + /** @internal Repository connection supplied by the operational DB owner. */ + databaseLease?: OperationalStateDatabaseLease; +} + +export interface SqliteWorkHubMessageAssignmentRequest { + readonly assignment: WorkHubDelegationAssignedMessage; + readonly admission: PendingMessageAdmission; + readonly projection: SessionCatalogMessageProjection; + readonly supersession?: WorkHubDelegationSupersededMessage; + readonly create?: { + readonly header: SessionHeader; + readonly requestFingerprint: string; + }; +} + +export interface SqliteWorkHubMessageAssignmentResult { + readonly kind: 'assigned' | 'existing'; + readonly targetCreated: boolean; + readonly assignment: WorkHubDelegationAssignedMessage; +} + +export interface SessionMetadataRecord { + header: SessionHeader; + metadataVersion: number; + committedAt: number; +} + +export interface SessionMetadataCatalogRecord extends SessionMetadataRecord { + readonly activityAt: number; + readonly lastMessagePreview?: string; +} + +export interface SessionCatalogRevisionState { + readonly epoch: string; + readonly generation: number; +} + +export type SessionMetadataCatalogCursor = SqliteSessionCatalogCursor; + +export interface SessionMetadataCatalogPage { + readonly revision: SessionCatalogRevisionState; + readonly records: readonly SessionMetadataCatalogRecord[]; + readonly hasMore: boolean; +} + +export interface SessionCatalogMessageProjection { + readonly lastMessageAt?: number; + readonly lastMessagePreview?: string; +} + +interface MessageAdmissionRow { + readonly turn_id?: unknown; + readonly run_id?: unknown; + readonly message_id?: unknown; + readonly content_json?: unknown; + readonly submitted_content_digest?: unknown; + readonly submitted_placement?: unknown; + readonly placement?: unknown; + readonly disposition?: unknown; + readonly queue_order?: unknown; + readonly admitted_at?: unknown; + readonly submitted_intent_json?: unknown; + readonly skill_invocation_json?: unknown; +} + +function decodeMessageAdmissionRow( + sessionId: string, + row: MessageAdmissionRow, +): PendingMessageAdmission { + if ( + typeof row.turn_id !== 'string' || + typeof row.run_id !== 'string' || + typeof row.message_id !== 'string' || + typeof row.content_json !== 'string' || + typeof row.skill_invocation_json !== 'string' || + typeof row.submitted_content_digest !== 'string' || + (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') || + (row.placement !== 'current_turn' && row.placement !== 'next_turn') || + (row.disposition !== 'steering' && row.disposition !== 'followup') || + typeof row.queue_order !== 'number' || + !Number.isSafeInteger(row.queue_order) || + row.queue_order < 0 || + typeof row.admitted_at !== 'number' + ) { + throw new SessionMetadataConflictError(`Invalid Message admission row for ${sessionId}`); + } + return normalizePendingMessageAdmission({ + sessionId, + turnId: row.turn_id, + runId: row.run_id, + messageId: row.message_id, + content: JSON.parse(row.content_json) as PendingMessageAdmission['content'], + submittedContentDigest: + row.submitted_content_digest as PendingMessageAdmission['submittedContentDigest'], + submittedPlacement: row.submitted_placement, + placement: row.placement, + disposition: row.disposition, + ...(typeof row.submitted_intent_json === 'string' + ? { submittedIntent: normalizeSubmittedTurnIntent(JSON.parse(row.submitted_intent_json)) } + : {}), + skillInvocation: JSON.parse( + row.skill_invocation_json, + ) as PendingMessageAdmission['skillInvocation'], + admittedAt: row.admitted_at, + }); +} + +export interface SessionAuthoritySnapshot { + record: SessionMetadataRecord; + boundary: ExecutionBoundary; +} + +export interface VersionedSessionIdentity { + readonly sessionId: string; + readonly expectedVersion: number; +} + +export type SessionRemovalProbe = + | { readonly kind: 'present'; readonly record: SessionMetadataRecord } + | { readonly kind: 'removed' } + | { readonly kind: 'absent' }; + +function uniqueVersionedSessionIdentities( + sessions: readonly VersionedSessionIdentity[], +): VersionedSessionIdentity[] { + if (sessions.length === 0) throw new Error('Session lifecycle requires at least one Session'); + const unique = new Map(); + for (const identity of sessions) { + assertSafeSessionId(identity.sessionId); + if (!Number.isSafeInteger(identity.expectedVersion) || identity.expectedVersion < 1) { + throw new Error(`Invalid Session metadata version: ${identity.expectedVersion}`); + } + const existing = unique.get(identity.sessionId); + if (existing && existing.expectedVersion !== identity.expectedVersion) { + throw new Error(`Conflicting Session metadata versions for ${identity.sessionId}`); + } + unique.set(identity.sessionId, identity); + } + return [...unique.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId)); +} + +export interface IdempotentSubagentSessionMetadataResult { + record: SessionMetadataRecord; + created: boolean; +} + +export type StableSessionCreateProbe = + | { readonly kind: 'absent' } + | { readonly kind: 'existing'; readonly record: SessionMetadataRecord } + | { + readonly kind: 'conflict'; + readonly reason: 'identity_mismatch' | 'removed'; + }; + +export type StableSessionMetadataCreateResult = + | { readonly kind: 'created'; readonly record: SessionMetadataRecord } + | { readonly kind: 'existing'; readonly record: SessionMetadataRecord } + | { + readonly kind: 'conflict'; + readonly reason: 'identity_mismatch' | 'removed'; + }; + +export interface SessionConfigurationMetadataUpdate { + readonly expectedVersion: number; + readonly configuration: { + readonly backend: SessionHeader['backend']; + readonly llmConnectionId: string; + readonly llmConnectionSlug: string; + readonly connectionLocked: boolean; + readonly model: string; + readonly thinkingLevel: SessionHeader['thinkingLevel']; + readonly permissionMode: SessionHeader['permissionMode']; + readonly collaborationMode: NonNullable; + readonly orchestrationMode: NonNullable; + readonly labels: readonly string[]; + }; + readonly lifecycle: + | { readonly kind: 'preserve' } + | { + readonly kind: 'clear_connection_block'; + readonly statusUpdatedAt: number; + }; +} + +export interface IdempotentAgentGraphOperatorMetadataResult + extends AgentGraphOperatorProvisionResult { + record: SessionMetadataRecord; +} + +export class SessionMetadataConflictError extends Error { + readonly name: string = 'SessionMetadataConflictError'; +} + +export class StoredSessionMessageIncompatibleError extends Error { + readonly name = 'StoredSessionMessageIncompatibleError'; + readonly code = 'stored_session_message_incompatible'; + + constructor( + readonly sessionId: string, + readonly sequence: number, + options?: ErrorOptions, + ) { + super(`Stored Session message ${sequence} for ${sessionId} is incompatible`, options); + } +} + +export class SessionMetadataVersionConflictError extends SessionMetadataConflictError { + readonly name = 'SessionMetadataVersionConflictError'; + + constructor( + readonly sessionId: string, + readonly expectedVersion: number, + readonly actualVersion: number, + ) { + super( + `Session metadata version conflict for ${sessionId}: expected ${expectedVersion}, found ${actualVersion}`, + ); + } +} + +export class AgentGraphIntentClaimConflictError extends SessionMetadataConflictError { + readonly name = 'AgentGraphIntentClaimConflictError'; +} + +export class AgentGraphScheduleUpdateConflictError extends SessionMetadataConflictError { + readonly name = 'AgentGraphScheduleUpdateConflictError'; +} + +export function createSqliteSessionMetadataStore( + path: string, + options: SqliteSessionMetadataStoreOptions = {}, +): SqliteSessionMetadataStore { + return new SqliteSessionMetadataStore(path, options); +} + +export class SqliteSessionMetadataStore { + private readonly db: DatabaseSync; + private readonly databaseLease?: OperationalStateDatabaseLease; + private readonly now: () => number; + private closed = false; + + constructor( + private readonly path: string, + private readonly options: SqliteSessionMetadataStoreOptions = {}, + ) { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); + if (options.databaseLease) { + this.databaseLease = options.databaseLease; + this.db = options.databaseLease.database; + this.now = options.now ?? Date.now; + return; + } + const { DatabaseSync } = loadSqliteModule(); + const database = new DatabaseSync(path); + try { + configureSqliteSessionMetadataDatabase(database); + migrateSqliteSessionMetadataDatabase(database); + } catch (error) { + database.close(); + throw error; + } + this.db = database; + this.now = options.now ?? Date.now; + } + + schemaVersion(): number { + this.assertOpen(); + return readSqliteSessionMetadataSchemaVersion(this.db); + } + + journalMode(): string { + this.assertOpen(); + const row = this.db.prepare('PRAGMA journal_mode').get() as + | { journal_mode?: unknown } + | undefined; + return typeof row?.journal_mode === 'string' ? row.journal_mode.toLowerCase() : ''; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.databaseLease) this.databaseLease.close(); + else this.db.close(); + } + + async backup(destinationPath: string): Promise { + this.assertOpen(); + if (!destinationPath) throw new Error('Session metadata backup destination is required'); + if (this.path !== ':memory:' && resolve(destinationPath) === resolve(this.path)) { + throw new Error('Session metadata backup destination must differ from the source database'); + } + if (existsSync(destinationPath)) { + throw new Error(`Session metadata backup destination already exists: ${destinationPath}`); + } + mkdirSync(dirname(destinationPath), { recursive: true }); + return loadSqliteModule().backup(this.db, destinationPath); + } + + async readExecutionBoundary(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.ensureGenesisExecutionBoundary(record.header); + return this.readCurrentExecutionBoundarySync(sessionId); + }); + } + + async readSessionAuthoritySnapshot(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.ensureGenesisExecutionBoundary(record.header); + return { + record, + boundary: this.readCurrentExecutionBoundarySync(sessionId), + }; + }); + } + + async createSandboxBoundaryRequest( + input: CreateSandboxBoundaryRequest, + ): Promise { + this.assertOpen(); + assertSafeSessionId(input.sessionId); + assertSafeBoundaryRequestId(input.requestId); + assertSandboxBoundaryProvenanceId(input.turnId, 'turn id'); + if (input.runId !== undefined) assertSandboxBoundaryProvenanceId(input.runId, 'run id'); + const validated = validateSandboxBoundaryExpansion(input.expansion); + if (!validated.ok) throw new Error(validated.message); + const justification = input.justification.trim(); + if (!justification || justification.length > 2_000) { + throw new Error('Sandbox boundary request justification must contain 1 to 2000 characters'); + } + + return this.transaction(() => { + const record = this.readRecordSync(input.sessionId); + if (!record) throw new SessionNotFoundError(input.sessionId); + this.ensureGenesisExecutionBoundary(record.header); + + const existing = this.readSandboxBoundaryRequestSync(input.sessionId, input.requestId); + if (existing) { + if ( + !isDeepStrictEqual(existing.expansion, validated.expansion) || + existing.justification !== justification || + existing.turnId !== input.turnId || + existing.runId !== input.runId + ) { + throw new SessionMetadataConflictError( + `Sandbox boundary request identity was reused with different content: ${input.requestId}`, + ); + } + return existing; + } + + const boundary = this.readCurrentExecutionBoundarySync(input.sessionId); + const createdAt = this.now(); + this.db + .prepare( + ` + INSERT INTO sandbox_boundary_log( + session_id, + entry_id, + entry_kind, + request_id, + status, + base_revision, + expansion_json, + justification, + created_at, + turn_id, + run_id + ) VALUES (?, ?, 'expansion_request', ?, 'pending', ?, ?, ?, ?, ?, ?) + `, + ) + .run( + input.sessionId, + `request:${input.requestId}`, + input.requestId, + boundary.revision, + JSON.stringify(validated.expansion), + justification, + createdAt, + input.turnId, + input.runId ?? null, + ); + this.options.failpoint?.('after_sandbox_boundary_write'); + return this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId); + }); + } + + async readSandboxBoundaryRequest( + sessionId: string, + requestId: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeBoundaryRequestId(requestId); + return this.transaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + return this.readSandboxBoundaryRequestSync(sessionId, requestId); + }); + } + + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.ensureGenesisExecutionBoundary(record.header); + const rows = this.db + .prepare( + ` + SELECT ${SANDBOX_BOUNDARY_REQUEST_COLUMNS} + FROM sandbox_boundary_log + WHERE session_id = ? AND status = 'pending' + ORDER BY created_at, entry_id + `, + ) + .all(sessionId) as unknown as SandboxBoundaryRequestRow[]; + return rows.map(decodeSandboxBoundaryRequestRow); + }); + } + + /** + * Every request this session closed because the host restarted, settled or + * not consumed. Recovery re-reads this instead of remembering what it just + * denied: a recovery pass interrupted between the settlement and the run's + * terminal commit must still find the closure on its next attempt, and the + * pending query cannot serve that because the row is no longer pending. + */ + async listSandboxBoundaryRestartClosures(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + const rows = this.db + .prepare( + ` + SELECT ${SANDBOX_BOUNDARY_REQUEST_COLUMNS} + FROM sandbox_boundary_log + WHERE session_id = ? + AND entry_kind = 'expansion_request' + AND status = 'denied' + AND outcome_reason = ? + ORDER BY created_at, entry_id + `, + ) + .all( + sessionId, + SANDBOX_BOUNDARY_HOST_RESTART_CLOSURE_REASON, + ) as unknown as SandboxBoundaryRequestRow[]; + return rows.map(decodeSandboxBoundaryRequestRow); + }); + } + + async hasExplicitSandboxBoundaryDenial( + identities: readonly { sessionId: string; runId: string; turnId: string }[], + ): Promise { + this.assertOpen(); + const query = this.db.prepare(` + SELECT + MAX(CASE WHEN outcome_reason = 'client_denied' THEN 1 ELSE 0 END) AS explicit, + MAX(CASE WHEN outcome_reason IN ('client_denied', 'turn_stopped', 'turn_terminal', 'host_restarted') + THEN 0 ELSE 1 END) AS ambiguous + FROM sandbox_boundary_log + WHERE session_id = ? AND run_id = ? AND turn_id = ? + AND entry_kind = 'expansion_request' AND status = 'denied' + `); + let denied = false; + for (const identity of identities) { + assertSafeSessionId(identity.sessionId); + assertSandboxBoundaryProvenanceId(identity.runId, 'run id'); + assertSandboxBoundaryProvenanceId(identity.turnId, 'turn id'); + const evidence = query.get(identity.sessionId, identity.runId, identity.turnId) as { + explicit: number | null; + ambiguous: number | null; + }; + // Legacy NULL also meant internal cleanup. Do not invent a user decision + // or erase a possible denial; ambiguous provenance blocks this continuation. + if (evidence.ambiguous === 1) { + throw new Error( + 'Historical sandbox denial cannot be attributed safely; start a new user Turn.', + ); + } + denied ||= evidence.explicit === 1; + } + return denied; + } + + async settleSandboxBoundaryRequest( + input: SettleSandboxBoundaryRequest, + ): Promise { + this.assertOpen(); + assertSafeSessionId(input.sessionId); + assertSafeBoundaryRequestId(input.requestId); + if (input.decision !== 'allow' && input.decision !== 'deny') { + throw new Error('Invalid sandbox boundary decision'); + } + if ( + input.closureReason !== undefined && + !SANDBOX_BOUNDARY_CLOSURE_REASONS.includes(input.closureReason) + ) { + throw new Error('Invalid sandbox boundary closure reason'); + } + + return this.transaction(() => { + const record = this.readRecordSync(input.sessionId); + if (!record) throw new SessionNotFoundError(input.sessionId); + this.ensureGenesisExecutionBoundary(record.header); + const request = this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId); + const current = this.readCurrentExecutionBoundarySync(input.sessionId); + if (request.status !== 'pending') { + return { request, boundary: current, changed: false }; + } + + const settledAt = this.now(); + if (input.decision === 'deny') { + this.settleSandboxBoundaryRequestRow({ + sessionId: input.sessionId, + requestId: input.requestId, + status: 'denied', + outcomeReason: input.closureReason ?? 'client_denied', + settledAt, + }); + return { + request: this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId), + boundary: current, + changed: false, + }; + } + + if (current.kind !== 'managed') { + this.settleSandboxBoundaryRequestRow({ + sessionId: input.sessionId, + requestId: input.requestId, + status: 'conflict', + outcomeReason: 'boundary_kind_changed', + settledAt, + }); + return { + request: this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId), + boundary: current, + changed: false, + }; + } + + const assessment = assessSandboxBoundaryExpansion(current.profile, request.expansion, { + root: record.header.cwd, + workspaceRoots: [record.header.cwd], + tmpdir: tmpdir(), + slashTmp: '/tmp', + }); + if (assessment.outcome === 'conflict') { + this.settleSandboxBoundaryRequestRow({ + sessionId: input.sessionId, + requestId: input.requestId, + status: 'conflict', + outcomeReason: assessment.reason, + settledAt, + }); + return { + request: this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId), + boundary: current, + changed: false, + }; + } + if (assessment.outcome === 'noop') { + this.settleSandboxBoundaryRequestRow({ + sessionId: input.sessionId, + requestId: input.requestId, + status: 'approved', + outcomeReason: 'already_applied', + settledAt, + }); + return { + request: this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId), + boundary: current, + changed: false, + }; + } + + const boundary: ExecutionBoundary = { + kind: 'managed', + profile: assessment.profile, + revision: current.revision + 1, + }; + assertExecutionBoundaryCapacity(boundary); + this.settleSandboxBoundaryRequestRow({ + sessionId: input.sessionId, + requestId: input.requestId, + status: 'approved', + appliedRevision: boundary.revision, + boundary, + settledAt, + }); + return { + request: this.requireSandboxBoundaryRequestSync(input.sessionId, input.requestId), + boundary, + changed: true, + }; + }); + } + + async setExecutionBoundaryKind( + sessionId: string, + kind: 'managed' | 'bypass', + projection?: { + permissionMode: SessionHeader['permissionMode']; + labels?: readonly string[]; + }, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction( + () => this.setExecutionBoundaryKindSync(sessionId, kind, projection).boundary, + ); + } + + async updateSessionConfiguration( + sessionId: string, + input: SessionConfigurationMetadataUpdate, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertMetadataVersion(input.expectedVersion, 'Session configuration expected version'); + const kind = input.configuration.permissionMode === 'bypass' ? 'bypass' : 'managed'; + return this.transaction(() => { + const current = this.readRecordSync(sessionId); + if (!current) throw new SessionNotFoundError(sessionId); + if (current.metadataVersion !== input.expectedVersion) { + throw new SessionMetadataVersionConflictError( + sessionId, + input.expectedVersion, + current.metadataVersion, + ); + } + const lifecyclePatch = + input.lifecycle.kind === 'preserve' + ? {} + : clearConnectionBlock(current, input.lifecycle.statusUpdatedAt); + return this.setExecutionBoundaryKindSync( + sessionId, + kind, + { + permissionMode: input.configuration.permissionMode, + labels: input.configuration.labels, + }, + { + expectedVersion: input.expectedVersion, + headerPatch: { + ...input.configuration, + labels: [...input.configuration.labels], + ...lifecyclePatch, + }, + }, + ).record; + }); + } + + async create( + header: SessionHeader, + initialBoundary?: ExecutionBoundary, + ): Promise { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + if (normalized.subagentSpawn) { + throw new Error('Subagent spawn metadata requires idempotent child-session creation'); + } + return this.transaction(() => { + if (this.hasTombstone(normalized.id)) { + throw new SessionMetadataConflictError( + `Session metadata id is tombstoned: ${normalized.id}`, + ); + } + if (this.readRecordSync(normalized.id)) { + throw new SessionMetadataConflictError(`Session metadata already exists: ${normalized.id}`); + } + return this.insertHeader(normalized, 1, this.now(), initialBoundary); + }); + } + + async probeStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + return this.readTransaction(() => + this.probeStableSessionCreateSync(sessionId, requestFingerprint), + ); + } + + async claimStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + return this.transaction(() => { + const probe = this.probeStableSessionCreateSync(sessionId, requestFingerprint); + if (probe.kind !== 'absent') return probe; + this.db + .prepare( + ` + INSERT OR IGNORE INTO session_create_claims( + session_id, + request_fingerprint, + claimed_at + ) VALUES (?, ?, ?) + `, + ) + .run(sessionId, requestFingerprint, this.now()); + return this.probeStableSessionCreateSync(sessionId, requestFingerprint); + }); + } + + async hasStableSessionCreateClaim( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + const row = this.db + .prepare( + 'SELECT request_fingerprint AS requestFingerprint FROM session_create_claims WHERE session_id = ?', + ) + .get(sessionId) as { requestFingerprint?: unknown } | undefined; + return row?.requestFingerprint === requestFingerprint; + } + + async createStableSession( + header: SessionHeader, + requestFingerprint: string, + initialBoundary?: ExecutionBoundary, + ): Promise { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + assertSessionCreateFingerprint(requestFingerprint); + if (normalized.subagentSpawn) { + throw new Error('Subagent spawn metadata requires idempotent child-session creation'); + } + return this.transaction(() => { + const probe = this.probeStableSessionCreateSync(normalized.id, requestFingerprint); + if (probe.kind !== 'absent') return probe; + const committedAt = this.now(); + this.db + .prepare( + ` + INSERT INTO session_create_claims(session_id, request_fingerprint, claimed_at) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO NOTHING + `, + ) + .run(normalized.id, requestFingerprint, committedAt); + return { + kind: 'created' as const, + record: this.insertHeader(normalized, 1, committedAt, initialBoundary), + }; + }); + } + + async discardStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + return this.transaction(() => { + const probe = this.probeStableSessionCreateSync(sessionId, requestFingerprint); + if (probe.kind === 'conflict') { + throw new SessionMetadataConflictError( + 'Stable Session identity belongs to a different request', + ); + } + if (probe.kind === 'existing') { + const copy = probe.record.header.conversationCopy; + if ( + copy?.requestFingerprint !== requestFingerprint || + !isDiscardableConversationCopy(probe.record.header) + ) { + throw new SessionMetadataConflictError( + 'Only a matching incomplete conversation copy can be discarded', + ); + } + } + const deleted = + this.db.prepare('DELETE FROM session_metadata WHERE session_id = ?').run(sessionId) + .changes === 1; + this.db + .prepare( + 'DELETE FROM session_create_claims WHERE session_id = ? AND request_fingerprint = ?', + ) + .run(sessionId, requestFingerprint); + return deleted; + }); + } + + async createSubagent( + header: SessionHeader, + initialBoundary?: ExecutionBoundary, + ): Promise { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + if (normalized.subagentParent?.graph) { + throw new Error('Graph operator metadata requires atomic topology provisioning'); + } + const identity = requireSubagentSpawnIdentity(normalized); + return this.transaction(() => { + if (this.hasTombstone(normalized.id)) { + throw new SessionMetadataConflictError( + `Session metadata id is tombstoned: ${normalized.id}`, + ); + } + if (this.readRecordSync(normalized.id)) { + throw new SessionMetadataConflictError(`Session metadata already exists: ${normalized.id}`); + } + const committedAt = this.now(); + const claim = this.tryClaimSubagentSpawn(normalized, committedAt); + if (claim.created) { + return { + record: this.insertHeader(normalized, 1, committedAt, initialBoundary), + created: true, + }; + } + const existing = this.readRecordSync(claim.childSessionId); + if (claim.requestFingerprint !== identity.spawn.requestFingerprint) { + throw new SessionMetadataConflictError( + 'Child-session spawn identity was reused for different work', + ); + } + if (!existing) { + throw new SessionMetadataConflictError( + `Child-session spawn identity belongs to deleted session: ${claim.childSessionId}`, + ); + } + if (!isDeepStrictEqual(existing.header.subagentParent, identity.parent)) { + throw new SessionMetadataConflictError( + 'Child-session spawn claim disagrees with live session metadata', + ); + } + this.assertMatchingSubagentSpawnClaim(existing.header); + return { record: existing, created: false }; + }); + } + + async createAgentGraphOperator( + header: SessionHeader, + request: AgentGraphOperatorProvisionRequest, + expectedRevision: number, + initialBoundary?: ExecutionBoundary, + ): Promise { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + assertAgentGraphOperatorProvisionRequest(request); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new Error('Agent graph schedule expected revision must be a non-negative safe integer'); + } + const identity = requireSubagentSpawnIdentity(normalized); + if ( + !identity.parent.graph || + identity.parent.graph.graphId !== request.graphId || + identity.parent.graph.workId !== request.workId || + identity.parent.graph.operatorId !== request.operatorId || + normalized.subagentRuntime?.agentId !== request.agentId || + identity.spawn.initialTurnId !== request.initialTurnId || + identity.spawn.initialRunId !== request.initialRunId + ) { + throw new Error('Graph operator Session metadata does not match its provision request'); + } + return this.transaction(() => { + const existing = this.readAgentGraphOperatorProvisionSync(request.graphId, request.workId); + if (existing) return this.matchAgentGraphOperatorProvision(existing, request); + const currentRevision = this.currentAgentGraphScheduleRevision(request.graphId); + if (currentRevision !== expectedRevision) { + throw new AgentGraphScheduleRevisionConflictError( + request.graphId, + expectedRevision, + currentRevision, + ); + } + if (this.hasClosedAgentGraphSchedule(request.graphId)) { + throw new AgentGraphScheduleClosedError(request.graphId); + } + if (this.hasTombstone(normalized.id)) { + throw new SessionMetadataConflictError( + `Session metadata id is tombstoned: ${normalized.id}`, + ); + } + if (this.readRecordSync(normalized.id)) { + throw new SessionMetadataConflictError(`Session metadata already exists: ${normalized.id}`); + } + const provisionedAt = this.now(); + const claim = this.tryClaimSubagentSpawn(normalized, provisionedAt); + if (!claim.created) { + throw new SessionMetadataConflictError( + 'Graph operator spawn identity exists without its topology provision', + ); + } + const record = this.insertHeader(normalized, 1, provisionedAt, initialBoundary); + const provision: AgentGraphOperatorProvision = { + ...request, + edges: request.edges.map((edge) => ({ ...edge })), + targetSessionId: normalized.id, + provisionedAt, + }; + this.db + .prepare( + ` + INSERT INTO agent_graph_operator_provisions( + graph_id, + work_id, + provision_id, + schema_version, + provision_fingerprint, + agent_id, + operator_id, + target_session_id, + payload_json, + provisioned_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + provision.graphId, + provision.workId, + provision.provisionId, + provision.schemaVersion, + provision.provisionFingerprint, + provision.agentId, + provision.operatorId, + provision.targetSessionId, + JSON.stringify(provision), + provision.provisionedAt, + ); + this.options.failpoint?.('after_agent_graph_operator_provision_write'); + return { + record, + provision: decodeAgentGraphOperatorProvision(provision), + created: true, + }; + }); + } + + async read(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + return record; + } + + async readCatalogRecord( + sessionId: string, + roleScope: 'ordinary' | 'recoverable' = 'ordinary', + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + const role = + roleScope === 'recoverable' + ? sqliteRecoverableSessionRolePredicate() + : sqliteOrdinarySessionRolePredicate(); + const row = this.db + .prepare( + ` + SELECT + metadata.session_id, + metadata.payload_json, + metadata.metadata_version, + metadata.committed_at, + projection.activity_at, + projection.last_message_preview + FROM session_catalog_projection projection + JOIN session_metadata metadata + ON metadata.session_id = projection.session_id + WHERE projection.session_id = ? + AND ${role.sql} + AND COALESCE( + json_extract(metadata.payload_json, '$.conversationCopy.state'), + '' + ) <> 'preparing' + AND COALESCE( + json_extract(metadata.payload_json, '$.transcriptLedgerVersion'), + 1 + ) <> 0 + `, + ) + .get(sessionId, ...role.parameters) as SessionMetadataCatalogRow | undefined; + if (!row) throw new SessionNotFoundError(sessionId); + return decodeCatalogRecord(row); + } + + async has(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.readRecordSync(sessionId) !== undefined; + } + + async isTombstoned(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.hasTombstone(sessionId); + } + + async probeRemoval(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.readTransaction(() => { + const record = this.readRecordSync(sessionId); + if (record) return { kind: 'present', record }; + return this.hasTombstone(sessionId) ? { kind: 'removed' } : { kind: 'absent' }; + }); + } + + async listPendingSessionRetirementCleanupIds(sessionId?: string): Promise { + this.assertOpen(); + if (sessionId !== undefined) assertSafeSessionId(sessionId); + const rows = + sessionId === undefined + ? this.db + .prepare( + ` + SELECT session_id AS sessionId + FROM session_metadata_tombstones + WHERE cleanup_pending = 1 + ORDER BY session_id + `, + ) + .all() + : this.db + .prepare( + ` + SELECT pending.session_id AS sessionId + FROM session_metadata_tombstones target + JOIN session_metadata_tombstones pending + ON pending.retirement_unit_id = target.retirement_unit_id + WHERE target.session_id = ? + AND pending.cleanup_pending = 1 + ORDER BY pending.session_id + `, + ) + .all(sessionId); + return (rows as unknown as Array<{ readonly sessionId: string }>).map((row) => row.sessionId); + } + + async reconcileOrphanedAgentGraphRetirements(): Promise { + this.assertOpen(); + return this.transaction(() => { + const rows = this.db + .prepare( + ` + SELECT + child.session_id, + child.payload_json, + child.metadata_version, + child.committed_at, + child.subagent_parent_session_id AS parent_session_id, + provision.graph_id, + provision.work_id, + provision.operator_id, + parent_tombstone.retirement_unit_id + FROM agent_graph_operator_provisions provision + JOIN session_metadata child + ON child.session_id = provision.target_session_id + JOIN session_metadata_tombstones parent_tombstone + ON parent_tombstone.session_id = child.subagent_parent_session_id + LEFT JOIN session_metadata live_parent + ON live_parent.session_id = child.subagent_parent_session_id + WHERE live_parent.session_id IS NULL + ORDER BY child.session_id + `, + ) + .all() as unknown as OrphanedAgentGraphOperatorRow[]; + const deletedAt = this.now(); + const reconciled: string[] = []; + for (const row of rows) { + const record = decodeRecord(row); + const parent = record.header.subagentParent; + if ( + !parent?.graph || + parent.parentSessionId !== row.parent_session_id || + parent.graph.graphId !== row.graph_id || + parent.graph.workId !== row.work_id || + parent.graph.operatorId !== row.operator_id || + !row.retirement_unit_id + ) { + throw new SessionMetadataConflictError( + `Cannot reconcile invalid graph operator Session ${row.session_id}`, + ); + } + const deleted = this.db + .prepare('DELETE FROM session_metadata WHERE session_id = ?') + .run(row.session_id); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError( + `Agent Graph retirement reconciliation lost Session ${row.session_id}`, + ); + } + this.db + .prepare( + ` + INSERT INTO session_metadata_tombstones( + session_id, + deleted_at, + retirement_unit_id, + cleanup_pending + ) + VALUES (?, ?, ?, 1) + `, + ) + .run(row.session_id, deletedAt, row.retirement_unit_id); + this.db + .prepare( + ` + UPDATE session_metadata_tombstones + SET cleanup_pending = 1 + WHERE session_id = ? + `, + ) + .run(row.parent_session_id); + reconciled.push(row.session_id); + } + this.db + .prepare( + ` + WITH graph_roots(root_session_id) AS ( + SELECT root_session_id + FROM agent_graph_client_projections + UNION + SELECT source_session_id + FROM agent_graph_schedule_updates + UNION + SELECT root_session_id + FROM agent_graph_supervisor_wakes + ) + UPDATE session_metadata_tombstones + SET cleanup_pending = 1 + WHERE cleanup_pending = 0 + AND session_id IN (SELECT root_session_id FROM graph_roots) + AND session_id NOT IN (SELECT session_id FROM session_metadata) + `, + ) + .run(); + return reconciled; + }); + } + + async listTombstonedSessionIdsAmong(sessionIds: readonly string[]): Promise { + this.assertOpen(); + const unique = [...new Set(sessionIds)].sort(); + for (const sessionId of unique) assertSafeSessionId(sessionId); + const tombstoned: string[] = []; + for (let offset = 0; offset < unique.length; offset += 100) { + const batch = unique.slice(offset, offset + 100); + if (batch.length === 0) continue; + const placeholders = batch.map(() => '?').join(', '); + const rows = this.db + .prepare( + ` + SELECT session_id AS sessionId + FROM session_metadata_tombstones + WHERE session_id IN (${placeholders}) + ORDER BY session_id + `, + ) + .all(...batch) as unknown as Array<{ readonly sessionId: string }>; + tombstoned.push(...rows.map((row) => row.sessionId)); + } + return tombstoned.sort(); + } + + async completeSessionRetirementCleanup(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + this.transaction(() => { + this.db + .prepare( + ` + UPDATE session_metadata_tombstones + SET cleanup_pending = 0 + WHERE session_id = ? + `, + ) + .run(sessionId); + }); + } + + /** + * Session records in catalog order, each carrying the projection the Session + * list shows. + */ + async list( + filter: SessionListFilter | undefined, + roleScope: SessionMetadataRoleScope, + ): Promise { + this.assertOpen(); + const { where, parameters } = buildSessionListPredicate(filter ?? {}); + if (roleScope === 'ordinary') { + const role = sqliteOrdinarySessionRolePredicate(); + where.push(role.sql); + parameters.push(...role.parameters); + } else if (roleScope === 'recoverable') { + const role = sqliteRecoverableSessionRolePredicate(); + where.push(role.sql); + parameters.push(...role.parameters); + } + const rows = this.db + .prepare( + ` + SELECT + metadata.session_id, + metadata.payload_json, + metadata.metadata_version, + metadata.committed_at, + COALESCE(projection.activity_at, 0) AS activity_at, + projection.last_message_preview + FROM session_metadata metadata + LEFT JOIN session_catalog_projection projection + ON projection.session_id = metadata.session_id + ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY activity_at DESC, metadata.session_id ASC + `, + ) + .all(...parameters) as unknown as SessionMetadataCatalogRow[]; + return rows.map(decodeCatalogRecord); + } + + async listCatalogPage( + filter: SessionListFilter, + cursor: SessionMetadataCatalogCursor | undefined, + limit: number, + ): Promise { + this.assertOpen(); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 128) { + throw new Error('Session catalog page limit must be between 1 and 128'); + } + if (cursor) { + assertSafeSessionId(cursor.sessionId); + if (!Number.isSafeInteger(cursor.activityAt) || cursor.activityAt < 0) { + throw new Error('Session catalog cursor activity is invalid'); + } + } + if (filter.subagentParentSessionId !== undefined) { + assertSafeSessionId(filter.subagentParentSessionId); + } + return this.readTransaction(() => { + const query = buildSqliteSessionCatalogPageQuery(filter, cursor); + const rows = this.db + .prepare(query.sql) + .all(...query.parameters, limit + 1) as unknown as SessionMetadataCatalogRow[]; + return { + revision: this.readCatalogRevisionSync(), + records: rows.slice(0, limit).map(decodeCatalogRecord), + hasMore: rows.length > limit, + }; + }); + } + + async readCatalogRevision(): Promise { + this.assertOpen(); + return this.readCatalogRevisionSync(); + } + + /** + * Import a session with its historical facts in a single SQLite + * transaction: the header row is written with the given (historical) + * timestamps and flags, and every message is appended in order. + * + * Idempotent by primary key: if the session id already exists — imported + * by an earlier run, created by the user, or written by a concurrent + * first-launch process — nothing is written and `'existing'` is returned. + * Tombstoned ids are never resurrected. Concurrent first launches converge + * on one winner for free: SQLite serializes the transaction and the loser + * observes the winner's row, so no create claims or fingerprints are + * needed. A failure anywhere inside the transaction (e.g. a failpoint) + * rolls back the whole import, so a partial session can never persist. + */ + async importSession( + header: SessionHeader, + messages: readonly StoredMessage[], + projection: SessionCatalogMessageProjection, + ): Promise<'imported' | 'existing'> { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + assertCatalogMessageProjection(projection); + // Canonicalize every record exactly like appendMessages: round-trip + // through JSON so the stored form matches what the recovery path reads. + const encoded = messages.map((message) => { + const json = JSON.stringify(message); + const canonical = decodeCanonicalMessage(JSON.parse(json) as unknown); + return { message: canonical, json }; + }); + return this.transaction(() => { + if (this.hasTombstone(normalized.id)) return 'existing'; + const inserted = this.tryInsertHeader(normalized, 1, normalized.createdAt, true); + if (!inserted) return 'existing'; + if (encoded.length > 0) { + this.insertSessionMessagesSync(normalized.id, 0, encoded); + // Align with appendMessages' connection-lock semantics: a session + // with any user message is treated as connection-locked, even when + // the legacy header did not record it. + const lockConnection = + !normalized.connectionLocked && encoded.some(({ message }) => message.type === 'user'); + this.updateCatalogProjectionSync(normalized.id, projection, false, lockConnection); + } + return 'imported'; + }); + } + + async lookupExternalSessionImports( + adapterId: string, + sourceSessionIds: readonly string[], + recentSessionIdLimit: number, + ): Promise { + this.assertOpen(); + if (sourceSessionIds.length === 0) return []; + const placeholders = sourceSessionIds.map(() => '?').join(', '); + const rows = this.db + .prepare( + ` + SELECT external_source_session_id, session_id, import_count + FROM ( + SELECT + external_source_session_id, + session_id, + COUNT(*) OVER ( + PARTITION BY external_source_session_id + ) AS import_count, + ROW_NUMBER() OVER ( + PARTITION BY external_source_session_id + ORDER BY created_at DESC, session_id + ) AS recent_rank + FROM session_metadata + WHERE external_adapter_id = ? + AND external_source_session_id IN (${placeholders}) + AND COALESCE( + json_extract(payload_json, '$.transcriptLedgerVersion'), + 1 + ) <> 0 + ) + WHERE recent_rank <= ? + ORDER BY external_source_session_id, recent_rank + `, + ) + .all(adapterId, ...sourceSessionIds, recentSessionIdLimit) as unknown as Array<{ + readonly external_source_session_id: string; + readonly session_id: string; + readonly import_count: number; + }>; + const bySource = new Map< + string, + { readonly livePublishedImportCount: number; readonly recentSessionIds: string[] } + >(); + for (const row of rows) { + const existing = bySource.get(row.external_source_session_id); + if (existing) { + existing.recentSessionIds.push(row.session_id); + } else { + bySource.set(row.external_source_session_id, { + livePublishedImportCount: row.import_count, + recentSessionIds: [row.session_id], + }); + } + } + return sourceSessionIds.flatMap((sourceSessionId) => { + const result = bySource.get(sourceSessionId); + return result ? [{ sourceSessionId, ...result }] : []; + }); + } + + /** + * Cheap existence probe used by the legacy importer before reading a + * transcript: an id already present in SQLite (live or tombstoned) is + * skipped without opening or parsing its file. Read-only; safe on every + * launch. + */ + async hasSession(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.readTransaction( + () => this.readRecordSync(sessionId) !== undefined || this.hasTombstone(sessionId), + ); + } + + async appendMessages( + sessionId: string, + messages: readonly StoredMessage[], + projection: SessionCatalogMessageProjection, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertCatalogMessageProjection(projection); + if (messages.length === 0) return; + const encoded = messages.map((message) => { + const json = JSON.stringify(message); + const canonical = decodeCanonicalMessage(JSON.parse(json) as unknown); + return { message: canonical, json }; + }); + this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + const lockConnection = + !record.header.connectionLocked && encoded.some(({ message }) => message.type === 'user'); + const row = this.db + .prepare( + 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', + ) + .get(sessionId) as { last_sequence?: unknown }; + if ( + typeof row.last_sequence !== 'number' || + !Number.isSafeInteger(row.last_sequence) || + row.last_sequence < -1 + ) { + throw new Error(`Invalid Session message sequence for ${sessionId}`); + } + const sequence = row.last_sequence + 1; + this.insertSessionMessagesSync(sessionId, sequence, encoded); + this.updateCatalogProjectionSync(sessionId, projection, false, lockConnection); + }); + } + + async commitMessageAdmission( + admission: PendingMessageAdmission, + ): Promise { + this.assertOpen(); + const stored = normalizePendingMessageAdmission(admission); + return this.transaction(() => { + if (!this.readRecordSync(stored.sessionId)) throw new SessionNotFoundError(stored.sessionId); + const existing = this.readMessageAdmissionSync(stored.sessionId, stored.messageId); + if (existing) { + if (!samePendingMessageAdmission(existing, stored)) { + throw new SessionMetadataConflictError('Message admission identity conflict'); + } + return existing; + } + this.insertMessageAdmissionSync(stored); + return stored; + }); + } + + private readMessageAdmissionSync( + sessionId: string, + messageId: string, + ): PendingMessageAdmission | undefined { + const row = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as MessageAdmissionRow | undefined; + return row ? decodeMessageAdmissionRow(sessionId, row) : undefined; + } + + private insertMessageAdmissionSync(stored: PendingMessageAdmission): void { + const cancelled = this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(stored.sessionId, stored.messageId); + if (cancelled) { + throw new SessionMetadataConflictError('Message admission identity is already cancelled'); + } + const orderRow = this.db + .prepare( + ` + SELECT COALESCE(MAX(queue_order), -1) + 1 AS next_order + FROM message_admissions + WHERE session_id = ? + `, + ) + .get(stored.sessionId) as { next_order?: unknown }; + if (typeof orderRow.next_order !== 'number' || !Number.isSafeInteger(orderRow.next_order)) { + throw new SessionMetadataConflictError('Invalid message admission order'); + } + this.db + .prepare( + ` + INSERT INTO message_admissions( + session_id, turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + stored.sessionId, + stored.turnId, + stored.runId, + stored.messageId, + JSON.stringify(stored.content), + stored.submittedContentDigest, + stored.submittedPlacement, + stored.placement, + stored.disposition, + orderRow.next_order, + stored.admittedAt, + stored.submittedIntent ? JSON.stringify(stored.submittedIntent) : null, + JSON.stringify(stored.skillInvocation), + ); + } + + async assignWorkHubMessage( + request: SqliteWorkHubMessageAssignmentRequest, + ): Promise { + const assignmentJson = JSON.stringify(request.assignment); + const assignment = decodeCanonicalMessage(JSON.parse(assignmentJson) as unknown); + const supersessionJson = request.supersession + ? JSON.stringify(request.supersession) + : undefined; + const supersession = supersessionJson + ? decodeCanonicalMessage(JSON.parse(supersessionJson) as unknown) + : undefined; + const admission = normalizePendingMessageAdmission(request.admission); + const suffix = createHash('sha256') + .update(request.assignment.actionId) + .digest('hex') + .slice(0, 48); + if ( + assignment.type !== 'workhub_coordination' || + assignment.kind !== 'delegation_assigned' || + assignment.targetSessionId !== admission.sessionId || + assignment.targetTurnId !== admission.turnId || + assignment.targetMessageId !== admission.messageId || + assignment.id !== `wha_${suffix}` || + assignment.targetMessageId !== `whm_${suffix}` || + assignment.delegationId !== `whd_${suffix}` || + !workHubAssignmentAttachmentsMatchTarget(assignment) || + !messageContentsEqual( + admission.content, + normalizeMessageContent({ + text: assignment.delegationText ?? assignment.userText, + ...(assignment.targetAttachments ? { attachments: assignment.targetAttachments } : {}), + }), + ) || + admission.submittedContentDigest !== messageContentDigest(admission.content) || + admission.submittedPlacement !== 'current_turn' || + admission.placement !== 'current_turn' || + admission.disposition !== 'steering' + ) { + throw new SessionMetadataConflictError('Invalid WorkHub assignment identity'); + } + if ( + (assignment.replacesActionId === undefined) !== + (assignment.replacesDelegationId === undefined) || + (assignment.replacesDelegationId === undefined) !== (supersession === undefined) || + (supersession !== undefined && + (supersession.type !== 'workhub_coordination' || + supersession.kind !== 'delegation_superseded' || + supersession.actionId !== assignment.actionId || + supersession.actionFingerprint !== assignment.actionFingerprint || + supersession.coordinationTurnId !== assignment.coordinationTurnId || + supersession.turnId !== assignment.coordinationTurnId || + supersession.supersededActionId !== assignment.replacesActionId || + supersession.supersededDelegationId !== assignment.replacesDelegationId || + supersession.replacementDelegationId !== assignment.delegationId || + supersession.id !== + `whx_${createHash('sha256') + .update(supersession.supersededDelegationId) + .digest('hex') + .slice(0, 48)}`)) + ) { + throw new SessionMetadataConflictError('Invalid WorkHub supersession identity'); + } + const create = request.create + ? { + header: normalizeSessionHeader(request.create.header), + requestFingerprint: request.create.requestFingerprint, + } + : undefined; + if (create) { + assertSessionCreateFingerprint(create.requestFingerprint); + if (create.header.id !== assignment.targetSessionId) { + throw new SessionMetadataConflictError('WorkHub create identity does not match target'); + } + } + assertCatalogMessageProjection(request.projection); + if ((assignment.disposition === 'create_new') !== Boolean(create)) { + throw new SessionMetadataConflictError( + 'WorkHub create request does not match assignment disposition', + ); + } + + return this.transaction(() => { + const coordination = this.readRecordSync(WORKHUB_COORDINATION_SESSION_ID); + if ( + !coordination || + coordination.header.role !== WORKHUB_COORDINATION_SESSION_ROLE || + coordination.header.isArchived + ) { + throw new SessionMetadataConflictError('WorkHub Coordination Session is unavailable'); + } + + const existingAssignment = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + assignment.id, + ); + if (existingAssignment) { + if ( + existingAssignment.type !== 'workhub_coordination' || + existingAssignment.kind !== 'delegation_assigned' || + !sameWorkHubAssignmentRequest(existingAssignment, assignment) + ) { + throw new SessionMetadataConflictError( + 'WorkHub action identity belongs to a different assignment', + ); + } + return { + kind: 'existing' as const, + targetCreated: false, + assignment: existingAssignment, + }; + } + + if (supersession && assignment.replacesActionId && assignment.replacesDelegationId) { + const replacedSuffix = createHash('sha256') + .update(assignment.replacesActionId) + .digest('hex') + .slice(0, 48); + const replaced = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `wha_${replacedSuffix}`, + ); + if ( + replaced?.type !== 'workhub_coordination' || + replaced.kind !== 'delegation_assigned' || + replaced.delegationId !== assignment.replacesDelegationId + ) { + throw new SessionMetadataConflictError('WorkHub supersession source is unavailable'); + } + const abortSuffix = createHash('sha256') + .update(assignment.replacesDelegationId) + .digest('hex') + .slice(0, 48); + const stopRequest = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whq_${abortSuffix}`, + ); + if (stopRequest) { + const stopResolution = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whz_${abortSuffix}`, + ); + if ( + stopResolution?.type !== 'workhub_coordination' || + stopResolution.kind !== 'delegation_stop_resolved' || + stopResolution.outcome !== 'not_owned' + ) { + throw new SessionMetadataConflictError('WorkHub delegation already has a stop claim'); + } + } + const existingAbort = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whb_${abortSuffix}`, + ); + if (existingAbort) { + throw new SessionMetadataConflictError('WorkHub delegation replacement is aborted'); + } + const existingSupersession = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + supersession.id, + ); + if (existingSupersession) { + throw new SessionMetadataConflictError('WorkHub delegation is already superseded'); + } + } + + let targetCreated = false; + if (create) { + const probe = this.probeStableSessionCreateSync( + create.header.id, + create.requestFingerprint, + ); + if (probe.kind === 'conflict') { + throw new SessionMetadataConflictError( + 'WorkHub target Session identity belongs to a different create request', + ); + } + if (probe.kind === 'absent') { + const committedAt = this.now(); + this.db + .prepare( + ` + INSERT INTO session_create_claims(session_id, request_fingerprint, claimed_at) + VALUES (?, ?, ?) + `, + ) + .run(create.header.id, create.requestFingerprint, committedAt); + this.insertHeader(create.header, 1, committedAt); + targetCreated = true; + } + } + + const target = this.readRecordSync(assignment.targetSessionId); + if (!target || target.header.isArchived) { + throw new SessionMetadataConflictError('WorkHub target Session is unavailable'); + } + if (target.header.status === 'waiting_for_user') { + throw new SessionMetadataConflictError('WorkHub target Session is waiting for user input'); + } + let committedAssignment = assignment; + let committedAssignmentJson = assignmentJson; + if (target.header.name !== assignment.targetSessionName) { + if ( + assignment.disposition !== 'delegate_existing' || + assignment.replacesDelegationId === undefined + ) { + throw new SessionMetadataConflictError('WorkHub target display identity changed'); + } + // A durable replacement owns the target Session id before retiring the + // source. Canonicalize its display-only name at the same transaction + // boundary that validates the target so a concurrent rename cannot + // strand the already-retired delegation. + committedAssignment = { ...assignment, targetSessionName: target.header.name }; + committedAssignmentJson = JSON.stringify(committedAssignment); + } + if (this.readMessageAdmissionSync(admission.sessionId, admission.messageId)) { + throw new SessionMetadataConflictError( + 'WorkHub target Message identity belongs to another admission', + ); + } + + this.insertMessageAdmissionSync(admission); + const sequenceRow = this.db + .prepare( + 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', + ) + .get(WORKHUB_COORDINATION_SESSION_ID) as { last_sequence?: unknown }; + if ( + typeof sequenceRow.last_sequence !== 'number' || + !Number.isSafeInteger(sequenceRow.last_sequence) || + sequenceRow.last_sequence < -1 + ) { + throw new SessionMetadataConflictError('Invalid WorkHub transcript sequence'); + } + this.insertSessionMessagesSync( + WORKHUB_COORDINATION_SESSION_ID, + sequenceRow.last_sequence + 1, + [ + { message: committedAssignment, json: committedAssignmentJson }, + ...(supersession && supersessionJson + ? [{ message: supersession, json: supersessionJson }] + : []), + ], + ); + this.updateCatalogProjectionSync(WORKHUB_COORDINATION_SESSION_ID, request.projection, false); + return { kind: 'assigned' as const, targetCreated, assignment: committedAssignment }; + }); + } + + async readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => { + const row = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as MessageAdmissionRow | undefined; + return row ? decodeMessageAdmissionRow(sessionId, row) : undefined; + }); + } + + async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => { + const row = this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(sessionId, messageId); + return row !== undefined; + }); + } + + /** + * Binds one WorkHub action identity to one exact operation, for good. + * + * Every other durable WorkHub record is keyed by what it is about, so none of + * them can see an action id that moved to a second delegation or a second + * disposition. This row is the global owner that rejects both, and it is + * written before the action's effect so a rejected or recovering attempt can + * never leak its identity into a different operation. + */ + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { + this.assertOpen(); + assertSafeSessionId(claim.actionId); + assertSafeSessionId(claim.subject); + if (!/^sha256:[a-f0-9]{64}$/u.test(claim.actionFingerprint)) { + throw new SessionMetadataConflictError('Invalid WorkHub action fingerprint'); + } + return this.transaction(() => { + const existing = this.readWorkHubActionClaimSync(claim.actionId); + if (existing) { + return existing.operation === claim.operation && + existing.actionFingerprint === claim.actionFingerprint && + existing.subject === claim.subject + ? 'same_claim' + : 'conflict'; + } + this.db + .prepare( + ` + INSERT INTO workhub_action_claims( + action_id, operation, action_fingerprint, subject, claimed_at + ) VALUES (?, ?, ?, ?, ?) + `, + ) + .run(claim.actionId, claim.operation, claim.actionFingerprint, claim.subject, this.now()); + return 'claimed'; + }); + } + + async readWorkHubActionClaim(actionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(actionId); + return this.readTransaction(() => this.readWorkHubActionClaimSync(actionId)); + } + + private readWorkHubActionClaimSync(actionId: string): WorkHubActionClaim | undefined { + const row = this.db + .prepare( + 'SELECT operation, action_fingerprint, subject FROM workhub_action_claims WHERE action_id = ?', + ) + .get(actionId) as + | { operation?: unknown; action_fingerprint?: unknown; subject?: unknown } + | undefined; + if (!row) return undefined; + if ( + !isWorkHubActionOperation(row.operation) || + typeof row.action_fingerprint !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(row.action_fingerprint) || + typeof row.subject !== 'string' + ) { + throw new SessionMetadataConflictError('Invalid WorkHub action claim row'); + } + return { + actionId, + operation: row.operation, + actionFingerprint: row.action_fingerprint as `sha256:${string}`, + subject: row.subject, + }; + } + + async claimMessageAdmissionCancellation( + sessionId: string, + messageId: string, + claimId: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + assertSafeSessionId(claimId); + return this.transaction(() => { + const cancelled = this.db + .prepare( + 'SELECT cancellation_claim_id FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(sessionId, messageId) as { cancellation_claim_id?: unknown } | undefined; + if (cancelled) { + return cancelled.cancellation_claim_id === claimId ? 'same_claim' : 'already_cancelled'; + } + const admission = this.db + .prepare( + ` + SELECT submitted_content_digest, submitted_placement + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as + | { submitted_content_digest?: unknown; submitted_placement?: unknown } + | undefined; + if ( + typeof admission?.submitted_content_digest !== 'string' || + (admission.submitted_placement !== 'current_turn' && + admission.submitted_placement !== 'next_turn') + ) { + throw new SessionMetadataConflictError('Message admission cancellation identity conflict'); + } + this.db + .prepare( + ` + INSERT INTO cancelled_message_admissions( + session_id, message_id, submitted_content_digest, submitted_placement, + cancellation_claim_id + ) VALUES (?, ?, ?, ?, ?) + `, + ) + .run( + sessionId, + messageId, + admission.submitted_content_digest, + admission.submitted_placement, + claimId, + ); + const deleted = this.db + .prepare('DELETE FROM message_admissions WHERE session_id = ? AND message_id = ?') + .run(sessionId, messageId); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError('Message admission cancellation identity conflict'); + } + return 'cancelled_by_claim'; + }); + } + + async listMessageAdmissions(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.readTransaction(() => { + const rows = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + FROM message_admissions + WHERE session_id = ? + ORDER BY queue_order, sequence + `, + ) + .all(sessionId) as MessageAdmissionRow[]; + return rows.map((row) => decodeMessageAdmissionRow(sessionId, row)); + }); + } + + async readActiveWorkHubAssignmentsByTarget( + targetSessionIds: readonly string[], + maxAssignmentsPerTarget?: number, + ): Promise { + this.assertOpen(); + for (const sessionId of targetSessionIds) assertSafeSessionId(sessionId); + if (targetSessionIds.length > WORKHUB_TARGET_LINKAGE_MAX_SESSIONS) { + throw new Error('Invalid WorkHub target Session count'); + } + if ( + maxAssignmentsPerTarget !== undefined && + (!Number.isSafeInteger(maxAssignmentsPerTarget) || + maxAssignmentsPerTarget < 1 || + maxAssignmentsPerTarget > 256) + ) { + throw new Error('Invalid WorkHub target Message limit'); + } + const targets = [...new Set(targetSessionIds)]; + if (targets.length === 0) return []; + return this.readTransaction(() => { + type Row = { session_id?: unknown; message_id?: unknown }; + const list = targets.map(() => '?').join(', '); + // One Message moves between these lifecycle tables — pending, admitted + // into a Turn, cancelled. Combine every target's identities once, then + // resolve activity from the canonical Coordination ledger in this same + // read transaction. That avoids rebuilding the target set once per page + // or once per candidate, without introducing another durable + // representation. + const rows = this.db + .prepare( + ` + WITH target_messages(session_id, message_id) AS ( + SELECT session_id, message_id + FROM message_admissions + WHERE session_id IN (${list}) + AND message_id GLOB 'whm_*' + AND length(message_id) = 52 + UNION + SELECT session_id, message_id + FROM core_root_source_message_proofs + WHERE session_id IN (${list}) + AND message_id GLOB 'whm_*' + AND length(message_id) = 52 + UNION + SELECT session_id, message_id + FROM cancelled_message_admissions + WHERE session_id IN (${list}) + AND message_id GLOB 'whm_*' + AND length(message_id) = 52 + ) + SELECT target.session_id, target.message_id + FROM target_messages AS target + CROSS JOIN session_messages AS assignment INDEXED BY session_messages_by_identity + WHERE assignment.session_id = ? + AND assignment.message_id = 'wha_' || substr(target.message_id, 5) + ORDER BY assignment.sequence DESC + `, + ) + .iterate( + ...targets, + ...targets, + ...targets, + WORKHUB_COORDINATION_SESSION_ID, + ) as Iterable; + const assignments: WorkHubDelegationAssignedMessage[] = []; + const acceptedPerTarget = new Map(); + for (const row of rows) { + if (typeof row.message_id !== 'string' || typeof row.session_id !== 'string') { + throw new SessionMetadataConflictError('Invalid WorkHub target Message identity'); + } + const targetSessionId = row.session_id; + if ( + maxAssignmentsPerTarget !== undefined && + (acceptedPerTarget.get(targetSessionId) ?? 0) >= maxAssignmentsPerTarget + ) { + continue; + } + const assignment = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `wha_${row.message_id.slice('whm_'.length)}`, + ); + if ( + assignment?.type !== 'workhub_coordination' || + assignment.kind !== 'delegation_assigned' || + assignment.targetSessionId !== targetSessionId || + assignment.targetMessageId !== row.message_id + ) { + continue; + } + const terminalSuffix = createHash('sha256') + .update(assignment.delegationId, 'utf8') + .digest('hex') + .slice(0, 48); + const supersession = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whx_${terminalSuffix}`, + ); + if ( + supersession?.type === 'workhub_coordination' && + supersession.kind === 'delegation_superseded' + ) { + continue; + } + const replacementAbort = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whb_${terminalSuffix}`, + ); + if ( + replacementAbort?.type === 'workhub_coordination' && + replacementAbort.kind === 'delegation_replacement_aborted' + ) { + continue; + } + const stopResolution = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whz_${terminalSuffix}`, + ); + if ( + stopResolution?.type === 'workhub_coordination' && + stopResolution.kind === 'delegation_stop_resolved' && + stopResolution.outcome !== 'not_owned' + ) { + continue; + } + assignments.push(assignment); + acceptedPerTarget.set(targetSessionId, (acceptedPerTarget.get(targetSessionId) ?? 0) + 1); + } + return assignments; + }); + } + + async readMessageById(sessionId: string, messageId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + return this.readTransaction(() => this.readMessageByIdSync(sessionId, messageId)); + } + + async markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise { + this.assertOpen(); + assertSafeSessionId(input.sessionId); + assertSafeSessionId(input.turnId); + const unique = [...new Set(input.messageIds)]; + for (const messageId of unique) assertSafeSessionId(messageId); + const requestedMessageIds = new Set(unique); + const provenRootMessages = new Map(); + for (const fallback of input.provenRootMessages ?? []) { + const normalized = normalizeProvenRootMessageHandoff(fallback); + if (!requestedMessageIds.has(normalized.messageId)) { + throw new SessionMetadataConflictError( + 'Proven Root Message identity is not present in messageIds', + ); + } + if (provenRootMessages.has(normalized.messageId)) { + throw new SessionMetadataConflictError('Proven Root Messages contain duplicate identities'); + } + provenRootMessages.set(normalized.messageId, normalized); + } + const provenSteeringMessages = new Map(); + for (const proof of input.provenSteeringMessages ?? []) { + const normalized = normalizeProvenSteeringMessageHandoff(proof); + if (!requestedMessageIds.has(normalized.messageId)) { + throw new SessionMetadataConflictError( + 'Proven steering Message identity is not present in messageIds', + ); + } + if (provenSteeringMessages.has(normalized.messageId)) { + throw new SessionMetadataConflictError( + 'Proven steering Messages contain duplicate identities', + ); + } + if (normalized.executionTurnId !== input.turnId) { + throw new SessionMetadataConflictError('Proven steering execution Turn conflict'); + } + provenSteeringMessages.set(normalized.messageId, normalized); + } + this.transaction(() => { + for (const messageId of unique) { + const fallback = provenRootMessages.get(messageId); + const steeringProof = provenSteeringMessages.get(messageId); + const admissionRow = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(input.sessionId, messageId) as MessageAdmissionRow | undefined; + const admission = admissionRow + ? decodeMessageAdmissionRow(input.sessionId, admissionRow) + : undefined; + const provenCrossTurnSteering = + admission !== undefined && + steeringProof !== undefined && + admission.disposition === 'steering' && + admission.turnId === steeringProof.admissionTurnId && + admission.runId === steeringProof.admissionRunId && + admission.admittedAt === steeringProof.admittedAt && + messageContentsEqual(admission.content, steeringProof.content); + if (admission !== undefined && steeringProof !== undefined && !provenCrossTurnSteering) { + throw new SessionMetadataConflictError('Proven steering admission identity conflict'); + } + if ( + admission !== undefined && + admission.turnId !== input.turnId && + admission.disposition !== 'followup' && + !provenCrossTurnSteering + ) { + throw new SessionMetadataConflictError('Message admission Turn conflict'); + } + if ( + admission !== undefined && + fallback !== undefined && + !messageContentsEqual(admission.content, fallback.content) + ) { + throw new SessionMetadataConflictError('Message admission fallback content conflict'); + } + if ( + !admission && + this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(input.sessionId, messageId) + ) { + throw new SessionMetadataConflictError('Message admission is already cancelled'); + } + if (admission === undefined && fallback === undefined && steeringProof === undefined) { + throw new SessionMetadataConflictError('Message admission does not exist'); + } + if (admission) { + const deleted = this.db + .prepare('DELETE FROM message_admissions WHERE session_id = ? AND message_id = ?') + .run(input.sessionId, messageId); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError('Message admission handoff identity conflict'); + } + } + } + }); + } + + /** + * The catalog facts a durable message carries, committed without a transcript + * row to carry them: the Session list's preview line, its time, and the + * connection lock a Session takes on its first user message. + */ + async commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.updateCatalogProjectionSync( + sessionId, + projectSessionCatalogMessages([message]), + false, + message.type === 'user' && !record.header.connectionLocked, + ); + }); + } + + async updateMessageAdmission(admission: PendingMessageAdmission): Promise { + this.assertOpen(); + const stored = normalizePendingMessageAdmission(admission); + this.transaction(() => { + const currentRow = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json, skill_invocation_json + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(stored.sessionId, stored.messageId) as MessageAdmissionRow | undefined; + if (!currentRow) throw new SessionMetadataConflictError('Message admission does not exist'); + const current = decodeMessageAdmissionRow(stored.sessionId, currentRow); + if ( + current.turnId !== stored.turnId || + current.runId !== stored.runId || + current.submittedPlacement !== stored.submittedPlacement || + current.admittedAt !== stored.admittedAt + ) { + throw new SessionMetadataConflictError('Message admission update identity conflict'); + } + this.db + .prepare( + ` + UPDATE message_admissions + SET content_json = ?, submitted_content_digest = ?, placement = ?, disposition = ?, + skill_invocation_json = ? + WHERE session_id = ? AND message_id = ? + `, + ) + .run( + JSON.stringify(stored.content), + stored.submittedContentDigest, + stored.placement, + stored.disposition, + JSON.stringify(stored.skillInvocation), + stored.sessionId, + stored.messageId, + ); + }); + } + + async cancelMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + const unique = [...new Set(messageIds)]; + for (const messageId of unique) assertSafeSessionId(messageId); + this.transaction(() => { + for (const messageId of unique) { + const admission = this.db + .prepare( + ` + SELECT submitted_content_digest, submitted_placement + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as + | { submitted_content_digest?: unknown; submitted_placement?: unknown } + | undefined; + if (!admission) { + const cancelled = this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(sessionId, messageId); + if (!cancelled) { + throw new SessionMetadataConflictError( + 'Message admission cancellation identity conflict', + ); + } + continue; + } + if ( + typeof admission.submitted_content_digest !== 'string' || + (admission.submitted_placement !== 'current_turn' && + admission.submitted_placement !== 'next_turn') + ) { + throw new SessionMetadataConflictError('Invalid Message admission cancellation identity'); + } + this.db + .prepare( + ` + INSERT INTO cancelled_message_admissions( + session_id, message_id, submitted_content_digest, submitted_placement + ) VALUES (?, ?, ?, ?) + `, + ) + .run( + sessionId, + messageId, + admission.submitted_content_digest, + admission.submitted_placement, + ); + const deleted = this.db + .prepare('DELETE FROM message_admissions WHERE session_id = ? AND message_id = ?') + .run(sessionId, messageId); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError( + 'Message admission cancellation identity conflict', + ); + } + } + }); + } + + async reorderMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + const unique = [...new Set(messageIds)]; + if (unique.length !== messageIds.length) { + throw new SessionMetadataConflictError( + 'Message admission reorder contains duplicate identities', + ); + } + for (const messageId of unique) assertSafeSessionId(messageId); + this.transaction(() => { + const rows = this.db + .prepare( + ` + SELECT message_id + FROM message_admissions + WHERE session_id = ? AND disposition = 'followup' + ORDER BY queue_order, sequence + `, + ) + .all(sessionId) as Array<{ message_id?: unknown }>; + const current = rows.map((row) => row.message_id); + const currentIds = new Set(current); + if ( + current.length !== unique.length || + unique.some((messageId) => !currentIds.has(messageId)) + ) { + throw new SessionMetadataConflictError('Message admission reorder identity conflict'); + } + const update = this.db.prepare( + ` + UPDATE message_admissions + SET queue_order = ? + WHERE session_id = ? AND message_id = ? + `, + ); + unique.forEach((messageId, index) => update.run(index, sessionId, messageId)); + }); + } + + async readCoordinationTranscriptIndexState(): Promise { + this.assertOpen(); + return this.db + .prepare(`SELECT (SELECT MAX(sequence) FROM coordination_transcript_index) AS highWater, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'legacy') AS legacy, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'runtime') AS runtime`) + .get() as unknown as CoordinationTranscriptIndexState; + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + this.assertOpen(); + if (records.length > 64) throw new Error('Coordination transcript index batch exceeds limit'); + this.transaction(() => { + let sequence = + ( + this.db + .prepare('SELECT MAX(sequence) AS value FROM coordination_transcript_index') + .get() as { value: number | null } + ).value ?? -1; + const insert = this.db.prepare(`INSERT INTO coordination_transcript_index + (sequence, source, source_sequence) VALUES (?, ?, ?) + ON CONFLICT(source, source_sequence) DO NOTHING`); + for (const record of records) { + if (!Number.isSafeInteger(record.sourceSequence) || record.sourceSequence < 0) + throw new Error('Invalid Coordination source sequence'); + const result = insert.run(sequence + 1, record.source, record.sourceSequence); + if (result.changes) sequence++; + } + }); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + this.assertOpen(); + if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 64) + throw new Error('Invalid Coordination transcript index limit'); + const older = request.direction === 'older'; + return this.db + .prepare(`SELECT sequence, source, source_sequence AS sourceSequence + FROM coordination_transcript_index WHERE sequence <= ? AND sequence ${older ? '<=' : '>='} ? + ORDER BY sequence ${older ? 'DESC' : 'ASC'} LIMIT ?`) + .all( + request.throughSequence, + request.position, + request.limit, + ) as unknown as CoordinationTranscriptIndexRecord[]; + } + + async readMessages(sessionId: string): Promise { + return this.readMessagesWith(sessionId, decodeStoredMessage); + } + + async readMessagesAfter( + sessionId: string, + request: SessionMessageScanRequest, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (!Number.isSafeInteger(request.maxMessages) || request.maxMessages < 1) { + throw new Error('Invalid Session message count limit'); + } + if (!Number.isSafeInteger(request.maxStoredBytes) || request.maxStoredBytes < 1) { + throw new Error('Invalid Session message byte limit'); + } + if (request.afterSequence !== undefined && request.beforeSequence !== undefined) { + throw new Error('Invalid Session message scan bounds'); + } + const backward = request.beforeSequence !== undefined; + return this.readTransaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const rows = this.db + .prepare(` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.sequence ${backward ? '<' : '>'} ? + ORDER BY message.sequence ${backward ? 'DESC' : 'ASC'} + LIMIT ? + `) + .all( + sessionId, + backward ? request.beforeSequence : (request.afterSequence ?? -1), + request.maxMessages, + ) as StoredSessionMessagePayloadRow[]; + const records: SessionMessageScanRecord[] = []; + let storedBytes = 0; + for (const row of rows) { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + // A record too large for one row is stored in chunks, with only a + // marker inline; its size is the chunk total, not the marker's. + const recordBytes = + typeof row.record_bytes === 'number' ? row.record_bytes : String(row.record_json).length; + // The first record of a page is always taken, so a single row larger + // than the budget still makes progress instead of stalling the scan. + if (records.length > 0 && storedBytes + recordBytes > request.maxStoredBytes) break; + storedBytes += recordBytes; + records.push({ + sequence, + message: decodeStoredMessageRecordRow(this.db, sessionId, row), + }); + } + const highWater = this.db + .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') + .get(sessionId) as { high_water?: unknown }; + return { + records, + highWaterSequence: nullableStoredMessageSequence(highWater.high_water, sessionId), + }; + }); + } + + async readTranscriptMessages( + sessionId: string, + request: SessionTranscriptMessageLookupRequest, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (request.messageIds.some((messageId) => typeof messageId !== 'string')) { + throw new Error('Invalid Session transcript message identity set'); + } + if ( + request.throughSequence !== null && + (!Number.isSafeInteger(request.throughSequence) || request.throughSequence < 0) + ) { + throw new Error('Invalid Session transcript watermark'); + } + if (!Number.isSafeInteger(request.maxBytes) || request.maxBytes < 1) { + throw new Error('Invalid Session transcript message byte limit'); + } + if (!Number.isSafeInteger(request.maxMessages) || request.maxMessages < 1) { + throw new Error('Invalid Session transcript message count limit'); + } + const messageIds = [...new Set(request.messageIds)]; + return this.readTransaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + if (messageIds.length === 0 || request.throughSequence === null) return []; + + const selected: number[] = []; + let selectedBytes = 0; + for ( + let offset = 0; + offset < messageIds.length; + offset += SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE + ) { + const batch = messageIds.slice( + offset, + offset + SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE, + ); + const placeholders = batch.map(() => '?').join(', '); + const remainingMessages = request.maxMessages - selected.length; + const rows = this.db + .prepare( + ` + SELECT message.sequence, + coalesce(payload.record_bytes, length(CAST(message.record_json AS BLOB))) + AS stored_bytes + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.sequence <= ? + AND message.message_id IN (${placeholders}) + ORDER BY message.sequence ASC + LIMIT ? + `, + ) + .all(sessionId, request.throughSequence, ...batch, remainingMessages + 1) as Array<{ + sequence?: unknown; + stored_bytes?: unknown; + }>; + if (rows.length > remainingMessages) { + throw new Error('Session transcript message lookup exceeds its message limit'); + } + for (const row of rows) { + if ( + typeof row.sequence !== 'number' || + !Number.isSafeInteger(row.sequence) || + row.sequence < 0 || + typeof row.stored_bytes !== 'number' || + !Number.isSafeInteger(row.stored_bytes) || + row.stored_bytes < 1 + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, -1); + } + selectedBytes += row.stored_bytes; + if (selectedBytes > request.maxBytes) { + throw new Error('Session transcript message lookup exceeds its byte limit'); + } + selected.push(row.sequence); + } + } + + selected.sort((left, right) => left - right); + const messages: StoredMessage[] = []; + for ( + let offset = 0; + offset < selected.length; + offset += SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE + ) { + const batch = selected.slice(offset, offset + SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE); + const placeholders = batch.map(() => '?').join(', '); + const rows = readStoredMessageRows(this.db, sessionId, batch, placeholders); + if (rows.length !== batch.length) { + throw new StoredSessionMessageIncompatibleError(sessionId, -1); + } + for (const row of rows) { + try { + messages.push(decodeStoredMessage(JSON.parse(row.recordJson) as unknown)); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, row.sequence, { + cause: error, + }); + } + } + } + return messages; + }); + } + + async readTranscriptHighWater(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const row = this.db + .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') + .get(sessionId) as { high_water?: unknown }; + return nullableStoredMessageSequence(row.high_water, sessionId); + } + + async beginCatalogProjectionWrite(): Promise { + this.assertOpen(); + this.transaction(() => { + const result = this.db + .prepare( + ` + UPDATE session_catalog_state + SET pending_writes = pending_writes + 1 + WHERE scope = 'catalog' + `, + ) + .run(); + if (result.changes !== 1) throw new Error('Session catalog revision state is unavailable'); + }); + } + + async commitCatalogProjectionWrite( + sessionId: string, + projection: SessionCatalogMessageProjection, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertCatalogMessageProjection(projection); + this.transaction(() => { + this.updateCatalogProjectionSync(sessionId, projection, false); + this.finishCatalogProjectionWriteSync(); + }); + } + + async requireCatalogProjectionRecovery(): Promise { + await this.beginCatalogProjectionWrite(); + } + + async hasPendingCatalogProjectionWrites(): Promise { + this.assertOpen(); + return this.readCatalogStateSync().pendingWrites > 0; + } + + async recoverCatalogProjections( + projections: ReadonlyMap, + ): Promise { + this.assertOpen(); + for (const [sessionId, projection] of projections) { + assertSafeSessionId(sessionId); + assertCatalogMessageProjection(projection); + } + this.transaction(() => { + for (const [sessionId, projection] of projections) { + this.updateCatalogProjectionSync(sessionId, projection, true); + } + const result = this.db + .prepare( + ` + UPDATE session_catalog_state + SET pending_writes = 0 + WHERE scope = 'catalog' + `, + ) + .run(); + if (result.changes !== 1) throw new Error('Session catalog revision state is unavailable'); + }); + } + + async claimAgentGraphIntent( + request: AgentGraphIntentClaimRequest, + ): Promise { + this.assertOpen(); + assertAgentGraphIntentClaimRequest(request); + return this.transaction(() => this.claimAgentGraphIntentSync(request)); + } + + async claimAgentGraphIntentAtScheduleRevision( + request: AgentGraphIntentClaimRequest, + expectedRevision: number, + ): Promise { + this.assertOpen(); + assertAgentGraphIntentClaimRequest(request); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new Error('Agent graph schedule expected revision must be a non-negative safe integer'); + } + return this.transaction(() => { + const currentRevision = this.currentAgentGraphScheduleRevision(request.graphId); + if (currentRevision !== expectedRevision) { + throw new AgentGraphScheduleRevisionConflictError( + request.graphId, + expectedRevision, + currentRevision, + ); + } + const existing = this.readAgentGraphIntentClaimSync(request.graphId, request.intentId); + if (!existing && this.hasClosedAgentGraphSchedule(request.graphId)) { + throw new AgentGraphScheduleClosedError(request.graphId); + } + return this.claimAgentGraphIntentSync(request); + }); + } + + async beginAgentGraphIntentExecutionAtScheduleRevision( + graphId: string, + intentId: string, + expectedRevision: number, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphIntentId(intentId); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new Error('Agent graph schedule expected revision must be a non-negative safe integer'); + } + return this.transaction(() => { + const currentRevision = this.currentAgentGraphScheduleRevision(graphId); + if (currentRevision !== expectedRevision) { + throw new AgentGraphScheduleRevisionConflictError( + graphId, + expectedRevision, + currentRevision, + ); + } + const previousState = this.readAgentGraphIntentAdmissionStateSync(graphId, intentId); + if (previousState !== 'claimed') { + return { state: previousState, previousState, changed: false }; + } + const changed = this.db + .prepare( + ` + UPDATE agent_graph_intent_claims + SET admission_status = 'executing', + admission_updated_at = ? + WHERE graph_id = ? + AND intent_id = ? + AND admission_status = 'claimed' + `, + ) + .run(this.now(), graphId, intentId).changes; + if (changed !== 1) { + throw new AgentGraphIntentClaimConflictError( + 'Agent graph intent execution admission changed concurrently', + ); + } + return { state: 'executing', previousState, changed: true }; + }); + } + + async cancelAgentGraphIntentExecution( + graphId: string, + intentId: string, + reason: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphIntentId(intentId); + if (!reason.trim() || reason.length > 4_000) { + throw new Error('Agent graph intent cancellation reason must be non-empty and bounded'); + } + return this.transaction(() => { + const previousState = this.readAgentGraphIntentAdmissionStateSync(graphId, intentId); + if (previousState === 'cancelled') { + return { state: 'cancelled', previousState, changed: false }; + } + const changed = this.db + .prepare( + ` + UPDATE agent_graph_intent_claims + SET admission_status = 'cancelled', + admission_updated_at = ?, + cancellation_reason = ? + WHERE graph_id = ? + AND intent_id = ? + AND admission_status = ? + `, + ) + .run(this.now(), reason, graphId, intentId, previousState).changes; + if (changed !== 1) { + throw new AgentGraphIntentClaimConflictError( + 'Agent graph intent cancellation admission changed concurrently', + ); + } + return { state: 'cancelled', previousState, changed: true }; + }); + } + + async readAgentGraphIntentClaim( + graphId: string, + intentId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphIntentId(intentId); + return this.readAgentGraphIntentClaimSync(graphId, intentId); + } + + async listAgentGraphIntentClaims(graphId?: string): Promise { + this.assertOpen(); + if (graphId !== undefined) assertGraphLookupIdentity(graphId, 'graph id'); + const rows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + claim_id AS claimId, + graph_id AS graphId, + intent_id AS intentId, + intent_fingerprint AS intentFingerprint, + readiness_context_fingerprint AS readinessContextFingerprint, + target_operator_id AS targetOperatorId, + target_session_id AS targetSessionId, + target_turn_id AS targetTurnId, + target_run_id AS targetRunId, + claimed_at AS claimedAt + FROM agent_graph_intent_claims + ${graphId === undefined ? '' : 'WHERE graph_id = ?'} + ORDER BY graph_id ASC, claimed_at ASC, intent_id ASC + `, + ) + .all(...(graphId === undefined ? [] : [graphId])) as unknown as AgentGraphIntentClaim[]; + return rows.map(decodeAgentGraphIntentClaim); + } + + async commitAgentGraphScheduleUpdate( + request: AgentGraphScheduleUpdateRequest, + ): Promise { + this.assertOpen(); + assertAgentGraphScheduleUpdateRequest(request); + return this.transaction(() => { + const existingById = this.readAgentGraphScheduleUpdateByIdSync(request.updateId); + if (existingById) return this.matchAgentGraphScheduleUpdate(existingById, request); + const existingBySource = this.readAgentGraphScheduleUpdateBySourceSync(request.source); + if (existingBySource) return this.matchAgentGraphScheduleUpdate(existingBySource, request); + if (this.hasClosedAgentGraphSchedule(request.graphId)) { + throw new AgentGraphScheduleUpdateConflictError('Agent graph schedule is already finished'); + } + const revision = this.nextAgentGraphScheduleRevision(request.graphId); + const update: AgentGraphScheduleUpdate = { + ...request, + source: { ...request.source }, + addWork: request.addWork.map((work) => ({ + ...work, + target: { ...work.target }, + inputIds: [...work.inputIds], + ...(work.selectedResultInputs + ? { selectedResultInputs: work.selectedResultInputs.map((input) => ({ ...input })) } + : {}), + })), + stop: request.stop.map((stopped) => ({ ...stopped })), + ...(request.finish + ? { + finish: { + resultIds: [...request.finish.resultIds], + reason: request.finish.reason, + }, + } + : {}), + revision, + committedAt: this.now(), + }; + this.db + .prepare( + ` + INSERT INTO agent_graph_schedule_updates( + graph_id, + revision, + update_id, + schema_version, + update_fingerprint, + source_session_id, + source_run_id, + source_turn_id, + source_tool_call_id, + closes_graph, + payload_json, + committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + update.graphId, + update.revision, + update.updateId, + update.schemaVersion, + update.updateFingerprint, + update.source.sessionId, + update.source.runId, + update.source.turnId, + update.source.toolCallId, + booleanInteger(update.finish !== undefined), + JSON.stringify(update), + update.committedAt, + ); + this.options.failpoint?.('after_agent_graph_schedule_update_write'); + return { update: decodeAgentGraphScheduleUpdate(update), created: true }; + }); + } + + async listAgentGraphScheduleUpdates(graphId: string): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'id'); + const rows = this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_schedule_updates + WHERE graph_id = ? + ORDER BY revision ASC + `, + ) + .all(graphId) as unknown as AgentGraphScheduleUpdateRow[]; + return rows.map(decodeAgentGraphScheduleUpdateRow); + } + + async claimAgentGraphSupervisorWake( + request: ClaimAgentGraphSupervisorWakeRequest, + ): Promise<{ wake: AgentGraphSupervisorWakeRecord; created: boolean }> { + this.assertOpen(); + assertAgentGraphSupervisorWakeClaim(request); + return this.transaction(() => { + const existing = this.readAgentGraphSupervisorWakeSync(request.graphId, request.wakeId); + if (existing) { + if ( + existing.snapshotVersion !== request.snapshotVersion || + existing.rootSessionId !== request.rootSessionId + ) { + throw new SessionMetadataConflictError( + 'Agent graph supervisor wake identity was reused for another snapshot', + ); + } + return { wake: existing, created: false }; + } + const now = this.now(); + this.db + .prepare( + ` + INSERT INTO agent_graph_supervisor_wakes( + graph_id, + wake_id, + schema_version, + snapshot_version, + root_session_id, + status, + attempt_count, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?) + `, + ) + .run( + request.graphId, + request.wakeId, + request.schemaVersion, + request.snapshotVersion, + request.rootSessionId, + now, + now, + ); + return { + wake: this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId), + created: true, + }; + }); + } + + async beginAgentGraphSupervisorWakeAttempt( + request: BeginAgentGraphSupervisorWakeAttemptRequest, + ): Promise<{ + wake: AgentGraphSupervisorWakeRecord; + attempt?: AgentGraphSupervisorWakeAttemptRecord; + acquired: boolean; + }> { + this.assertOpen(); + assertAgentGraphSupervisorWakeAttempt(request); + return this.transaction(() => { + const wake = this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId); + if ( + wake.status === 'delivered' || + wake.status === 'running' || + wake.status === 'waiting_permission' + ) { + return { wake, acquired: false }; + } + const now = this.now(); + const updated = this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wakes + SET status = 'running', + attempt_count = attempt_count + 1, + current_attempt_id = ?, + current_turn_id = ?, + failure_reason = NULL, + updated_at = ? + WHERE graph_id = ? + AND wake_id = ? + AND status IN ('pending', 'retryable_failed') + `, + ) + .run(request.attemptId, request.turnId, now, request.graphId, request.wakeId); + if (updated.changes !== 1) { + return { + wake: this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId), + acquired: false, + }; + } + this.db + .prepare( + ` + INSERT INTO agent_graph_supervisor_wake_attempts( + graph_id, + wake_id, + attempt_id, + turn_id, + status, + started_at + ) VALUES (?, ?, ?, ?, 'running', ?) + `, + ) + .run(request.graphId, request.wakeId, request.attemptId, request.turnId, now); + return { + wake: this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId), + attempt: this.requireAgentGraphSupervisorWakeAttemptSync( + request.graphId, + request.wakeId, + request.attemptId, + ), + acquired: true, + }; + }); + } + + async completeAgentGraphSupervisorWakeAttempt( + request: CompleteAgentGraphSupervisorWakeAttemptRequest, + ): Promise { + this.assertOpen(); + assertAgentGraphSupervisorWakeCompletion(request); + return this.transaction(() => { + const wake = this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId); + const attempt = this.requireAgentGraphSupervisorWakeAttemptSync( + request.graphId, + request.wakeId, + request.attemptId, + ); + if (wake.currentAttemptId !== request.attemptId || attempt.status !== wake.status) { + if (wake.status === request.status && attempt.status === request.status) return wake; + throw new SessionMetadataConflictError( + 'Agent graph supervisor wake attempt is no longer current', + ); + } + if (attempt.status !== 'running' && attempt.status !== 'waiting_permission') { + if (wake.status === request.status && attempt.status === request.status) return wake; + throw new SessionMetadataConflictError( + 'Agent graph supervisor wake attempt is already terminal', + ); + } + if (attempt.status === 'waiting_permission' && request.status === 'waiting_permission') { + return wake; + } + const now = this.now(); + const failureReason = + request.status === 'retryable_failed' || request.status === 'superseded' + ? request.failureReason + : undefined; + const completedAt = request.status === 'waiting_permission' ? null : now; + this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wake_attempts + SET status = ?, failure_reason = ?, completed_at = ? + WHERE graph_id = ? AND wake_id = ? AND attempt_id = ? AND status = ? + `, + ) + .run( + request.status, + failureReason ?? null, + completedAt, + request.graphId, + request.wakeId, + request.attemptId, + attempt.status, + ); + this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wakes + SET status = ?, failure_reason = ?, updated_at = ? + WHERE graph_id = ? AND wake_id = ? AND current_attempt_id = ? AND status = ? + `, + ) + .run( + request.status, + failureReason ?? null, + now, + request.graphId, + request.wakeId, + request.attemptId, + wake.status, + ); + return this.requireAgentGraphSupervisorWakeSync(request.graphId, request.wakeId); + }); + } + + async supersedeAgentGraphSupervisorWakes( + request: SupersedeAgentGraphSupervisorWakesRequest, + ): Promise { + this.assertOpen(); + const sessionIds = [...new Set(request.rootSessionIds)]; + const graphIds = request.graphIds ? [...new Set(request.graphIds)] : undefined; + sessionIds.forEach(assertSafeSessionId); + graphIds?.forEach((graphId) => assertGraphLookupIdentity(graphId, 'graph id')); + if (!request.reason.trim() || request.reason.length > 4_000) { + throw new Error( + 'Agent graph supervisor wake supersession reason must be non-empty and bounded', + ); + } + if (sessionIds.length === 0 || graphIds?.length === 0) return 0; + return this.transaction(() => { + const now = this.now(); + const placeholders = sessionIds.map(() => '?').join(', '); + const graphFilter = graphIds + ? `AND wakes.graph_id IN (${graphIds.map(() => '?').join(', ')})` + : ''; + const wakeGraphFilter = graphIds + ? `AND graph_id IN (${graphIds.map(() => '?').join(', ')})` + : ''; + this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wake_attempts + SET status = 'superseded', failure_reason = ?, completed_at = ? + WHERE status IN ('running', 'waiting_permission') + AND EXISTS ( + SELECT 1 + FROM agent_graph_supervisor_wakes wakes + WHERE wakes.graph_id = agent_graph_supervisor_wake_attempts.graph_id + AND wakes.wake_id = agent_graph_supervisor_wake_attempts.wake_id + AND wakes.root_session_id IN (${placeholders}) + ${graphFilter} + ) + `, + ) + .run(request.reason, now, ...sessionIds, ...(graphIds ?? [])); + const updated = this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wakes + SET status = 'superseded', failure_reason = ?, updated_at = ? + WHERE root_session_id IN (${placeholders}) + ${wakeGraphFilter} + AND status IN ('pending', 'running', 'waiting_permission', 'retryable_failed') + `, + ) + .run(request.reason, now, ...sessionIds, ...(graphIds ?? [])); + return Number(updated.changes); + }); + } + + async readAgentGraphSupervisorWake( + graphId: string, + wakeId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphLookupIdentity(wakeId, 'supervisor wake id'); + return this.readAgentGraphSupervisorWakeSync(graphId, wakeId); + } + + async listAgentGraphSupervisorWakeAttempts( + graphId: string, + wakeId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphLookupIdentity(wakeId, 'supervisor wake id'); + const rows = this.db + .prepare( + ` + SELECT + graph_id AS graphId, + wake_id AS wakeId, + attempt_id AS attemptId, + turn_id AS turnId, + status, + failure_reason AS failureReason, + started_at AS startedAt, + completed_at AS completedAt + FROM agent_graph_supervisor_wake_attempts + WHERE graph_id = ? AND wake_id = ? + ORDER BY started_at ASC, attempt_id ASC + `, + ) + .all(graphId, wakeId) as unknown as AgentGraphSupervisorWakeAttemptRow[]; + return rows.map(decodeAgentGraphSupervisorWakeAttemptRow); + } + + async listRetryableAgentGraphSupervisorWakes(): Promise { + this.assertOpen(); + const rows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + wake_id AS wakeId, + snapshot_version AS snapshotVersion, + root_session_id AS rootSessionId, + status, + attempt_count AS attemptCount, + current_attempt_id AS currentAttemptId, + current_turn_id AS currentTurnId, + failure_reason AS failureReason, + created_at AS createdAt, + updated_at AS updatedAt + FROM agent_graph_supervisor_wakes + WHERE status = 'retryable_failed' + ORDER BY updated_at ASC, graph_id ASC, wake_id ASC + `, + ) + .all() as unknown as AgentGraphSupervisorWakeRow[]; + return rows.map(decodeAgentGraphSupervisorWakeRow); + } + + async listUnsettledAgentGraphSupervisorWakes(): Promise { + this.assertOpen(); + const rows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + wake_id AS wakeId, + snapshot_version AS snapshotVersion, + root_session_id AS rootSessionId, + status, + attempt_count AS attemptCount, + current_attempt_id AS currentAttemptId, + current_turn_id AS currentTurnId, + failure_reason AS failureReason, + created_at AS createdAt, + updated_at AS updatedAt + FROM agent_graph_supervisor_wakes + WHERE status IN ('running', 'waiting_permission') + ORDER BY updated_at ASC, graph_id ASC, wake_id ASC + `, + ) + .all() as unknown as AgentGraphSupervisorWakeRow[]; + return rows.map(decodeAgentGraphSupervisorWakeRow); + } + + async recoverAgentGraphSupervisorWakes(): Promise { + this.assertOpen(); + return this.transaction(() => { + const now = this.now(); + const recovered = this.db + .prepare( + ` + UPDATE agent_graph_supervisor_wakes + SET status = 'retryable_failed', + failure_reason = 'host_restart', + updated_at = ? + WHERE status = 'pending' + `, + ) + .run(now).changes; + return Number(recovered); + }); + } + + async resolveCurrentAgentGraphEpoch( + request: ResolveAgentGraphEpochRequest, + ): Promise { + this.assertOpen(); + assertResolveAgentGraphEpochRequest(request); + return this.readTransaction(() => { + const current = this.readCurrentAgentGraphEpochSync(request.rootSessionId); + if (current) { + const first = this.readAgentGraphEpochSync(request.rootSessionId, 1); + if (first?.graphId !== request.legacyGraphId) { + throw new AgentGraphEpochConflictError( + `Agent Graph epoch 1 identity does not match for root Session ${request.rootSessionId}`, + ); + } + return current; + } + + if (this.readAgentGraphEpochByGraphIdSync(request.legacyGraphId)) { + throw new AgentGraphEpochConflictError( + `Agent Graph epoch 1 could not be resolved for root Session ${request.rootSessionId}`, + ); + } + + return { + schemaVersion: AGENT_GRAPH_EPOCH_SCHEMA_VERSION, + rootSessionId: request.rootSessionId, + epoch: 1, + graphId: request.legacyGraphId, + createdAt: 0, + }; + }); + } + + async advanceAgentGraphEpoch( + request: AdvanceAgentGraphEpochRequest, + ): Promise { + this.assertOpen(); + assertAdvanceAgentGraphEpochRequest(request); + return this.transaction(() => { + const current = this.readCurrentAgentGraphEpochSync(request.rootSessionId); + if (current?.epoch === request.expectedEpoch + 1 && current.graphId === request.nextGraphId) { + return current; + } + if (!current && request.expectedEpoch === 1) { + if ( + this.readAgentGraphEpochByGraphIdSync(request.expectedGraphId) || + this.readAgentGraphEpochByGraphIdSync(request.nextGraphId) + ) { + throw new AgentGraphEpochConflictError( + `Agent Graph epoch could not advance for root Session ${request.rootSessionId}`, + ); + } + this.insertAgentGraphEpochSync({ + schemaVersion: AGENT_GRAPH_EPOCH_SCHEMA_VERSION, + rootSessionId: request.rootSessionId, + epoch: 1, + graphId: request.expectedGraphId, + createdAt: 0, + }); + const binding: AgentGraphEpochBinding = { + schemaVersion: AGENT_GRAPH_EPOCH_SCHEMA_VERSION, + rootSessionId: request.rootSessionId, + epoch: 2, + graphId: request.nextGraphId, + createdAt: this.now(), + }; + this.insertAgentGraphEpochSync(binding); + return binding; + } + if ( + !current || + current.epoch !== request.expectedEpoch || + current.graphId !== request.expectedGraphId + ) { + throw new AgentGraphEpochConflictError( + `Agent Graph epoch changed for root Session ${request.rootSessionId}`, + ); + } + + const binding: AgentGraphEpochBinding = { + schemaVersion: AGENT_GRAPH_EPOCH_SCHEMA_VERSION, + rootSessionId: request.rootSessionId, + epoch: request.expectedEpoch + 1, + graphId: request.nextGraphId, + createdAt: this.now(), + }; + if (this.readAgentGraphEpochByGraphIdSync(binding.graphId)) { + throw new AgentGraphEpochConflictError( + `Agent Graph epoch could not advance for root Session ${request.rootSessionId}`, + ); + } + this.insertAgentGraphEpochSync(binding); + return binding; + }); + } + + async listAgentGraphEpochs(rootSessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(rootSessionId); + const rows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + root_session_id AS rootSessionId, + epoch, + graph_id AS graphId, + created_at AS createdAt + FROM agent_graph_epochs + WHERE root_session_id = ? + ORDER BY epoch ASC + `, + ) + .all(rootSessionId) as unknown as AgentGraphEpochRow[]; + return rows.map(decodeAgentGraphEpochBinding); + } + + async readAgentGraphEpochByGraphId(graphId: string): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + return this.readAgentGraphEpochByGraphIdSync(graphId); + } + + async listAgentGraphEpochPage(request: { + rootSessionId: string; + beforeEpoch?: number; + limit: number; + }): Promise<{ + epochs: AgentGraphEpochBinding[]; + nextBeforeEpoch: number | null; + currentEpoch: number | null; + }> { + this.assertOpen(); + assertSafeSessionId(request.rootSessionId); + if ( + !Number.isSafeInteger(request.limit) || + request.limit < 1 || + request.limit > 128 || + (request.beforeEpoch !== undefined && + (!Number.isSafeInteger(request.beforeEpoch) || request.beforeEpoch < 1)) + ) { + throw new Error('Invalid Agent Graph epoch page request'); + } + return this.readTransaction(() => { + const current = this.readCurrentAgentGraphEpochSync(request.rootSessionId); + const rows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + root_session_id AS rootSessionId, + epoch, + graph_id AS graphId, + created_at AS createdAt + FROM agent_graph_epochs + WHERE root_session_id = ? AND epoch < ? + ORDER BY epoch DESC + LIMIT ? + `, + ) + .all( + request.rootSessionId, + request.beforeEpoch ?? Number.MAX_SAFE_INTEGER, + request.limit + 1, + ) as unknown as AgentGraphEpochRow[]; + const hasMore = rows.length > request.limit; + const epochs = rows.slice(0, request.limit).map(decodeAgentGraphEpochBinding); + return { + epochs, + nextBeforeEpoch: hasMore ? (epochs.at(-1)?.epoch ?? null) : null, + currentEpoch: current?.epoch ?? null, + }; + }); + } + + async purgeAgentGraphEpochs(rootSessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(rootSessionId); + return this.transaction(() => + Number( + this.db + .prepare('DELETE FROM agent_graph_epochs WHERE root_session_id = ?') + .run(rootSessionId).changes, + ), + ); + } + + async listAgentGraphOperatorProvisions(graphId: string): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + const rows = this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_operator_provisions + WHERE graph_id = ? + ORDER BY provisioned_at ASC, operator_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphOperatorProvisionRow[]; + return rows.map((row) => + decodeAgentGraphOperatorProvision(JSON.parse(row.payloadJson) as unknown), + ); + } + + async purgeAgentGraphControlState(graphId: string): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + return this.transaction(() => + AGENT_GRAPH_CONTROL_DELETE_TABLES.reduce( + (removed, table) => + removed + + Number(this.db.prepare(`DELETE FROM ${table} WHERE graph_id = ?`).run(graphId).changes), + 0, + ), + ); + } + + async readAgentGraphTimelineMetadata( + graphId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + return this.readTransaction(() => { + const scheduleUpdates = ( + this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_schedule_updates + WHERE graph_id = ? + ORDER BY revision ASC + `, + ) + .all(graphId) as unknown as AgentGraphScheduleUpdateRow[] + ).map(decodeAgentGraphScheduleUpdateRow); + const operatorProvisions = ( + this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_operator_provisions + WHERE graph_id = ? + ORDER BY provisioned_at ASC, operator_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphOperatorProvisionRow[] + ).map((row) => decodeAgentGraphOperatorProvision(JSON.parse(row.payloadJson) as unknown)); + const intentClaims = ( + this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + claim_id AS claimId, + graph_id AS graphId, + intent_id AS intentId, + intent_fingerprint AS intentFingerprint, + readiness_context_fingerprint AS readinessContextFingerprint, + target_operator_id AS targetOperatorId, + target_session_id AS targetSessionId, + target_turn_id AS targetTurnId, + target_run_id AS targetRunId, + claimed_at AS claimedAt + FROM agent_graph_intent_claims + WHERE graph_id = ? + ORDER BY claimed_at ASC, intent_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphIntentClaim[] + ).map(decodeAgentGraphIntentClaim); + const intentAdmissions = ( + this.db + .prepare( + ` + SELECT + graph_id AS graphId, + intent_id AS intentId, + admission_status AS state, + admission_updated_at AS updatedAt, + cancellation_reason AS cancellationReason + FROM agent_graph_intent_claims + WHERE graph_id = ? + ORDER BY claimed_at ASC, intent_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphIntentAdmissionSnapshotRow[] + ).map(decodeAgentGraphIntentAdmissionSnapshotRow); + const wakeRows = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + wake_id AS wakeId, + snapshot_version AS snapshotVersion, + root_session_id AS rootSessionId, + status, + attempt_count AS attemptCount, + current_attempt_id AS currentAttemptId, + current_turn_id AS currentTurnId, + failure_reason AS failureReason, + created_at AS createdAt, + updated_at AS updatedAt + FROM agent_graph_supervisor_wakes + WHERE graph_id = ? + ORDER BY created_at ASC, wake_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphSupervisorWakeRow[]; + const attemptRows = this.db + .prepare( + ` + SELECT + graph_id AS graphId, + wake_id AS wakeId, + attempt_id AS attemptId, + turn_id AS turnId, + status, + failure_reason AS failureReason, + started_at AS startedAt, + completed_at AS completedAt + FROM agent_graph_supervisor_wake_attempts + WHERE graph_id = ? + ORDER BY started_at ASC, attempt_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphSupervisorWakeAttemptRow[]; + const attemptsByWake = new Map(); + for (const row of attemptRows) { + const attempt = decodeAgentGraphSupervisorWakeAttemptRow(row); + const attempts = attemptsByWake.get(attempt.wakeId) ?? []; + attempts.push(attempt); + attemptsByWake.set(attempt.wakeId, attempts); + } + const supervisorWakes = wakeRows.map((row) => { + const wake = decodeAgentGraphSupervisorWakeRow(row); + const attempts = attemptsByWake.get(wake.wakeId) ?? []; + attemptsByWake.delete(wake.wakeId); + return { + wake, + attempts, + }; + }); + if (attemptsByWake.size > 0) { + throw new Error(`Agent graph ${graphId} has orphan supervisor wake attempts`); + } + return { + graphId, + scheduleUpdates, + operatorProvisions, + intentClaims, + intentAdmissions, + supervisorWakes, + }; + }); + } + + async commitAgentGraphClientProjection( + request: CommitAgentGraphClientProjectionRequest, + ): Promise { + this.assertOpen(); + assertAgentGraphClientProjectionRequest(request); + return this.transaction(() => { + if (!this.readRecordSync(request.rootSessionId)) { + throw new SessionNotFoundError(request.rootSessionId); + } + const current = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + root_session_id AS rootSessionId, + snapshot_version AS snapshotVersion, + payload_json AS payloadJson, + materialized_at AS materializedAt + FROM agent_graph_client_projections + WHERE graph_id = ? + `, + ) + .get(request.graphId) as AgentGraphClientProjectionRow | undefined; + if ( + request.expectedSnapshotVersion === null + ? current !== undefined + : current?.snapshotVersion !== request.expectedSnapshotVersion + ) { + throw new AgentGraphClientProjectionConflictError( + `Agent graph client projection ${request.graphId} version conflict: expected ${ + request.expectedSnapshotVersion ?? 'no existing projection' + }, found ${current?.snapshotVersion ?? 'none'}`, + ); + } + + const readAppliedRecord = this.db.prepare(` + SELECT event_time AS eventTime + FROM agent_graph_client_applied_records + WHERE graph_id = ? AND record_id = ? + `); + if (request.incrementalRecordId) { + const existing = readAppliedRecord.get(request.graphId, request.incrementalRecordId) as + | AgentGraphClientAppliedRecordRow + | undefined; + if (existing) { + const requested = request.activityRecords.find( + (record) => record.recordId === request.incrementalRecordId, + )!; + if (existing.eventTime !== requested.eventTime) { + throw new SessionMetadataConflictError( + `Agent graph activity ${requested.recordId} changed after materialization`, + ); + } + if (!current) { + throw new Error('Incremental agent graph projection has no current snapshot'); + } + return decodeAgentGraphClientProjectionRow(current); + } + } + + const materializedAt = this.now(); + const snapshotPayloadJson = encodeProjectionPayload(request.snapshot, 'snapshot'); + this.db + .prepare( + ` + INSERT INTO agent_graph_client_projections( + graph_id, + root_session_id, + schema_version, + snapshot_version, + payload_json, + materialized_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(graph_id) DO UPDATE SET + root_session_id = excluded.root_session_id, + schema_version = excluded.schema_version, + snapshot_version = excluded.snapshot_version, + payload_json = excluded.payload_json, + materialized_at = excluded.materialized_at + `, + ) + .run( + request.graphId, + request.rootSessionId, + request.schemaVersion, + request.snapshotVersion, + snapshotPayloadJson, + materializedAt, + ); + + const insertAppliedRecord = this.db.prepare(` + INSERT INTO agent_graph_client_applied_records( + graph_id, + record_id, + event_time + ) VALUES (?, ?, ?) + `); + for (const record of request.activityRecords) { + const existing = readAppliedRecord.get(request.graphId, record.recordId) as + | AgentGraphClientAppliedRecordRow + | undefined; + if (existing) { + if (existing.eventTime !== record.eventTime) { + throw new SessionMetadataConflictError( + `Agent graph activity ${record.recordId} changed after materialization`, + ); + } + continue; + } + insertAppliedRecord.run(request.graphId, record.recordId, record.eventTime); + } + + if (request.replaceOperators) { + this.db + .prepare('DELETE FROM agent_graph_client_operator_projections WHERE graph_id = ?') + .run(request.graphId); + } + const insertOperator = this.db.prepare(` + INSERT INTO agent_graph_client_operator_projections( + graph_id, + operator_id, + snapshot_version, + payload_json, + materialized_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(graph_id, operator_id) DO UPDATE SET + snapshot_version = excluded.snapshot_version, + payload_json = excluded.payload_json, + materialized_at = excluded.materialized_at + `); + for (const operator of request.operators) { + insertOperator.run( + request.graphId, + operator.operatorId, + request.snapshotVersion, + encodeProjectionPayload(operator.payload, 'operator'), + materializedAt, + ); + } + + const readTerminal = this.db.prepare(` + SELECT event_time AS eventTime, payload_json AS payloadJson + FROM agent_graph_client_terminal_activity + WHERE graph_id = ? AND record_id = ? + `); + const insertTerminal = this.db.prepare(` + INSERT INTO agent_graph_client_terminal_activity( + graph_id, + record_id, + event_time, + payload_json + ) VALUES (?, ?, ?, ?) + `); + for (const terminal of request.terminalActivities) { + const payloadJson = encodeProjectionPayload(terminal.payload, 'terminal activity'); + const existing = readTerminal.get(request.graphId, terminal.recordId) as + | AgentGraphClientTerminalActivityRow + | undefined; + if (existing) { + if (existing.eventTime !== terminal.eventTime || existing.payloadJson !== payloadJson) { + throw new SessionMetadataConflictError( + `Agent graph terminal activity ${terminal.recordId} changed after materialization`, + ); + } + continue; + } + insertTerminal.run(request.graphId, terminal.recordId, terminal.eventTime, payloadJson); + } + + return { + schemaVersion: request.schemaVersion, + graphId: request.graphId, + rootSessionId: request.rootSessionId, + snapshotVersion: request.snapshotVersion, + payload: structuredClone(request.snapshot), + materializedAt, + }; + }); + } + + async readAgentGraphClientProjection( + graphId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + root_session_id AS rootSessionId, + snapshot_version AS snapshotVersion, + payload_json AS payloadJson, + materialized_at AS materializedAt + FROM agent_graph_client_projections + WHERE graph_id = ? + `, + ) + .get(graphId) as AgentGraphClientProjectionRow | undefined; + return row ? decodeAgentGraphClientProjectionRow(row) : undefined; + } + + async readAgentGraphClientOperatorProjection( + graphId: string, + operatorId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphLookupIdentity(operatorId, 'operator id'); + const row = this.db + .prepare( + ` + SELECT + graph_id AS graphId, + operator_id AS operatorId, + snapshot_version AS snapshotVersion, + payload_json AS payloadJson, + materialized_at AS materializedAt + FROM agent_graph_client_operator_projections + WHERE graph_id = ? AND operator_id = ? + `, + ) + .get(graphId, operatorId) as AgentGraphClientOperatorProjectionRow | undefined; + return row ? decodeAgentGraphClientOperatorProjectionRow(row) : undefined; + } + + async readAgentGraphClientProjectionWithOperator( + graphId: string, + operatorId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + assertGraphLookupIdentity(operatorId, 'operator id'); + const row = this.db + .prepare( + ` + SELECT + graph.schema_version AS projectionSchemaVersion, + graph.graph_id AS projectionGraphId, + graph.root_session_id AS projectionRootSessionId, + graph.snapshot_version AS projectionSnapshotVersion, + graph.payload_json AS projectionPayloadJson, + graph.materialized_at AS projectionMaterializedAt, + operator.graph_id AS operatorGraphId, + operator.operator_id AS operatorId, + operator.snapshot_version AS operatorSnapshotVersion, + operator.payload_json AS operatorPayloadJson, + operator.materialized_at AS operatorMaterializedAt + FROM agent_graph_client_projections AS graph + LEFT JOIN agent_graph_client_operator_projections AS operator + ON operator.graph_id = graph.graph_id + AND operator.operator_id = ? + WHERE graph.graph_id = ? + `, + ) + .get(operatorId, graphId) as AgentGraphClientProjectionWithOperatorRow | undefined; + if (!row) return undefined; + const projection = decodeAgentGraphClientProjectionRow({ + schemaVersion: row.projectionSchemaVersion, + graphId: row.projectionGraphId, + rootSessionId: row.projectionRootSessionId, + snapshotVersion: row.projectionSnapshotVersion, + payloadJson: row.projectionPayloadJson, + materializedAt: row.projectionMaterializedAt, + }); + if (row.operatorGraphId === null) return { projection }; + return { + projection, + operator: decodeAgentGraphClientOperatorProjectionRow({ + graphId: row.operatorGraphId, + operatorId: row.operatorId!, + snapshotVersion: row.operatorSnapshotVersion!, + payloadJson: row.operatorPayloadJson!, + materializedAt: row.operatorMaterializedAt!, + }), + }; + } + + async listAgentGraphClientTerminalActivities( + graphId: string, + input: { + limit: number; + before?: { eventTime: number; recordId: string }; + }, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 256) { + throw new Error('Agent graph terminal activity limit must be between 1 and 256'); + } + if (input.before) { + assertGraphEventTime(input.before.eventTime); + assertGraphLookupIdentity(input.before.recordId, 'terminal record id'); + const cursor = this.db + .prepare( + ` + SELECT event_time AS eventTime + FROM agent_graph_client_terminal_activity + WHERE graph_id = ? AND record_id = ? + `, + ) + .get(graphId, input.before.recordId) as { eventTime?: unknown } | undefined; + if (cursor?.eventTime !== input.before.eventTime) { + throw new AgentGraphClientTerminalCursorError( + 'Agent graph terminal activity cursor is stale or invalid', + ); + } + } + const rows = this.db + .prepare( + ` + SELECT + graph_id AS graphId, + record_id AS recordId, + event_time AS eventTime, + payload_json AS payloadJson + FROM agent_graph_client_terminal_activity + WHERE graph_id = ? + ${ + input.before + ? `AND ( + event_time < ? + OR (event_time = ? AND record_id < ?) + )` + : '' + } + ORDER BY event_time DESC, record_id DESC + LIMIT ? + `, + ) + .all( + graphId, + ...(input.before + ? [input.before.eventTime, input.before.eventTime, input.before.recordId] + : []), + input.limit + 1, + ) as unknown as AgentGraphClientTerminalActivityRowWithIdentity[]; + return { + records: rows.slice(0, input.limit).map((row) => ({ + graphId: row.graphId, + recordId: row.recordId, + eventTime: row.eventTime, + payload: JSON.parse(row.payloadJson) as unknown, + })), + hasMore: rows.length > input.limit, + }; + } + + async listAgentGraphClientClaimAdmissions( + graphId: string, + ): Promise { + this.assertOpen(); + assertGraphLookupIdentity(graphId, 'graph id'); + const rows = this.db + .prepare( + ` + SELECT + intent_id AS intentId, + admission_status AS state + FROM agent_graph_intent_claims + WHERE graph_id = ? + ORDER BY claimed_at ASC, intent_id ASC + `, + ) + .all(graphId) as unknown as AgentGraphClientClaimAdmission[]; + return rows.map((row) => { + if (row.state !== 'claimed' && row.state !== 'executing' && row.state !== 'cancelled') { + throw new Error(`Invalid agent graph admission state for ${row.intentId}`); + } + return { intentId: row.intentId, state: row.state }; + }); + } + + async update( + sessionId: string, + patch: SessionHeaderPatch, + options: { expectedVersion?: number; skipNoop?: boolean } = {}, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (Object.prototype.hasOwnProperty.call(patch, 'subagentParent')) { + throw new Error('Subagent session parent relation is immutable'); + } + if (Object.prototype.hasOwnProperty.call(patch, 'subagentRuntime')) { + throw new Error('Subagent session runtime snapshot is immutable'); + } + if (Object.prototype.hasOwnProperty.call(patch, 'subagentSpawn')) { + throw new Error('Subagent session spawn identity is immutable'); + } + if (Object.prototype.hasOwnProperty.call(patch, 'subagentWorkspace')) { + throw new Error('Subagent session workspace binding is immutable'); + } + if (Object.prototype.hasOwnProperty.call(patch, 'externalOrigin')) { + throw new Error('External Session origin is immutable'); + } + if (Object.prototype.hasOwnProperty.call(patch, 'role')) { + throw new Error('Session role is immutable'); + } + return this.transaction(() => + this.updateHeaderSync(sessionId, patch, { + ...(options.expectedVersion === undefined + ? {} + : { expectedVersion: options.expectedVersion }), + ...(options.skipNoop === undefined ? {} : { skipNoop: options.skipNoop }), + }), + ); + } + + async setArchivedVersioned( + sessions: readonly VersionedSessionIdentity[], + isArchived: boolean, + ): Promise { + this.assertOpen(); + const identities = uniqueVersionedSessionIdentities(sessions); + return this.transaction(() => { + const records = identities.map(({ sessionId, expectedVersion }) => + this.setArchivedSync(sessionId, expectedVersion, isArchived), + ); + if (isArchived) this.deleteGoalAuthorities(identities); + return records; + }); + } + + async removeVersioned( + sessions: readonly VersionedSessionIdentity[], + archiveSessions: readonly VersionedSessionIdentity[] = [], + ): Promise { + this.assertOpen(); + const identities = uniqueVersionedSessionIdentities(sessions); + const archiveIdentities = + archiveSessions.length === 0 ? [] : uniqueVersionedSessionIdentities(archiveSessions); + const retirementSessionIds = new Set(identities.map(({ sessionId }) => sessionId)); + for (const { sessionId } of archiveIdentities) { + if (retirementSessionIds.has(sessionId)) { + throw new SessionMetadataConflictError( + `Session cannot be archived and removed in one retirement: ${sessionId}`, + ); + } + } + const retirementUnitId = identities[0]!.sessionId; + return this.transaction(() => { + const present: VersionedSessionIdentity[] = []; + for (const identity of identities) { + const record = this.readRecordSync(identity.sessionId); + if (!record) { + if (this.hasTombstone(identity.sessionId)) continue; + throw new SessionNotFoundError(identity.sessionId); + } + if (record.metadataVersion !== identity.expectedVersion) { + throw new SessionMetadataVersionConflictError( + identity.sessionId, + identity.expectedVersion, + record.metadataVersion, + ); + } + this.assertSessionCanBeRemoved(identity.sessionId, retirementSessionIds); + present.push(identity); + } + for (const identity of archiveIdentities) { + const record = this.readRecordSync(identity.sessionId); + if (!record) throw new SessionNotFoundError(identity.sessionId); + if (record.metadataVersion !== identity.expectedVersion) { + throw new SessionMetadataVersionConflictError( + identity.sessionId, + identity.expectedVersion, + record.metadataVersion, + ); + } + } + const deletedAt = this.now(); + for (const { sessionId, expectedVersion } of archiveIdentities) { + this.setArchivedSync(sessionId, expectedVersion, true); + } + for (const { sessionId } of present) { + const deleted = this.db + .prepare('DELETE FROM session_metadata WHERE session_id = ?') + .run(sessionId); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError( + `Session metadata remove lost its admitted row: ${sessionId}`, + ); + } + this.db + .prepare( + ` + INSERT INTO session_metadata_tombstones( + session_id, + deleted_at, + retirement_unit_id, + cleanup_pending + ) + VALUES (?, ?, ?, 1) + ON CONFLICT(session_id) DO NOTHING + `, + ) + .run(sessionId, deletedAt, retirementUnitId); + } + this.deleteGoalAuthorities([...identities, ...archiveIdentities]); + return identities.map((identity) => identity.sessionId); + }); + } + + private deleteGoalAuthorities(sessions: readonly VersionedSessionIdentity[]): void { + // Standalone metadata stores have no workflow schema. An operational lease + // guarantees that Goal authority shares this exact transaction boundary. + if (!this.databaseLease) return; + const remove = this.db.prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?'); + for (const { sessionId } of sessions) remove.run(sessionId); + } + + async remove(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + this.assertSessionCanBeRemoved(sessionId); + const deleted = + this.db.prepare('DELETE FROM session_metadata WHERE session_id = ?').run(sessionId) + .changes === 1; + this.db + .prepare( + ` + INSERT INTO session_metadata_tombstones( + session_id, + deleted_at, + retirement_unit_id, + cleanup_pending + ) + VALUES (?, ?, ?, 1) + ON CONFLICT(session_id) DO NOTHING + `, + ) + .run(sessionId, this.now(), sessionId); + return deleted; + }); + } + + private insertHeader( + header: SessionHeader, + metadataVersion: number, + committedAt: number, + initialBoundary?: ExecutionBoundary, + ): SessionMetadataRecord { + const inserted = this.tryInsertHeader( + header, + metadataVersion, + committedAt, + false, + initialBoundary, + ); + if (!inserted) { + throw new SessionMetadataConflictError(`Session metadata already exists: ${header.id}`); + } + return inserted; + } + + private tryInsertHeader( + header: SessionHeader, + metadataVersion: number, + committedAt: number, + ignoreConflicts: boolean, + initialBoundary?: ExecutionBoundary, + ): SessionMetadataRecord | undefined { + const result = this.db + .prepare( + ` + INSERT ${ignoreConflicts ? 'OR IGNORE' : ''} INTO session_metadata( + session_id, + payload_json, + created_at, + last_message_at, + name, + is_flagged, + is_archived, + parent_session_id, + subagent_parent_session_id, + subagent_parent_run_id, + subagent_tool_call_id, + subagent_swarm_id, + subagent_item_id, + subagent_request_fingerprint, + subagent_initial_turn_id, + subagent_initial_run_id, + external_adapter_id, + external_source_session_id, + revision_root_session_id, + revision_index, + has_unread, + backend, + llm_connection_slug, + model, + metadata_version, + committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + header.id, + JSON.stringify(header), + header.createdAt, + header.lastMessageAt ?? null, + header.name, + booleanInteger(header.isFlagged), + booleanInteger(header.isArchived), + header.parentSessionId ?? null, + header.subagentParent?.parentSessionId ?? null, + header.subagentParent?.spawnedBy.parentRunId ?? null, + header.subagentParent?.spawnedBy.toolCallId ?? null, + header.subagentParent?.swarm?.swarmId ?? null, + header.subagentParent?.swarm?.itemId ?? null, + header.subagentSpawn?.requestFingerprint ?? null, + header.subagentSpawn?.initialTurnId ?? null, + header.subagentSpawn?.initialRunId ?? null, + header.externalOrigin?.adapterId ?? null, + header.externalOrigin?.sourceSessionId ?? null, + header.revisionRootSessionId ?? null, + header.revisionIndex ?? null, + booleanInteger(header.hasUnread), + header.backend, + header.llmConnectionSlug, + header.model, + metadataVersion, + committedAt, + ); + if (result.changes !== 1) return undefined; + this.options.failpoint?.('after_session_row_write'); + this.ensureGenesisExecutionBoundary(header, initialBoundary); + return { header, metadataVersion, committedAt }; + } + + private ensureGenesisExecutionBoundary( + header: SessionHeader, + initialBoundary?: ExecutionBoundary, + ): void { + const existing = this.db + .prepare( + `SELECT 1 AS found FROM sandbox_boundary_log WHERE session_id = ? AND applied_revision = 0`, + ) + .get(header.id); + if (existing) return; + + const boundary = initialBoundary + ? { ...decodeExecutionBoundary(initialBoundary), revision: 0 } + : createGenesisExecutionBoundary(header.permissionMode); + this.db + .prepare( + ` + INSERT INTO sandbox_boundary_log( + session_id, + entry_id, + entry_kind, + status, + applied_revision, + boundary_json, + created_at, + settled_at + ) VALUES (?, 'genesis', 'genesis', 'applied', 0, ?, ?, ?) + `, + ) + .run(header.id, JSON.stringify(boundary), header.createdAt, header.createdAt); + this.options.failpoint?.('after_sandbox_boundary_write'); + } + + private readCurrentExecutionBoundarySync(sessionId: string): ExecutionBoundary { + const row = this.db + .prepare( + ` + SELECT boundary_json AS boundaryJson + FROM sandbox_boundary_log + WHERE session_id = ? AND applied_revision IS NOT NULL + ORDER BY applied_revision DESC + LIMIT 1 + `, + ) + .get(sessionId) as { boundaryJson?: unknown } | undefined; + if (!row || typeof row.boundaryJson !== 'string') { + throw new SessionMetadataConflictError(`Session execution boundary is missing: ${sessionId}`); + } + return decodeExecutionBoundary(JSON.parse(row.boundaryJson) as unknown); + } + + private readLatestAutoSandboxProfileSync( + sessionId: string, + ): Extract['profile'] { + const rows = this.db + .prepare( + ` + SELECT boundary_json AS boundaryJson + FROM sandbox_boundary_log + WHERE + session_id = ? + AND applied_revision IS NOT NULL + AND json_extract(boundary_json, '$.kind') = 'managed' + ORDER BY applied_revision DESC + `, + ) + .all(sessionId) as unknown as Array<{ boundaryJson?: unknown }>; + for (const row of rows) { + if (typeof row.boundaryJson !== 'string') { + throw new SessionMetadataConflictError( + `Managed sandbox boundary history is invalid: ${sessionId}`, + ); + } + const boundary = decodeExecutionBoundary(JSON.parse(row.boundaryJson) as unknown); + if (boundary.kind !== 'managed') { + throw new SessionMetadataConflictError( + `Managed sandbox boundary history is invalid: ${sessionId}`, + ); + } + if (!isCanonicalReadOnlySandboxProfile(boundary.profile)) return boundary.profile; + } + return requireManagedProfile(createGenesisExecutionBoundary('ask')); + } + + private readSandboxBoundaryRequestSync( + sessionId: string, + requestId: string, + ): SandboxBoundaryRequest | undefined { + const row = this.db + .prepare( + ` + SELECT ${SANDBOX_BOUNDARY_REQUEST_COLUMNS} + FROM sandbox_boundary_log + WHERE session_id = ? AND request_id = ? + `, + ) + .get(sessionId, requestId) as SandboxBoundaryRequestRow | undefined; + return row ? decodeSandboxBoundaryRequestRow(row) : undefined; + } + + private requireSandboxBoundaryRequestSync( + sessionId: string, + requestId: string, + ): SandboxBoundaryRequest { + const request = this.readSandboxBoundaryRequestSync(sessionId, requestId); + if (!request) { + throw new SessionMetadataConflictError( + `Sandbox boundary request was not found: ${requestId}`, + ); + } + return request; + } + + private settleSandboxBoundaryRequestRow(input: { + sessionId: string; + requestId: string; + status: 'approved' | 'denied' | 'conflict'; + settledAt: number; + appliedRevision?: number; + boundary?: ExecutionBoundary; + outcomeReason?: string; + }): void { + const result = this.db + .prepare( + ` + UPDATE sandbox_boundary_log + SET + status = ?, + applied_revision = ?, + boundary_json = ?, + outcome_reason = ?, + settled_at = ? + WHERE session_id = ? AND request_id = ? AND status = 'pending' + `, + ) + .run( + input.status, + input.appliedRevision ?? null, + input.boundary ? JSON.stringify(input.boundary) : null, + input.outcomeReason ?? null, + input.settledAt, + input.sessionId, + input.requestId, + ); + if (result.changes !== 1) { + throw new SessionMetadataConflictError( + `Sandbox boundary request was already settled: ${input.requestId}`, + ); + } + this.options.failpoint?.('after_sandbox_boundary_write'); + } + + private updateHeaderSync( + sessionId: string, + patch: SessionHeaderPatch, + options: { + expectedVersion?: number; + skipNoop?: boolean; + catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; + } = {}, + ): SessionMetadataRecord { + if (Object.prototype.hasOwnProperty.call(patch, 'isArchived')) { + throw new Error('Session archive state requires the dedicated lifecycle writer'); + } + const current = this.readRecordSync(sessionId); + if (!current) throw new SessionNotFoundError(sessionId); + if ( + options.expectedVersion !== undefined && + options.expectedVersion !== current.metadataVersion + ) { + throw new SessionMetadataVersionConflictError( + sessionId, + options.expectedVersion, + current.metadataVersion, + ); + } + assertConversationCopyTransition(current.header, patch); + const next = normalizeSessionHeader( + { + ...current.header, + ...patch, + }, + sessionId, + ); + return this.persistHeaderSync(sessionId, current, next, options); + } + + private setArchivedSync( + sessionId: string, + expectedVersion: number, + isArchived: boolean, + ): SessionMetadataRecord { + const current = this.readRecordSync(sessionId); + if (!current) throw new SessionNotFoundError(sessionId); + if (expectedVersion !== current.metadataVersion) { + throw new SessionMetadataVersionConflictError( + sessionId, + expectedVersion, + current.metadataVersion, + ); + } + const next = normalizeSessionHeader({ ...current.header, isArchived }, sessionId); + return this.persistHeaderSync(sessionId, current, next, { skipNoop: true }); + } + + private persistHeaderSync( + sessionId: string, + current: SessionMetadataRecord, + next: SessionHeader, + options: { + skipNoop?: boolean; + catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; + } = {}, + ): SessionMetadataRecord { + if (next.id !== sessionId) { + throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); + } + const currentPreview = + options.catalogPreview === undefined ? undefined : this.readCatalogPreviewSync(sessionId); + const previewChanged = + options.catalogPreview !== undefined && options.catalogPreview.value !== currentPreview; + if (options.skipNoop && isDeepStrictEqual(next, current.header) && !previewChanged) { + return current; + } + const metadataVersion = current.metadataVersion + 1; + const committedAt = this.now(); + const updated = this.db + .prepare( + ` + UPDATE session_metadata + SET + payload_json = ?, + created_at = ?, + last_message_at = ?, + name = ?, + is_flagged = ?, + is_archived = ?, + parent_session_id = ?, + subagent_parent_session_id = ?, + revision_root_session_id = ?, + revision_index = ?, + has_unread = ?, + backend = ?, + llm_connection_slug = ?, + model = ?, + metadata_version = ?, + committed_at = ? + WHERE session_id = ? AND metadata_version = ? + `, + ) + .run( + JSON.stringify(next), + next.createdAt, + next.lastMessageAt ?? null, + next.name, + booleanInteger(next.isFlagged), + booleanInteger(next.isArchived), + next.parentSessionId ?? null, + next.subagentParent?.parentSessionId ?? null, + next.revisionRootSessionId ?? null, + next.revisionIndex ?? null, + booleanInteger(next.hasUnread), + next.backend, + next.llmConnectionSlug, + next.model, + metadataVersion, + committedAt, + sessionId, + current.metadataVersion, + ); + if (updated.changes !== 1) { + throw new SessionMetadataConflictError( + `Session metadata compare-and-set failed: ${sessionId}`, + ); + } + this.options.failpoint?.('after_session_row_write'); + if (options.catalogPreview) { + const preview = this.db + .prepare( + ` + UPDATE session_catalog_projection + SET last_message_preview = ? + WHERE session_id = ? + `, + ) + .run(options.catalogPreview.value ?? null, sessionId); + if (preview.changes !== 1) { + throw new SessionMetadataConflictError( + `Session catalog projection is missing: ${sessionId}`, + ); + } + } + return { header: next, metadataVersion, committedAt }; + } + + private setExecutionBoundaryKindSync( + sessionId: string, + kind: 'managed' | 'bypass', + projection?: { + permissionMode: SessionHeader['permissionMode']; + labels?: readonly string[]; + }, + options: { + expectedVersion?: number; + headerPatch?: SessionHeaderPatch; + } = {}, + ): { boundary: ExecutionBoundary; record: SessionMetadataRecord } { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + if ( + options.expectedVersion !== undefined && + options.expectedVersion !== record.metadataVersion + ) { + throw new SessionMetadataVersionConflictError( + sessionId, + options.expectedVersion, + record.metadataVersion, + ); + } + this.ensureGenesisExecutionBoundary(record.header); + const current = this.readCurrentExecutionBoundarySync(sessionId); + if (current.kind === 'external') { + throw new SessionMetadataConflictError( + 'An externally isolated session cannot enter Auto or Bypass', + ); + } + const projectedMode = + projection?.permissionMode ?? + (kind === 'bypass' + ? 'bypass' + : record.header.permissionMode === 'bypass' + ? 'ask' + : record.header.permissionMode); + if ((projectedMode === 'bypass') !== (kind === 'bypass')) { + throw new Error('Execution boundary kind and projected permission mode disagree'); + } + + let boundary: ExecutionBoundary = current; + const nextManagedProfile = + kind === 'managed' + ? projectedMode === 'explore' + ? requireManagedProfile(createGenesisExecutionBoundary('explore')) + : current.kind === 'managed' && !isCanonicalReadOnlySandboxProfile(current.profile) + ? current.profile + : this.readLatestAutoSandboxProfileSync(sessionId) + : undefined; + const boundaryChanged = + current.kind !== kind || + (kind === 'managed' && + current.kind === 'managed' && + !isDeepStrictEqual(current.profile, nextManagedProfile)); + if (boundaryChanged) { + const revision = current.revision + 1; + boundary = + kind === 'bypass' + ? { kind: 'bypass', revision } + : { + kind: 'managed', + profile: nextManagedProfile!, + revision, + }; + const committedAt = this.now(); + this.db + .prepare( + ` + INSERT INTO sandbox_boundary_log( + session_id, + entry_id, + entry_kind, + status, + applied_revision, + boundary_json, + created_at, + settled_at + ) VALUES (?, ?, 'user_change', 'applied', ?, ?, ?, ?) + `, + ) + .run( + sessionId, + `change:${revision}`, + revision, + JSON.stringify(boundary), + committedAt, + committedAt, + ); + this.options.failpoint?.('after_sandbox_boundary_write'); + } + + const projectedLabels = projection?.labels ? [...projection.labels] : record.header.labels; + const patch = { + ...options.headerPatch, + permissionMode: projectedMode, + labels: projectedLabels, + }; + const updated = this.updateHeaderSync(sessionId, patch, { + ...(options.expectedVersion === undefined + ? {} + : { expectedVersion: options.expectedVersion }), + skipNoop: true, + }); + return { boundary, record: updated }; + } + + private readRecordSync(sessionId: string): SessionMetadataRecord | undefined { + const row = this.db + .prepare( + ` + SELECT session_id, payload_json, metadata_version, committed_at + FROM session_metadata + WHERE session_id = ? + `, + ) + .get(sessionId) as SessionMetadataRow | undefined; + return row ? decodeRecord(row) : undefined; + } + + private readMessageByIdSync(sessionId: string, messageId: string): StoredMessage | undefined { + const row = this.db + .prepare( + ` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.message_id = ? + `, + ) + .get(sessionId, messageId) as StoredSessionMessagePayloadRow | undefined; + return row ? decodeStoredMessageRecordRow(this.db, sessionId, row) : undefined; + } + + private insertSessionMessagesSync( + sessionId: string, + firstSequence: number, + entries: readonly { + readonly message: StoredMessage; + readonly json: string; + }[], + ): void { + if ( + !Number.isSafeInteger(firstSequence) || + firstSequence < 0 || + entries.length > Number.MAX_SAFE_INTEGER - firstSequence + 1 + ) { + throw new SessionMetadataConflictError('Session message sequence overflow'); + } + const insertMessage = this.db.prepare(` + INSERT INTO session_messages( + session_id, sequence, message_id, message_type, message_ts, record_json + ) VALUES (?, ?, ?, ?, ?, ?) + `); + const insertPayload = this.db.prepare(` + INSERT INTO session_message_payloads(session_id, sequence, record_bytes, sha256) + VALUES (?, ?, ?, ?) + `); + const insertChunk = this.db.prepare(` + INSERT INTO session_message_chunks(session_id, sequence, chunk_index, data, sha256) + VALUES (?, ?, ?, ?, ?) + `); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + const sequence = firstSequence + index; + const encoded = Buffer.from(entry.json, 'utf8'); + const chunked = encoded.byteLength > SQLITE_SESSION_MESSAGE_CHUNK_BYTES; + insertMessage.run( + sessionId, + sequence, + entry.message.id, + entry.message.type, + entry.message.ts, + chunked ? SQLITE_SESSION_MESSAGE_CHUNK_MARKER : entry.json, + ); + if (!chunked) continue; + insertPayload.run( + sessionId, + sequence, + encoded.byteLength, + createHash('sha256').update(encoded).digest('hex'), + ); + for ( + let offset = 0; + offset < encoded.byteLength; + offset += SQLITE_SESSION_MESSAGE_CHUNK_BYTES + ) { + const chunk = encoded.subarray(offset, offset + SQLITE_SESSION_MESSAGE_CHUNK_BYTES); + insertChunk.run( + sessionId, + sequence, + offset / SQLITE_SESSION_MESSAGE_CHUNK_BYTES, + chunk, + createHash('sha256').update(chunk).digest('hex'), + ); + } + } + } + + private replaceSessionMessageSync( + sessionId: string, + sequence: number, + message: StoredMessage, + json = JSON.stringify(message), + ): void { + const encoded = Buffer.from(json, 'utf8'); + this.db + .prepare('DELETE FROM session_message_chunks WHERE session_id = ? AND sequence = ?') + .run(sessionId, sequence); + this.db + .prepare('DELETE FROM session_message_payloads WHERE session_id = ? AND sequence = ?') + .run(sessionId, sequence); + if (encoded.byteLength <= SQLITE_SESSION_MESSAGE_CHUNK_BYTES) { + this.db + .prepare( + 'UPDATE session_messages SET record_json = ? WHERE session_id = ? AND sequence = ?', + ) + .run(json, sessionId, sequence); + return; + } + this.db + .prepare('UPDATE session_messages SET record_json = ? WHERE session_id = ? AND sequence = ?') + .run(SQLITE_SESSION_MESSAGE_CHUNK_MARKER, sessionId, sequence); + this.db + .prepare( + 'INSERT INTO session_message_payloads(session_id, sequence, record_bytes, sha256) VALUES (?, ?, ?, ?)', + ) + .run( + sessionId, + sequence, + encoded.byteLength, + createHash('sha256').update(encoded).digest('hex'), + ); + for ( + let offset = 0; + offset < encoded.byteLength; + offset += SQLITE_SESSION_MESSAGE_CHUNK_BYTES + ) { + const chunk = encoded.subarray(offset, offset + SQLITE_SESSION_MESSAGE_CHUNK_BYTES); + this.db + .prepare( + 'INSERT INTO session_message_chunks(session_id, sequence, chunk_index, data, sha256) VALUES (?, ?, ?, ?, ?)', + ) + .run( + sessionId, + sequence, + offset / SQLITE_SESSION_MESSAGE_CHUNK_BYTES, + chunk, + createHash('sha256').update(chunk).digest('hex'), + ); + } + } + + private readMessagesWith( + sessionId: string, + decode: (value: unknown) => StoredMessage, + ): StoredMessage[] { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const sequences = ( + this.db + .prepare('SELECT sequence FROM session_messages WHERE session_id = ? ORDER BY sequence') + .all(sessionId) as Array<{ sequence?: unknown }> + ).map((row) => requireStoredMessageSequence(row.sequence, sessionId)); + const rows: Array<{ sequence: number; recordJson: string }> = []; + for ( + let offset = 0; + offset < sequences.length; + offset += SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE + ) { + rows.push( + ...readStoredMessageRows( + this.db, + sessionId, + sequences.slice(offset, offset + SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE), + ), + ); + } + return rows.map((row) => { + try { + return decode(JSON.parse(row.recordJson) as unknown); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, row.sequence, { cause: error }); + } + }); + } + + private readCatalogPreviewSync(sessionId: string): string | undefined { + const row = this.db + .prepare( + ` + SELECT last_message_preview + FROM session_catalog_projection + WHERE session_id = ? + `, + ) + .get(sessionId) as { last_message_preview?: unknown } | undefined; + if (!row) { + throw new SessionMetadataConflictError(`Session catalog projection is missing: ${sessionId}`); + } + return decodeCatalogPreview(row.last_message_preview, sessionId); + } + + private updateCatalogProjectionSync( + sessionId: string, + projection: SessionCatalogMessageProjection, + replacePreview: boolean, + lockConnection = false, + ): void { + const current = this.readRecordSync(sessionId); + if (!current) throw new SessionNotFoundError(sessionId); + const lastMessageAt = maxTimestamp(current.header.lastMessageAt, projection.lastMessageAt); + // The preview refuses to move backwards for the same reason the timestamp + // does: a message older than the one on show is a repair of something the + // catalog already passed, and recovery replays exactly those. + const stale = + !replacePreview && + projection.lastMessageAt !== undefined && + current.header.lastMessageAt !== undefined && + projection.lastMessageAt < current.header.lastMessageAt; + this.updateHeaderSync( + sessionId, + { + ...(lockConnection ? { connectionLocked: true } : {}), + ...(lastMessageAt === undefined ? {} : { lastMessageAt }), + }, + { + skipNoop: true, + ...(!stale && (replacePreview || projection.lastMessagePreview !== undefined) + ? { + catalogPreview: { + kind: 'replace', + ...(projection.lastMessagePreview === undefined + ? {} + : { value: projection.lastMessagePreview }), + } as const, + } + : {}), + }, + ); + } + + private finishCatalogProjectionWriteSync(): void { + const result = this.db + .prepare( + ` + UPDATE session_catalog_state + SET pending_writes = pending_writes - 1 + WHERE scope = 'catalog' AND pending_writes > 0 + `, + ) + .run(); + if (result.changes !== 1) { + throw new Error('Session catalog projection write was not pending'); + } + } + + private readCatalogRevisionSync(): SessionCatalogRevisionState { + const state = this.readCatalogStateSync(); + return { epoch: state.epoch, generation: state.generation }; + } + + private readCatalogStateSync(): SessionCatalogRevisionState & { + readonly pendingWrites: number; + } { + const row = this.db + .prepare( + ` + SELECT epoch, generation, pending_writes + FROM session_catalog_state + WHERE scope = 'catalog' + `, + ) + .get() as { epoch?: unknown; generation?: unknown; pending_writes?: unknown } | undefined; + if ( + !row || + typeof row.epoch !== 'string' || + !/^[0-9a-f]{32}$/.test(row.epoch) || + !Number.isSafeInteger(row.generation) || + (row.generation as number) < 0 || + !Number.isSafeInteger(row.pending_writes) || + (row.pending_writes as number) < 0 + ) { + throw new Error('Invalid Session catalog revision state'); + } + return { + epoch: row.epoch, + generation: row.generation as number, + pendingWrites: row.pending_writes as number, + }; + } + + private probeStableSessionCreateSync( + sessionId: string, + requestFingerprint: string, + ): StableSessionCreateProbe { + const claim = this.db + .prepare( + ` + SELECT request_fingerprint AS requestFingerprint + FROM session_create_claims + WHERE session_id = ? + `, + ) + .get(sessionId) as { requestFingerprint?: unknown } | undefined; + const record = this.readRecordSync(sessionId); + if (!claim) { + if (record || this.hasTombstone(sessionId)) { + return { + kind: 'conflict', + reason: record ? 'identity_mismatch' : 'removed', + }; + } + return { kind: 'absent' }; + } + if (this.hasTombstone(sessionId)) { + return { kind: 'conflict', reason: 'removed' }; + } + if ( + typeof claim.requestFingerprint !== 'string' || + claim.requestFingerprint !== requestFingerprint + ) { + return { kind: 'conflict', reason: 'identity_mismatch' }; + } + return record ? { kind: 'existing', record } : { kind: 'absent' }; + } + + private tryClaimSubagentSpawn( + header: SessionHeader, + claimedAt: number, + ): SubagentSpawnClaim & { created: boolean } { + const identity = requireSubagentSpawnIdentity(header); + const result = this.db + .prepare( + ` + INSERT OR IGNORE INTO subagent_spawns( + parent_session_id, + parent_run_id, + tool_call_id, + swarm_id, + item_id, + request_fingerprint, + child_session_id, + initial_turn_id, + initial_run_id, + claimed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + identity.parent.parentSessionId, + identity.parent.spawnedBy.parentRunId, + identity.parent.spawnedBy.toolCallId, + subagentSpawnScope(identity.parent).scopeId, + subagentSpawnScope(identity.parent).itemId, + identity.spawn.requestFingerprint, + header.id, + identity.spawn.initialTurnId, + identity.spawn.initialRunId, + claimedAt, + ); + const claim = this.readSubagentSpawnClaim(identity.parent); + if (!claim) throw new Error('Subagent spawn claim was not persisted'); + return { ...claim, created: result.changes === 1 }; + } + + private assertMatchingSubagentSpawnClaim(header: SessionHeader): void { + const identity = requireSubagentSpawnIdentity(header); + const claim = this.readSubagentSpawnClaim(identity.parent); + if ( + !claim || + claim.childSessionId !== header.id || + claim.requestFingerprint !== identity.spawn.requestFingerprint || + claim.initialTurnId !== identity.spawn.initialTurnId || + claim.initialRunId !== identity.spawn.initialRunId + ) { + throw new SessionMetadataConflictError( + 'Child-session spawn claim disagrees with session metadata', + ); + } + } + + private readSubagentSpawnClaim(parent: SubagentSessionParent): SubagentSpawnClaim | undefined { + return this.db + .prepare( + ` + SELECT + request_fingerprint AS requestFingerprint, + child_session_id AS childSessionId, + initial_turn_id AS initialTurnId, + initial_run_id AS initialRunId + FROM subagent_spawns + WHERE parent_session_id = ? + AND parent_run_id = ? + AND tool_call_id = ? + AND swarm_id = ? + AND item_id = ? + `, + ) + .get( + parent.parentSessionId, + parent.spawnedBy.parentRunId, + parent.spawnedBy.toolCallId, + subagentSpawnScope(parent).scopeId, + subagentSpawnScope(parent).itemId, + ) as SubagentSpawnClaim | undefined; + } + + private readAgentGraphOperatorProvisionSync( + graphId: string, + workId: string, + ): AgentGraphOperatorProvision | undefined { + const row = this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_operator_provisions + WHERE graph_id = ? AND work_id = ? + `, + ) + .get(graphId, workId) as AgentGraphOperatorProvisionRow | undefined; + return row + ? decodeAgentGraphOperatorProvision(JSON.parse(row.payloadJson) as unknown) + : undefined; + } + + private matchAgentGraphOperatorProvision( + existing: AgentGraphOperatorProvision, + request: AgentGraphOperatorProvisionRequest, + ): IdempotentAgentGraphOperatorMetadataResult { + if (existing.provisionFingerprint !== request.provisionFingerprint) { + throw new SessionMetadataConflictError( + 'Graph operator provision identity was reused for different work', + ); + } + const record = this.readRecordSync(existing.targetSessionId); + if (!record) { + throw new SessionMetadataConflictError( + `Graph operator provision belongs to deleted session: ${existing.targetSessionId}`, + ); + } + if ( + record.header.subagentParent?.graph?.graphId !== existing.graphId || + record.header.subagentParent.graph.workId !== existing.workId || + record.header.subagentParent.graph.operatorId !== existing.operatorId + ) { + throw new SessionMetadataConflictError( + 'Graph operator provision disagrees with live session metadata', + ); + } + this.assertMatchingSubagentSpawnClaim(record.header); + return { + record, + provision: decodeAgentGraphOperatorProvision(existing), + created: false, + }; + } + + private readAgentGraphIntentClaimSync( + graphId: string, + intentId: string, + ): AgentGraphIntentClaim | undefined { + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + claim_id AS claimId, + graph_id AS graphId, + intent_id AS intentId, + intent_fingerprint AS intentFingerprint, + readiness_context_fingerprint AS readinessContextFingerprint, + target_operator_id AS targetOperatorId, + target_session_id AS targetSessionId, + target_turn_id AS targetTurnId, + target_run_id AS targetRunId, + claimed_at AS claimedAt + FROM agent_graph_intent_claims + WHERE graph_id = ? AND intent_id = ? + `, + ) + .get(graphId, intentId) as AgentGraphIntentClaim | undefined; + return row ? decodeAgentGraphIntentClaim(row) : undefined; + } + + private readAgentGraphIntentAdmissionStateSync( + graphId: string, + intentId: string, + ): AgentGraphIntentAdmissionState { + const row = this.db + .prepare( + ` + SELECT admission_status AS admissionState + FROM agent_graph_intent_claims + WHERE graph_id = ? AND intent_id = ? + `, + ) + .get(graphId, intentId) as { admissionState?: unknown } | undefined; + if ( + row?.admissionState !== 'claimed' && + row?.admissionState !== 'executing' && + row?.admissionState !== 'cancelled' + ) { + throw new AgentGraphIntentClaimConflictError( + `Agent graph intent ${graphId}/${intentId} has no durable admission`, + ); + } + return row.admissionState; + } + + private claimAgentGraphIntentSync( + request: AgentGraphIntentClaimRequest, + ): AgentGraphIntentClaimResult { + const claimedAt = this.now(); + const inserted = this.db + .prepare( + ` + INSERT OR IGNORE INTO agent_graph_intent_claims( + claim_id, + schema_version, + graph_id, + intent_id, + intent_fingerprint, + readiness_context_fingerprint, + target_operator_id, + target_session_id, + target_turn_id, + target_run_id, + claimed_at, + admission_status, + admission_updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'claimed', ?) + `, + ) + .run( + request.claimId, + request.schemaVersion, + request.graphId, + request.intentId, + request.intentFingerprint, + request.readinessContextFingerprint, + request.targetOperatorId, + request.targetSessionId, + request.targetTurnId, + request.targetRunId, + claimedAt, + claimedAt, + ); + if (inserted.changes === 1) { + this.options.failpoint?.('after_agent_graph_intent_claim_write'); + } + const claim = this.readAgentGraphIntentClaimSync(request.graphId, request.intentId); + if (!claim) { + throw new AgentGraphIntentClaimConflictError( + 'Agent graph intent claim identity collides with another claim', + ); + } + if ( + claim.claimId !== request.claimId || + claim.intentFingerprint !== request.intentFingerprint || + claim.readinessContextFingerprint !== request.readinessContextFingerprint || + claim.targetOperatorId !== request.targetOperatorId || + claim.targetSessionId !== request.targetSessionId + ) { + throw new AgentGraphIntentClaimConflictError( + 'Agent graph intent identity was reused for different work', + ); + } + return { claim, created: inserted.changes === 1 }; + } + + private readAgentGraphScheduleUpdateByIdSync( + updateId: string, + ): AgentGraphScheduleUpdate | undefined { + const row = this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_schedule_updates + WHERE update_id = ? + `, + ) + .get(updateId) as AgentGraphScheduleUpdateRow | undefined; + return row ? decodeAgentGraphScheduleUpdateRow(row) : undefined; + } + + private readAgentGraphScheduleUpdateBySourceSync( + source: AgentGraphScheduleUpdateRequest['source'], + ): AgentGraphScheduleUpdate | undefined { + const row = this.db + .prepare( + ` + SELECT payload_json AS payloadJson + FROM agent_graph_schedule_updates + WHERE source_session_id = ? + AND source_run_id = ? + AND source_tool_call_id = ? + `, + ) + .get(source.sessionId, source.runId, source.toolCallId) as + | AgentGraphScheduleUpdateRow + | undefined; + return row ? decodeAgentGraphScheduleUpdateRow(row) : undefined; + } + + private matchAgentGraphScheduleUpdate( + existing: AgentGraphScheduleUpdate, + request: AgentGraphScheduleUpdateRequest, + ): AgentGraphScheduleUpdateResult { + if (!isDeepStrictEqual(agentGraphScheduleUpdateRequest(existing), request)) { + throw new AgentGraphScheduleUpdateConflictError( + 'Agent graph schedule update identity was reused for different work', + ); + } + return { update: existing, created: false }; + } + + private hasClosedAgentGraphSchedule(graphId: string): boolean { + return ( + this.db + .prepare( + ` + SELECT 1 AS found + FROM agent_graph_schedule_updates + WHERE graph_id = ? AND closes_graph = 1 + LIMIT 1 + `, + ) + .get(graphId) !== undefined + ); + } + + private nextAgentGraphScheduleRevision(graphId: string): number { + return this.currentAgentGraphScheduleRevision(graphId) + 1; + } + + private currentAgentGraphScheduleRevision(graphId: string): number { + const row = this.db + .prepare( + ` + SELECT COALESCE(MAX(revision), 0) AS revision + FROM agent_graph_schedule_updates + WHERE graph_id = ? + `, + ) + .get(graphId) as { revision?: unknown } | undefined; + const revision = row?.revision; + if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) { + throw new Error(`Invalid agent graph schedule revision for ${graphId}`); + } + return revision; + } + + private readAgentGraphSupervisorWakeSync( + graphId: string, + wakeId: string, + ): AgentGraphSupervisorWakeRecord | undefined { + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + graph_id AS graphId, + wake_id AS wakeId, + snapshot_version AS snapshotVersion, + root_session_id AS rootSessionId, + status, + attempt_count AS attemptCount, + current_attempt_id AS currentAttemptId, + current_turn_id AS currentTurnId, + failure_reason AS failureReason, + created_at AS createdAt, + updated_at AS updatedAt + FROM agent_graph_supervisor_wakes + WHERE graph_id = ? AND wake_id = ? + `, + ) + .get(graphId, wakeId) as AgentGraphSupervisorWakeRow | undefined; + return row ? decodeAgentGraphSupervisorWakeRow(row) : undefined; + } + + private requireAgentGraphSupervisorWakeSync( + graphId: string, + wakeId: string, + ): AgentGraphSupervisorWakeRecord { + const wake = this.readAgentGraphSupervisorWakeSync(graphId, wakeId); + if (!wake) { + throw new SessionMetadataConflictError( + `Agent graph supervisor wake ${graphId}/${wakeId} was not claimed`, + ); + } + return wake; + } + + private requireAgentGraphSupervisorWakeAttemptSync( + graphId: string, + wakeId: string, + attemptId: string, + ): AgentGraphSupervisorWakeAttemptRecord { + const row = this.db + .prepare( + ` + SELECT + graph_id AS graphId, + wake_id AS wakeId, + attempt_id AS attemptId, + turn_id AS turnId, + status, + failure_reason AS failureReason, + started_at AS startedAt, + completed_at AS completedAt + FROM agent_graph_supervisor_wake_attempts + WHERE graph_id = ? AND wake_id = ? AND attempt_id = ? + `, + ) + .get(graphId, wakeId, attemptId) as AgentGraphSupervisorWakeAttemptRow | undefined; + if (!row) { + throw new SessionMetadataConflictError( + `Agent graph supervisor wake attempt ${attemptId} was not found`, + ); + } + return decodeAgentGraphSupervisorWakeAttemptRow(row); + } + + private hasTombstone(sessionId: string): boolean { + return ( + this.db + .prepare('SELECT 1 AS found FROM session_metadata_tombstones WHERE session_id = ?') + .get(sessionId) !== undefined + ); + } + + private assertSessionCanBeRemoved( + sessionId: string, + retirementSessionIds?: ReadonlySet, + ): void { + const graphOwner = this.db + .prepare( + ` + SELECT graph_id AS graphId, work_id AS workId, operator_id AS operatorId + FROM agent_graph_operator_provisions + WHERE target_session_id = ? + `, + ) + .get(sessionId) as { graphId: string; workId: string; operatorId: string } | undefined; + if (graphOwner) { + const parent = this.readRecordSync(sessionId)?.header.subagentParent; + if ( + !retirementSessionIds?.has(parent?.parentSessionId ?? '') || + parent?.graph?.graphId !== graphOwner.graphId || + parent.graph.workId !== graphOwner.workId || + parent.graph.operatorId !== graphOwner.operatorId + ) { + throw new SessionMetadataConflictError( + `Cannot remove graph operator Session ${sessionId}; owned by ${graphOwner.graphId}/${graphOwner.workId}`, + ); + } + } + const ownedOperators = this.db + .prepare( + ` + SELECT + child.session_id, + child.payload_json, + child.metadata_version, + child.committed_at, + provision.graph_id, + provision.work_id, + provision.operator_id + FROM agent_graph_operator_provisions provision + JOIN session_metadata child + ON child.session_id = provision.target_session_id + WHERE child.subagent_parent_session_id = ? + ORDER BY child.session_id + `, + ) + .all(sessionId) as unknown as OwnedAgentGraphOperatorRow[]; + for (const row of ownedOperators) { + const parent = decodeRecord(row).header.subagentParent; + if ( + !parent?.graph || + parent.parentSessionId !== sessionId || + parent.graph.graphId !== row.graph_id || + parent.graph.workId !== row.work_id || + parent.graph.operatorId !== row.operator_id + ) { + throw new SessionMetadataConflictError( + `Cannot remove Session ${sessionId}; graph operator ${row.session_id} has invalid ownership`, + ); + } + if (!retirementSessionIds?.has(row.session_id)) { + throw new SessionMetadataConflictError( + `Cannot remove Session ${sessionId}; graph operator ${row.session_id} is outside the retirement unit`, + ); + } + } + } + + private transaction(operation: () => T): T { + if (this.databaseLease) return this.databaseLease.transaction('write', operation); + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = operation(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Preserve the original storage or protocol failure. + } + throw error; + } + } + + private readTransaction(operation: () => T): T { + if (this.databaseLease) return this.databaseLease.transaction('read', operation); + this.db.exec('BEGIN'); + try { + const result = operation(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Preserve the original storage or protocol failure. + } + throw error; + } + } + + private readCurrentAgentGraphEpochSync( + rootSessionId: string, + ): AgentGraphEpochBinding | undefined { + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + root_session_id AS rootSessionId, + epoch, + graph_id AS graphId, + created_at AS createdAt + FROM agent_graph_epochs + WHERE root_session_id = ? + ORDER BY epoch DESC + LIMIT 1 + `, + ) + .get(rootSessionId) as AgentGraphEpochRow | undefined; + return row ? decodeAgentGraphEpochBinding(row) : undefined; + } + + private readAgentGraphEpochSync( + rootSessionId: string, + epoch: number, + ): AgentGraphEpochBinding | undefined { + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + root_session_id AS rootSessionId, + epoch, + graph_id AS graphId, + created_at AS createdAt + FROM agent_graph_epochs + WHERE root_session_id = ? AND epoch = ? + `, + ) + .get(rootSessionId, epoch) as AgentGraphEpochRow | undefined; + return row ? decodeAgentGraphEpochBinding(row) : undefined; + } + + private insertAgentGraphEpochSync(binding: AgentGraphEpochBinding): void { + this.db + .prepare( + ` + INSERT INTO agent_graph_epochs( + root_session_id, + epoch, + graph_id, + schema_version, + created_at + ) VALUES (?, ?, ?, ?, ?) + `, + ) + .run( + binding.rootSessionId, + binding.epoch, + binding.graphId, + binding.schemaVersion, + binding.createdAt, + ); + } + + private readAgentGraphEpochByGraphIdSync(graphId: string): AgentGraphEpochBinding | undefined { + const row = this.db + .prepare( + ` + SELECT + schema_version AS schemaVersion, + root_session_id AS rootSessionId, + epoch, + graph_id AS graphId, + created_at AS createdAt + FROM agent_graph_epochs + WHERE graph_id = ? + `, + ) + .get(graphId) as AgentGraphEpochRow | undefined; + return row ? decodeAgentGraphEpochBinding(row) : undefined; + } + + private assertOpen(): void { + if (this.closed) throw new Error('SQLite session metadata store is closed'); + } +} + +function requireSubagentSpawnIdentity(header: SessionHeader): { + parent: SubagentSessionParent; + spawn: NonNullable; +} { + if ( + !isSubagentSessionParent(header.subagentParent) || + !isSubagentSessionRuntime(header.subagentRuntime) || + !isSubagentSessionSpawn(header.subagentSpawn) + ) { + throw new Error( + 'Idempotent child-session creation requires parent, runtime, and spawn metadata', + ); + } + return { parent: header.subagentParent, spawn: header.subagentSpawn }; +} + +interface SessionMetadataRow { + session_id: string; + payload_json: string; + metadata_version: number; + committed_at: number; +} + +interface OwnedAgentGraphOperatorRow extends SessionMetadataRow { + graph_id: string; + work_id: string; + operator_id: string; +} + +interface OrphanedAgentGraphOperatorRow extends OwnedAgentGraphOperatorRow { + parent_session_id: string; + retirement_unit_id: string | null; +} + +interface SessionMetadataCatalogRow extends SessionMetadataRow { + activity_at: number; + last_message_preview: string | null; +} + +function buildSessionListPredicate(filter: SessionListFilter): { + where: string[]; + parameters: Array; +} { + const where: string[] = []; + const parameters: Array = []; + if (filter.subagentParentSessionId !== undefined) { + assertSafeSessionId(filter.subagentParentSessionId); + where.push('metadata.subagent_parent_session_id = ?'); + parameters.push(filter.subagentParentSessionId); + } + return { where, parameters }; +} + +function clearConnectionBlock( + current: SessionMetadataRecord, + statusUpdatedAt: number, +): Pick { + if (current.header.blockedReason !== 'NO_REAL_CONNECTION') { + throw new SessionMetadataConflictError('Session no longer has a connection block to clear'); + } + if (!Number.isSafeInteger(statusUpdatedAt) || statusUpdatedAt < 0) { + throw new Error('Session connection unblock timestamp is invalid'); + } + return { + status: 'active', + blockedReason: undefined, + statusUpdatedAt, + }; +} + +const SANDBOX_BOUNDARY_REQUEST_COLUMNS = ` + session_id AS sessionId, + request_id AS requestId, + status, + base_revision AS baseRevision, + applied_revision AS appliedRevision, + expansion_json AS expansionJson, + justification, + outcome_reason AS outcomeReason, + created_at AS createdAt, + settled_at AS settledAt, + turn_id AS turnId, + run_id AS runId +`; + +interface SandboxBoundaryRequestRow { + sessionId: string; + requestId: string; + status: string; + baseRevision: number; + appliedRevision: number | null; + expansionJson: string; + justification: string; + outcomeReason: string | null; + createdAt: number; + settledAt: number | null; + turnId: string | null; + runId: string | null; +} + +interface SubagentSpawnClaim { + requestFingerprint: string; + childSessionId: string; + initialTurnId: string; + initialRunId: string; +} + +interface AgentGraphScheduleUpdateRow { + payloadJson: string; +} + +interface AgentGraphEpochRow { + schemaVersion: number; + rootSessionId: string; + epoch: number; + graphId: string; + createdAt: number; +} + +interface AgentGraphOperatorProvisionRow { + payloadJson: string; +} + +interface AgentGraphIntentAdmissionSnapshotRow { + graphId: string; + intentId: string; + state: string; + updatedAt: number; + cancellationReason: string | null; +} + +interface AgentGraphClientProjectionRow { + schemaVersion: number; + graphId: string; + rootSessionId: string; + snapshotVersion: string; + payloadJson: string; + materializedAt: number; +} + +interface AgentGraphClientOperatorProjectionRow { + graphId: string; + operatorId: string; + snapshotVersion: string; + payloadJson: string; + materializedAt: number; +} + +interface AgentGraphClientProjectionWithOperatorRow { + projectionSchemaVersion: number; + projectionGraphId: string; + projectionRootSessionId: string; + projectionSnapshotVersion: string; + projectionPayloadJson: string; + projectionMaterializedAt: number; + operatorGraphId: string | null; + operatorId: string | null; + operatorSnapshotVersion: string | null; + operatorPayloadJson: string | null; + operatorMaterializedAt: number | null; +} + +interface AgentGraphClientTerminalActivityRow { + eventTime: number; + payloadJson: string; +} + +interface AgentGraphClientAppliedRecordRow { + eventTime: number; +} + +interface AgentGraphSupervisorWakeRow { + schemaVersion: number; + graphId: string; + wakeId: string; + snapshotVersion: string; + rootSessionId: string; + status: string; + attemptCount: number; + currentAttemptId: string | null; + currentTurnId: string | null; + failureReason: string | null; + createdAt: number; + updatedAt: number; +} + +interface AgentGraphSupervisorWakeAttemptRow { + graphId: string; + wakeId: string; + attemptId: string; + turnId: string; + status: string; + failureReason: string | null; + startedAt: number; + completedAt: number | null; +} + +interface AgentGraphClientTerminalActivityRowWithIdentity + extends AgentGraphClientTerminalActivityRow { + graphId: string; + recordId: string; +} + +function decodeAgentGraphIntentAdmissionSnapshotRow( + row: AgentGraphIntentAdmissionSnapshotRow, +): AgentGraphIntentAdmissionSnapshot { + assertGraphLookupIdentity(row.graphId, 'graph id'); + assertGraphIntentId(row.intentId); + if (row.state !== 'claimed' && row.state !== 'executing' && row.state !== 'cancelled') { + throw new Error(`Invalid agent graph admission state for ${row.intentId}`); + } + if (!Number.isSafeInteger(row.updatedAt) || row.updatedAt < 0) { + throw new Error(`Invalid agent graph admission timestamp for ${row.intentId}`); + } + return { + graphId: row.graphId, + intentId: row.intentId, + state: row.state, + updatedAt: row.updatedAt, + ...(row.cancellationReason ? { cancellationReason: row.cancellationReason } : {}), + }; +} + +function decodeAgentGraphScheduleUpdateRow( + row: AgentGraphScheduleUpdateRow, +): AgentGraphScheduleUpdate { + return decodeAgentGraphScheduleUpdate(JSON.parse(row.payloadJson) as unknown); +} + +function decodeAgentGraphSupervisorWakeRow( + row: AgentGraphSupervisorWakeRow, +): AgentGraphSupervisorWakeRecord { + if ( + row.schemaVersion !== AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION || + ![ + 'pending', + 'running', + 'waiting_permission', + 'delivered', + 'superseded', + 'retryable_failed', + ].includes(row.status) || + !Number.isSafeInteger(row.attemptCount) || + row.attemptCount < 0 || + !Number.isSafeInteger(row.createdAt) || + row.createdAt < 0 || + !Number.isSafeInteger(row.updatedAt) || + row.updatedAt < 0 + ) { + throw new Error(`Invalid agent graph supervisor wake ${row.graphId}/${row.wakeId}`); + } + assertGraphLookupIdentity(row.graphId, 'graph id'); + assertGraphLookupIdentity(row.wakeId, 'supervisor wake id'); + assertGraphLookupIdentity(row.snapshotVersion, 'snapshot version'); + assertSafeSessionId(row.rootSessionId); + return { + schemaVersion: row.schemaVersion, + graphId: row.graphId, + wakeId: row.wakeId, + snapshotVersion: row.snapshotVersion, + rootSessionId: row.rootSessionId, + status: row.status as AgentGraphSupervisorWakeRecord['status'], + attemptCount: row.attemptCount, + ...(row.currentAttemptId ? { currentAttemptId: row.currentAttemptId } : {}), + ...(row.currentTurnId ? { currentTurnId: row.currentTurnId } : {}), + ...(row.failureReason ? { failureReason: row.failureReason } : {}), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function decodeAgentGraphSupervisorWakeAttemptRow( + row: AgentGraphSupervisorWakeAttemptRow, +): AgentGraphSupervisorWakeAttemptRecord { + if ( + !['running', 'waiting_permission', 'delivered', 'superseded', 'retryable_failed'].includes( + row.status, + ) || + !Number.isSafeInteger(row.startedAt) || + row.startedAt < 0 || + (row.completedAt !== null && (!Number.isSafeInteger(row.completedAt) || row.completedAt < 0)) + ) { + throw new Error(`Invalid agent graph supervisor wake attempt ${row.attemptId}`); + } + assertGraphLookupIdentity(row.graphId, 'graph id'); + assertGraphLookupIdentity(row.wakeId, 'supervisor wake id'); + assertGraphLookupIdentity(row.attemptId, 'supervisor wake attempt id'); + assertGraphLookupIdentity(row.turnId, 'supervisor wake turn id'); + return { + graphId: row.graphId, + wakeId: row.wakeId, + attemptId: row.attemptId, + turnId: row.turnId, + status: row.status as AgentGraphSupervisorWakeAttemptRecord['status'], + ...(row.failureReason ? { failureReason: row.failureReason } : {}), + startedAt: row.startedAt, + ...(row.completedAt !== null ? { completedAt: row.completedAt } : {}), + }; +} + +function subagentSpawnScope(parent: SubagentSessionParent): { + scopeId: string; + itemId: string; +} { + if (parent.graph) { + return { + scopeId: `graph:${parent.graph.graphId}`, + itemId: parent.graph.workId, + }; + } + return { + scopeId: parent.swarm?.swarmId ?? '', + itemId: parent.swarm?.itemId ?? '', + }; +} + +function agentGraphScheduleUpdateRequest( + update: AgentGraphScheduleUpdate, +): AgentGraphScheduleUpdateRequest { + const { revision: _revision, committedAt: _committedAt, ...request } = update; + return request; +} + +function decodeRecord(row: SessionMetadataRow): SessionMetadataRecord { + const parsed = JSON.parse(row.payload_json) as SessionHeader; + if ( + !Number.isSafeInteger(row.metadata_version) || + row.metadata_version < 1 || + !Number.isFinite(row.committed_at) + ) { + throw new Error(`Invalid SQLite session metadata record for ${row.session_id}`); + } + return { + header: decodePersistedSessionHeader(markPersisted(parsed), row.session_id), + metadataVersion: row.metadata_version, + committedAt: row.committed_at, + }; +} + +function decodeCatalogRecord(row: SessionMetadataCatalogRow): SessionMetadataCatalogRecord { + if (!Number.isSafeInteger(row.activity_at) || row.activity_at < 0) { + throw new Error(`Invalid SQLite Session catalog activity for ${row.session_id}`); + } + const lastMessagePreview = decodeCatalogPreview(row.last_message_preview, row.session_id); + return { + ...decodeRecord(row), + activityAt: row.activity_at, + ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + }; +} + +function decodeCatalogPreview(value: unknown, sessionId: string): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string' || Array.from(value).length > 96) { + throw new Error(`Invalid SQLite Session catalog preview for ${sessionId}`); + } + return value; +} + +function assertCatalogMessageProjection(projection: SessionCatalogMessageProjection): void { + if ( + projection.lastMessageAt !== undefined && + (!Number.isSafeInteger(projection.lastMessageAt) || projection.lastMessageAt < 0) + ) { + throw new Error('Session catalog message timestamp is invalid'); + } + if ( + projection.lastMessagePreview !== undefined && + Array.from(projection.lastMessagePreview).length > 96 + ) { + throw new Error('Session catalog message preview is too long'); + } +} + +function maxTimestamp(left: number | undefined, right: number | undefined): number | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.max(left, right); +} + +function decodeSandboxBoundaryRequestRow(row: SandboxBoundaryRequestRow): SandboxBoundaryRequest { + const validated = validateSandboxBoundaryExpansion(JSON.parse(row.expansionJson) as unknown); + if ( + !validated.ok || + !['pending', 'approved', 'denied', 'conflict'].includes(row.status) || + !Number.isSafeInteger(row.baseRevision) || + row.baseRevision < 0 || + (row.appliedRevision !== null && + (!Number.isSafeInteger(row.appliedRevision) || row.appliedRevision < 0)) || + !row.justification || + row.justification.length > 2_000 || + !Number.isSafeInteger(row.createdAt) || + row.createdAt < 0 || + (row.settledAt !== null && (!Number.isSafeInteger(row.settledAt) || row.settledAt < 0)) + ) { + throw new Error(`Invalid sandbox boundary request ${row.requestId}`); + } + assertSafeSessionId(row.sessionId); + assertSafeBoundaryRequestId(row.requestId); + // Rows written before provenance existed read back as null. They are long + // settled, so an absent turn simply means "not attributable" rather than a + // corrupt row worth rejecting. + if (row.turnId !== null) assertSandboxBoundaryProvenanceId(row.turnId, 'turn id'); + if (row.runId !== null) assertSandboxBoundaryProvenanceId(row.runId, 'run id'); + return { + sessionId: row.sessionId, + requestId: row.requestId, + status: row.status as SandboxBoundaryRequest['status'], + baseRevision: row.baseRevision, + expansion: validated.expansion, + justification: row.justification, + createdAt: row.createdAt, + ...(row.settledAt === null ? {} : { settledAt: row.settledAt }), + ...(row.appliedRevision === null ? {} : { appliedRevision: row.appliedRevision }), + ...(row.outcomeReason === null ? {} : { outcomeReason: row.outcomeReason }), + ...(row.turnId === null ? {} : { turnId: row.turnId }), + ...(row.runId === null ? {} : { runId: row.runId }), + }; +} + +function booleanInteger(value: boolean): 0 | 1 { + return value ? 1 : 0; +} + +function assertMetadataVersion(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive safe integer`); + } +} + +function assertSessionCreateFingerprint(value: string): void { + if (!/^sha256:[0-9a-f]{64}$/.test(value)) { + throw new Error('Session create request fingerprint is invalid'); + } +} + +function assertConversationCopyTransition(current: SessionHeader, patch: SessionHeaderPatch): void { + if (!Object.prototype.hasOwnProperty.call(patch, 'conversationCopy')) return; + if (!isValidConversationCopyTransition(current, patch.conversationCopy)) { + throw new SessionMetadataConflictError('Session conversation-copy identity is immutable'); + } +} + +function requireManagedProfile( + boundary: ExecutionBoundary, +): Extract['profile'] { + if (boundary.kind !== 'managed') throw new Error('Expected a managed execution boundary'); + return boundary.profile; +} + +function isCanonicalReadOnlySandboxProfile( + profile: Extract['profile'], +): boolean { + const { name: _profileName, ...profilePolicy } = profile; + const { name: _canonicalName, ...canonicalPolicy } = requireManagedProfile( + createGenesisExecutionBoundary('explore'), + ); + return isDeepStrictEqual(profilePolicy, canonicalPolicy); +} + +function assertGraphLookupIdentity(value: string, name: string): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value.trim() !== value || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`Invalid agent graph ${name}`); + } +} + +function assertSafeBoundaryRequestId(value: string): void { + if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw new Error('Invalid sandbox boundary request id'); + } +} + +function assertSandboxBoundaryProvenanceId(value: string, name: string): void { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value.trim() !== value || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`Invalid sandbox boundary ${name}`); + } +} + +function assertAgentGraphSupervisorWakeClaim(request: ClaimAgentGraphSupervisorWakeRequest): void { + if (request.schemaVersion !== AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION) { + throw new Error('Invalid agent graph supervisor wake schema'); + } + assertGraphLookupIdentity(request.graphId, 'graph id'); + assertGraphLookupIdentity(request.wakeId, 'supervisor wake id'); + assertGraphLookupIdentity(request.snapshotVersion, 'snapshot version'); + assertSafeSessionId(request.rootSessionId); +} + +function assertAgentGraphSupervisorWakeAttempt( + request: BeginAgentGraphSupervisorWakeAttemptRequest, +): void { + assertGraphLookupIdentity(request.graphId, 'graph id'); + assertGraphLookupIdentity(request.wakeId, 'supervisor wake id'); + assertGraphLookupIdentity(request.attemptId, 'supervisor wake attempt id'); + assertGraphLookupIdentity(request.turnId, 'supervisor wake turn id'); +} + +function assertAgentGraphSupervisorWakeCompletion( + request: CompleteAgentGraphSupervisorWakeAttemptRequest, +): void { + assertGraphLookupIdentity(request.graphId, 'graph id'); + assertGraphLookupIdentity(request.wakeId, 'supervisor wake id'); + assertGraphLookupIdentity(request.attemptId, 'supervisor wake attempt id'); + if ( + request.status !== 'waiting_permission' && + request.status !== 'delivered' && + request.status !== 'superseded' && + request.status !== 'retryable_failed' + ) { + throw new Error('Invalid agent graph supervisor wake completion status'); + } + if ( + (request.status === 'retryable_failed' || request.status === 'superseded') && + (!request.failureReason?.trim() || request.failureReason.length > 4_000) + ) { + throw new Error('Agent graph supervisor wake failure reason must be non-empty and bounded'); + } +} + +function assertAgentGraphClientProjectionRequest( + request: CommitAgentGraphClientProjectionRequest, +): void { + if ( + request.schemaVersion !== AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION || + (request.expectedSnapshotVersion !== null && + typeof request.expectedSnapshotVersion !== 'string') || + typeof request.replaceOperators !== 'boolean' || + !Array.isArray(request.operators) || + !Array.isArray(request.terminalActivities) || + !Array.isArray(request.activityRecords) + ) { + throw new Error('Invalid agent graph client projection request'); + } + assertGraphLookupIdentity(request.graphId, 'graph id'); + assertSafeSessionId(request.rootSessionId); + if (request.expectedSnapshotVersion !== null) { + assertGraphLookupIdentity(request.expectedSnapshotVersion, 'expected snapshot version'); + } + assertGraphLookupIdentity(request.snapshotVersion, 'snapshot version'); + const operatorIds = new Set(); + for (const operator of request.operators) { + assertGraphLookupIdentity(operator.operatorId, 'operator id'); + if (operatorIds.has(operator.operatorId)) { + throw new Error(`Duplicate agent graph client operator ${operator.operatorId}`); + } + operatorIds.add(operator.operatorId); + } + const terminalIds = new Set(); + for (const terminal of request.terminalActivities) { + assertGraphLookupIdentity(terminal.recordId, 'terminal record id'); + assertGraphEventTime(terminal.eventTime); + if (terminalIds.has(terminal.recordId)) { + throw new Error(`Duplicate agent graph terminal activity ${terminal.recordId}`); + } + terminalIds.add(terminal.recordId); + } + const activityIds = new Set(); + for (const record of request.activityRecords) { + assertGraphLookupIdentity(record.recordId, 'activity record id'); + assertGraphEventTime(record.eventTime); + if (activityIds.has(record.recordId)) { + throw new Error(`Duplicate agent graph activity ${record.recordId}`); + } + activityIds.add(record.recordId); + } + if (request.incrementalRecordId !== undefined) { + assertGraphLookupIdentity(request.incrementalRecordId, 'incremental record id'); + if (request.expectedSnapshotVersion === null || !activityIds.has(request.incrementalRecordId)) { + throw new Error('Invalid incremental agent graph projection record'); + } + } +} + +function encodeProjectionPayload(payload: unknown, name: string): string { + const encoded = JSON.stringify(payload); + if (encoded === undefined) { + throw new Error(`Invalid agent graph ${name} payload`); + } + return encoded; +} + +function decodeAgentGraphClientProjectionRow( + row: AgentGraphClientProjectionRow, +): AgentGraphClientProjectionRecord { + if ( + row.schemaVersion !== AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION || + !Number.isSafeInteger(row.materializedAt) || + row.materializedAt < 0 + ) { + throw new Error(`Invalid agent graph client projection for ${row.graphId}`); + } + assertGraphLookupIdentity(row.graphId, 'graph id'); + assertSafeSessionId(row.rootSessionId); + assertGraphLookupIdentity(row.snapshotVersion, 'snapshot version'); + return { + schemaVersion: row.schemaVersion, + graphId: row.graphId, + rootSessionId: row.rootSessionId, + snapshotVersion: row.snapshotVersion, + payload: JSON.parse(row.payloadJson) as unknown, + materializedAt: row.materializedAt, + }; +} + +function decodeAgentGraphClientOperatorProjectionRow( + row: AgentGraphClientOperatorProjectionRow, +): AgentGraphClientOperatorProjectionRecord { + if (!Number.isSafeInteger(row.materializedAt) || row.materializedAt < 0) { + throw new Error(`Invalid agent graph operator projection for ${row.operatorId}`); + } + assertGraphLookupIdentity(row.graphId, 'graph id'); + assertGraphLookupIdentity(row.operatorId, 'operator id'); + assertGraphLookupIdentity(row.snapshotVersion, 'snapshot version'); + return { + graphId: row.graphId, + operatorId: row.operatorId, + snapshotVersion: row.snapshotVersion, + payload: JSON.parse(row.payloadJson) as unknown, + materializedAt: row.materializedAt, + }; +} + +function assertGraphEventTime(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid agent graph terminal activity event time'); + } +} + +function assertGraphIntentId(value: string): void { + if (!/^graph_intent_[a-f0-9]{32}$/.test(value)) { + throw new Error('Invalid agent graph intent id'); + } +} + +function decodeStoredMessageRow( + row: { sequence?: unknown; record_json?: unknown }, + sessionId: string, +): StoredMessage { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + if (typeof row.record_json !== 'string') { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + try { + const parsed = JSON.parse(row.record_json) as unknown; + return decodeStoredMessage(markPersisted(parsed)); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { + cause: error, + }); + } +} + +interface StoredSessionMessagePayloadRow { + readonly sequence?: unknown; + readonly record_json?: unknown; + readonly record_bytes?: unknown; + readonly sha256?: unknown; +} + +function decodeStoredMessageRecordRow( + db: DatabaseSync, + sessionId: string, + row: StoredSessionMessagePayloadRow, +): StoredMessage { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + return decodeStoredMessageRow( + { + sequence, + record_json: readStoredMessageRecordJson(db, sessionId, sequence, row), + }, + sessionId, + ); +} + +function readStoredMessageRecordJson( + db: DatabaseSync, + sessionId: string, + sequence: number, + row: StoredSessionMessagePayloadRow, +): string { + let recordJson: string; + if (row.record_bytes === null) { + if ( + typeof row.record_json !== 'string' || + row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + recordJson = row.record_json; + } else { + const recordBytes = requireTranscriptRecordByteLength(row.record_bytes, sessionId, sequence); + if ( + row.record_json !== SQLITE_SESSION_MESSAGE_CHUNK_MARKER || + recordBytes <= SQLITE_SESSION_MESSAGE_CHUNK_BYTES || + typeof row.sha256 !== 'string' + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + const data = readChunkedTranscriptRecord(db, sessionId, sequence, recordBytes); + if (createHash('sha256').update(data).digest('hex') !== row.sha256) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + recordJson = data.toString('utf8'); + } + return recordJson; +} + +function isWorkHubActionOperation(value: unknown): value is WorkHubActionOperation { + return ( + value === 'answer_here' || + value === 'clarify' || + value === 'delegate_existing' || + value === 'create_new' || + value === 'replace' || + value === 'stop' + ); +} + +function workHubAssignmentAttachmentsMatchTarget( + assignment: WorkHubDelegationAssignedMessage, +): boolean { + const source = assignment.attachments ?? []; + const target = assignment.targetAttachments ?? []; + return ( + source.length === target.length && + source.every((attachment, index) => { + const copied = target[index]!; + const { ref: sourceRef, ...sourceMetadata } = attachment; + const { ref: targetRef, ...targetMetadata } = copied; + return ( + sourceRef.kind === 'session_file' && + sourceRef.sessionId === WORKHUB_COORDINATION_SESSION_ID && + targetRef.kind === 'session_file' && + targetRef.sessionId === assignment.targetSessionId && + isDeepStrictEqual(sourceMetadata, targetMetadata) + ); + }) + ); +} + +function sameWorkHubAssignmentRequest( + existing: WorkHubDelegationAssignedMessage, + requested: WorkHubDelegationAssignedMessage, +): boolean { + return isDeepStrictEqual( + { + actionId: existing.actionId, + actionFingerprint: existing.actionFingerprint, + coordinationTurnId: existing.coordinationTurnId, + targetSessionId: existing.targetSessionId, + disposition: existing.disposition, + userText: existing.userText, + delegationText: existing.delegationText, + attachments: existing.attachments, + create: existing.create, + replacesActionId: existing.replacesActionId, + replacesDelegationId: existing.replacesDelegationId, + }, + { + actionId: requested.actionId, + actionFingerprint: requested.actionFingerprint, + coordinationTurnId: requested.coordinationTurnId, + targetSessionId: requested.targetSessionId, + disposition: requested.disposition, + userText: requested.userText, + delegationText: requested.delegationText, + attachments: requested.attachments, + create: requested.create, + replacesActionId: requested.replacesActionId, + replacesDelegationId: requested.replacesDelegationId, + }, + ); +} + +function readStoredMessageRows( + db: DatabaseSync, + sessionId: string, + sequences: readonly number[], + placeholders = sequences.map(() => '?').join(', '), +): Array<{ sequence: number; recordJson: string }> { + if (sequences.length === 0) return []; + const rows = db + .prepare( + ` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.sequence IN (${placeholders}) + ORDER BY message.sequence + `, + ) + .all(sessionId, ...sequences) as StoredSessionMessagePayloadRow[]; + if (rows.length !== sequences.length) { + throw new StoredSessionMessageIncompatibleError(sessionId, -1); + } + return rows.map((row) => { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + return { + sequence, + recordJson: readStoredMessageRecordJson(db, sessionId, sequence, row), + }; + }); +} + +function readChunkedTranscriptRecord( + db: DatabaseSync, + sessionId: string, + sequence: number, + recordBytes: number, +): Buffer { + const rows = db + .prepare( + ` + SELECT chunk_index, data, sha256 + FROM session_message_chunks + WHERE session_id = ? AND sequence = ? + ORDER BY chunk_index + `, + ) + .all(sessionId, sequence) as Array<{ + chunk_index?: unknown; + data?: unknown; + sha256?: unknown; + }>; + const expectedChunks = Math.ceil(recordBytes / SQLITE_SESSION_MESSAGE_CHUNK_BYTES); + if (rows.length !== expectedChunks) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + const chunks = rows.map((row, index) => { + if ( + row.chunk_index !== index || + !(row.data instanceof Uint8Array) || + typeof row.sha256 !== 'string' + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + const chunk = Buffer.from(row.data); + if (createHash('sha256').update(chunk).digest('hex') !== row.sha256) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + return chunk; + }); + const data = Buffer.concat(chunks, recordBytes); + if (data.byteLength !== recordBytes) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + return data; +} + +function requireStoredMessageSequence(value: unknown, sessionId: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new StoredSessionMessageIncompatibleError(sessionId, -1); + } + return value as number; +} + +function nullableStoredMessageSequence(value: unknown, sessionId: string): number | null { + if (value === null || value === undefined) return null; + return requireStoredMessageSequence(value, sessionId); +} + +function requireTranscriptRecordByteLength( + value: unknown, + sessionId: string, + sequence: number, +): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + return value as number; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3a135a7bffe2c0fa864a552f511c4e8cfe4607c0a406eac91367303b157a708.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3a135a7bffe2c0fa864a552f511c4e8cfe4607c0a406eac91367303b157a708.source new file mode 100644 index 0000000000..84a880ed8b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3a135a7bffe2c0fa864a552f511c4e8cfe4607c0a406eac91367303b157a708.source @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; +import { + decodeArtifactRecordJsons, + isSafeRelativeArtifactPath, +} from './artifact-metadata-codec.js'; + +export const SQLITE_ARTIFACT_SCHEMA_VERSION = 3; + +export function migrateSqliteArtifactDatabase(db: DatabaseSync): void { + const columns = db.prepare('PRAGMA table_info(artifact_records)').all() as Array<{ + name?: unknown; + }>; + const retained: string[] = []; + // Every path the old table named. Whatever is not carried over is a file no + // catalog will name again, and this is the last moment anything knows it is + // there. Unlinking here is not an option: a rollback after one would be + // unrecoverable, so the paths are recorded for the store to reclaim later. + const scanned: string[] = []; + const hasStatusColumn = columns.some(({ name }) => name === 'status'); + if (hasStatusColumn || columns.some(({ name }) => name === 'storage_key')) { + const rows = db.prepare('SELECT * FROM artifact_records').all(); + for (const row of rows) { + if (typeof row.relative_path === 'string' && isSafeRelativeArtifactPath(row.relative_path)) { + scanned.push(row.relative_path); + } + try { + const parsed = JSON.parse(String(row.record_json)); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + if ( + parsed.id !== row.artifact_id || + parsed.sessionId !== row.session_id || + parsed.createdAt !== row.created_at || + parsed.relativePath !== row.relative_path + ) + continue; + if ( + [hasStatusColumn ? row.status : undefined, parsed.status].some( + (value) => value !== undefined && value !== null && value !== 'live', + ) + ) + continue; + delete parsed.status; + retained.push(JSON.stringify(parsed)); + } catch {} + } + db.exec('DROP TABLE artifact_records'); + } + db.exec(` + CREATE TABLE IF NOT EXISTS artifact_records ( + artifact_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + relative_path TEXT NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS artifact_records_session_order + ON artifact_records(session_id, created_at, artifact_id); + + CREATE UNIQUE INDEX IF NOT EXISTS artifact_records_relative_path + ON artifact_records(relative_path); + + CREATE TABLE IF NOT EXISTS artifact_upgrade_orphan_paths ( + relative_path TEXT PRIMARY KEY + ); + `); + const carried = decodeArtifactRecordJsons(retained); + const kept = new Set(carried.map((record) => record.relativePath)); + const orphan = db.prepare(` + INSERT INTO artifact_upgrade_orphan_paths VALUES (?) + ON CONFLICT(relative_path) DO NOTHING + `); + for (const relativePath of scanned) if (!kept.has(relativePath)) orphan.run(relativePath); + const insert = db.prepare(` + INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?) + `); + for (const record of carried) { + insert.run( + record.id, + record.sessionId, + record.createdAt, + record.relativePath, + JSON.stringify(record), + ); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3e8638a33ec726581fe1f60597733bffe42c802ca50b84b8b7f4bfb21a8c8dc.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3e8638a33ec726581fe1f60597733bffe42c802ca50b84b8b7f4bfb21a8c8dc.source new file mode 100644 index 0000000000..18d759bd88 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c3e8638a33ec726581fe1f60597733bffe42c802ca50b84b8b7f4bfb21a8c8dc.source @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { fork } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import assert from 'node:assert/strict'; +import { test, type TestContext } from 'node:test'; +import { withProcessLifetimeFileUpdateLock } from '../process-lifetime-file-update-lock.js'; + +test('releases a file update lock when its process is killed', async (t) => { + await assertKilledHolderCanBeRecovered(t, []); +}); + +test('recovers a supervised legacy directory lock when its process is killed', async (t) => { + await assertKilledHolderCanBeRecovered(t, ['legacy']); +}); + +test('keeps the authority lease held by an inherited package-switch descriptor', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-inherited-file-update-lock-')); + const targetPath = join(root, 'state'); + const holder = fork( + new URL('./fixtures/file-update-lock-holder.js', import.meta.url), + [targetPath, 'inherit'], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + let inheritorPid: number | undefined; + t.after(async () => { + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + if (inheritorPid !== undefined) killIfRunning(inheritorPid); + await rm(root, { recursive: true, force: true }); + }); + inheritorPid = await new Promise((resolve, reject) => { + holder.once('message', (message) => { + if ( + typeof message === 'object' && + message !== null && + 'kind' in message && + message.kind === 'locked' && + 'inheritorPid' in message && + typeof message.inheritorPid === 'number' + ) { + resolve(message.inheritorPid); + } else reject(new Error(`Unexpected child message: ${String(message)}`)); + }); + holder.once('error', reject); + }); + + holder.kill('SIGKILL'); + await new Promise((resolve) => holder.once('exit', () => resolve())); + await assert.rejects( + withProcessLifetimeFileUpdateLock(targetPath, async () => undefined, 150), + /locked by another process/u, + ); + + killIfRunning(inheritorPid); + await waitForExit(inheritorPid); + await withProcessLifetimeFileUpdateLock(targetPath, async () => undefined, 2_000); +}); + +async function assertKilledHolderCanBeRecovered( + t: TestContext, + args: readonly string[], +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-file-update-lock-')); + const targetPath = join(root, 'state'); + const child = fork( + new URL('./fixtures/file-update-lock-holder.js', import.meta.url), + [targetPath, ...args], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + }); + await new Promise((resolve, reject) => { + child.once('message', (message) => { + if (message === 'locked') resolve(); + else reject(new Error(`Unexpected child message: ${String(message)}`)); + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + reject(new Error(`Lock holder exited before acquisition (${String(code)}, ${signal})`)); + }); + }); + + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', () => resolve())); + + let entered = false; + await withProcessLifetimeFileUpdateLock( + targetPath, + async () => { + entered = true; + }, + 2_000, + ); + assert.equal(entered, true); +} + +function killIfRunning(pid: number): void { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error; + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Inherited lock holder ${pid} did not exit`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c4518486119ca165bd47e47eebf70db8cb66d9e844e43bb701bc563b14843179.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c4518486119ca165bd47e47eebf70db8cb66d9e844e43bb701bc563b14843179.source new file mode 100644 index 0000000000..fac74a4f02 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c4518486119ca165bd47e47eebf70db8cb66d9e844e43bb701bc563b14843179.source @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { createProjectCatalog, type ProjectCatalog } from './project-catalog.js'; + +const writerBrand: unique symbol = Symbol('InteractiveProjectCatalogWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface InteractiveProjectCatalogWriter extends ProjectCatalog { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; +} + +export function authenticateInteractiveProjectCatalogWriter( + writer: InteractiveProjectCatalogWriter, +): InteractiveProjectCatalogWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive Project Catalog writer', + ); + } + return writer; +} + +export async function openInteractiveProjectCatalogForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let catalog: ProjectCatalog | undefined; + try { + catalog = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => + createProjectCatalog(root), + ); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) { + catalog.close(); + return recoveredExisting; + } + const writer = createWriterFacade(lease, catalog); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + catalog?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + catalog: ProjectCatalog, +): InteractiveProjectCatalogWriter { + let closed = false; + const run = (operation: () => Promise): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Project Catalog writer is closed'), + ); + } + return runWithStorageRootLease(lease, 'interactive', 'write', operation); + }; + const writer: InteractiveProjectCatalogWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + list: () => run(() => catalog.list()), + register: (path, options) => run(() => catalog.register(path, options)), + resolveHistoricalPath: (path, usedAt) => run(() => catalog.resolveHistoricalPath(path, usedAt)), + select: (projectId) => run(() => catalog.select(projectId)), + touch: (projectId, path) => run(() => catalog.touch(projectId, path)), + relink: (projectId, path) => run(() => catalog.relink(projectId, path)), + relinkWithSessions: (projectId, path) => run(() => catalog.relinkWithSessions(projectId, path)), + rename: (projectId, name) => run(() => catalog.rename(projectId, name)), + archive: (projectId) => run(() => catalog.archive(projectId)), + restore: (projectId) => run(() => catalog.restore(projectId)), + close: () => { + if (closed) return; + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + catalog.close(); + }, + }; + return Object.freeze(writer); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c584098d8bd3389fe9e89fcb5b4372c136bb901d9b7a2c37a0369c32d4a5417f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c584098d8bd3389fe9e89fcb5b4372c136bb901d9b7a2c37a0369c32d4a5417f.source new file mode 100644 index 0000000000..9d27552b39 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c584098d8bd3389fe9e89fcb5b4372c136bb901d9b7a2c37a0369c32d4a5417f.source @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import { createWorkspaceRuntimeStore } from '../../runtime-event-persistence.js'; + +export interface InvocationIdentity { + sessionId: string; + invocationId?: string; + runId: string; + turnId: string; + openedAt?: number; +} + +export function invocationOpening( + overrides: Partial = {}, +): RuntimeEventInvocationOpenedContent { + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + ...overrides, + }; +} + +/** + * Commit the one fact that makes an invocation exist, the way the Runtime Host + * does, so the AgentRunEvent ledger has an anchor to hang its events on. + */ +export async function openInvocation( + workspaceRoot: string, + identity: InvocationIdentity, + content: RuntimeEventInvocationOpenedContent = invocationOpening(), +): Promise { + const invocationId = identity.invocationId ?? identity.runId; + const { event } = encodeCanonicalRuntimeEvent( + buildInvocationOpenedEvent({ + id: `invocation_opened:${invocationId}`, + run: { + sessionId: identity.sessionId, + invocationId, + runId: identity.runId, + turnId: identity.turnId, + }, + openedAt: identity.openedAt ?? 1, + opening: content, + }), + ); + const store = createWorkspaceRuntimeStore(workspaceRoot); + try { + await store.appendRuntimeEvent(identity.sessionId, identity.runId, event); + } finally { + store.close(); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c6e1c9c53b43d18715182db67d89ab8c7dc60dd06adf9368d071be0a3c2a2790.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c6e1c9c53b43d18715182db67d89ab8c7dc60dd06adf9368d071be0a3c2a2790.source new file mode 100644 index 0000000000..d58ec04d5c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c6e1c9c53b43d18715182db67d89ab8c7dc60dd06adf9368d071be0a3c2a2790.source @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { createSessionStore } from '../session-store.js'; +import { exportSessionBundleState } from '../session-bundle-policy.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; + +test('exports one Session as filtered SQLite', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-session-bundle-')); + const stateRoot = join(base, 'state'); + const configRoot = join(base, 'config'); + const destinationRoot = join(base, 'bundle'); + await mkdir(configRoot, { recursive: true }); + const sessions = createSessionStore(stateRoot); + try { + const selected = await sessions.create(input('Selected')); + const excluded = await sessions.create(input('Excluded')); + await sessions.appendMessage(selected.id, message('selected-message')); + await sessions.appendMessage(excluded.id, message('excluded-message')); + await sessions.close?.(); + const sourceDatabase = new DatabaseSync(join(stateRoot, 'runtime.sqlite')); + sourceDatabase + .prepare('INSERT INTO usage_pricing_overrides(model_key, record_json) VALUES (?, ?)') + .run('private-model', '{}'); + sourceDatabase.close(); + + const plan = await exportSessionBundleState({ + stateRoot, + configRoot, + destinationRoot, + sessionId: selected.id, + }); + assert.deepEqual(plan.includedEntries, ['runtime.sqlite']); + const database = new DatabaseSync(join(destinationRoot, 'runtime.sqlite'), { readOnly: true }); + try { + const ids = database + .prepare('SELECT session_id FROM session_metadata ORDER BY session_id') + .all() + .map((row) => (row as { session_id: string }).session_id); + assert.deepEqual(ids, [selected.id]); + assert.equal( + ( + database.prepare('SELECT COUNT(*) AS count FROM session_messages').get() as { + count: number; + } + ).count, + 1, + ); + assert.equal( + ( + database.prepare('SELECT COUNT(*) AS count FROM usage_pricing_overrides').get() as { + count: number; + } + ).count, + 0, + ); + } finally { + database.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('retains only the selected Session partial stream segments', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-session-bundle-partials-')); + const stateRoot = join(base, 'state'); + const configRoot = join(base, 'config'); + const destinationRoot = join(base, 'bundle'); + const databasePath = join(stateRoot, 'runtime.sqlite'); + await mkdir(configRoot, { recursive: true }); + const sessions = createSessionStore(stateRoot); + try { + const selected = await sessions.create(input('Selected')); + const excluded = await sessions.create(input('Excluded')); + await sessions.close?.(); + + const runtime = createSqliteRuntimeStore(databasePath); + try { + await runtime.appendRuntimeEvent( + selected.id, + 'selected-run', + partialEvent(selected.id, 'selected-run', 'selected', 1, 'a'), + ); + await runtime.appendRuntimeEvent( + selected.id, + 'selected-run', + partialEvent(selected.id, 'selected-run', 'selected', 2, 'b'), + ); + await runtime.appendRuntimeEvent( + excluded.id, + 'excluded-run', + partialEvent(excluded.id, 'excluded-run', 'excluded', 3, 'secret'), + ); + } finally { + runtime.close(); + } + + await exportSessionBundleState({ + stateRoot, + configRoot, + destinationRoot, + sessionId: selected.id, + }); + + const bundledDatabasePath = join(destinationRoot, 'runtime.sqlite'); + const bundledRuntime = createSqliteRuntimeStore(bundledDatabasePath, { readOnly: true }); + try { + const selectedEvents = await bundledRuntime.readRuntimeEvents(selected.id, 'selected-run'); + assert.equal(selectedEvents.length, 1); + assert.equal( + selectedEvents[0]?.content?.kind === 'text' ? selectedEvents[0].content.text : undefined, + 'ab', + ); + assert.deepEqual(await bundledRuntime.readRuntimeEvents(excluded.id, 'excluded-run'), []); + } finally { + bundledRuntime.close(); + } + const bundledDatabase = new DatabaseSync(bundledDatabasePath, { readOnly: true }); + try { + assert.deepEqual( + bundledDatabase + .prepare('SELECT text_content FROM runtime_partial_segments ORDER BY segment_seq') + .all() + .map((row) => (row as { text_content: string }).text_content), + ['ab'], + ); + } finally { + bundledDatabase.close(); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +function input(name: string): CreateSessionInput { + return { + cwd: '/tmp/cwd', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask' as const, + name, + labels: [], + }; +} + +function message(id: string) { + return { type: 'user' as const, id, turnId: 'turn-1', ts: 1, text: id }; +} + +function partialEvent( + sessionId: string, + runId: string, + prefix: string, + ts: number, + text: string, +): RuntimeEvent { + return { + id: `${prefix}-partial-${ts}`, + invocationId: `${prefix}-invocation`, + runId, + sessionId, + turnId: `${prefix}-turn`, + ts, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + refs: { providerEventId: `${prefix}-message` }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c75ed03ed75758326a4b5c3c002aa811572a97ef3d53e5f74e84f185930b7eea.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c75ed03ed75758326a4b5c3c002aa811572a97ef3d53e5f74e84f185930b7eea.source new file mode 100644 index 0000000000..d52f9143df --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c75ed03ed75758326a4b5c3c002aa811572a97ef3d53e5f74e84f185930b7eea.source @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; +import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; + +test('Agent Graph supervisor admission durably binds wake identity and Graph orchestration', async () => { + await withTempRoot(async (_root, openStore) => { + const store = openStore(); + const admitted = await store.admitRootTurn(admissionInput()); + + assert.equal(admitted.kind, 'admitted'); + assert.deepEqual(admitted.admission.execution, admissionInput().execution); + assert.deepEqual(admitted.admission.turnOrchestration, { + mode: 'graph', + source: 'host_api', + }); + + const reopened = openStore(); + assert.deepEqual( + await reopened.readRootTurnAdmission('root-session', 'supervisor-turn'), + admitted.admission, + ); + }); +}); + +test('Agent Graph supervisor admission rejects malformed identity and non-Graph orchestration', async () => { + await withTempRoot(async (_root, openStore) => { + const store = openStore(); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + ...admissionInput().execution, + wakeId: ' wake ', + } as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + turnOrchestration: { mode: 'swarm', source: 'host_api' }, + }), + ), + /requires Host Graph orchestration/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + execution: { + ...admissionInput().execution, + wakeId: 'agent_graph_ffffffffffffffffffffffffffffffff:snapshot-1', + } as RootExecutionDescriptor, + }), + ), + /Invalid root execution descriptor/, + ); + await assert.rejects( + () => + store.admitRootTurn( + admissionInput({ + turnOrchestration: undefined, + }), + ), + /requires Host Graph orchestration/, + ); + }); +}); + +function admissionInput(overrides: Partial = {}): AdmitRootTurnInput { + return { + sessionId: 'root-session', + turnId: 'supervisor-turn', + proposedRunId: 'supervisor-run', + proposedUserMessageId: 'supervisor-message', + execution: { + kind: 'agent_graph_supervisor_wake', + graphId: 'agent_graph_0123456789abcdef0123456789abcdef', + wakeId: 'agent_graph_0123456789abcdef0123456789abcdef:snapshot-1', + attemptId: 'attempt-1', + }, + previousRootTurnId: null, + normalizedInput: { text: 'Inspect the durable graph.' }, + turnOrchestration: { mode: 'graph', source: 'host_api' }, + sourceMessages: [], + admittedAt: 50, + ...overrides, + }; +} + +async function withTempRoot( + run: ( + root: string, + openStore: () => ReturnType, + ) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-graph-supervisor-admission-')); + const stores: ReturnType[] = []; + try { + await run(root, () => { + const store = createSqliteAgentRunStore(root); + stores.push(store); + return store; + }); + } finally { + for (const store of stores.reverse()) store.close?.(); + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c8619f33df98a891b8af93899fcda927504f89562ccfc7b1d0eac71cc644fae1.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c8619f33df98a891b8af93899fcda927504f89562ccfc7b1d0eac71cc644fae1.source new file mode 100644 index 0000000000..717ec80559 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c8619f33df98a891b8af93899fcda927504f89562ccfc7b1d0eac71cc644fae1.source @@ -0,0 +1,526 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { decodeCanonicalMessage, type StoredMessage } from '@maka/core/session'; +import { ClaudeCodeSessionAdapter } from '../claude-code-session-adapter.js'; +import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; + +/** + * The load-bearing question for this adapter is which turns get a terminal + * `turn_state`. The Ledger refuses a reconstructed terminal that no record + * corroborates (`runtime-ledger-repair.ts`), so emitting one for a turn that + * was killed mid-answer would import a crash as a clean completion — and + * emitting none for a turn that did finish leaves every imported Run repaired + * to `failed`. + * + * `stop_reason` is the record that answers it. Measured across 1130 local + * transcripts: 86% of turns carry a terminal one, and the rest end with no + * assistant reply at all or stopped at `tool_use` — genuinely unfinished. + */ + +const CWD = '/workspace/project'; + +describe('ClaudeCodeSessionAdapter', () => { + test('a turn that stopped with end_turn imports as completed', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000001', [ + userRecord('ship the parser'), + assistantRecord({ text: 'Done.', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000001'); + const state = terminalState(messages); + assert.equal(state?.status, 'completed'); + }); + }); + + test('a turn stopped at tool_use with no result is recorded as a snapshot cutoff', async () => { + // The process died between the call and its result. Reporting `completed` + // here is the failure mode this adapter exists to avoid. + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000002', [ + userRecord('read the file'), + assistantRecord({ toolUse: { id: 'toolu_1', name: 'Read' }, stopReason: 'tool_use' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000002'); + const state = terminalState(messages); + assert.equal(state?.status, 'aborted'); + assert.equal(state?.abortSource, 'external_session_snapshot'); + // The call itself still imports — the conversation is real up to the cut. + assert.equal(messages.filter((m) => m.type === 'tool_call').length, 1); + }); + }); + + test('a prompt with no answer at all is recorded as a snapshot cutoff', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000003', [userRecord('are you there?')]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000003'); + const state = terminalState(messages); + assert.equal(state?.status, 'aborted'); + assert.equal(state?.abortSource, 'external_session_snapshot'); + assert.equal(messages.filter((m) => m.type === 'user').length, 1); + }); + }); + + test('an interrupt notice imports as aborted, not as a user message', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000004', [ + userRecord('start the long job'), + assistantRecord({ text: 'Working…', stopReason: 'tool_use' }), + userRecord('[Request interrupted by user for tool use]'), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000004'); + const state = terminalState(messages); + assert.equal(state?.status, 'aborted'); + // The notice is the harness speaking, not the human. One user message. + assert.equal(messages.filter((m) => m.type === 'user').length, 1); + }); + }); + + test('an API error imports as failed', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000005', [ + userRecord('summarize this'), + { ...assistantRecord({ text: 'API Error: overloaded' }), isApiErrorMessage: true }, + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000005'); + const state = terminalState(messages); + assert.equal(state?.status, 'failed'); + assert.equal(state?.errorClass, 'claude_code_api_error'); + }); + }); + + test('tool results arrive as user records and must not import as user turns', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000006', [ + userRecord('read it'), + assistantRecord({ toolUse: { id: 'toolu_9', name: 'Read' }, stopReason: 'tool_use' }), + toolResultRecord('toolu_9', 'file contents'), + assistantRecord({ text: 'Here it is.', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000006'); + assert.equal(messages.filter((m) => m.type === 'user').length, 1); + const results = messages.filter((m) => m.type === 'tool_result'); + assert.equal(results.length, 1); + // The result must point back at the call, or the pair renders detached. + assert.equal(results[0]?.type === 'tool_result' ? results[0].toolUseId : null, 'toolu_9'); + }); + }); + + test('a turn cut off by max_tokens is not reported as completed', async () => { + // `max_tokens` does say generation stopped — because the answer hit the + // output limit mid-sentence. Treating "stopped" as "finished" is the + // specific mistake this adapter is built to avoid. + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000011', [ + userRecord('write the whole file'), + assistantRecord({ text: 'Here is the beg', stopReason: 'max_tokens' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000011'); + const state = terminalState(messages); + // Cut off, not completed — and named as the snapshot's edge rather than + // as a user Stop. + assert.equal(state?.status, 'aborted'); + assert.equal(state?.abortSource, 'external_session_snapshot'); + }); + }); + + test('a tool result with no tool_use_id is dropped rather than left detached', async () => { + // Minting an id produces a result guaranteed not to pair with any call — + // a detached row in the transcript view, worse than an absent one. + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000012', [ + userRecord('read it'), + assistantRecord({ toolUse: { id: 'toolu_a', name: 'Read' }, stopReason: 'tool_use' }), + { + type: 'user', + timestamp: '2026-08-01T00:00:02.000Z', + message: { role: 'user', content: [{ type: 'tool_result', content: 'orphan' }] }, + }, + assistantRecord({ text: 'done', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000012'); + assert.equal(messages.filter((m) => m.type === 'tool_result').length, 0); + // Every result that does survive must name a call that is present. + const callIds = new Set(messages.filter((m) => m.type === 'tool_call').map((m) => m.id)); + for (const message of messages) { + if (message.type === 'tool_result') assert.ok(callIds.has(message.toolUseId)); + } + }); + }); + + test('turn ids are dense and never collide with message ids', async () => { + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000013', [ + userRecord('one'), + assistantRecord({ text: 'a', stopReason: 'end_turn' }), + userRecord('two'), + assistantRecord({ text: 'b', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000013'); + const turnIds = [...new Set(messages.map((m) => m.turnId))]; + assert.deepEqual(turnIds, [ + 'claude-code:aaaaaaaa-0000-4000-8000-000000000013:turn:0', + 'claude-code:aaaaaaaa-0000-4000-8000-000000000013:turn:1', + ]); + const messageIds = new Set(messages.map((m) => m.id)); + for (const turnId of turnIds) assert.equal(messageIds.has(turnId), false); + }); + }); + + test('a record written twice emits one message, not two', async () => { + // A transcript is an append log: resume and recovery can replay a line. + // Persisting both copies duplicates the prompt in canonical history, and + // re-importing produces the same result — it is not recoverable after the + // fact. + await withClaudeHome(async (home) => { + const prompt = { ...userRecord('build the thing'), uuid: 'u-1' }; + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000014', [ + prompt, + prompt, + assistantRecord({ text: 'Done.', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000014'); + assert.equal(messages.filter((m) => m.type === 'user').length, 1); + assert.equal(new Set(messages.map((m) => m.turnId)).size, 1); + }); + }); + + test('one response is assembled from every fragment, calls before their results', async () => { + // The real shape, measured across 1130 local transcripts: a response is + // written as several records sharing `message.id`, the pieces separated by + // the results of calls the earlier pieces made. In 4093 of 14095 + // responses the visible text is NOT in the first fragment — so a guard + // that emits prose at the first record and suppresses it afterwards drops + // the reply. + // + // Both calls share one `message.id`, so they came from one API response + // and were issued together however the log interleaved them with the + // results arriving: they are one assistant step, and both precede both + // results. + await withClaudeHome(async (home) => { + const fragment = (blocks: Parameters[1]) => + assistantFragment('msg_shared', blocks); + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000015', [ + userRecord('read both files'), + fragment([{ type: 'thinking', thinking: 'Two files to read.' }]), + fragment([{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: {} }]), + toolResultRecord('toolu_1', 'first file'), + // The text arrives last, after a call and its result — the 4093 case. + fragment([ + { type: 'text', text: 'Reading them now.' }, + { type: 'tool_use', id: 'toolu_2', name: 'Read', input: {} }, + ]), + toolResultRecord('toolu_2', 'second file'), + assistantRecord({ text: 'Both read.', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000015'); + + const assistants = messages.filter( + (m): m is Extract => m.type === 'assistant', + ); + // The reply survives even though no fragment before it carried text, and + // the response is still one reply rather than one per fragment. + assert.deepEqual( + assistants.filter((m) => m.text.length > 0).map((m) => m.text), + ['Reading them now.', 'Both read.'], + ); + // The thinking from the first fragment belongs to the same response. + assert.deepEqual( + assistants.filter((m) => m.thinking).map((m) => m.thinking?.text), + ['Two files to read.'], + ); + + const order = messages + .filter((m) => m.type === 'tool_call' || m.type === 'tool_result') + .map((m) => (m.type === 'tool_call' ? `call:${m.id}` : `result:${m.toolUseId}`)); + assert.deepEqual(order, ['call:toolu_1', 'call:toolu_2', 'result:toolu_1', 'result:toolu_2']); + }); + }); + + test('a sidechain transcript is neither listed nor readable', async () => { + // Sub-agent transcripts are whole files, so exclusion is per file. + // Importing one would present a fragment of a conversation as a whole. + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000007', [ + { ...userRecord('sub-agent work'), isSidechain: true }, + { ...assistantRecord({ text: 'done', stopReason: 'end_turn' }), isSidechain: true }, + ]); + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + assert.deepEqual(await adapter.listSessions(), []); + await assert.rejects( + () => adapter.readSession('aaaaaaaa-0000-4000-8000-000000000007'), + /sidechain/u, + ); + }); + }); + + test('a text query filters the source, before paging', async () => { + // The catalog pages 16 at a time over a source with 1128 sessions here, so + // the term has to reach the adapter. A filter applied to an assembled page + // would search the rows already fetched, which is worse than none. + await withClaudeHome(async (home) => { + await seed( + home, + 'aaaaaaaa-0000-4000-8000-000000000030', + [userRecord('fix the parser'), assistantRecord({ text: 'ok', stopReason: 'end_turn' })], + '/Users/someone/compiler', + ); + await seed( + home, + 'aaaaaaaa-0000-4000-8000-000000000031', + [userRecord('write the docs'), assistantRecord({ text: 'ok', stopReason: 'end_turn' })], + '/Users/someone/handbook', + ); + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + + const byTitle = await adapter.listSessions({ text: 'parser' }); + assert.deepEqual( + byTitle.map((s) => s.id), + ['aaaaaaaa-0000-4000-8000-000000000030'], + ); + + // The path is the other half of what a user remembers. + const byPath = await adapter.listSessions({ text: 'handbook' }); + assert.deepEqual( + byPath.map((s) => s.id), + ['aaaaaaaa-0000-4000-8000-000000000031'], + ); + + assert.equal((await adapter.listSessions({ text: 'kubernetes' })).length, 0); + // A blank box is not a filter. + assert.equal((await adapter.listSessions({ text: ' ' })).length, 2); + assert.equal((await adapter.listSessions()).length, 2); + }); + }); + + test('a rewritten transcript is re-read, a deleted one drops out', async () => { + // Listing parses every transcript, and the catalog is listed once per + // search term — so summaries are cached against the file's own mtime and + // size. The risk a cache carries is serving a stale answer, so both + // directions are pinned: an appended transcript must be re-read, and a + // removed one must not survive in the map. + await withClaudeHome(async (home) => { + const id = 'aaaaaaaa-0000-4000-8000-000000000033'; + await seed(home, id, [ + userRecord('first title'), + assistantRecord({ text: 'ok', stopReason: 'end_turn' }), + ]); + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + assert.equal((await adapter.listSessions())[0]?.name, 'first title'); + + // Rewritten with a different title. Same path, new content. + await seed(home, id, [ + userRecord('second title'), + assistantRecord({ text: 'ok', stopReason: 'end_turn' }), + ]); + assert.equal( + (await adapter.listSessions())[0]?.name, + 'second title', + 'a changed transcript must not keep its cached summary', + ); + + await rm(join(home, 'projects', CWD.replace(/\//gu, '-'), `${id}.jsonl`)); + assert.deepEqual(await adapter.listSessions(), []); + }); + }); + + test('a project query tolerates a trailing separator', async () => { + // The adapter compared raw strings, so the same project reached with a + // trailing slash answered "no such project". Both sources now share one + // path rule. + await withClaudeHome(async (home) => { + await seed( + home, + 'aaaaaaaa-0000-4000-8000-000000000032', + [userRecord('one'), assistantRecord({ text: 'ok', stopReason: 'end_turn' })], + '/Users/someone/my-project', + ); + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + assert.equal((await adapter.listSessions({ cwd: '/Users/someone/my-project/' })).length, 1); + }); + }); + + test('a project query matches the record cwd, not the mangled directory name', async () => { + // `-Users-a-b` cannot be reversed — it is ambiguous between `/Users/a/b` + // and `/Users/a-b` — so only a record's own `cwd` can answer this. + await withClaudeHome(async (home) => { + await seed( + home, + 'aaaaaaaa-0000-4000-8000-000000000008', + [userRecord('one'), assistantRecord({ text: 'ok', stopReason: 'end_turn' })], + '/Users/someone/my-project', + ); + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + assert.equal((await adapter.listSessions({ cwd: '/Users/someone/my-project' })).length, 1); + assert.equal((await adapter.listSessions({ cwd: '/Users/someone/my' })).length, 0); + }); + }); + + test('every emitted message survives the canonical decoder', async () => { + // The importer round-trips adapter output through `decodeStoredMessage`, + // so a shape this adapter invents is rejected at persistence rather than + // stored. Asserting it here names the adapter as the culprit instead. + await withClaudeHome(async (home) => { + await seed(home, 'aaaaaaaa-0000-4000-8000-000000000009', [ + userRecord('do the thing'), + assistantRecord({ thinking: 'considering', stopReason: 'tool_use' }), + assistantRecord({ toolUse: { id: 'toolu_x', name: 'Bash' }, stopReason: 'tool_use' }), + toolResultRecord('toolu_x', 'ok'), + assistantRecord({ text: 'Finished.', stopReason: 'end_turn' }), + ]); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000009'); + assert.ok(messages.length > 0); + for (const message of messages) { + assert.doesNotThrow(() => decodeCanonicalMessage(JSON.parse(JSON.stringify(message)))); + } + }); + }); + + test('a corrupt line does not fail an otherwise readable transcript', async () => { + await withClaudeHome(async (home) => { + const dir = join(home, 'projects', '-workspace-project'); + await mkdir(dir, { recursive: true }); + const lines = [ + JSON.stringify(userRecord('first')), + '{ this is not json', + JSON.stringify(assistantRecord({ text: 'second', stopReason: 'end_turn' })), + ]; + await writeFile( + join(dir, 'aaaaaaaa-0000-4000-8000-000000000010.jsonl'), + `${lines.join('\n')}\n`, + ); + const messages = await read(home, 'aaaaaaaa-0000-4000-8000-000000000010'); + assert.equal(terminalState(messages)?.status, 'completed'); + }); + }); + + test('is registered by the internal default registry', () => { + const registry = createExternalSessionAdapterRegistry(); + assert.equal(registry.require('claude-code').id, 'claude-code'); + // Codex must still be there — this adds a source rather than replacing one. + assert.equal(registry.require('codex').id, 'codex'); + }); +}); + +/* ------------------------------------------------------------------ */ + +function terminalState( + messages: readonly StoredMessage[], +): Extract | undefined { + return messages.find( + (message): message is Extract => + message.type === 'turn_state', + ); +} + +async function read(home: string, sessionId: string): Promise { + const adapter = new ClaudeCodeSessionAdapter({ claudeHome: home }); + return (await adapter.readSession(sessionId)).messages; +} + +function userRecord(text: string): Record { + return { + type: 'user', + cwd: CWD, + timestamp: '2026-08-01T00:00:00.000Z', + message: { role: 'user', content: text }, + }; +} + +function toolResultRecord(toolUseId: string, text: string): Record { + return { + type: 'user', + cwd: CWD, + timestamp: '2026-08-01T00:00:02.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text }], + }, + }; +} + +function assistantFragment( + messageId: string, + content: readonly Record[], +): Record { + return { + type: 'assistant', + cwd: CWD, + timestamp: '2026-08-01T00:00:01.000Z', + message: { + role: 'assistant', + id: messageId, + model: 'claude-opus-5', + content, + stop_reason: 'tool_use', + }, + }; +} + +function assistantRecord(input: { + text?: string; + thinking?: string; + toolUse?: { id: string; name: string }; + stopReason?: string; +}): Record { + const content: Record[] = []; + if (input.thinking) content.push({ type: 'thinking', thinking: input.thinking }); + if (input.text) content.push({ type: 'text', text: input.text }); + if (input.toolUse) { + content.push({ type: 'tool_use', id: input.toolUse.id, name: input.toolUse.name, input: {} }); + } + return { + type: 'assistant', + cwd: CWD, + timestamp: '2026-08-01T00:00:01.000Z', + message: { + role: 'assistant', + id: 'msg_test', + model: 'claude-opus-5', + content, + ...(input.stopReason ? { stop_reason: input.stopReason } : {}), + }, + }; +} + +async function seed( + home: string, + sessionId: string, + records: readonly Record[], + cwd = CWD, +): Promise { + const dir = join(home, 'projects', cwd.replace(/\//gu, '-')); + await mkdir(dir, { recursive: true }); + const lines = records.map((record) => JSON.stringify({ ...record, cwd })); + await writeFile(join(dir, `${sessionId}.jsonl`), `${lines.join('\n')}\n`); +} + +async function withClaudeHome(run: (home: string) => Promise): Promise { + const home = await mkdtemp(join(tmpdir(), 'maka-claude-home-')); + try { + await run(home); + } finally { + await rm(home, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c891d5f71a2c80d9adf5dd0f06916904d9403646a4a39388444304756aaeb96c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c891d5f71a2c80d9adf5dd0f06916904d9403646a4a39388444304756aaeb96c.source new file mode 100644 index 0000000000..edfe15ba81 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c891d5f71a2c80d9adf5dd0f06916904d9403646a4a39388444304756aaeb96c.source @@ -0,0 +1,572 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, lstatSync } from 'node:fs'; +import { + chmod, + copyFile, + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js'; +import { withArtifactWriterLock } from './artifact-writer-lock.js'; +import { + withOfflineContextSnapshot, + copyContextSnapshot, + validateContextSnapshot, +} from './context-offload-snapshot.js'; +import { decodeStoredMessage } from './execution-record-codec.js'; +import { + acquireOperationalStateDatabase, + inspectOperationalStateSchema, + OPERATIONAL_STATE_DATABASE_NAME, +} from './operational-state-store.js'; +import { assertCurrentOperationalTargetSchema } from './operational-target-schema.js'; +import { + SQLITE_SESSION_MESSAGE_CHUNK_BYTES, + SQLITE_SESSION_MESSAGE_CHUNK_MARKER, +} from './sqlite-session-metadata-schema.js'; +import { syncDirectory, syncDirectoryChain, syncFile } from './stable-storage.js'; + +export const OPERATIONAL_BACKUP_FORMAT = 'maka-operational-backup'; +export const OPERATIONAL_BACKUP_SCHEMA_VERSION = 4 as const; +export const OPERATIONAL_BACKUP_MANIFEST_FILE = 'operational-backup.json'; + +export type OperationalBackupErrorCode = + | 'invalid_root' + | 'overlapping_roots' + | 'destination_not_empty' + | 'unsupported_schema' + | 'corrupt_backup'; + +export class OperationalBackupError extends Error { + constructor( + readonly code: OperationalBackupErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'OperationalBackupError'; + } +} + +export interface OperationalBackupFile { + readonly path: string; + readonly size: number; + readonly sha256: `sha256:${string}`; +} + +export interface OperationalBackupManifest { + readonly format: typeof OPERATIONAL_BACKUP_FORMAT; + readonly schemaVersion: 3 | typeof OPERATIONAL_BACKUP_SCHEMA_VERSION; + readonly createdAt: number; + readonly files: readonly OperationalBackupFile[]; +} + +export interface CreateOperationalBackupInput { + readonly stateRoot: string; + readonly destinationRoot: string; + readonly now?: () => number; +} + +export interface RestoreOperationalBackupInput { + readonly backupRoot: string; + readonly destinationRoot: string; +} + +export async function createOperationalStateBackup( + input: CreateOperationalBackupInput, +): Promise { + const stateRoot = resolve(input.stateRoot); + const destinationRoot = resolve(input.destinationRoot); + assertSeparateRoots(stateRoot, destinationRoot); + await assertMissing(destinationRoot, 'backup destination'); + return withOfflineContextSnapshot(stateRoot, (contextLocked) => + withArtifactWriterLock(stateRoot, async (canonicalStateRoot) => { + assertSeparateRoots(canonicalStateRoot, destinationRoot); + const stagingRoot = `${destinationRoot}.${process.pid}.${randomUUID()}.tmp`; + try { + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + const database = acquireOperationalStateDatabase(canonicalStateRoot); + const databasePath = resolve(stagingRoot, OPERATIONAL_STATE_DATABASE_NAME); + try { + await database.backup(databasePath); + } finally { + database.close(); + } + normalizeStandaloneSqliteSnapshot(databasePath); + await chmod(databasePath, 0o600); + await syncFile(databasePath); + const artifactRoot = resolve(canonicalStateRoot, 'artifacts'); + if (await pathExists(artifactRoot)) { + await copyRegularTree( + artifactRoot, + resolve(stagingRoot, 'artifacts'), + artifactRoot, + stagingRoot, + ); + } + await copyContextSnapshot(canonicalStateRoot, stagingRoot, contextLocked); + const createdAt = (input.now ?? Date.now)(); + if (!Number.isSafeInteger(createdAt) || createdAt < 0) { + throw new OperationalBackupError('corrupt_backup', 'Backup creation time is invalid'); + } + const manifest: OperationalBackupManifest = { + format: OPERATIONAL_BACKUP_FORMAT, + schemaVersion: OPERATIONAL_BACKUP_SCHEMA_VERSION, + createdAt, + files: await inventory(stagingRoot), + }; + const manifestPath = resolve(stagingRoot, OPERATIONAL_BACKUP_MANIFEST_FILE); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + await syncFile(manifestPath); + await validateOperationalStateBackup(stagingRoot); + await syncDirectoryChain(stagingRoot, stagingRoot); + await mkdir(dirname(destinationRoot), { recursive: true }); + await rename(stagingRoot, destinationRoot); + await syncDirectory(dirname(destinationRoot)); + return manifest; + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } + }), + ); +} + +export async function validateOperationalStateBackup( + backupRoot: string, +): Promise { + const root = resolve(backupRoot); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(resolve(root, OPERATIONAL_BACKUP_MANIFEST_FILE), 'utf8')); + } catch (error) { + throw new OperationalBackupError('corrupt_backup', 'Backup manifest is missing or invalid', { + cause: error, + }); + } + const manifest = decodeManifest(parsed); + const actual = await inventory(root); + if (JSON.stringify(actual) !== JSON.stringify(manifest.files)) { + throw new OperationalBackupError('corrupt_backup', 'Backup file inventory does not match'); + } + validateSqlite(resolve(root, OPERATIONAL_STATE_DATABASE_NAME), manifest.files); + await validateContextSnapshot(root); + return manifest; +} + +export async function restoreOperationalStateBackup( + input: RestoreOperationalBackupInput, +): Promise { + const backupRoot = resolve(input.backupRoot); + const destinationRoot = resolve(input.destinationRoot); + assertSeparateRoots(backupRoot, destinationRoot); + await assertMissing(destinationRoot, 'restore destination'); + const manifest = await validateOperationalStateBackup(backupRoot); + const stagingRoot = `${destinationRoot}.${process.pid}.${randomUUID()}.tmp`; + try { + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + for (const file of manifest.files) { + const source = resolveInside(backupRoot, file.path); + const destination = resolveInside(stagingRoot, file.path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + await chmod(destination, 0o600); + await syncFile(destination); + await syncDirectoryChain(dirname(destination), stagingRoot); + } + const actual = await inventory(stagingRoot); + if (JSON.stringify(actual) !== JSON.stringify(manifest.files)) { + throw new OperationalBackupError('corrupt_backup', 'Restored file inventory does not match'); + } + validateSqlite(resolve(stagingRoot, OPERATIONAL_STATE_DATABASE_NAME), manifest.files); + await validateContextSnapshot(stagingRoot); + await syncDirectoryChain(stagingRoot, stagingRoot); + await mkdir(dirname(destinationRoot), { recursive: true }); + await rename(stagingRoot, destinationRoot); + await syncDirectory(dirname(destinationRoot)); + return manifest; + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} + +function decodeManifest(value: unknown): OperationalBackupManifest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OperationalBackupError('corrupt_backup', 'Backup manifest must be an object'); + } + const record = value as Record; + if (record.format !== OPERATIONAL_BACKUP_FORMAT) { + throw new OperationalBackupError('corrupt_backup', 'Backup format is invalid'); + } + if (record.schemaVersion !== 3 && record.schemaVersion !== OPERATIONAL_BACKUP_SCHEMA_VERSION) { + throw new OperationalBackupError('unsupported_schema', 'Backup schema is unsupported'); + } + if ( + !Number.isSafeInteger(record.createdAt) || + (record.createdAt as number) < 0 || + !Array.isArray(record.files) + ) { + throw new OperationalBackupError('corrupt_backup', 'Backup manifest fields are invalid'); + } + const files = record.files.map((value): OperationalBackupFile => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OperationalBackupError('corrupt_backup', 'Backup file entry is invalid'); + } + const file = value as Record; + if ( + typeof file.path !== 'string' || + file.path === OPERATIONAL_BACKUP_MANIFEST_FILE || + !Number.isSafeInteger(file.size) || + (file.size as number) < 0 || + typeof file.sha256 !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(file.sha256) + ) { + throw new OperationalBackupError('corrupt_backup', 'Backup file entry is invalid'); + } + resolveInside('/backup-root', file.path); + return file as unknown as OperationalBackupFile; + }); + if (!files.some((file) => file.path === OPERATIONAL_STATE_DATABASE_NAME)) { + throw new OperationalBackupError('corrupt_backup', 'Backup runtime.sqlite is missing'); + } + return { + format: OPERATIONAL_BACKUP_FORMAT, + schemaVersion: record.schemaVersion, + createdAt: record.createdAt as number, + files, + }; +} + +async function inventory(root: string): Promise { + const result: OperationalBackupFile[] = []; + await walk(root, root, result); + return result.sort((left, right) => left.path.localeCompare(right.path)); +} + +async function walk(root: string, current: string, result: OperationalBackupFile[]): Promise { + for (const entry of (await readdir(current, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if (entry.name === OPERATIONAL_BACKUP_MANIFEST_FILE) continue; + if ( + entry.name === `${OPERATIONAL_STATE_DATABASE_NAME}-shm` || + entry.name === `${OPERATIONAL_STATE_DATABASE_NAME}-wal` + ) { + continue; + } + const path = resolve(current, entry.name); + if (entry.isSymbolicLink()) { + throw new OperationalBackupError('corrupt_backup', 'Backup cannot contain symlinks'); + } + if (entry.isDirectory()) { + await walk(root, path, result); + continue; + } + if (!entry.isFile()) { + throw new OperationalBackupError('corrupt_backup', 'Backup contains a non-regular file'); + } + result.push(await describeFile(root, path)); + } +} + +async function describeFile(root: string, path: string): Promise { + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of createReadStream(path)) { + const bytes = chunk as Buffer; + size += bytes.byteLength; + hash.update(bytes); + } + return { + path: relative(root, path).split('\\').join('/'), + size, + sha256: `sha256:${hash.digest('hex')}`, + }; +} + +async function copyRegularTree( + source: string, + destination: string, + root: string, + destinationRoot: string, +): Promise { + const metadata = await lstat(source); + if (metadata.isSymbolicLink()) { + throw new OperationalBackupError('invalid_root', 'Artifact tree cannot contain symlinks'); + } + if (metadata.isDirectory()) { + await mkdir(destination, { recursive: true, mode: 0o700 }); + for (const entry of await readdir(source)) { + await copyRegularTree( + resolve(source, entry), + resolve(destination, entry), + root, + destinationRoot, + ); + } + return; + } + if (!metadata.isFile()) { + throw new OperationalBackupError('invalid_root', 'Artifact tree contains a non-regular file'); + } + resolveInside(root, relative(root, source)); + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + await copyFile(source, destination); + await chmod(destination, 0o600); + await syncFile(destination); + await syncDirectoryChain(dirname(destination), destinationRoot); +} + +function validateSqlite(path: string, files: readonly OperationalBackupFile[]): void { + try { + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error('runtime.sqlite is not a regular file'); + } + const database = new DatabaseSync(path, { readOnly: true }); + try { + database.exec('PRAGMA query_only = ON; PRAGMA foreign_keys = ON; BEGIN'); + const integrity = database.prepare('PRAGMA integrity_check').all() as Array<{ + integrity_check?: unknown; + }>; + if (integrity.length !== 1 || integrity[0]?.integrity_check !== 'ok') { + throw new Error('integrity_check failed'); + } + if (database.prepare('PRAGMA foreign_key_check').all().length > 0) { + throw new Error('foreign_key_check failed'); + } + if (inspectOperationalStateSchema(database).status !== 'current') { + throw new Error('operational schema versions do not match'); + } + + assertCurrentOperationalTargetSchema(database); + + const artifactRows = database + .prepare(` + SELECT artifact_id, session_id, created_at, relative_path, record_json + FROM artifact_records + ORDER BY created_at, artifact_id + `) + .all() as Array<{ + artifact_id?: unknown; + session_id?: unknown; + created_at?: unknown; + relative_path?: unknown; + record_json?: unknown; + }>; + const filesByPath = new Map(files.map((file) => [file.path, file])); + for (const row of artifactRows) { + const [record] = decodeArtifactRecordJsons([row.record_json]); + if (!record) continue; + if ( + row?.artifact_id !== record.id || + row.session_id !== record.sessionId || + row.created_at !== record.createdAt || + row.relative_path !== record.relativePath + ) { + throw new Error(`artifact indexes do not match record: ${record.id}`); + } + const payload = filesByPath.get(`artifacts/${record.relativePath}`); + if (!payload || payload.size !== record.sizeBytes) { + throw new Error(`artifact payload does not match metadata: ${record.id}`); + } + } + + const messageRows = database + .prepare(` + SELECT message.session_id, message.sequence, message.message_id, + message.message_type, message.message_ts, message.record_json, + payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + ORDER BY message.session_id, message.sequence + `) + .all() as Array<{ + session_id?: unknown; + sequence?: unknown; + message_id?: unknown; + message_type?: unknown; + message_ts?: unknown; + record_json?: unknown; + record_bytes?: unknown; + sha256?: unknown; + }>; + const readMessageChunks = database.prepare(` + SELECT chunk_index, data, sha256 + FROM session_message_chunks + WHERE session_id = ? AND sequence = ? + ORDER BY chunk_index + `); + for (const row of messageRows) { + if ( + typeof row.session_id !== 'string' || + !Number.isSafeInteger(row.sequence) || + (row.sequence as number) < 0 || + typeof row.record_json !== 'string' || + (row.record_bytes !== null && !Number.isSafeInteger(row.record_bytes)) + ) { + throw new Error('session message index is invalid'); + } + const chunks = readMessageChunks.all(row.session_id, row.sequence as number) as Array<{ + chunk_index?: unknown; + data?: unknown; + sha256?: unknown; + }>; + const chunked = row.record_bytes !== null; + let encoded: Buffer; + if (chunked) { + if ( + row.record_json !== SQLITE_SESSION_MESSAGE_CHUNK_MARKER || + !Number.isSafeInteger(row.record_bytes) || + (row.record_bytes as number) <= SQLITE_SESSION_MESSAGE_CHUNK_BYTES || + typeof row.sha256 !== 'string' + ) { + throw new Error('session message payload is invalid'); + } + if ( + chunks.some( + (chunk, index) => + chunk.chunk_index !== index || + !(chunk.data instanceof Uint8Array) || + typeof chunk.sha256 !== 'string' || + chunk.sha256 !== createHash('sha256').update(chunk.data).digest('hex'), + ) + ) { + throw new Error('session message chunks do not match payload'); + } + encoded = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.data as Uint8Array))); + if ( + encoded.byteLength !== row.record_bytes || + createHash('sha256').update(encoded).digest('hex') !== row.sha256 + ) { + throw new Error('session message chunks do not match payload'); + } + } else { + if (row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER || chunks.length !== 0) { + throw new Error('session message payload is invalid'); + } + encoded = Buffer.from(row.record_json, 'utf8'); + } + const expectedChunks = chunked + ? Math.ceil(encoded.byteLength / SQLITE_SESSION_MESSAGE_CHUNK_BYTES) + : 0; + if (chunks.length !== expectedChunks) { + throw new Error('session message chunks do not match payload'); + } + const message = decodeStoredMessage(JSON.parse(encoded.toString('utf8'))); + if ( + message.id !== row.message_id || + message.type !== row.message_type || + message.ts !== row.message_ts + ) { + throw new Error(`session message indexes do not match record: ${message.id}`); + } + } + } finally { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the validation error. + } + database.close(); + } + } catch (error) { + // The reason has to reach the message: validation covers integrity, + // foreign keys, schema versions, target schema, Artifact payload + // reconciliation and message decoding, and a bare "is invalid" leaves an + // operator with no way to tell those apart in a log that dropped `cause`. + const reason = error instanceof Error ? error.message : String(error); + throw new OperationalBackupError( + 'corrupt_backup', + `Backup runtime.sqlite is invalid: ${reason}`, + { cause: error }, + ); + } +} + +function normalizeStandaloneSqliteSnapshot(path: string): void { + const database = new DatabaseSync(path); + try { + const row = database.prepare('PRAGMA journal_mode = DELETE').get() as + | { journal_mode?: unknown } + | undefined; + if (row?.journal_mode !== 'delete') { + throw new OperationalBackupError( + 'corrupt_backup', + 'Unable to make the SQLite backup self-contained', + ); + } + } finally { + database.close(); + } +} + +function resolveInside(root: string, path: string): string { + const candidate = resolve(root, path); + const rel = relative(root, candidate); + if (rel === '' || rel.startsWith('..') || rel.includes(':')) { + throw new OperationalBackupError('corrupt_backup', `Unsafe backup path: ${path}`); + } + return candidate; +} + +function assertSeparateRoots(left: string, right: string): void { + const leftToRight = relative(left, right); + const rightToLeft = relative(right, left); + if ( + left === right || + (!leftToRight.startsWith('..') && !leftToRight.includes(':')) || + (!rightToLeft.startsWith('..') && !rightToLeft.includes(':')) + ) { + throw new OperationalBackupError('overlapping_roots', 'Backup roots must not overlap'); + } +} + +async function assertMissing(path: string, label: string): Promise { + if (await pathExists(path)) { + throw new OperationalBackupError('destination_not_empty', `${label} already exists: ${path}`); + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c9eb514a847f5a0364eeb149efa5e1a41307f01bd7be056b968f0402f3883918.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c9eb514a847f5a0364eeb149efa5e1a41307f01bd7be056b968f0402f3883918.source new file mode 100644 index 0000000000..b4bbdabc3b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/c9eb514a847f5a0364eeb149efa5e1a41307f01bd7be056b968f0402f3883918.source @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExternalSessionAdapterRegistry } from '@maka/core/external-session'; +import { + ClaudeCodeSessionAdapter, + type ClaudeCodeSessionAdapterOptions, +} from './claude-code-session-adapter.js'; +import { CodexSessionAdapter, type CodexSessionAdapterOptions } from './codex-session-adapter.js'; +import { + OpenCodeSessionAdapter, + type OpenCodeSessionAdapterOptions, +} from './opencode-session-adapter.js'; + +export interface ExternalSessionAdapterOptions { + codex?: CodexSessionAdapterOptions; + claudeCode?: ClaudeCodeSessionAdapterOptions; + opencode?: OpenCodeSessionAdapterOptions; +} + +/** Default source registry shared by product-facing external Session import surfaces. */ +export function createExternalSessionAdapterRegistry( + options: ExternalSessionAdapterOptions = {}, +): ExternalSessionAdapterRegistry { + return new ExternalSessionAdapterRegistry([ + new CodexSessionAdapter(options.codex), + new ClaudeCodeSessionAdapter(options.claudeCode), + new OpenCodeSessionAdapter(options.opencode), + ]); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cce9bb5694aa1d060ef31bcf0fc8a86da6e1d8029cc7e2a5319b427f31bd0565.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cce9bb5694aa1d060ef31bcf0fc8a86da6e1d8029cc7e2a5319b427f31bd0565.source new file mode 100644 index 0000000000..9d0fa6ed6c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cce9bb5694aa1d060ef31bcf0fc8a86da6e1d8029cc7e2a5319b427f31bd0565.source @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; + +test('core execution migration preserves databases with historical continuation forks', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec(` + CREATE TABLE core_root_turn_admissions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id) + ); + `); + const insert = database.prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `); + for (const [turnId, admittedAt] of [ + ['continuation-a', 20], + ['continuation-b', 30], + ] as const) { + insert.run( + 'session', + turnId, + admittedAt, + JSON.stringify({ + sessionId: 'session', + turnId, + execution: { + kind: 'safe_boundary_continuation', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + }, + }), + ); + } + + assert.doesNotThrow(() => migrateSqliteCoreExecutionDatabase(database)); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM core_root_turn_admissions').get()?.count, + 2, + ); + } finally { + database.close(); + } +}); + +test('safe-boundary continuation admission is indexed by its source execution', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-continuation-admission-')); + try { + const store = createSqliteAgentRunStore(root); + const origin = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'source-turn', + proposedRunId: 'source-run', + proposedUserMessageId: 'source-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'Start work' }, + sourceMessages: [], + admittedAt: 10, + }); + assert.equal(origin.kind, 'admitted'); + const continuation = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'continuation-turn', + proposedRunId: 'continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'continuation-claim', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + providerReplayDigest: `sha256:${'b'.repeat(64)}`, + safetyDigest: `sha256:${'c'.repeat(64)}`, + targetInvocationId: 'continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 20, + }); + assert.equal(continuation.kind, 'admitted'); + const competing = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'competing-continuation-turn', + proposedRunId: 'competing-continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'competing-continuation-claim', + boundaryDigest: `sha256:${'d'.repeat(64)}`, + providerReplayDigest: `sha256:${'e'.repeat(64)}`, + safetyDigest: `sha256:${'f'.repeat(64)}`, + targetInvocationId: 'competing-continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 30, + }); + assert.deepEqual(competing, { kind: 'conflict', admission: continuation.admission }); + + assert.deepEqual( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), + continuation.admission, + ); + assert.equal( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'other-run'), + undefined, + ); + store.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('historical continuation forks resolve to the earliest durable admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-continuation-fork-')); + try { + const store = createSqliteAgentRunStore(root); + const source = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'source-turn', + proposedRunId: 'source-run', + proposedUserMessageId: 'source-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'Start work' }, + sourceMessages: [], + admittedAt: 10, + }); + const first = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'continuation-a', + proposedRunId: 'continuation-run-a', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: source.admission.runId, + sourceTurnId: source.admission.turnId, + sourceRuntimeEventHighWater: 7, + claimId: 'continuation-claim-a', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + providerReplayDigest: `sha256:${'b'.repeat(64)}`, + safetyDigest: `sha256:${'c'.repeat(64)}`, + targetInvocationId: 'continuation-invocation-a', + }, + previousRootTurnId: source.admission.turnId, + normalizedInput: null, + sourceMessages: [], + admittedAt: 20, + }); + store.close?.(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + const fork = { + ...first.admission, + turnId: 'continuation-b', + runId: 'continuation-run-b', + admittedAt: 30, + execution: { + ...first.admission.execution, + claimId: 'continuation-claim-b', + targetInvocationId: 'continuation-invocation-b', + }, + }; + database + .prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `) + .run(fork.sessionId, fork.turnId, fork.admittedAt, JSON.stringify(fork)); + } finally { + database.close(); + } + + const reopened = createSqliteAgentRunStore(root); + try { + assert.deepEqual( + await reopened.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), + first.admission, + ); + } finally { + reopened.close?.(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfa22a7f78e0046d0ae446854293eea480cf3b5ed2679d591b99d75da7347bf6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfa22a7f78e0046d0ae446854293eea480cf3b5ed2679d591b99d75da7347bf6.source new file mode 100644 index 0000000000..0298285976 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfa22a7f78e0046d0ae446854293eea480cf3b5ed2679d591b99d75da7347bf6.source @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION } from '@maka/core/agent-graph-control'; +import { + AgentGraphIntentClaimConflictError, + createSqliteSessionMetadataStore, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, +} from '../sqlite-session-metadata-store.js'; + +describe('SQLite agent graph intent claims', () => { + test('atomically allocates one stable activation identity per intent', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 42 }); + try { + const first = await store.claimAgentGraphIntent(request()); + const retry = await store.claimAgentGraphIntent( + request({ targetTurnId: 'discarded-turn', targetRunId: 'discarded-run' }), + ); + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.deepEqual(retry.claim, first.claim); + assert.equal(retry.claim.targetTurnId, 'turn-next'); + assert.equal(retry.claim.targetRunId, 'run-next'); + assert.deepEqual(await store.readAgentGraphIntentClaim('graph-1', request().intentId), { + ...request(), + claimedAt: 42, + }); + assert.deepEqual(await store.listAgentGraphIntentClaims('graph-1'), [first.claim]); + } finally { + store.close(); + } + }); + + test('rejects reused intent and activation identities with different semantics', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.claimAgentGraphIntent(request()); + await assert.rejects( + store.claimAgentGraphIntent(request({ intentFingerprint: `sha256:${'e'.repeat(64)}` })), + AgentGraphIntentClaimConflictError, + ); + await assert.rejects( + store.claimAgentGraphIntent( + request({ + claimId: `graph_claim_${'f'.repeat(32)}`, + intentId: `graph_intent_${'e'.repeat(32)}`, + }), + ), + AgentGraphIntentClaimConflictError, + ); + await assert.rejects( + store.claimAgentGraphIntent( + request({ + claimId: `graph_claim_${'1'.repeat(32)}`, + intentId: `graph_intent_${'2'.repeat(32)}`, + targetRunId: 'different-run', + }), + ), + AgentGraphIntentClaimConflictError, + ); + assert.equal((await store.listAgentGraphIntentClaims()).length, 1); + } finally { + store.close(); + } + }); + + test('rolls back a claim when the transaction fails before commit', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint(point) { + if (point === 'after_agent_graph_intent_claim_write') throw new Error('crash'); + }, + }); + try { + await assert.rejects(store.claimAgentGraphIntent(request()), /crash/); + assert.deepEqual(await store.listAgentGraphIntentClaims(), []); + } finally { + store.close(); + } + }); + + test('atomically transitions claimed work to executing or cancelled', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNumber(50) }); + try { + await store.claimAgentGraphIntent(request()); + assert.deepEqual( + await store.beginAgentGraphIntentExecutionAtScheduleRevision( + 'graph-1', + request().intentId, + 0, + ), + { + state: 'executing', + previousState: 'claimed', + changed: true, + }, + ); + assert.deepEqual( + await store.cancelAgentGraphIntentExecution( + 'graph-1', + request().intentId, + 'Supervisor stopped the work.', + ), + { + state: 'cancelled', + previousState: 'executing', + changed: true, + }, + ); + assert.deepEqual( + await store.beginAgentGraphIntentExecutionAtScheduleRevision( + 'graph-1', + request().intentId, + 0, + ), + { + state: 'cancelled', + previousState: 'cancelled', + changed: false, + }, + ); + } finally { + store.close(); + } + }); +}); + +function request( + overrides: Partial> = {}, +): ReturnType { + return { ...baseRequest(), ...overrides }; +} + +function baseRequest() { + return { + schemaVersion: AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, + claimId: `graph_claim_${'a'.repeat(32)}`, + graphId: 'graph-1', + intentId: `graph_intent_${'b'.repeat(32)}`, + intentFingerprint: `sha256:${'c'.repeat(64)}`, + readinessContextFingerprint: `sha256:${'d'.repeat(64)}`, + targetOperatorId: 'summarizer', + targetSessionId: 'session-child', + targetTurnId: 'turn-next', + targetRunId: 'run-next', + }; +} + +function nextNumber(start: number): () => number { + let value = start; + return () => value++; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfdbf41ae122697978b6424b86658d701f7c664d931c1a50fa5cd149b0e6c003.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfdbf41ae122697978b6424b86658d701f7c664d931c1a50fa5cd149b0e6c003.source new file mode 100644 index 0000000000..22ed636cff --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/cfdbf41ae122697978b6424b86658d701f7c664d931c1a50fa5cd149b0e6c003.source @@ -0,0 +1,335 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { promisify } from 'node:util'; + +import { + resolveWorkspaceIdentity, + WORKSPACE_IDENTITY_PREFIX, + WORKSPACE_MARKER_FILE, + WorkspaceIdentityError, +} from '../workspace-identity.js'; + +const execFileAsync = promisify(execFile); + +test('a new workspace marker contains only its schema version and UUID', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-marker-shape-')); + try { + const resolution = await resolveWorkspaceIdentity({ path: workspace }); + + assert.deepEqual(JSON.parse(await readFile(join(workspace, WORKSPACE_MARKER_FILE), 'utf8')), { + schemaVersion: 1, + workspaceId: resolution.workspaceIdentity.slice(WORKSPACE_IDENTITY_PREFIX.length), + }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('creating a workspace identity keeps a Git worktree clean', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-clean-')); + try { + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + + await resolveWorkspaceIdentity({ path: workspace }); + + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=normal'], + { cwd: workspace, encoding: 'utf8' }, + ); + assert.equal(stdout, ''); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('an existing workspace marker becomes locally ignored after Git initialization', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-existing-')); + try { + const original = await resolveWorkspaceIdentity({ path: workspace }); + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + + const resolved = await resolveWorkspaceIdentity({ path: workspace }); + + assert.equal(resolved.workspaceIdentity, original.workspaceIdentity); + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=normal'], + { cwd: workspace, encoding: 'utf8' }, + ); + assert.equal(stdout, ''); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a subdirectory workspace stays clean inside a linked Git worktree', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workspace-git-linked-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + const workspace = join(linkedWorktree, 'nested', 'workspace'); + await mkdir(repository); + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); + await writeFile(join(repository, 'tracked.txt'), 'tracked\n', 'utf8'); + await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repository }); + await execFileAsync( + 'git', + [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'init', + ], + { cwd: repository }, + ); + await execFileAsync( + 'git', + ['worktree', 'add', '--quiet', '-b', 'linked-test', linkedWorktree], + { + cwd: repository, + }, + ); + await mkdir(workspace, { recursive: true }); + + await resolveWorkspaceIdentity({ path: workspace }); + + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=normal'], + { cwd: linkedWorktree, encoding: 'utf8' }, + ); + assert.equal(stdout, ''); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('repeated Git workspace resolution adds one local exclude rule', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-idempotent-')); + try { + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + + await resolveWorkspaceIdentity({ path: workspace }); + await resolveWorkspaceIdentity({ path: workspace }); + + const { stdout } = await execFileAsync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], + { cwd: workspace, encoding: 'utf8' }, + ); + const rules = (await readFile(stdout.trim(), 'utf8')) + .split(/\r?\n/) + .filter((line) => line === WORKSPACE_MARKER_FILE); + assert.equal(rules.length, 1); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('concurrent Git workspace resolution returns one clean identity', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-concurrent-')); + try { + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + + const resolutions = await Promise.all( + Array.from({ length: 8 }, () => resolveWorkspaceIdentity({ path: workspace })), + ); + + assert.equal(new Set(resolutions.map((resolution) => resolution.workspaceIdentity)).size, 1); + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=normal'], + { cwd: workspace, encoding: 'utf8' }, + ); + assert.equal(stdout, ''); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a full Git exclude does not grow when resolving workspace identity', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-exclude-full-')); + try { + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + const { stdout } = await execFileAsync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], + { cwd: workspace, encoding: 'utf8' }, + ); + const excludePath = stdout.trim(); + const originalContents = '#'.repeat(1024 * 1024); + await writeFile(excludePath, originalContents, 'utf8'); + + await assert.rejects( + () => resolveWorkspaceIdentity({ path: workspace }), + (error: unknown) => + error instanceof WorkspaceIdentityError && error.code === 'workspace_io_failed', + ); + assert.equal(await readFile(excludePath, 'utf8'), originalContents); + await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a malformed enclosing Git repository prevents publishing a new marker', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-malformed-')); + try { + await writeFile(join(workspace, '.git'), 'gitdir: /missing/maka-git-dir\n', 'utf8'); + + await assert.rejects( + () => resolveWorkspaceIdentity({ path: workspace }), + (error: unknown) => + error instanceof WorkspaceIdentityError && error.code === 'workspace_io_failed', + ); + await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a non-Git workspace resolves when the Git executable is unavailable', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-no-git-required-')); + try { + await resolveWorkspaceIdentityWithoutGit(workspace); + + await access(join(workspace, WORKSPACE_MARKER_FILE)); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a Git workspace does not publish a marker when the Git executable is unavailable', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-unavailable-')); + try { + await execFileAsync('git', ['init', '--quiet'], { cwd: workspace }); + + await assert.rejects(resolveWorkspaceIdentityWithoutGit(workspace)); + await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('an unmarked read-only workspace fails without leaving marker state', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to create a read-only workspace fixture' + : false, +}, async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-read-only-')); + try { + await chmod(workspace, 0o555); + + await assert.rejects( + () => resolveWorkspaceIdentity({ path: workspace }), + (error: unknown) => + error instanceof WorkspaceIdentityError && error.code === 'workspace_io_failed', + ); + await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' }); + } finally { + await chmod(workspace, 0o755); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('a tar archive round-trip preserves workspace identity', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workspace-identity-')); + try { + const source = join(base, 'source'); + const imported = join(base, 'imported'); + const archive = join(base, 'workspace.tar'); + await mkdir(source); + const original = await resolveWorkspaceIdentity({ path: source }); + const markerBefore = await readFile(join(source, WORKSPACE_MARKER_FILE), 'utf8'); + + await mkdir(imported); + await execFileAsync('tar', ['-cf', archive, '-C', source, '.']); + await execFileAsync('tar', ['-xf', archive, '-C', imported]); + assert.equal(await readFile(join(imported, WORKSPACE_MARKER_FILE), 'utf8'), markerBefore); + const adopted = await resolveWorkspaceIdentity({ path: imported }); + + assert.equal(adopted.workspaceIdentity, original.workspaceIdentity); + assert.match(adopted.workspaceIdentity, new RegExp(`^${WORKSPACE_IDENTITY_PREFIX}`)); + assert.equal(await readFile(join(imported, WORKSPACE_MARKER_FILE), 'utf8'), markerBefore); + assert.equal( + JSON.parse(await readFile(join(imported, WORKSPACE_MARKER_FILE), 'utf8')).workspaceId, + JSON.parse(markerBefore).workspaceId, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('importing an existing marker into a Git worktree keeps it clean', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workspace-import-git-')); + try { + const source = join(base, 'source'); + const imported = join(base, 'imported'); + await mkdir(source); + await mkdir(imported); + const original = await resolveWorkspaceIdentity({ path: source }); + await writeFile( + join(imported, WORKSPACE_MARKER_FILE), + await readFile(join(source, WORKSPACE_MARKER_FILE)), + ); + await execFileAsync('git', ['init', '--quiet'], { cwd: imported }); + + const adopted = await resolveWorkspaceIdentity({ path: imported }); + + assert.equal(adopted.workspaceIdentity, original.workspaceIdentity); + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=normal'], + { cwd: imported, encoding: 'utf8' }, + ); + assert.equal(stdout, ''); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +async function resolveWorkspaceIdentityWithoutGit(workspace: string): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, PATH: '' }; + delete env.Path; + const moduleUrl = new URL('../workspace-identity.js', import.meta.url).href; + await execFileAsync( + process.execPath, + [ + '--input-type=module', + '-e', + 'const [moduleUrl, workspace] = process.argv.slice(1); const { resolveWorkspaceIdentity } = await import(moduleUrl); await resolveWorkspaceIdentity({ path: workspace });', + moduleUrl, + workspace, + ], + { env }, + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d138eb9ae0da1d4f403abf4b292c612079b036090afc91a3b3da73ed7c3ff90c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d138eb9ae0da1d4f403abf4b292c612079b036090afc91a3b3da73ed7c3ff90c.source new file mode 100644 index 0000000000..96abb5d5c3 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d138eb9ae0da1d4f403abf4b292c612079b036090afc91a3b3da73ed7c3ff90c.source @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSessionBundleFileService } from '../../session-bundle-file-service.js'; +import type { SessionBundleLimits } from '../../session-bundle-contract.js'; + +const [archivePath, archiveDigest, limitsJson, destinationRoot] = process.argv.slice(2); +if (!archivePath || !archiveDigest || !limitsJson) process.exit(2); + +const limits = JSON.parse(limitsJson) as SessionBundleLimits; +const service = createSessionBundleFileService(); +const source = { + path: archivePath, + expectedArchiveDigest: archiveDigest as `sha256:${string}`, +}; +const result = destinationRoot + ? await service.hydrate({ + source, + limits, + expectedSessionId: 'cloud-session-1', + destinationRoot, + }) + : await service.inspect({ source, limits }); +process.stdout.write( + JSON.stringify({ + sessionId: result.manifest.envelope.sessionId, + archiveDigest: result.archiveDigest, + identityHex: Buffer.from(result.stateIdentity.bytes).toString('hex'), + verified: result.verified, + ...(destinationRoot ? { destinationRoot } : {}), + }), +); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d1bf25181eb1343fe3d2c48cd7ff20d605bb13ceee265ca177e1107b87bc5175.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d1bf25181eb1343fe3d2c48cd7ff20d605bb13ceee265ca177e1107b87bc5175.source new file mode 100644 index 0000000000..b3b344d6db --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d1bf25181eb1343fe3d2c48cd7ff20d605bb13ceee265ca177e1107b87bc5175.source @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { decodeGoalAuthorityRecord, type GoalAuthorityRecord } from '@maka/core/goal'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { assertSafeStorageId } from './storage-id.js'; + +const writerBrand: unique symbol = Symbol('InteractiveGoalAuthorityWriter'); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface GoalAuthoritySnapshot { + readonly authorityRevision: number; + readonly record: GoalAuthorityRecord; +} + +export interface CommitGoalAuthorityInput { + readonly sessionId: string; + readonly expectedAuthorityRevision: number | null; + readonly record: GoalAuthorityRecord | null; +} + +export type CommitGoalAuthorityResult = + | { + readonly kind: 'committed'; + readonly snapshot: GoalAuthoritySnapshot | null; + } + | { + readonly kind: 'revision_conflict'; + readonly actualAuthorityRevision: number | null; + }; + +export interface InteractiveGoalAuthorityWriter { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + list(): Promise; + read(sessionId: string): Promise; + commit(input: CommitGoalAuthorityInput): Promise; + close(): Promise; +} + +export function authenticateInteractiveGoalAuthorityWriter( + writer: InteractiveGoalAuthorityWriter, +): InteractiveGoalAuthorityWriter { + if (!writers.has(writer)) { + throw new StorageRootAuthorityError( + 'invalid_lease', + 'Expected an authentic interactive Goal authority writer', + ); + } + return writer; +} + +export async function openInteractiveGoalAuthorityForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const pending = Promise.resolve().then(async () => { + let repository: SqliteGoalAuthority | undefined; + try { + repository = await runWithStorageRootLease(lease, 'interactive', 'write', async (root) => + createSqliteGoalAuthority(root), + ); + await assertStorageRootLease(lease, 'interactive', 'write'); + const raced = writerByLease.get(lease); + if (raced) { + repository.close(); + return raced; + } + const writer = createWriterFacade(lease, repository); + writers.add(writer); + writerByLease.set(lease, writer); + return writer; + } catch (error) { + repository?.close(); + throw error; + } + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + repository: SqliteGoalAuthority, +): InteractiveGoalAuthorityWriter { + let closed = false; + let closeTask: Promise | undefined; + const activeOperations = new Set>(); + const run = (operation: () => T): Promise => { + if (closed) { + return Promise.reject( + new StorageRootAuthorityError('invalid_lease', 'Goal authority writer is closed'), + ); + } + const pending = runWithStorageRootLease(lease, 'interactive', 'write', async () => operation()); + activeOperations.add(pending); + void pending.finally(() => activeOperations.delete(pending)).catch(() => undefined); + return pending; + }; + const writer: InteractiveGoalAuthorityWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + list: () => run(() => repository.list()), + read: (sessionId) => run(() => repository.read(sessionId)), + commit: (input) => { + const normalized = normalizeCommitInput(input); + return run(() => repository.commit(normalized)); + }, + close: () => { + closeTask ??= (async () => { + closed = true; + if (writerByLease.get(lease) === writer) writerByLease.delete(lease); + writers.delete(writer); + await Promise.allSettled([...activeOperations]); + repository.close(); + })(); + return closeTask; + }, + }; + return Object.freeze(writer); +} + +class SqliteGoalAuthority { + readonly #database: OperationalStateDatabaseLease; + + constructor(root: string) { + this.#database = acquireOperationalStateDatabase(root); + } + + list(): readonly GoalAuthoritySnapshot[] { + return this.#database.transaction('read', () => + this.#database.database + .prepare( + `SELECT session_id, authority_revision, goal_id, goal_revision, status, record_json + FROM workflow_goal_authority + ORDER BY session_id`, + ) + .all() + .map(readRow), + ); + } + + read(sessionId: string): GoalAuthoritySnapshot | null { + requireId(sessionId, 'Session'); + return this.#database.transaction('read', () => { + const row = this.#database.database + .prepare( + `SELECT session_id, authority_revision, goal_id, goal_revision, status, record_json + FROM workflow_goal_authority + WHERE session_id = ?`, + ) + .get(sessionId); + return row === undefined ? null : readRow(row); + }); + } + + commit(input: CommitGoalAuthorityInput): CommitGoalAuthorityResult { + return this.#database.transaction('write', () => { + const current = readRevision(this.#database, input.sessionId); + if (current !== input.expectedAuthorityRevision) { + return { kind: 'revision_conflict', actualAuthorityRevision: current }; + } + if (input.record === null) { + this.#database.database + .prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?') + .run(input.sessionId); + return { kind: 'committed', snapshot: null }; + } + const authorityRevision = (current ?? -1) + 1; + const record = input.record; + this.#database.database + .prepare( + `INSERT INTO workflow_goal_authority( + session_id, authority_revision, goal_id, goal_revision, status, record_json + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + authority_revision = excluded.authority_revision, + goal_id = excluded.goal_id, + goal_revision = excluded.goal_revision, + status = excluded.status, + record_json = excluded.record_json`, + ) + .run( + input.sessionId, + authorityRevision, + record.goal.id, + record.goal.revision, + record.goal.status, + JSON.stringify(record), + ); + return { + kind: 'committed', + snapshot: cloneSnapshot({ authorityRevision, record }), + }; + }); + } + + close(): void { + this.#database.close(); + } +} + +function createSqliteGoalAuthority(root: string): SqliteGoalAuthority { + return new SqliteGoalAuthority(root); +} + +function normalizeCommitInput(input: CommitGoalAuthorityInput): CommitGoalAuthorityInput { + requireId(input.sessionId, 'Session'); + if ( + input.expectedAuthorityRevision !== null && + (!Number.isSafeInteger(input.expectedAuthorityRevision) || input.expectedAuthorityRevision < 0) + ) { + throw new TypeError('Goal authority expected revision is invalid'); + } + const record = input.record === null ? null : decodeGoalAuthorityRecord(input.record); + if (record && record.goal.sessionId !== input.sessionId) { + throw new TypeError('Goal authority Session identity changed'); + } + return { ...input, record }; +} + +function readRevision(database: OperationalStateDatabaseLease, sessionId: string): number | null { + const row = database.database + .prepare( + 'SELECT authority_revision AS authorityRevision FROM workflow_goal_authority WHERE session_id = ?', + ) + .get(sessionId) as { authorityRevision?: unknown } | undefined; + if (!row) return null; + if (!Number.isSafeInteger(row.authorityRevision) || (row.authorityRevision as number) < 0) { + throw new Error('Goal authority revision is corrupt'); + } + return row.authorityRevision as number; +} + +function readRow(value: unknown): GoalAuthoritySnapshot { + if (typeof value !== 'object' || value === null) throw new Error('Goal authority row is corrupt'); + const row = value as Record; + const authorityRevision = row.authority_revision; + if (!Number.isSafeInteger(authorityRevision) || (authorityRevision as number) < 0) { + throw new Error('Goal authority revision is corrupt'); + } + if (typeof row.record_json !== 'string') throw new Error('Goal authority record is corrupt'); + const record = decodeGoalAuthorityRecord(JSON.parse(row.record_json)); + if ( + row.session_id !== record.goal.sessionId || + row.goal_id !== record.goal.id || + row.goal_revision !== record.goal.revision || + row.status !== record.goal.status + ) { + throw new Error('Goal authority indexed identity is corrupt'); + } + return cloneSnapshot({ + authorityRevision: authorityRevision as number, + record, + }); +} + +function cloneSnapshot(snapshot: GoalAuthoritySnapshot): GoalAuthoritySnapshot { + return Object.freeze({ + authorityRevision: snapshot.authorityRevision, + record: decodeGoalAuthorityRecord(structuredClone(snapshot.record)), + }); +} + +function requireId(value: string, label: string): void { + assertSafeStorageId(value, `${label} identity is invalid`); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d53c752587ce1286de28d4656f393a624cefdffee157e524ddf022dede6888ba.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d53c752587ce1286de28d4656f393a624cefdffee157e524ddf022dede6888ba.source new file mode 100644 index 0000000000..d72040d5ce --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d53c752587ce1286de28d4656f393a624cefdffee157e524ddf022dede6888ba.source @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Production interaction-store surface. + * + * The facade is backed by the canonical operational SQLite database. + */ +export { + STORED_INTERACTION_OUTCOME_MAX_BYTES, + STORED_INTERACTION_REQUEST_MAX_BYTES, + STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES, + InteractionStoreError, + authenticateInteractionStoreReader, + authenticateInteractionStoreWriter, + closeSqliteInteractionStoreFacade, + openSqliteInteractiveInteractionStoreForRead, + openSqliteInteractiveInteractionStoreForWrite, +} from './interaction-store.js'; +export type { + CommitInteractionOutcomeResult, + EstablishInteractionRequestResult, + InteractionIdentity, + InteractionMutationFailureResult, + InteractionRecord, + InteractionStoreErrorCode, + InteractionStoreReader, + InteractionStoreWriter, + InteractiveInteractionStoreReaderFacade, + InteractiveInteractionStoreWriterFacade, + PendingInteractionFilter, + StoredInteractionOutcome, + StoredInteractionRequest, +} from './interaction-store.js'; diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d66655ebf390860cfa6222cdb35a0d9929039f8d5498871affe5c6a00705c152.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d66655ebf390860cfa6222cdb35a0d9929039f8d5498871affe5c6a00705c152.source new file mode 100644 index 0000000000..a601ccf9c9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d66655ebf390860cfa6222cdb35a0d9929039f8d5498871affe5c6a00705c152.source @@ -0,0 +1,821 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, win32 } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { + FOREIGN_SESSION_SCAN_MAX_SESSIONS, + type ForeignSessionSummary, +} from '@maka/core/foreign-session'; +import { + codexCwdSqlVariants, + createForeignSessionStore, + isClaudeCodeImportEnabled, + isCodexImportEnabled, + isOpencodeImportEnabled, +} from '../foreign-session-store.js'; + +const NOW = Date.now(); + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), 'maka-foreign-')); +} + +function claudeLine(record: Record): string { + return JSON.stringify(record) + '\n'; +} + +async function seedClaudeSession( + home: string, + options: { + id: string; + cwd: string; + aiTitle?: string; + sidechain?: boolean; + userText?: string; + assistantText?: string; + filePath?: string; + /** Bytes of leading summary noise before the cwd-bearing record. */ + leadingPadBytes?: number; + }, +): Promise { + const dir = join(home, '.claude', 'projects', options.cwd.replace(/\//g, '-')); + await mkdir(dir, { recursive: true }); + const path = join(dir, `${options.id}.jsonl`); + const lines = [ + claudeLine({ type: 'mode', sessionId: options.id, mode: 'default' }), + ...(options.leadingPadBytes + ? [ + claudeLine({ + type: 'summary', + sessionId: options.id, + summary: 'x'.repeat(options.leadingPadBytes), + }), + ] + : []), + claudeLine({ + type: 'user', + sessionId: options.id, + cwd: options.cwd, + gitBranch: 'main', + isSidechain: options.sidechain ?? false, + timestamp: new Date(NOW - 60_000).toISOString(), + message: { role: 'user', content: options.userText ?? 'do the thing' }, + }), + claudeLine({ + type: 'assistant', + sessionId: options.id, + cwd: options.cwd, + isSidechain: options.sidechain ?? false, + timestamp: new Date(NOW - 30_000).toISOString(), + message: { + role: 'assistant', + content: [ + { type: 'text', text: options.assistantText ?? 'done' }, + ...(options.filePath + ? [{ type: 'tool_use', name: 'Edit', input: { file_path: options.filePath } }] + : []), + ], + }, + }), + 'not valid json\n', + ...(options.aiTitle + ? [claudeLine({ type: 'ai-title', sessionId: options.id, aiTitle: options.aiTitle })] + : []), + ]; + await writeFile(path, lines.join(''), 'utf8'); + return path; +} + +type CodexThreadSeed = { + id: string; + cwd: string; + title?: string; + updatedAtMs?: number; + archived?: number; + source?: string | null; + rolloutRelPath?: string; +}; + +function seedCodexSqlite(home: string, threads: CodexThreadSeed[]): Promise { + return seedCodexSqliteGen(home, 3, threads); +} + +async function seedCodexSqliteGen( + home: string, + gen: number, + threads: CodexThreadSeed[], +): Promise { + const codexRoot = join(home, '.codex'); + await mkdir(join(codexRoot, 'sessions', '2026', '07', '18'), { recursive: true }); + const db = new DatabaseSync(join(codexRoot, `state_${gen}.sqlite`)); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, cwd TEXT, title TEXT, + first_user_message TEXT, updated_at_ms INTEGER, git_branch TEXT, + archived INTEGER DEFAULT 0, source TEXT DEFAULT 'cli' + )`); + const insert = db.prepare( + 'INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms, archived, source) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + for (const t of threads) { + const rollout = join( + codexRoot, + t.rolloutRelPath ?? `sessions/2026/07/18/rollout-1750000000000-${t.id}.jsonl`, + ); + await writeFile( + rollout, + [ + JSON.stringify({ + type: 'session_meta', + timestamp: new Date(NOW - 60_000).toISOString(), + payload: { id: t.id, cwd: t.cwd, git: { branch: 'main' } }, + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'codex task' }], + }, + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'codex reply' }], + }, + }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'shell', arguments: '{"cmd":"rm -rf /"}' }, + }), + ].join('\n') + '\n', + 'utf8', + ).catch(() => {}); + insert.run( + t.id, + rollout, + t.cwd, + t.title ?? null, + t.updatedAtMs ?? NOW - 60_000, + t.archived ?? 0, + t.source === undefined ? 'cli' : t.source, + ); + } + db.close(); +} + +describe('foreign session store — enable flags', () => { + it('defaults on, disabled by exactly "0"', () => { + assert.equal(isClaudeCodeImportEnabled({}), true); + assert.equal(isClaudeCodeImportEnabled({ MAKA_IMPORT_CLAUDE_CODE: '0' }), false); + assert.equal(isCodexImportEnabled({ MAKA_IMPORT_CODEX: '1' }), true); + assert.equal(isCodexImportEnabled({ MAKA_IMPORT_CODEX: '0' }), false); + }); + + it('reports only sources that are enabled AND present on disk', async () => { + const home = await tempHome(); + await mkdir(join(home, '.claude', 'projects'), { recursive: true }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual(await store.availableSources(), ['claude-code']); + const disabled = createForeignSessionStore({ + homeDir: home, + env: { MAKA_IMPORT_CLAUDE_CODE: '0' }, + }); + assert.deepEqual(await disabled.availableSources(), []); + }); +}); + +describe('foreign session store — Claude scan', () => { + it('lists sessions with title, cwd filter, and drops sidechains', async () => { + const home = await tempHome(); + await seedClaudeSession(home, { id: 'aaa', cwd: '/repo/one', aiTitle: '修复登录 bug' }); + await seedClaudeSession(home, { id: 'bbb', cwd: '/repo/two' }); + await seedClaudeSession(home, { id: 'ccc', cwd: '/repo/one', sidechain: true }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + + const all = await store.listSessions(); + assert.deepEqual(all.map((s) => s.id).sort(), ['aaa', 'bbb']); + + const filtered = await store.listSessions({ cwd: '/repo/one' }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0]!.id, 'aaa'); + assert.equal(filtered[0]!.title, '修复登录 bug'); + assert.equal(filtered[0]!.source, 'claude-code'); + assert.equal(filtered[0]!.gitBranch, 'main'); + }); + + it('sanitizes hostile titles at the scan boundary', async () => { + const home = await tempHome(); + await seedClaudeSession(home, { + id: 'evil', + cwd: '/repo', + aiTitle: 'safe‮titlewith sk-ant-api03-abcdefghijklmnop injected', + }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + assert.ok(!session.title.includes('‮')); + assert.ok(!session.title.includes('')); + assert.ok(!session.title.includes('sk-ant-api03-abcdefghijklmnop'), session.title); + }); + + it('caps the number of listed sessions', async () => { + const home = await tempHome(); + for (let i = 0; i < FOREIGN_SESSION_SCAN_MAX_SESSIONS + 5; i++) { + await seedClaudeSession(home, { id: `s${String(i).padStart(3, '0')}`, cwd: '/repo' }); + } + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const all = await store.listSessions(); + assert.equal(all.length, FOREIGN_SESSION_SCAN_MAX_SESSIONS); + }); + + it('finds cwd past the 4KB head via the adaptive window (does not drop the session)', async () => { + const home = await tempHome(); + // 100KB of leading summary noise pushes the cwd-bearing user record far + // past a fixed 4KB head — the adaptive read must still find it. + await seedClaudeSession(home, { + id: 'big', + cwd: '/repo', + leadingPadBytes: 100_000, + aiTitle: '大会话', + }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const all = await store.listSessions(); + assert.deepEqual( + all.map((s) => s.id), + ['big'], + ); + assert.equal(all[0]!.cwd, '/repo'); + }); + + it('sanitizes and redacts cwd / gitBranch in the returned summary', async () => { + const home = await tempHome(); + const dir = join(home, '.claude', 'projects', '-repo'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, '0fb0463a-ec8e-4d50-896d-c825c3148ae7.jsonl'), + claudeLine({ + type: 'user', + // A cwd carrying a bidi override and a branch carrying a secret must + // not reach a TUI consumer verbatim. + cwd: '/repo' + '\u202E' + 'spoof', + gitBranch: 'feat-AIzaSyA1234567890abcdefghijklmnop', + isSidechain: false, + message: { content: 'hi' }, + }), + 'utf8', + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + assert.ok(!session.cwd.includes('\u202E'), 'bidi override must be stripped from summary cwd'); + assert.ok( + !session.gitBranch!.includes('AIzaSyA1234567890abcdefghijklmnop'), + 'secret must be redacted from branch', + ); + }); + + it('drops a session whose transcript filename is not a safe id', async () => { + const home = await tempHome(); + const dir = join(home, '.claude', 'projects', '-repo'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'has space.jsonl'), + claudeLine({ type: 'user', cwd: '/repo', isSidechain: false, message: { content: 'hi' } }), + 'utf8', + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [], + ); + }); +}); + +describe('foreign session store — Codex scan', () => { + it('includes POSIX-shaped SQL variants for a native Windows cwd', () => { + const native = win32.join('C:\\', 'Users', 'me', 'project'); + const variants = codexCwdSqlVariants(native); + assert.ok(variants.includes('C:/Users/me/project')); + assert.ok(variants.includes('C:/Users/me/project/')); + }); + + it('lists threads from sqlite, dropping archived and foreign-source rows', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [ + { id: 't1', cwd: '/repo', title: 'Codex 任务' }, + { id: 't2', cwd: '/repo', archived: 1 }, + { id: 't3', cwd: '/repo', source: 'exotic' }, + { id: 't4', cwd: '/elsewhere' }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const all = await store.listSessions(); + assert.deepEqual(all.map((s) => s.id).sort(), ['t1', 't4']); + const filtered = await store.listSessions({ cwd: '/repo' }); + assert.deepEqual( + filtered.map((s) => s.id), + ['t1'], + ); + assert.equal(filtered[0]!.title, 'Codex 任务'); + }); + + it('lists atlas/chatgpt threads whose source is a JSON object', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [ + { id: 'atl', cwd: '/repo', title: 'Atlas', source: '{"custom":"atlas"}' }, + { id: 'gpt', cwd: '/repo', title: 'ChatGPT', source: '{"custom":"chatgpt"}' }, + { id: 'bad', cwd: '/repo', source: '{"custom":"unknown"}' }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual((await store.listSessions()).map((s) => s.id).sort(), ['atl', 'gpt']); + }); + + it('routes every supported sqlite source shape through the shared gate', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [ + { id: 'bare-exec', cwd: '/repo', source: 'exec' }, + { id: 'bare-atlas', cwd: '/repo', source: 'atlas' }, + { id: 'bare-chatgpt', cwd: '/repo', source: 'chatgpt' }, + { id: 'wrapped-cli', cwd: '/repo', source: '{ "custom": "cli" }' }, + { id: 'wrapped-vscode', cwd: '/repo', source: '{"custom":"vscode"}' }, + { id: 'legacy-null', cwd: '/repo', source: null }, + { id: 'unsupported', cwd: '/repo', source: '{"custom":"other"}' }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual((await store.listSessions()).map((session) => session.id).sort(), [ + 'bare-atlas', + 'bare-chatgpt', + 'bare-exec', + 'legacy-null', + 'wrapped-cli', + 'wrapped-vscode', + ]); + }); + + it('rejects rollout paths that escape ~/.codex', async () => { + const home = await tempHome(); + const outside = join(home, 'outside.jsonl'); + await writeFile( + outside, + JSON.stringify({ type: 'session_meta', payload: { id: 'x', cwd: '/repo' } }), + 'utf8', + ); + await seedCodexSqlite(home, [{ id: 'esc', cwd: '/repo', rolloutRelPath: '../outside.jsonl' }]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const all = await store.listSessions(); + assert.deepEqual( + all.map((s) => s.id), + [], + ); + }); + + it('applies the cwd filter in SQL so a LIMIT of newer other-project rows cannot hide it', async () => { + const home = await tempHome(); + const threads = []; + // 120 newer threads in /other, then one older thread in /target. If cwd + // were filtered only after a LIMIT, the target row would be truncated away. + for (let i = 0; i < 120; i++) { + threads.push({ + id: `o${String(i).padStart(3, '0')}`, + cwd: '/other', + updatedAtMs: NOW - 1000 * i, + }); + } + threads.push({ id: 'target', cwd: '/target', title: 'the one', updatedAtMs: NOW - 10_000_000 }); + await seedCodexSqlite(home, threads); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const found = await store.listSessions({ cwd: '/target' }); + assert.deepEqual( + found.map((s) => s.id), + ['target'], + ); + }); + + it('matches a stored trailing-slash cwd against a caller path without one', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [{ id: 'ts', cwd: '/target/', title: 'trailing slash' }]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual( + (await store.listSessions({ cwd: '/target' })).map((s) => s.id), + ['ts'], + ); + }); + + it('treats the first usable DB as authoritative: an all-archived newest gen does not resurface older rows', async () => { + const home = await tempHome(); + // Newest gen (state_5) has only an archived thread; an older gen has an + // active one. The archived-in-newest session must stay hidden. + await seedCodexSqliteGen(home, 5, [{ id: 'archived-now', cwd: '/repo', archived: 1 }]); + await seedCodexSqliteGen(home, 2, [{ id: 'stale-active', cwd: '/repo', title: 'old' }]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [], + ); + }); + + it('descends to an older generation only when the newest DB lacks the threads schema', async () => { + const home = await tempHome(); + await seedCodexSqliteGen(home, 2, [{ id: 'real', cwd: '/repo', title: 'real' }]); + // Newest gen has no threads table → unusable → skip to gen 2. + const codexRoot = join(home, '.codex'); + const badDb = new DatabaseSync(join(codexRoot, 'state_9.sqlite')); + badDb.exec('CREATE TABLE other (x TEXT)'); + badDb.close(); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + ['real'], + ); + }); + + it('drops a thread whose rollout filename uuid does not match the row id', async () => { + const home = await tempHome(); + // rollout file names a different session than the thread row claims. + await seedCodexSqlite(home, [ + { + id: 'realid', + cwd: '/repo', + rolloutRelPath: 'sessions/2026/07/18/rollout-1750000000000-otherid.jsonl', + }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [], + ); + }); + + it('falls back to the rollout walk when no sqlite exists', async () => { + const home = await tempHome(); + const day = join(home, '.codex', 'sessions', '2026', '07', '18'); + await mkdir(day, { recursive: true }); + await writeFile( + join(day, 'rollout-t9.jsonl'), + [ + JSON.stringify({ + type: 'session_meta', + timestamp: new Date(NOW - 60_000).toISOString(), + payload: { id: 't9', cwd: '/repo' }, + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '走兜底路径' }], + }, + }), + ].join('\n'), + 'utf8', + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const all = await store.listSessions(); + assert.equal(all.length, 1); + assert.equal(all[0]!.id, 't9'); + assert.equal(all[0]!.title, '走兜底路径'); + }); +}); + +describe('foreign session store — OpenCode scan (#5053)', () => { + async function seedOpencodeSession( + home: string, + session: { + id: string; + directory: string; + title: string; + timeUpdated: number; + parentId?: string; + timeArchived?: number | null; + }, + transcript: { + messages: { id: string; timeCreated: number; data: unknown }[]; + parts: { id: string; messageId: string; timeCreated: number; data: unknown }[]; + }, + ): Promise { + const dbPath = join(home, '.local', 'share', 'opencode', 'opencode.db'); + await mkdir(join(home, '.local', 'share', 'opencode'), { recursive: true }); + // IF NOT EXISTS: one test seeds several session rows into the same db. + const db = new DatabaseSync(dbPath); + try { + db.exec( + 'CREATE TABLE IF NOT EXISTS session (id text PRIMARY KEY, project_id text, workspace_id text, parent_id text, slug text, directory text NOT NULL, path text, title text, version text, time_created integer, time_updated integer, time_compacting integer, time_archived integer)', + ); + db.exec( + 'CREATE TABLE IF NOT EXISTS message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer, data text NOT NULL)', + ); + db.exec( + 'CREATE TABLE IF NOT EXISTS part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer, data text NOT NULL)', + ); + db.prepare( + 'INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run( + session.id, + session.parentId ?? null, + session.directory, + session.title, + session.timeUpdated - 1_000, + session.timeUpdated, + session.timeArchived ?? null, + ); + const message = db.prepare( + 'INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)', + ); + for (const row of transcript.messages) { + message.run(row.id, session.id, row.timeCreated, JSON.stringify(row.data)); + } + const part = db.prepare( + 'INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)', + ); + for (const row of transcript.parts) { + part.run(row.id, row.messageId, session.id, row.timeCreated, JSON.stringify(row.data)); + } + } finally { + db.close(); + } + } + + it('defaults the flag on and disables it with exactly "0"', () => { + assert.equal(isOpencodeImportEnabled({}), true); + assert.equal(isOpencodeImportEnabled({ MAKA_IMPORT_OPENCODE: '0' }), false); + }); + + it('reports opencode only when enabled AND the database exists', async () => { + const home = await tempHome(); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual(await store.availableSources(), []); + await seedOpencodeSession( + home, + { id: 'ses_1', directory: '/repo', title: 't', timeUpdated: NOW }, + { messages: [], parts: [] }, + ); + assert.deepEqual(await store.availableSources(), ['opencode']); + const disabled = createForeignSessionStore({ + homeDir: home, + env: { MAKA_IMPORT_OPENCODE: '0' }, + }); + assert.deepEqual(await disabled.availableSources(), []); + }); + + it('lists parent sessions and drops archived ones', async () => { + const home = await tempHome(); + await seedOpencodeSession( + home, + { id: 'ses_live', directory: '/repo', title: 'live session', timeUpdated: NOW }, + { messages: [], parts: [] }, + ); + await seedOpencodeSession( + home, + { + id: 'ses_archived', + directory: '/repo', + title: 'old', + timeUpdated: NOW, + timeArchived: NOW, + }, + { messages: [], parts: [] }, + ); + await seedOpencodeSession( + home, + { + id: 'ses_child', + directory: '/repo', + title: 'child', + timeUpdated: NOW, + parentId: 'ses_live', + }, + { messages: [], parts: [] }, + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const sessions = await store.listSessions(); + assert.deepEqual( + sessions.map((s) => s.id), + ['ses_live'], + ); + assert.equal(sessions[0]!.source, 'opencode'); + assert.equal(sessions[0]!.title, 'live session'); + assert.equal(sessions[0]!.cwd, '/repo'); + }); + + it('filters by cwd through to the opencode database', async () => { + const home = await tempHome(); + await seedOpencodeSession( + home, + { id: 'ses_one', directory: '/repo/one', title: 'one', timeUpdated: NOW }, + { messages: [], parts: [] }, + ); + await seedOpencodeSession( + home, + { id: 'ses_two', directory: '/repo/two', title: 'two', timeUpdated: NOW }, + { messages: [], parts: [] }, + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const filtered = await store.listSessions({ cwd: '/repo/one' }); + assert.deepEqual( + filtered.map((s) => s.id), + ['ses_one'], + ); + }); + + it('builds a digest with user/assistant text and file paths, excluding thinking', async () => { + const home = await tempHome(); + await seedOpencodeSession( + home, + { id: 'ses_digest', directory: '/repo', title: 'with content', timeUpdated: NOW }, + { + messages: [ + { id: 'msg_u1', timeCreated: 1, data: { role: 'user', time: { created: 1 } } }, + { + id: 'msg_a1', + timeCreated: 2, + data: { role: 'assistant', time: { created: 2 }, finish: 'stop', modelID: 'm' }, + }, + { + id: 'msg_a2', + timeCreated: 3, + data: { role: 'assistant', time: { created: 3 }, finish: 'tool-calls', modelID: 'm' }, + }, + ], + parts: [ + { + id: 'p_u1', + messageId: 'msg_u1', + timeCreated: 1, + data: { type: 'text', text: '帮我修复解析器' }, + }, + { + id: 'p_a1', + messageId: 'msg_a1', + timeCreated: 2, + data: { type: 'reasoning', text: 'internal thinking' }, + }, + { + id: 'p_a2', + messageId: 'msg_a1', + timeCreated: 2, + data: { type: 'text', text: '已修复' }, + }, + { + id: 'p_a3', + messageId: 'msg_a2', + timeCreated: 3, + data: { + type: 'tool', + callID: 'call_1', + tool: 'edit', + state: { + status: 'completed', + input: { file_path: '/repo/src/parser.ts' }, + output: 'ok', + }, + }, + }, + ], + }, + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + const digest = await store.readDigest(session); + assert.equal(digest.source, 'opencode'); + assert.deepEqual(digest.userMessages, ['帮我修复解析器']); + assert.deepEqual(digest.assistantTexts, ['已修复']); + assert.deepEqual(digest.filesTouched, ['/repo/src/parser.ts']); + assert.ok( + !JSON.stringify(digest).includes('internal thinking'), + 'thinking blocks never enter the digest', + ); + }); +}); + +describe('foreign session store — digest', () => { + it('builds a digest with user/assistant text and file paths, dropping tool output', async () => { + const home = await tempHome(); + await seedClaudeSession(home, { + id: 'd1', + cwd: '/repo', + userText: '帮我修复解析器', + assistantText: '已修复并补了测试', + filePath: '/repo/src/parser.ts', + }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + const digest = await store.readDigest(session); + assert.deepEqual(digest.userMessages, ['帮我修复解析器']); + assert.deepEqual(digest.assistantTexts, ['已修复并补了测试']); + assert.deepEqual(digest.filesTouched, ['/repo/src/parser.ts']); + // The seeded transcript contains one deliberately-broken line. + assert.ok( + digest.warnings.some((w) => w.includes('malformed')), + JSON.stringify(digest.warnings), + ); + }); + + it('excludes interleaved sidechain records (both user and assistant) from the digest', async () => { + const home = await tempHome(); + // A main-session transcript (first record is not sidechain, so the file + // is not dropped) with a sub-agent's sidechain user AND assistant records + // interleaved. None of the sidechain content may enter the main handoff. + const dir = join(home, '.claude', 'projects', '-repo'); + await mkdir(dir, { recursive: true }); + const id = '0fb0463a-ec8e-4d50-896d-c825c3148ae7'; + await writeFile( + join(dir, `${id}.jsonl`), + [ + claudeLine({ + type: 'user', + cwd: '/repo', + isSidechain: false, + message: { content: 'main request' }, + }), + claudeLine({ + type: 'user', + isSidechain: true, + message: { content: 'SIDECHAIN USER PROMPT' }, + }), + claudeLine({ + type: 'assistant', + isSidechain: true, + message: { + content: [ + { type: 'text', text: 'SIDECHAIN ASSISTANT REPLY' }, + { type: 'tool_use', name: 'Edit', input: { file_path: '/repo/sidechain-only.ts' } }, + ], + }, + }), + claudeLine({ + type: 'assistant', + isSidechain: false, + message: { + content: [ + { type: 'text', text: 'main reply' }, + { type: 'tool_use', name: 'Edit', input: { file_path: '/repo/main.ts' } }, + ], + }, + }), + ].join(''), + 'utf8', + ); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + const digest = await store.readDigest(session); + assert.deepEqual(digest.userMessages, ['main request']); + assert.deepEqual(digest.assistantTexts, ['main reply']); + assert.deepEqual(digest.filesTouched, ['/repo/main.ts']); + const flat = JSON.stringify(digest); + assert.ok(!flat.includes('SIDECHAIN'), flat); + assert.ok(!flat.includes('sidechain-only.ts'), flat); + }); + + it('reads codex rollout digests and drops function calls', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [{ id: 'c1', cwd: '/repo' }]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + const digest = await store.readDigest(session); + assert.deepEqual(digest.userMessages, ['codex task']); + assert.deepEqual(digest.assistantTexts, ['codex reply']); + const flat = JSON.stringify(digest); + assert.ok(!flat.includes('rm -rf'), flat); + }); + + it('refuses a transcript path replaced by an out-of-root symlink', async () => { + const home = await tempHome(); + const path = await seedClaudeSession(home, { id: 'sym', cwd: '/repo' }); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + const [session] = await store.listSessions(); + assert.ok(session); + // Swap the transcript for a symlink pointing outside ~/.claude. + const secret = join(home, 'secret.txt'); + await writeFile(secret, 'not yours', 'utf8'); + const { rm } = await import('node:fs/promises'); + await rm(path); + await symlink(secret, path); + await assert.rejects(() => store.readDigest(session as ForeignSessionSummary), /escaped/); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d77a99b2e7350aee723d5181f2046475b7ccd2e6f278e258d1a9c874a5d75822.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d77a99b2e7350aee723d5181f2046475b7ccd2e6f278e258d1a9c874a5d75822.source new file mode 100644 index 0000000000..980750025a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d77a99b2e7350aee723d5181f2046475b7ccd2e6f278e258d1a9c874a5d75822.source @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/// + +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, unlink, type FileHandle } from 'node:fs/promises'; +import { tryLock, unlock } from 'fs-native-extensions'; + +export async function openStableNativeLockFile(path: string): Promise { + const handle = await open( + path, + fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + await assertStableRegularFile(handle, path); + if (process.platform !== 'win32') await handle.chmod(0o600); + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +export function tryAcquireNativeFileLock(handle: FileHandle): boolean { + return tryLock(handle.fd); +} + +export function releaseNativeFileLock(handle: FileHandle): void { + try { + unlock(handle.fd); + } catch { + // Closing the OS handle is the authoritative release path. + } +} + +export async function unlinkStableNativeLockFile(handle: FileHandle, path: string): Promise { + try { + await assertStableRegularFile(handle, path); + await unlink(path); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + } +} + +async function assertStableRegularFile(handle: FileHandle, path: string): Promise { + const [handleStat, pathStat] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(path, { bigint: true }), + ]); + if ( + !handleStat.isFile() || + !pathStat.isFile() || + handleStat.dev !== pathStat.dev || + handleStat.ino !== pathStat.ino + ) { + throw new Error(`Native lock is not one stable regular file: ${path}`); + } +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7840849fd8327bb9a305f7708f08929872d3fe31a789c8dadff52e276499ae0.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7840849fd8327bb9a305f7708f08929872d3fe31a789c8dadff52e276499ae0.source new file mode 100644 index 0000000000..1264656455 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7840849fd8327bb9a305f7708f08929872d3fe31a789c8dadff52e276499ae0.source @@ -0,0 +1,1627 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { chmod, copyFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import type { SessionHeader } from '@maka/core/session'; +import { + acquireOperationalStateDatabase, + OperationalStateMigrationBlockedError, +} from '../operational-state-store.js'; +import { SQLITE_ARTIFACT_SCHEMA_VERSION } from '../sqlite-artifact-schema.js'; +import { SQLITE_RUNTIME_SCHEMA_VERSION } from '../sqlite-runtime-schema.js'; +import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from '../sqlite-session-metadata-schema.js'; +import { SQLITE_USAGE_SCHEMA_VERSION } from '../sqlite-usage-schema.js'; +import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; + +test('shares one operational database and produces an online backup', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-state-')); + const backupPath = join(root, 'backup.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + const secondLease = acquireOperationalStateDatabase(root); + assert.equal(secondLease.database, lease.database); + secondLease.close(); + + const metadata = createSqliteSessionMetadataStore(join(root, 'runtime.sqlite'), { + databaseLease: lease, + }); + await metadata.create(sessionHeader()); + const backup = lease.backup(backupPath); + metadata.close(); + assert.ok((await backup) > 0); + + const reopened = new DatabaseSync(backupPath, { readOnly: true }); + try { + assert.equal( + ( + reopened.prepare('SELECT COUNT(*) AS count FROM session_metadata').get() as { + count: number; + } + ).count, + 1, + ); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('atomically reapplies current owner schema without republishing its registry', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-current-convergence-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + const registry = lease.database + .prepare( + "SELECT version, applied_at FROM operational_schema_migrations WHERE scope = 'usage'", + ) + .get(); + lease.close(); + + const damaged = new DatabaseSync(databasePath); + damaged.exec('DROP TABLE usage_llm_calls'); + damaged.close(); + + const reopened = acquireOperationalStateDatabase(root); + assert.ok( + reopened.database + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'usage_llm_calls'") + .get(), + ); + assert.deepEqual( + reopened.database + .prepare( + "SELECT version, applied_at FROM operational_schema_migrations WHERE scope = 'usage'", + ) + .get(), + registry, + ); + reopened.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a non-owner rejects an older schema without migrating it behind the Runtime Host', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-non-owner-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const older = new DatabaseSync(databasePath); + older.exec(` + ALTER TABLE core_agent_runs ADD COLUMN record_json TEXT; + UPDATE operational_schema_migrations + SET version = 6 + WHERE scope = 'core_execution'; + `); + older.close(); + + assert.throws( + () => + acquireOperationalStateDatabase(root, { + schemaMigration: 'require_current', + }), + (error: unknown) => + error instanceof OperationalStateMigrationBlockedError && + error.reason === 'requires_host_migration' && + /requires migration by its Runtime Host/u.test(error.message), + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + const columns = preserved.prepare('PRAGMA table_info(core_agent_runs)').all() as Array<{ + name: string; + }>; + assert.ok(columns.some(({ name }) => name === 'record_json')); + assert.equal( + ( + preserved + .prepare( + "SELECT version FROM operational_schema_migrations WHERE scope = 'core_execution'", + ) + .get() as { version: number } + ).version, + 6, + ); + } finally { + preserved.close(); + } + + const hostOwned = acquireOperationalStateDatabase(root); + try { + const columns = hostOwned.database + .prepare('PRAGMA table_info(core_agent_runs)') + .all() as Array<{ name: string }>; + assert.equal( + columns.some(({ name }) => name === 'record_json'), + false, + ); + } finally { + hostOwned.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves live supported v1 Artifacts when opening existing Sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-artifact-v1-retirement-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + const metadata = createSqliteSessionMetadataStore(databasePath, { databaseLease: lease }); + await metadata.create(sessionHeader()); + metadata.close(); + lease.close(); + + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + storage_key TEXT PRIMARY KEY, + artifact_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + status TEXT NOT NULL CHECK (status IN ('live', 'deleted')), + relative_path TEXT NOT NULL, + record_json TEXT NOT NULL + ); + CREATE INDEX artifact_records_session_order + ON artifact_records(session_id, created_at, storage_key); + CREATE UNIQUE INDEX artifact_records_relative_path + ON artifact_records(relative_path); + INSERT INTO artifact_records VALUES ( + 'legacy-key', + 'legacy-artifact', + 'session-1', + 1, + 'live', + 'session-1/legacy-artifact-result.txt', + '{"id":"legacy-artifact","sessionId":"session-1","turnId":"turn-1","createdAt":1,"name":"result.txt","kind":"file","sizeBytes":4,"relativePath":"session-1/legacy-artifact-result.txt","source":"tool_result_archive","status":"live"}' + ); + UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact'; + `); + legacy.close(); + + const reopened = acquireOperationalStateDatabase(root); + assert.equal( + ( + reopened.database + .prepare("SELECT COUNT(*) AS count FROM session_metadata WHERE session_id = 'session-1'") + .get() as { count: number } + ).count, + 1, + ); + assert.equal( + ( + reopened.database.prepare('SELECT COUNT(*) AS count FROM artifact_records').get() as { + count: number; + } + ).count, + 1, + ); + assert.equal( + ( + reopened.database + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'artifact'") + .get() as { version: number } + ).version, + SQLITE_ARTIFACT_SCHEMA_VERSION, + ); + reopened.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('retires completed released migration metadata during schema convergence', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-retirement-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const metadata = createSqliteSessionMetadataStore(databasePath, { + databaseLease: acquireOperationalStateDatabase(root), + }); + await metadata.create(sessionHeader()); + metadata.close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + createLegacyImportSourceTables(legacy); + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'session_metadata', + join(root, 'sessions.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify(releasedSessionMetadataValidation()), + ); + legacy + .prepare(` + INSERT INTO runtime_import_sources(source_path, fingerprint, imported_at) + VALUES (?, ?, ?) + `) + .run(join(root, 'runtime-events.jsonl'), 'sha256:released-events', 20); + legacy + .prepare(` + INSERT INTO session_metadata_import_sources( + source_path, + fingerprint, + session_id, + imported_at + ) VALUES (?, ?, ?, ?) + `) + .run(join(root, 'sessions.json'), 'sha256:released-session', 'session-1', 20); + legacy.close(); + + const migrated = acquireOperationalStateDatabase(root); + assert.equal( + migrated.database + .prepare(` + SELECT 1 + FROM sqlite_schema + WHERE type = 'table' + AND name IN ( + 'cutover_journal', + 'runtime_import_sources', + 'session_metadata_import_sources' + ) + LIMIT 1 + `) + .get(), + undefined, + ); + // Retirement must not touch legitimate data or half-apply schema convergence. + assert.equal( + ( + migrated.database + .prepare("SELECT COUNT(*) AS count FROM session_metadata WHERE session_id = 'session-1'") + .get() as { count: number } + ).count, + 1, + ); + assert.equal( + ( + migrated.database + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'runtime'") + .get() as { version?: number } | undefined + )?.version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + assert.equal( + ( + migrated.database + .prepare( + "SELECT version FROM operational_schema_migrations WHERE scope = 'session_metadata'", + ) + .get() as { version?: number } | undefined + )?.version, + SQLITE_SESSION_METADATA_SCHEMA_VERSION, + ); + migrated.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves an interrupted released cutover journal and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-interrupted-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at + ) VALUES (?, ?, ?, 'started', ?) + `) + .run('session_metadata', join(root, 'sessions.sqlite'), 'sha256:released-source', 10); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /cutover journal is incomplete or invalid/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.deepEqual( + { + ...(preserved.prepare('SELECT store_name, state FROM cutover_journal').get() as Record< + string, + unknown + >), + }, + { store_name: 'session_metadata', state: 'started' }, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a cutover journal with an unfamiliar column shape and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-shape-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + CREATE TABLE cutover_journal ( + store_name TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('started', 'completed')), + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + validation_json TEXT, + unexpected_column TEXT + ) + `); + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'session_metadata', + join(root, 'sessions.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify({ session_metadata: 4 }), + ); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unfamiliar released shape/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + ( + preserved.prepare('SELECT COUNT(*) AS count FROM cutover_journal').get() as { + count: number; + } + ).count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a cutover journal with an altered constraint and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-constraint-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + // Released columns/types verbatim, but a loosened CHECK bound. A column-only + // gate would accept and DROP this; the full-signature gate must not. + legacy.exec(` + CREATE TABLE cutover_journal ( + store_name TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('started', 'completed')), + started_at INTEGER NOT NULL CHECK (started_at >= -1), + completed_at INTEGER, + validation_json TEXT + ) + `); + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'session_metadata', + join(root, 'sessions.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify({ session_metadata: 4 }), + ); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /unfamiliar released shape/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + ( + preserved.prepare('SELECT COUNT(*) AS count FROM cutover_journal').get() as { + count: number; + } + ).count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a cutover journal carrying an extra trigger and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-trigger-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + // An extra schema object grafted onto the released table: retirement must + // refuse to DROP a table whose full object set it cannot recognize. + legacy.exec(` + CREATE TRIGGER cutover_journal_guard + AFTER INSERT ON cutover_journal + BEGIN + DELETE FROM cutover_journal WHERE store_name = NEW.store_name; + END; + `); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /carries an unexpected object/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.ok( + preserved + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'cutover_journal'") + .get(), + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a cutover journal naming an unknown store and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-unknown-store-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + // Released shape and internally well-formed, but names a store no released + // writer ever emitted — unrecognized evidence must fail closed, not drop. + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'future_store', + join(root, 'future.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify({ future_store: 1 }), + ); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /cutover journal is incomplete or invalid/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + (preserved.prepare('SELECT store_name FROM cutover_journal').get() as { store_name: string }) + .store_name, + 'future_store', + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a completed row carrying an unfamiliar validation key and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-extra-key-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + // A known store, released shape, well-formed counts — but one key beyond the + // set the released writer emitted. No released writer produced this contract, + // so the journal must be preserved rather than retired. + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'session_metadata', + join(root, 'sessions.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify({ ...releasedSessionMetadataValidation(), unexpected_evidence: 0 }), + ); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /invalid validation evidence/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + (preserved.prepare('SELECT store_name FROM cutover_journal').get() as { store_name: string }) + .store_name, + 'session_metadata', + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a completed row missing a released validation key and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-missing-key-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyCutoverJournal(legacy); + const incomplete = releasedSessionMetadataValidation(); + delete incomplete.sandbox_boundary_log; + legacy + .prepare(` + INSERT INTO cutover_journal( + store_name, + source_path, + source_fingerprint, + state, + started_at, + completed_at, + validation_json + ) VALUES (?, ?, ?, 'completed', ?, ?, ?) + `) + .run( + 'session_metadata', + join(root, 'sessions.sqlite'), + 'sha256:released-source', + 10, + 20, + JSON.stringify(incomplete), + ); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /invalid validation evidence/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + ( + preserved.prepare('SELECT COUNT(*) AS count FROM cutover_journal').get() as { + count: number; + } + ).count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a malformed released import source and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-import-malformed-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyImportSourceTables(legacy); + legacy + .prepare(` + INSERT INTO runtime_import_sources(source_path, fingerprint, imported_at) + VALUES (?, ?, ?) + `) + .run(join(root, 'runtime-events.jsonl'), '', 20); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /import source is incomplete or invalid/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + ( + preserved.prepare('SELECT COUNT(*) AS count FROM runtime_import_sources').get() as { + count: number; + } + ).count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('preserves a session import source with a missing session and fails closed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-import-missing-session-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const legacy = new DatabaseSync(databasePath); + createLegacyImportSourceTables(legacy); + // node:sqlite enforces foreign keys by default; disable them only to plant + // the orphaned import row this defensive branch must reject. + legacy.exec('PRAGMA foreign_keys = OFF'); + legacy + .prepare(` + INSERT INTO session_metadata_import_sources( + source_path, + fingerprint, + session_id, + imported_at + ) VALUES (?, ?, ?, ?) + `) + .run(join(root, 'sessions.json'), 'sha256:released-session', 'missing-session', 20); + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + /session import source is incomplete or invalid/u.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + ( + preserved + .prepare('SELECT COUNT(*) AS count FROM session_metadata_import_sources') + .get() as { count: number } + ).count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rolls back every scope when migration publication fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-rollback-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec('DELETE FROM automation_pending_fires; DELETE FROM automation_definitions'); + const versions = legacy + .prepare( + 'SELECT scope, version, applied_at FROM operational_schema_migrations ORDER BY scope', + ) + .all(); + const runtimeVersion = (legacy.prepare('PRAGMA user_version').get() as { user_version: number }) + .user_version; + const sessionMetadataVersion = legacy + .prepare("SELECT version FROM session_metadata_schema WHERE scope = 'session_metadata'") + .get()?.version; + const reminder = legacy + .prepare('SELECT record_json FROM workflow_plan_reminders') + .get()?.record_json; + legacy.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root, { now: () => -1 }), + /CHECK constraint failed/, + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.deepEqual( + preserved + .prepare( + 'SELECT scope, version, applied_at FROM operational_schema_migrations ORDER BY scope', + ) + .all(), + versions, + ); + assert.equal( + (preserved.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + runtimeVersion, + ); + assert.equal( + preserved + .prepare("SELECT version FROM session_metadata_schema WHERE scope = 'session_metadata'") + .get()?.version, + sessionMetadataVersion, + ); + assert.equal( + preserved.prepare('SELECT record_json FROM workflow_plan_reminders').get()?.record_json, + reminder, + ); + assert.equal( + preserved + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_scheduled_tasks'") + .get(), + undefined, + ); + assert.equal( + preserved + .prepare( + "SELECT 1 FROM sqlite_schema WHERE name IN ('session_message_payloads', 'session_message_chunks') LIMIT 1", + ) + .get(), + undefined, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('migrates released Reminder state after Automation is retired', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-v016-')); + try { + const databasePath = join(root, 'runtime.sqlite'); + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec('DELETE FROM automation_pending_fires; DELETE FROM automation_definitions'); + legacy.close(); + const lease = acquireOperationalStateDatabase(root); + const rows = lease.database + .prepare('SELECT task_id, record_json FROM workflow_scheduled_tasks ORDER BY task_id') + .all() as Array<{ task_id: string; record_json: string }>; + assert.deepEqual( + rows.map(({ task_id }) => task_id), + ['60999192-d3b2-45b6-affb-e76355d4cf85'], + ); + lease.close(); + const reopened = acquireOperationalStateDatabase(root); + const reminder = JSON.parse(rows[0]?.record_json ?? '') as Record; + assert.deepEqual(reminder, { + id: '60999192-d3b2-45b6-affb-e76355d4cf85', + title: 'Reminder v0.1.6', + intent: { kind: 'text', body: 'preserve reminder' }, + schedule: { kind: 'once', runAt: 10_000 }, + effect: { kind: 'notify', channel: 'local' }, + status: 'active', + nextFireAt: 10_000, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: 100, + updatedAt: 100, + runs: [], + lastError: null, + }); + assert.equal( + reopened.database.prepare('SELECT COUNT(*) AS count FROM session_metadata').get()?.count, + 1, + ); + assert.equal( + reopened.database.prepare('SELECT COUNT(*) AS count FROM session_messages').get()?.count, + 1, + ); + assert.equal( + reopened.database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'automation_definitions'", + ) + .get(), + undefined, + ); + assert.equal( + reopened.database + .prepare("SELECT 1 FROM operational_schema_migrations WHERE scope = 'automation'") + .get(), + undefined, + ); + reopened.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('finishes a released cleanup backfill interrupted after adding its column', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-interrupted-cleanup-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DELETE FROM automation_pending_fires; + DELETE FROM automation_definitions; + INSERT INTO workflow_quote_companion_cleanup(session_id, tracked_at) + VALUES ('session-interrupted', 42); + ALTER TABLE workflow_quote_companion_cleanup ADD COLUMN record_json TEXT; + `); + legacy.close(); + + const migrated = acquireOperationalStateDatabase(root); + const record = migrated.database + .prepare( + "SELECT record_json FROM workflow_quote_companion_cleanup WHERE session_id = 'session-interrupted'", + ) + .get() as { record_json: string }; + assert.equal(JSON.parse(record.record_json).sessionId, 'session-interrupted'); + migrated.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('leaves released Automation unchanged when its configuration cannot be preserved', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-v016-automation-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + assert.throws(() => acquireOperationalStateDatabase(root), /cannot be migrated without losing/); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + preserved.prepare('SELECT COUNT(*) AS count FROM automation_definitions').get()?.count, + 1, + ); + assert.equal( + preserved + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_scheduled_tasks'") + .get(), + undefined, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects legacy Automation tables without their registry authority', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-v016-missing-automation-scope-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec("DELETE FROM operational_schema_migrations WHERE scope = 'automation'"); + legacy.close(); + + assert.throws(() => acquireOperationalStateDatabase(root), /Automation schema registry/); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + preserved.prepare('SELECT COUNT(*) AS count FROM automation_definitions').get()?.count, + 1, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a released Workflow registry whose reminder table is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-v016-missing-reminders-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DROP TABLE workflow_plan_reminders; + UPDATE operational_schema_migrations SET version = 5 WHERE scope = 'workflow'; + `); + legacy.close(); + + assert.throws(() => acquireOperationalStateDatabase(root), /missing workflow_plan_reminders/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a released Reminder table after its Workflow authority removed it', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-stale-reminders-')); + try { + const lease = acquireOperationalStateDatabase(root); + lease.database.exec('CREATE TABLE workflow_plan_reminders (reminder_id TEXT PRIMARY KEY)'); + rewindRuntimeSchema(lease.database); + lease.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /still contains released Plan Reminder/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a current Runtime schema whose versioned authority table is missing', async () => { + await assertCurrentDatabaseRejected( + 'missing-runtime-authority', + (database) => database.exec('DROP TABLE runtime_session_event_ordinals'), + /missing required schema object table:runtime_session_event_ordinals/, + (database) => { + assert.equal( + database + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'runtime_session_event_ordinals'", + ) + .get(), + undefined, + ); + assert.equal( + (database.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + }, + ); +}); + +for (const { name, mutation } of [ + { + name: 'a current authority index with an incompatible predicate', + mutation: (database: DatabaseSync) => + database.exec(` + DROP INDEX artifact_records_relative_path; + CREATE UNIQUE INDEX artifact_records_relative_path + ON artifact_records(relative_path) + WHERE relative_path <> ''; + `), + }, + { + name: 'a current authority check with a changed string literal', + mutation: (database: DatabaseSync) => + database.exec(` + DROP TABLE artifact_records; + CREATE TABLE artifact_records ( + artifact_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + relative_path TEXT NOT NULL CHECK (relative_path <> ''), + record_json TEXT NOT NULL + ); + `), + }, + { + name: 'a current authority table with an extra rejecting constraint', + mutation: (database: DatabaseSync) => + database.exec(` + DROP TABLE usage_llm_calls; + CREATE TABLE usage_llm_calls ( + storage_key TEXT PRIMARY KEY, + id TEXT NOT NULL, + ts INTEGER NOT NULL CHECK (ts >= 0), + record_json TEXT NOT NULL, + CHECK (0) + ); + CREATE INDEX IF NOT EXISTS usage_llm_calls_ts ON usage_llm_calls(ts DESC, id); + `), + }, + { + name: 'a current authority table with an extra destructive trigger', + mutation: (database: DatabaseSync) => + database.exec(` + CREATE TRIGGER delete_usage_llm_call_after_insert + AFTER INSERT ON usage_llm_calls + BEGIN + DELETE FROM usage_llm_calls WHERE storage_key = NEW.storage_key; + END; + `), + }, + { + name: 'a current authority database with an unexpected view', + mutation: (database: DatabaseSync) => + database.exec('CREATE VIEW unexpected_operational_view AS SELECT * FROM usage_llm_calls'), + }, +] as const) { + test(`rejects ${name}`, async () => { + await assertCurrentDatabaseRejected( + name.replaceAll(' ', '-'), + mutation, + /Incomplete operational SQLite schema/, + ); + }); +} + +test('rejects a null operational scope without migrating', async () => { + await assertCurrentDatabaseRejected( + 'null-scope', + (database) => { + database.exec(` + INSERT INTO operational_schema_migrations(scope, version, applied_at) VALUES (NULL, 0, 0); + PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}; + `); + }, + /invalid scope/, + (database) => + assert.equal( + (database.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION - 1, + ), + ); +}); + +test('rejects a nonempty database with no operational registry', async () => { + await assertCurrentDatabaseRejected( + 'missing-registry', + (database) => + database.exec( + 'DROP TABLE operational_schema_migrations; DROP TABLE workflow_session_todo_documents', + ), + /registry is missing from a nonempty database/, + (database) => + assert.equal( + database + .prepare("SELECT 1 FROM sqlite_schema WHERE name = 'workflow_session_todo_documents'") + .get(), + undefined, + ), + ); +}); + +test('cleans the known removed Automation v2 scope', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-automation-v2-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DELETE FROM automation_pending_fires; + DELETE FROM automation_definitions; + ALTER TABLE automation_definitions DROP COLUMN durable; + UPDATE operational_schema_migrations SET version = 2 WHERE scope = 'automation'; + `); + legacy.close(); + + const lease = acquireOperationalStateDatabase(root); + assert.equal( + lease.database + .prepare("SELECT 1 FROM operational_schema_migrations WHERE scope = 'automation'") + .get(), + undefined, + ); + lease.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('leaves an oversized released scheduling catalog unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-oversized-catalog-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + WITH RECURSIVE sequence(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM sequence WHERE value < 256 + ) + INSERT INTO workflow_plan_reminders(reminder_id, created_at, updated_at, record_json) + SELECT + 'reminder-' || value, + created_at + value, + updated_at + value, + json_set( + record_json, + '$.id', 'reminder-' || value, + '$.createdAt', created_at + value, + '$.updatedAt', updated_at + value + ) + FROM workflow_plan_reminders, sequence + WHERE reminder_id = '60999192-d3b2-45b6-affb-e76355d4cf85'; + DELETE FROM automation_pending_fires; + DELETE FROM automation_definitions; + `); + legacy.close(); + + assert.throws(() => acquireOperationalStateDatabase(root), /exceeding the supported 256/); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + assert.equal( + preserved.prepare('SELECT COUNT(*) AS count FROM workflow_plan_reminders').get()?.count, + 257, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('does not classify a SQLite write failure as a migration blocker', { + skip: + process.platform === 'win32' + ? 'POSIX permissions are required to make the SQLite database read-only' + : false, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-readonly-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const legacy = new DatabaseSync(databasePath); + legacy.exec(` + DELETE FROM automation_pending_fires; + DELETE FROM automation_definitions; + `); + legacy.close(); + await chmod(databasePath, 0o444); + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code !== 'operational_state_migration_blocked' && + /readonly/i.test(error.message), + ); + } finally { + await chmod(databasePath, 0o644).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a contradictory legacy history fact before migration', async () => { + await assertReleasedReminderRejected( + 'contradictory-history', + /lastRun contradicts runs/, + (row) => { + row.runs = [{ id: 'run-newest', at: 200, status: 'triggered', message: 'newest' }]; + row.lastRun = { id: 'run-other', at: 100, status: 'blocked', message: 'other' }; + row.runCount = 1; + }, + ); +}); + +test('keeps a released Reminder with an unrepresentable block reason unchanged', async () => { + await assertReleasedReminderRejected( + 'block-reason', + /block reason cannot be preserved/, + (row) => { + const blockedRun = { + id: 'blocked-run', + at: 200, + status: 'blocked', + message: 'Incognito mode is active', + blockReason: 'incognito_active', + }; + row.runs = [blockedRun]; + row.lastRun = blockedRun; + row.runCount = 1; + }, + (row) => + assert.equal( + (row.runs as Array>)[0]?.blockReason, + 'incognito_active', + ), + ); +}); + +test('rejects a newer scope before migrating an older scope', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-mixed-version-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + lease.close(); + + const database = new DatabaseSync(databasePath); + rewindRuntimeSchema(database); + database + .prepare(`UPDATE operational_schema_migrations SET version = ? WHERE scope = 'usage'`) + .run(SQLITE_USAGE_SCHEMA_VERSION + 1); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /Operational schema usage is newer than supported/, + ); + assert.throws( + () => acquireOperationalStateDatabase(root, { schemaMigration: 'require_current' }), + (error: unknown) => + error instanceof OperationalStateMigrationBlockedError && + error.reason === 'blocked' && + /Operational schema usage is newer than supported/u.test(error.message), + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + assert.equal( + (preserved.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION - 1, + ); + } finally { + preserved.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a newer runtime schema without changing the database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-newer-runtime-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + lease.close(); + + const database = new DatabaseSync(databasePath); + database.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION + 1}`); + database.exec('CREATE TABLE runtime_future_sentinel (value TEXT NOT NULL)'); + database.exec("INSERT INTO runtime_future_sentinel(value) VALUES ('preserved')"); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /Operational schema runtime is newer than supported/, + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + assert.equal( + (preserved.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION + 1, + ); + assert.equal( + ( + preserved.prepare('SELECT value FROM runtime_future_sentinel').get() as { + value: string; + } + ).value, + 'preserved', + ); + } finally { + preserved.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects newer session metadata before migrating older runtime state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-newer-metadata-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + lease.close(); + + const database = new DatabaseSync(databasePath); + rewindRuntimeSchema(database); + database + .prepare(`UPDATE session_metadata_schema SET version = ? WHERE scope = 'session_metadata'`) + .run(SQLITE_SESSION_METADATA_SCHEMA_VERSION + 1); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /Operational schema session_metadata is newer than supported/, + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + assert.equal( + (preserved.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION - 1, + ); + } finally { + preserved.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects an unknown operational schema without changing the database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-unknown-scope-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + lease.close(); + + const database = new DatabaseSync(databasePath); + database + .prepare( + `INSERT INTO operational_schema_migrations(scope, version, applied_at) VALUES (?, ?, ?)`, + ) + .run('future_scope', 1, 1); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /Operational schema future_scope is unknown to this Maka build/, + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + const row = preserved + .prepare( + `SELECT scope, version, applied_at FROM operational_schema_migrations WHERE scope = ?`, + ) + .get('future_scope') as { scope: string; version: number; applied_at: number }; + assert.equal(row.scope, 'future_scope'); + assert.equal(row.version, 1); + assert.equal(row.applied_at, 1); + } finally { + preserved.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects an invalid registered schema version before migrating', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-invalid-version-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + const lease = acquireOperationalStateDatabase(root); + lease.close(); + + const database = new DatabaseSync(databasePath); + rewindRuntimeSchema(database); + database + .prepare(`UPDATE operational_schema_migrations SET version = ? WHERE scope = 'usage'`) + .run(1.5); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + /Operational schema usage has invalid version 1.5/, + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + assert.equal( + (preserved.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION - 1, + ); + } finally { + preserved.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function rewindRuntimeSchema(database: DatabaseSync): void { + database.exec('DROP TABLE runtime_session_event_ordinals'); + database.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); +} + +function createLegacyCutoverJournal(database: DatabaseSync): void { + database.exec(` + CREATE TABLE cutover_journal ( + store_name TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('started', 'completed')), + started_at INTEGER NOT NULL CHECK (started_at >= 0), + completed_at INTEGER, + validation_json TEXT + ) + `); +} + +// The exact validation-evidence key set the released session_metadata cutover +// writer emitted (commit 1caea265c^): one row count per copied session-metadata +// table. Kept as an explicit fixture so a drift from the source contract in +// operational-state-store.ts turns this suite red rather than silently +// accepting a narrower shape. +function releasedSessionMetadataValidation(): Record { + return { + session_metadata: 4, + session_metadata_labels: 0, + session_metadata_import_sources: 1, + session_metadata_tombstones: 0, + subagent_spawns: 0, + agent_graph_intent_claims: 0, + agent_graph_schedule_updates: 0, + agent_graph_operator_provisions: 0, + agent_graph_client_projections: 0, + agent_graph_client_operator_projections: 0, + agent_graph_client_terminal_activity: 0, + agent_graph_client_applied_records: 0, + agent_graph_supervisor_wakes: 0, + agent_graph_supervisor_wake_attempts: 0, + sandbox_boundary_log: 0, + }; +} + +function createLegacyImportSourceTables(database: DatabaseSync): void { + database.exec(` + CREATE TABLE runtime_import_sources ( + source_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + imported_at INTEGER NOT NULL + ); + + CREATE TABLE session_metadata_import_sources ( + source_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + session_id TEXT NOT NULL, + imported_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ) + `); +} + +async function copyV016Database(databasePath: string): Promise { + await copyFile( + new URL('../../test-fixtures/v0.1.6-operational-state/runtime.sqlite', import.meta.url), + databasePath, + ); +} + +async function assertReleasedReminderRejected( + name: string, + message: RegExp, + mutate: (row: Record) => void, + verify: (row: Record) => void = () => {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), `maka-operational-v016-${name}-`)); + const databasePath = join(root, 'runtime.sqlite'); + try { + await copyV016Database(databasePath); + const database = new DatabaseSync(databasePath); + const stored = database.prepare('SELECT record_json FROM workflow_plan_reminders').get() as { + record_json: string; + }; + const reminder = JSON.parse(stored.record_json) as Record; + mutate(reminder); + database + .prepare('UPDATE workflow_plan_reminders SET record_json = ?') + .run(JSON.stringify(reminder)); + database.exec('DELETE FROM automation_pending_fires; DELETE FROM automation_definitions'); + database.close(); + + assert.throws( + () => acquireOperationalStateDatabase(root), + (error: unknown) => + error instanceof Error && + (error as { code?: unknown }).code === 'operational_state_migration_blocked' && + message.test(error.message), + ); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + const preservedRow = preserved + .prepare('SELECT record_json FROM workflow_plan_reminders') + .get() as { record_json: string }; + verify(JSON.parse(preservedRow.record_json) as Record); + assert.equal( + preserved + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'workflow_scheduled_tasks'", + ) + .get(), + undefined, + ); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function assertCurrentDatabaseRejected( + name: string, + mutate: (database: DatabaseSync) => void, + message: RegExp, + verify: (database: DatabaseSync) => void = () => {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), `maka-operational-${name}-`)); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const database = new DatabaseSync(databasePath); + mutate(database); + database.close(); + + assert.throws(() => acquireOperationalStateDatabase(root), message); + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + verify(preserved); + preserved.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function sessionHeader(): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/workspace', + cwd: '/workspace', + createdAt: 1, + name: 'Session', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + hasUnread: false, + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + schemaVersion: 1, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7996dc5368d97a754564cf6eff3cf8c9f07fb57ecb44c4ae87fd88bc91befd5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7996dc5368d97a754564cf6eff3cf8c9f07fb57ecb44c4ae87fd88bc91befd5.source new file mode 100644 index 0000000000..0fc3c24245 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7996dc5368d97a754564cf6eff3cf8c9f07fb57ecb44c4ae87fd88bc91befd5.source @@ -0,0 +1,815 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Read-only scanner + digest reader over foreign agent session stores + * (#1057): Claude Code (~/.claude/projects) and Codex (~/.codex). + * + * Boundary rules, in order of importance: + * + * 1. READ-ONLY. This store never writes, renames, locks, or truncates + * anything. It deliberately does NOT take the root-authority + * capability — that contract exists for Maka's own workspace; foreign + * stores belong to other tools and must stay byte-identical. + * 2. SCOPED. All reads resolve under the configured home directory's + * known subtrees (`.claude/projects`, `.codex`). Paths obtained from + * foreign metadata (Codex `rollout_path`) are realpath-checked to + * still live inside the source root — a hostile row cannot point the + * reader at ~/.ssh. + * 3. BOUNDED. Byte caps from @maka/core/foreign-session apply to every + * read (head window for metadata, head+tail window for titles, hard + * cap for digests); scan results cap at 50 sessions / 30 days. + * 4. UNTRUSTED. All extracted text passes the core sanitize/redact gate; + * malformed lines and unreadable files are skipped, never fatal. + * + * Codex is read SQLite-first (node:sqlite, readOnly; column availability + * introspected via PRAGMA so version drift degrades gracefully) with a + * rollout-file directory walk as fallback. + */ + +import { existsSync } from 'node:fs'; +import { open, readdir, realpath, stat, type FileHandle } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; +import { + CODEX_SUPPORTED_THREAD_SOURCES, + FOREIGN_SESSION_DIGEST_MAX_READ_BYTES, + FOREIGN_SESSION_HEAD_BYTES, + FOREIGN_SESSION_PATH_MAX_CODE_POINTS, + FOREIGN_SESSION_SCAN_MAX_AGE_MS, + FOREIGN_SESSION_SCAN_MAX_SESSIONS, + FOREIGN_SESSION_TITLE_WINDOW_BYTES, + claudeAssistantText, + claudeToolFilePaths, + claudeUserAuthoredText, + codexRolloutMessage, + codexRolloutSessionMeta, + collectClaudeMeta, + collectClaudeTitle, + createDigestAccumulator, + finishDigest, + isSafeForeignId, + normalizeCodexThreadRow, + parseForeignJsonLine, + pickClaudeTitle, + pushDigestFile, + pushDigestMessage, + sanitizeForeignMessage, + sanitizeForeignText, + sanitizeForeignTitle, + type ClaudeTitleCandidates, + type ClaudeTranscriptMeta, + type CodexThreadRow, + type ForeignSessionDigest, + type ForeignSessionSource, + type ForeignSessionSummary, +} from '@maka/core/foreign-session'; +import { OpenCodeSessionAdapter } from './opencode-session-adapter.js'; + +export interface ForeignSessionScanOptions { + /** Only sessions whose recorded cwd equals this path (after realpath-free + * string normalization). Empty/undefined lists across all cwds. */ + cwd?: string; +} + +export interface ForeignSessionStore { + /** Which sources are enabled AND present on this machine. */ + availableSources(): Promise; + listSessions(options?: ForeignSessionScanOptions): Promise; + readDigest(summary: ForeignSessionSummary): Promise; +} + +export interface ForeignSessionStoreOptions { + /** Overridable for tests. Defaults to os.homedir(). */ + homeDir?: string; + /** Env for per-source enable flags. Defaults to process.env. */ + env?: Record; +} + +/** Default on; set to '0' to disable (cloak-flag convention). */ +export function isClaudeCodeImportEnabled( + env: Record = process.env, +): boolean { + return env.MAKA_IMPORT_CLAUDE_CODE !== '0'; +} + +export function isCodexImportEnabled( + env: Record = process.env, +): boolean { + return env.MAKA_IMPORT_CODEX !== '0'; +} + +export function isOpencodeImportEnabled( + env: Record = process.env, +): boolean { + return env.MAKA_IMPORT_OPENCODE !== '0'; +} + +export function createForeignSessionStore( + options: ForeignSessionStoreOptions = {}, +): ForeignSessionStore { + return new FileForeignSessionStore(options.homeDir ?? homedir(), options.env ?? process.env); +} + +class FileForeignSessionStore implements ForeignSessionStore { + constructor( + private readonly homeDir: string, + private readonly env: Record, + ) {} + + private get claudeRoot(): string { + return join(this.homeDir, '.claude', 'projects'); + } + + private get codexRoot(): string { + return join(this.homeDir, '.codex'); + } + + private get opencodeHome(): string { + return join(this.homeDir, '.local', 'share', 'opencode'); + } + + async availableSources(): Promise { + const sources: ForeignSessionSource[] = []; + if (isClaudeCodeImportEnabled(this.env) && (await isDirectory(this.claudeRoot))) { + sources.push('claude-code'); + } + if (isCodexImportEnabled(this.env) && (await isDirectory(this.codexRoot))) { + sources.push('codex'); + } + if (isOpencodeImportEnabled(this.env) && existsSync(join(this.opencodeHome, 'opencode.db'))) { + sources.push('opencode'); + } + return sources; + } + + async listSessions(options: ForeignSessionScanOptions = {}): Promise { + const sources = await this.availableSources(); + const now = Date.now(); + const results: ForeignSessionSummary[] = []; + if (sources.includes('claude-code')) { + results.push(...(await this.listClaudeSessions(options, now))); + } + if (sources.includes('codex')) { + results.push(...(await this.listCodexSessions(options, now))); + } + if (sources.includes('opencode')) { + results.push(...(await this.listOpencodeSessions(options, now))); + } + results.sort((a, b) => b.updatedAtMs - a.updatedAtMs); + // Sanitize + redact display metadata at the single return choke point. + // cwd matching upstream used the raw values, so it is safe to scrub the + // returned cwd/gitBranch here — a TUI consumer must never receive terminal + // control characters, bidi overrides, or secrets in these fields. (title + // and id are already gated at their source; transcriptPath stays raw as an + // internal lookup key confined to the source roots.) + return results.slice(0, FOREIGN_SESSION_SCAN_MAX_SESSIONS).map((s) => ({ + ...s, + cwd: sanitizeForeignMessage(s.cwd), + ...(s.gitBranch !== undefined ? { gitBranch: sanitizeForeignTitle(s.gitBranch) } : {}), + })); + } + + /* ------------------------------ Claude ------------------------------ */ + + private async listClaudeSessions( + options: ForeignSessionScanOptions, + now: number, + ): Promise { + const projectDirs = await listSubdirectories(this.claudeRoot); + const candidates: { path: string; mtimeMs: number }[] = []; + for (const dir of projectDirs) { + for (const entry of await listFilesWithSuffix(dir, '.jsonl')) { + candidates.push(entry); + } + } + // Newest transcripts first so the per-source cap keeps the useful ones + // and old files never get opened at all. + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const results: ForeignSessionSummary[] = []; + for (const candidate of candidates) { + if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break; + if (now - candidate.mtimeMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) break; + const summary = await this.scanClaudeTranscript( + candidate.path, + candidate.mtimeMs, + options.cwd, + ); + if (summary) results.push(summary); + } + return results; + } + + private async scanClaudeTranscript( + path: string, + mtimeMs: number, + cwdFilter: string | undefined, + ): Promise { + const id = basename(path, '.jsonl'); + if (!isSafeForeignId(id)) return undefined; + + // cwd and isSidechain both live in the first `user`/`assistant` record, + // but a continued session can open with a run of `summary`/`mode` lines + // or a huge first message, so a fixed 4KB head silently misses them and + // drops the session. Grow the head window (64KB → 4MB) until cwd is seen. + // isSidechain is a per-file property (every record in the file carries the + // same value), so first-defined wins — no need to scan the whole file. + const meta: ClaudeTranscriptMeta = {}; + for (const record of await readClaudeHeadRecords(path)) { + collectClaudeMeta(record, meta); + if (meta.cwd !== undefined && meta.isSidechain !== undefined) break; + } + if (meta.isSidechain === true) return undefined; + if (meta.cwd === undefined) return undefined; + if (cwdFilter !== undefined && normalizePath(meta.cwd) !== normalizePath(cwdFilter)) + return undefined; + + // Title fields use last-wins (freshest title in the tail beats an older + // one); firstUserMessage uses first-wins (opening request). Feed the head + // window first, then the tail, so both semantics fall out of iteration + // order (see collectClaudeTitle). + const titles: ClaudeTitleCandidates = {}; + const titleHead = await readWindow(path, 'head', FOREIGN_SESSION_TITLE_WINDOW_BYTES); + const titleTail = await readWindow(path, 'tail', FOREIGN_SESSION_TITLE_WINDOW_BYTES); + for (const window of [titleHead, titleTail]) { + if (window === undefined) continue; + for (const line of window.split('\n')) { + const record = parseForeignJsonLine(line); + if (record) { + collectClaudeTitle(record, titles); + collectClaudeMeta(record, meta); + } + } + } + return { + source: 'claude-code', + id, + title: pickClaudeTitle(titles) || id, + cwd: meta.cwd, + updatedAtMs: meta.timestampMs ?? mtimeMs, + gitBranch: meta.gitBranch, + transcriptPath: path, + }; + } + + /* ------------------------------ Codex ------------------------------- */ + + private async listOpencodeSessions( + options: ForeignSessionScanOptions, + now: number, + ): Promise { + // The adapter owns every opencode read (schema introspection, child/parent + // rules, transcript conversion); the scan layers the #1057 bounds on top. + const adapter = new OpenCodeSessionAdapter({ opencodeHome: this.opencodeHome }); + const externals = await adapter.listSessions( + options.cwd !== undefined ? { cwd: options.cwd } : undefined, + ); + const dbPath = join(this.opencodeHome, 'opencode.db'); + const results: ForeignSessionSummary[] = []; + for (const session of externals) { + if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break; + if (session.archived === true) continue; + if (!isSafeForeignId(session.id)) continue; + const updatedAtMs = session.updatedAt ?? 0; + if (now - updatedAtMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) continue; + results.push({ + source: 'opencode', + id: session.id, + title: sanitizeForeignTitle(session.name) || session.id, + cwd: session.cwd ?? '', + updatedAtMs, + transcriptPath: dbPath, + }); + } + return results; + } + + private async readOpencodeDigest(summary: ForeignSessionSummary): Promise { + if (!isSafeForeignId(summary.id)) { + throw new Error('opencode session id is not usable'); + } + const adapter = new OpenCodeSessionAdapter({ opencodeHome: this.opencodeHome }); + const session = await adapter.readSession(summary.id); + const acc = createDigestAccumulator(); + for (const message of session.messages) { + if (message.type === 'user') { + pushDigestMessage(acc, 'user', message.text); + } else if (message.type === 'assistant') { + // Thinking-only rows carry text: '' and never enter the digest — + // the #1057 contract excludes thinking blocks. + if (message.text.length > 0) pushDigestMessage(acc, 'assistant', message.text); + } else if (message.type === 'tool_call') { + const args = message.args as Record; + for (const key of ['file_path', 'path', 'notebook_path']) { + const value = args?.[key]; + if (typeof value === 'string' && value.length > 0) { + pushDigestFile(acc, sanitizeForeignText(value, FOREIGN_SESSION_PATH_MAX_CODE_POINTS)); + } + } + } + } + return finishDigest(acc, { + source: 'opencode', + id: summary.id, + title: summary.title, + cwd: summary.cwd, + updatedAtMs: summary.updatedAtMs, + }); + } + + private async listCodexSessions( + options: ForeignSessionScanOptions, + now: number, + ): Promise { + // Try state DBs newest-generation first. A DB that cannot be opened or + // lacks the threads schema (rows === undefined) is skipped so a freshly + // created generation missing the schema doesn't shadow an older usable + // one. The FIRST usable DB is authoritative — its result is returned even + // when empty. Descending past it on an empty result would resurface stale + // rows from an older generation (e.g. a session archived in the newest DB + // reappearing active in an older one), and would send every no-match-cwd + // listing down the expensive rollout walk. + for (const dbPath of await codexStateDbsNewestFirst(this.codexRoot)) { + const rows = await readCodexThreadRows(dbPath, options.cwd); + if (rows === undefined) continue; + return this.codexRowsToSummaries(rows, options, now); + } + // No usable state DB at all → fall back to the rollout directory walk. + return this.listCodexSessionsFromRollouts(options, now); + } + + private async codexRowsToSummaries( + rows: CodexThreadRow[], + options: ForeignSessionScanOptions, + now: number, + ): Promise { + const results: ForeignSessionSummary[] = []; + for (const row of rows) { + if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break; + const normalized = normalizeCodexThreadRow(row); + if (!normalized) continue; + if (now - normalized.updatedAtMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) continue; + if (options.cwd !== undefined && normalizePath(normalized.cwd) !== normalizePath(options.cwd)) + continue; + const transcriptPath = await this.resolveCodexRolloutPath( + normalized.rolloutPath, + normalized.id, + ); + if (transcriptPath === undefined) continue; + results.push({ + source: normalized.source, + id: normalized.id, + title: normalized.title, + cwd: normalized.cwd, + updatedAtMs: normalized.updatedAtMs, + gitBranch: normalized.gitBranch, + transcriptPath, + }); + } + return results; + } + + private async listCodexSessionsFromRollouts( + options: ForeignSessionScanOptions, + now: number, + ): Promise { + const sessionsRoot = join(this.codexRoot, 'sessions'); + const files = await walkRolloutFiles(sessionsRoot, now); + const results: ForeignSessionSummary[] = []; + for (const file of files) { + if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break; + const head = await readWindow(file.path, 'head', FOREIGN_SESSION_HEAD_BYTES); + if (head === undefined) continue; + let meta: ReturnType; + let firstUserText: string | undefined; + for (const line of head.split('\n')) { + const record = parseForeignJsonLine(line); + if (!record) continue; + meta ??= codexRolloutSessionMeta(record); + if (firstUserText === undefined) { + const message = codexRolloutMessage(record); + if (message?.role === 'user') firstUserText = message.text; + } + if (meta && firstUserText !== undefined) break; + } + if (!meta?.id || meta.cwd === undefined) continue; + if (!isSafeForeignId(meta.id)) continue; + // The transcript filename must belong to this session (defends against + // renamed / planted rollout files, as in the DB path). + if (!rolloutFilenameMatchesId(basename(file.path), meta.id)) continue; + if (options.cwd !== undefined && normalizePath(meta.cwd) !== normalizePath(options.cwd)) + continue; + results.push({ + source: 'codex', + id: meta.id, + // session_meta has no title; the first user message in the head + // window is the best available label (Grok Build does the same). + title: sanitizeForeignTitle(firstUserText) || meta.id, + cwd: meta.cwd, + updatedAtMs: meta.timestampMs ?? file.mtimeMs, + gitBranch: meta.gitBranch, + transcriptPath: file.path, + }); + } + return results; + } + + /** + * Realpath-confine a rollout path from the (untrusted) DB to ~/.codex, and + * require the transcript filename to belong to this thread — the id (a uuid) + * is the trailing component of `rollout--.jsonl`, so a + * mismatch means the row points at some other session's transcript (orphan + * row or a forged path) and is dropped. The timestamp format varies across + * Codex versions (ISO datetime or epoch), so match by the id suffix rather + * than parsing the timestamp. + */ + private async resolveCodexRolloutPath( + rolloutPath: string, + expectedId: string, + ): Promise { + try { + const real = await realpath(resolve(rolloutPath)); + const root = await realpath(this.codexRoot); + if (real !== root && !real.startsWith(root + sep)) return undefined; + if (!(await stat(real)).isFile()) return undefined; + if (!rolloutFilenameMatchesId(basename(real), expectedId)) return undefined; + return real; + } catch { + return undefined; + } + } + + /* ------------------------------ Digest ------------------------------ */ + + async readDigest(summary: ForeignSessionSummary): Promise { + // OpenCode is SQLite-backed, not a transcript file: the digest reads + // through the adapter by session id, so the file-confinement checks + // below do not apply. + if (summary.source === 'opencode') return this.readOpencodeDigest(summary); + // The transcript path was produced by our own scan, but re-confine it + // anyway: digests can be requested long after the scan, and the file + // may have been swapped for a symlink in between. + const root = summary.source === 'claude-code' ? this.claudeRoot : this.codexRoot; + const real = await realpath(resolve(summary.transcriptPath)); + const realRoot = await realpath(root); + if (real !== realRoot && !real.startsWith(realRoot + sep)) { + throw new Error('Foreign transcript escaped its source root'); + } + + const acc = createDigestAccumulator(); + // Open ONCE and read through the single fd: a stat-then-readFile pair has + // a TOCTOU window (the regular file could be swapped for a FIFO — which + // would block readFile forever — or grown past the cap between the two + // calls). fstat on the held fd, reject anything but a regular file, and + // never read more than the cap regardless of the size we observe. + let handle: Awaited> | undefined; + let text: string; + try { + handle = await open(real, 'r'); + const st = await handle.stat(); + if (!st.isFile()) throw new Error('Foreign transcript is not a regular file'); + if (st.size > FOREIGN_SESSION_DIGEST_MAX_READ_BYTES) { + text = await readHandleTailWindow(handle, st.size, FOREIGN_SESSION_DIGEST_MAX_READ_BYTES); + acc.warnings.push( + `transcript is ${st.size} bytes; only the trailing ${FOREIGN_SESSION_DIGEST_MAX_READ_BYTES} bytes were read`, + ); + } else { + const buffer = Buffer.alloc(st.size); + await handle.read(buffer, 0, st.size, 0); + text = buffer.toString('utf8'); + } + } finally { + await handle?.close(); + } + + let dropped = 0; + for (const line of text.split('\n')) { + if (line.trim().length === 0) continue; + const record = parseForeignJsonLine(line); + if (!record) { + dropped += 1; + continue; + } + if (summary.source === 'claude-code') { + // Sidechain records are a sub-agent's own conversation interleaved + // into the main transcript; they belong to neither role of the main + // session and must not enter its handoff (drop them for BOTH the user + // and assistant branches, not just the user one). + if (record.isSidechain === true) continue; + if (record.type === 'user') { + // claudeUserAuthoredText drops isMeta / isCompactSummary records so + // Claude's own injected context and generated compaction summaries + // never enter the handoff as user-authored text. + const text = claudeUserAuthoredText(record); + if (text !== undefined) pushDigestMessage(acc, 'user', text); + } else if (record.type === 'assistant') { + const text = claudeAssistantText(record); + if (text !== undefined) pushDigestMessage(acc, 'assistant', text); + for (const path of claudeToolFilePaths(record)) pushDigestFile(acc, path); + } + } else { + const message = codexRolloutMessage(record); + if (message) pushDigestMessage(acc, message.role, message.text); + } + } + if (dropped > 0) acc.warnings.push(`${dropped} malformed transcript lines were skipped`); + + return finishDigest(acc, { + source: summary.source, + id: summary.id, + title: summary.title, + cwd: summary.cwd, + gitBranch: summary.gitBranch, + updatedAtMs: summary.updatedAtMs, + }); + } +} + +/* ------------------------------ fs helpers ------------------------------ */ + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +async function listSubdirectories(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name)); + } catch { + return []; + } +} + +async function listFilesWithSuffix( + dir: string, + suffix: string, +): Promise<{ path: string; mtimeMs: number }[]> { + const out: { path: string; mtimeMs: number }[] = []; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(suffix)) continue; + const path = join(dir, entry.name); + try { + out.push({ path, mtimeMs: (await stat(path)).mtimeMs }); + } catch { + // Deleted mid-scan; skip. + } + } + } catch { + // Unreadable project dir; skip. + } + return out; +} + +/** Codex sessions/YYYY/MM/DD/rollout-*.jsonl walk, newest days first. */ +async function walkRolloutFiles( + root: string, + now: number, +): Promise<{ path: string; mtimeMs: number }[]> { + const out: { path: string; mtimeMs: number }[] = []; + const years = (await listSubdirectories(root)).sort().reverse(); + for (const year of years) { + const months = (await listSubdirectories(year)).sort().reverse(); + for (const month of months) { + const days = (await listSubdirectories(month)).sort().reverse(); + for (const day of days) { + for (const file of await listFilesWithSuffix(day, '.jsonl')) { + if (!basename(file.path).startsWith('rollout-')) continue; + if (now - file.mtimeMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) continue; + out.push(file); + } + // Enough candidates for the cap even after per-file drops. + if (out.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS * 2) { + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; + } + } + } + } + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; +} + +/** All ~/.codex/state_N.sqlite paths, newest generation first. */ +async function codexStateDbsNewestFirst(codexRoot: string): Promise { + try { + const entries = await readdir(codexRoot); + return entries + .filter((name) => /^state_\d+\.sqlite$/.test(name)) + .sort((a, b) => Number(b.match(/\d+/)?.[0] ?? 0) - Number(a.match(/\d+/)?.[0] ?? 0)) + .map((name) => join(codexRoot, name)); + } catch { + return []; + } +} + +/** + * Read candidate thread rows from one state DB, filtered and ordered in SQL. + * undefined = DB unusable (cannot open, or lacks the id/rollout_path columns) + * so the caller descends to an older generation. An empty array is a real + * "this DB has no matching threads". + */ +async function readCodexThreadRows( + dbPath: string, + cwdFilter?: string, +): Promise { + try { + const sqlite = await import('node:sqlite'); + const db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + const columns = new Set( + (db.prepare('PRAGMA table_info(threads)').all() as { name?: unknown }[]) + .map((c) => (typeof c.name === 'string' ? c.name : '')) + .filter((n) => n.length > 0), + ); + if (!columns.has('id') || !columns.has('rollout_path')) return undefined; + // Every identifier below is drawn from this fixed allowlist, never from + // the DB, so interpolation is injection-safe; values are bound params. + const wanted = [ + 'id', + 'rollout_path', + 'cwd', + 'title', + 'first_user_message', + 'updated_at_ms', + 'updated_at', + 'git_branch', + 'archived', + 'source', + ].filter((c) => columns.has(c)); + const where: string[] = []; + const params: string[] = []; + if (columns.has('archived')) where.push('(archived IS NULL OR archived = 0)'); + if (columns.has('source')) { + const sourceTokens = [...CODEX_SUPPORTED_THREAD_SOURCES]; + const placeholders = sourceTokens.map(() => '?').join(', '); + // Keep unsupported rows from consuming the bounded SQL window, but + // derive this coarse prefilter from the same token authority as + // normalizeCodexThreadRow(). The JS gate remains authoritative over + // exact shapes after bare, wrapped-custom, and legacy NULL sources + // have survived the query. + where.push(`( + source IS NULL + OR source IN (${placeholders}) + OR CASE WHEN json_valid(source) + THEN json_extract(source, '$.custom') + END IN (${placeholders}) + )`); + params.push(...sourceTokens, ...sourceTokens); + } + // Filter cwd IN SQL, before LIMIT: otherwise a multi-project store with + // many newer threads from other directories fills the LIMIT window and + // the target project's older thread never reaches the JS-side filter. + // This is a COARSE pre-filter across source-native and host-normalized + // separator forms. The authoritative two-sided normalizePath() + // comparison still runs in codexRowsToSummaries(). + if (cwdFilter !== undefined && columns.has('cwd')) { + const variants = codexCwdSqlVariants(cwdFilter); + where.push(`cwd IN (${variants.map(() => '?').join(', ')})`); + params.push(...variants); + } + const orderColumn = columns.has('updated_at_ms') + ? 'updated_at_ms' + : columns.has('updated_at') + ? 'updated_at' + : 'id'; + const sql = + `SELECT ${wanted.join(', ')} FROM threads` + + (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') + + ` ORDER BY ${orderColumn} DESC LIMIT ${FOREIGN_SESSION_SCAN_MAX_SESSIONS * 2}`; + return db.prepare(sql).all(...params) as CodexThreadRow[]; + } finally { + db.close(); + } + } catch { + return undefined; + } +} + +/** + * Parsed records from the head of a Claude transcript, growing the read + * window (64KB → 4MB) so a session that opens with a run of summary lines or + * a very large first message still yields its cwd record. Stops early once a + * record carrying `cwd` is seen. + */ +async function readClaudeHeadRecords(path: string): Promise[]> { + for (let bytes = 64 * 1024; ; bytes *= 4) { + const capped = Math.min(bytes, CLAUDE_HEAD_MAX_BYTES); + const window = await readWindow(path, 'head', capped); + if (window === undefined) return []; + const records: Record[] = []; + let sawCwd = false; + for (const line of window.split('\n')) { + const record = parseForeignJsonLine(line); + if (!record) continue; + records.push(record); + if (typeof record.cwd === 'string') sawCwd = true; + } + if (sawCwd || capped >= CLAUDE_HEAD_MAX_BYTES || capped >= (await fileSize(path))) + return records; + } +} + +const CLAUDE_HEAD_MAX_BYTES = 4 * 1024 * 1024; + +async function fileSize(path: string): Promise { + try { + return (await stat(path)).size; + } catch { + return 0; + } +} + +/** Read the trailing `bytes` of an open handle, dropping the partial first line. */ +async function readHandleTailWindow( + handle: FileHandle, + size: number, + bytes: number, +): Promise { + const length = Math.min(bytes, size); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, size - length); + const text = buffer.toString('utf8'); + if (length >= size) return text; // whole file — no partial first line + const nl = text.indexOf('\n'); + return nl === -1 ? '' : text.slice(nl + 1); +} + +/** + * Bounded read of a file's head or tail window; undefined on any error. A + * tail window drops its partial first line so a mid-line cut isn't parsed as + * a malformed record (and isn't reported as one). + */ +async function readWindow( + path: string, + where: 'head' | 'tail', + bytes: number, +): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, 'r'); + const size = (await handle.stat()).size; + if (where === 'tail') return await readHandleTailWindow(handle, size, bytes); + const length = Math.min(bytes, size); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, 0); + return buffer.toString('utf8'); + } catch { + return undefined; + } finally { + await handle?.close(); + } +} + +/** + * A Codex rollout file `rollout--.jsonl` belongs to thread + * `id` when the basename opens with `rollout-` and ends with `-.jsonl`. + * Timestamp-format-agnostic: the id (a uuid) is always the trailing segment. + */ +function rolloutFilenameMatchesId(base: string, id: string): boolean { + return base.startsWith('rollout-') && base.endsWith(`-${id}.jsonl`); +} + +function normalizePath(path: string): string { + const resolved = resolve(path); + return resolved.endsWith(sep) && resolved !== sep ? resolved.slice(0, -1) : resolved; +} + +export function codexCwdSqlVariants(path: string): string[] { + const variants = new Set(); + for (const candidate of [path, normalizePath(path)]) { + for (const separatorForm of [ + candidate, + candidate.replaceAll('\\', '/'), + candidate.replaceAll('/', '\\'), + ]) { + const withoutTrailingSeparator = separatorForm.replace(/[\\/]+$/, '') || separatorForm; + variants.add(withoutTrailingSeparator); + variants.add(`${withoutTrailingSeparator}/`); + variants.add(`${withoutTrailingSeparator}\\`); + } + } + return [...variants]; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7f7374d14840e482b1a7f4fb4e9185e8a738d931615d21a5a985a7d5f47b9f5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7f7374d14840e482b1a7f4fb4e9185e8a738d931615d21a5a985a7d5f47b9f5.source new file mode 100644 index 0000000000..3b194e8404 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/d7f7374d14840e482b1a7f4fb4e9185e8a738d931615d21a5a985a7d5f47b9f5.source @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, test } from 'node:test'; +import type { ShellRunRecord } from '@maka/core/shell-run'; +import { + authenticateInteractiveShellRunWriter, + openInteractiveShellRunStoreForWrite, + type InteractiveShellRunWriter, +} from '../shell-run-authority.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, + type StorageRootLease, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +describe('interactive ShellRun authority', () => { + test('single-flights one authentic writer and preserves durable lifecycle state', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const [first, second] = await Promise.all([ + openInteractiveShellRunStoreForWrite(owner.lease), + openInteractiveShellRunStoreForWrite(owner.lease), + ]); + assert.equal(first, second); + assert.equal(authenticateInteractiveShellRunWriter(first), first); + + const input = record(); + const createdPromise = first.createShellRun(input); + if (input.output.mode !== 'pipes') assert.fail('Expected pipe output fixture'); + input.output.stdout = 'mutated after acceptance'; + const created = await createdPromise; + assert.equal(created.output.mode === 'pipes' ? created.output.stdout : undefined, ''); + + const completed = await second.updateShellRun('session-1', 'shell-1', { + status: 'completed', + exitCode: 0, + completedAt: 2, + updatedAt: 2, + output: pipeOutput('done'), + }); + assert.equal(completed.status, 'completed'); + assert.equal(completed.revision, 2); + + first.close(); + assert.throws(() => authenticateInteractiveShellRunWriter(first), isInvalidLease); + const reopened = await openInteractiveShellRunStoreForWrite(owner.lease); + assert.notEqual(reopened, first); + assert.equal((await reopened.readShellRun('session-1', 'shell-1')).status, 'completed'); + reopened.close(); + } finally { + if (!owner.closed) await owner.close(); + } + }); + }); + + test('rejects reads and mutations after its owner lease is released', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveShellRunStoreForWrite(owner.lease); + await writer.createShellRun(record()); + await owner.close(); + + await assert.rejects(() => writer.readShellRun('session-1', 'shell-1'), isInvalidLease); + await assert.rejects( + () => writer.updateShellRun('session-1', 'shell-1', { updatedAt: 2 }), + isInvalidLease, + ); + writer.close(); + }); + }); + + test('rejects forged leases and forged writer facades', async () => { + await assert.rejects( + () => openInteractiveShellRunStoreForWrite({} as StorageRootLease<'interactive', 'write'>), + isInvalidLease, + ); + + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveShellRunStoreForWrite(owner.lease); + try { + assert.throws( + () => + authenticateInteractiveShellRunWriter({ + ...writer, + } as InteractiveShellRunWriter), + isInvalidLease, + ); + } finally { + writer.close(); + await owner.close(); + } + }); + }); +}); + +function record(): ShellRunRecord { + return { + shellRunId: 'shell-1', + sessionId: 'session-1', + sourceTurnId: 'turn-1', + sourceToolCallId: 'tool-1', + cwd: '/workspace', + command: 'printf done', + status: 'running', + startedAt: 1, + updatedAt: 1, + revision: 1, + output: pipeOutput(''), + }; +} + +function pipeOutput(stdout: string): Extract { + return { + mode: 'pipes', + stdout, + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }; +} + +async function withInteractiveRoot( + run: (input: { + capability: Awaited>>; + }) => Promise, +): Promise { + await withTempDir(async (base) => { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + await run({ capability }); + }); +} + +async function withTempDir(run: (base: string) => Promise): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-shell-run-authority-')); + try { + await run(base); + } finally { + await rm(base, { recursive: true, force: true }); + } +} + +function isInvalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/daa932cc51bd3390e17db280faf1b88966c6bebb72eba62964cc15632eebf6a6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/daa932cc51bd3390e17db280faf1b88966c6bebb72eba62964cc15632eebf6a6.source new file mode 100644 index 0000000000..4f78399a4c --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/daa932cc51bd3390e17db280faf1b88966c6bebb72eba62964cc15632eebf6a6.source @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { + computeManagedDependencyEnvironmentIdentity, + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, + type ManagedDependencyEnvironmentFailpoint, +} from '../../managed-dependency-environment.js'; + +const storageRoot = process.env.MAKA_DEPENDENCY_CRASH_ROOT; +const failpoint = process.env.MAKA_DEPENDENCY_CRASH_POINT as + | ManagedDependencyEnvironmentFailpoint + | 'during_environment_provision' + | undefined; +if (!storageRoot || !failpoint) throw new Error('Missing dependency crash fixture input'); + +const producerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); + +const source = { + manifestPath: 'package.json', + manifestBytes: Buffer.from('{"packageManager":"npm@11.12.1"}\n'), + lockfilePath: 'package-lock.json', + lockfileBytes: Buffer.from('{"lockfileVersion":3}\n'), + packageManagerName: 'npm' as const, + packageManagerVersion: '11.12.1', + nodeVersion: '24.7.0', + nodeAbi: '137', + platform: process.platform, + arch: process.arch, + producerRuntimeIdentitySha256: producerCapability.runtimeIdentitySha256, + producerPolicyIdentitySha256: producerCapability.policyIdentitySha256, + policyVersion: 'managed_dependency_environment_v1' as const, +}; +const authority = await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision(input) { + await mkdir(joinPath(input.outputRoot, 'fixture-package'), { + recursive: true, + }); + await writeFile(joinPath(input.outputRoot, 'fixture-package', 'index.js'), 'safe\n'); + if (failpoint === 'during_environment_provision') process.exit(73); + }, + }, + failpoint(point) { + if (point === failpoint) process.exit(73); + }, +}); +await authority.acquire(computeManagedDependencyEnvironmentIdentity(source), source); +throw new Error('Crash failpoint was not reached'); + +function joinPath(...parts: string[]): string { + return parts.join(process.platform === 'win32' ? '\\' : '/'); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/deb514a0e5cd5efb1dc0b97f48658cfd0e5ea67c4d18abf3546bfd439dbabafe.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/deb514a0e5cd5efb1dc0b97f48658cfd0e5ea67c4d18abf3546bfd439dbabafe.source new file mode 100644 index 0000000000..ead185c92b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/deb514a0e5cd5efb1dc0b97f48658cfd0e5ea67c4d18abf3546bfd439dbabafe.source @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const [stateRoot, workspaceRoot, destination, resultPath, limitsJson, identityHex] = + process.argv.slice(2); +if ( + stateRoot === undefined || + workspaceRoot === undefined || + destination === undefined || + resultPath === undefined || + limitsJson === undefined || + identityHex === undefined +) { + process.exit(2); +} + +const originalLink = fs.promises.link.bind(fs.promises); +let capturedPath: string | undefined; +fs.promises.link = async (existingPath, newPath) => { + await originalLink(existingPath, newPath); + const temporaryPath = existingPath.toString(); + capturedPath = `${temporaryPath}.captured`; + await fs.promises.rename(temporaryPath, capturedPath); +}; +syncBuiltinESMExports(); + +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); +try { + await createSessionBundleFileService().pack({ + snapshot: { + stateRoot, + workspaceRoot, + stateIdentity: { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from(identityHex, 'hex'), + }, + }, + envelope: { + sessionId: 'cloud-session-1', + lastCommittedActivationId: 'activation-9', + }, + destination, + limits: JSON.parse(limitsJson), + }); + process.exit(3); +} catch (error) { + await fs.promises.writeFile( + resultPath, + JSON.stringify({ + code: error instanceof SessionBundleFileError ? error.code : 'unexpected', + capturedPath, + }), + ); + process.exit(error instanceof SessionBundleFileError && error.code === 'io_failure' ? 0 : 4); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/df88da47ceff7b4fcb8336bbfb3062c3bdade41a721ae3398def25f3b62644f5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/df88da47ceff7b4fcb8336bbfb3062c3bdade41a721ae3398def25f3b62644f5.source new file mode 100644 index 0000000000..5558fd5602 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/df88da47ceff7b4fcb8336bbfb3062c3bdade41a721ae3398def25f3b62644f5.source @@ -0,0 +1,421 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION = 3; +const SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS = 5_000; +const SQLITE_INITIALIZATION_RETRY_DELAY_MS = 10; +const initializationRetryGate = new Int32Array(new SharedArrayBuffer(4)); + +const INITIAL_SCHEMA = ` + CREATE TABLE context_blobs ( + blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32), + storage_kind TEXT NOT NULL CHECK(storage_kind IN ('inline', 'managed_file')), + payload BLOB NOT NULL, + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + created_at INTEGER NOT NULL CHECK(created_at >= 0), + CHECK( + (storage_kind = 'inline' AND length(payload) = size_bytes) OR + (storage_kind = 'managed_file' AND length(payload) BETWEEN 1 AND 512) + ) + ); + + CREATE TABLE context_refs ( + ref_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + owner_kind TEXT NOT NULL CHECK( + owner_kind IN ('read_image_snapshot', 'tool_result_archive') + ), + owner_id TEXT NOT NULL, + blob_id BLOB NOT NULL REFERENCES context_blobs(blob_id) ON DELETE RESTRICT, + media_type TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK(created_at >= 0), + UNIQUE(session_id, owner_kind, owner_id) + ); + + CREATE INDEX context_refs_session + ON context_refs(session_id, created_at, ref_id); + + CREATE INDEX context_refs_blob + ON context_refs(blob_id); + + CREATE TABLE context_gc_candidates ( + blob_id BLOB PRIMARY KEY + REFERENCES context_blobs(blob_id) ON DELETE CASCADE, + unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0) + ); + + CREATE INDEX context_gc_candidates_eligible + ON context_gc_candidates(unreferenced_at, blob_id); + + CREATE TABLE context_file_deletions ( + locator BLOB PRIMARY KEY CHECK(length(locator) BETWEEN 1 AND 512), + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + enqueued_at INTEGER NOT NULL CHECK(enqueued_at >= 0) + ); + + CREATE INDEX context_file_deletions_pending + ON context_file_deletions(enqueued_at, locator); + + CREATE TABLE context_session_usage ( + session_id TEXT PRIMARY KEY, + reference_count INTEGER NOT NULL CHECK(reference_count >= 0), + logical_bytes INTEGER NOT NULL CHECK(logical_bytes >= 0) + ); + + CREATE TABLE context_store_usage ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + blob_count INTEGER NOT NULL CHECK(blob_count >= 0), + physical_bytes INTEGER NOT NULL CHECK(physical_bytes >= 0) + ); + + INSERT INTO context_store_usage(singleton, blob_count, physical_bytes) + VALUES (1, 0, 0); +`; + +const SCHEMA_V2_MIGRATION = ` + CREATE TABLE context_gc_candidates ( + blob_id BLOB PRIMARY KEY + REFERENCES context_blobs(blob_id) ON DELETE CASCADE, + unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0) + ); + + CREATE INDEX context_gc_candidates_eligible + ON context_gc_candidates(unreferenced_at, blob_id); + + INSERT INTO context_gc_candidates(blob_id, unreferenced_at) + SELECT b.blob_id, b.created_at + FROM context_blobs b + WHERE NOT EXISTS ( + SELECT 1 FROM context_refs r WHERE r.blob_id = b.blob_id + ); +`; + +const SCHEMA_V3_MIGRATION = ` + CREATE TABLE context_blobs_v3 ( + blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32), + storage_kind TEXT NOT NULL CHECK(storage_kind IN ('inline', 'managed_file')), + payload BLOB NOT NULL, + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + created_at INTEGER NOT NULL CHECK(created_at >= 0), + CHECK( + (storage_kind = 'inline' AND length(payload) = size_bytes) OR + (storage_kind = 'managed_file' AND length(payload) BETWEEN 1 AND 512) + ) + ); + + CREATE TABLE context_refs_v3 ( + ref_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + owner_kind TEXT NOT NULL CHECK( + owner_kind IN ('read_image_snapshot', 'tool_result_archive') + ), + owner_id TEXT NOT NULL, + blob_id BLOB NOT NULL REFERENCES context_blobs_v3(blob_id) ON DELETE RESTRICT, + media_type TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK(created_at >= 0), + UNIQUE(session_id, owner_kind, owner_id) + ); + + CREATE TABLE context_gc_candidates_v3 ( + blob_id BLOB PRIMARY KEY + REFERENCES context_blobs_v3(blob_id) ON DELETE CASCADE, + unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0) + ); + + INSERT INTO context_blobs_v3(blob_id, storage_kind, payload, size_bytes, created_at) + SELECT blob_id, 'inline', payload, size_bytes, created_at FROM context_blobs; + + INSERT INTO context_refs_v3( + ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at + ) + SELECT ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at + FROM context_refs; + + INSERT INTO context_gc_candidates_v3(blob_id, unreferenced_at) + SELECT blob_id, unreferenced_at FROM context_gc_candidates; + + DROP TABLE context_refs; + DROP TABLE context_gc_candidates; + DROP TABLE context_blobs; + + ALTER TABLE context_blobs_v3 RENAME TO context_blobs; + ALTER TABLE context_refs_v3 RENAME TO context_refs; + ALTER TABLE context_gc_candidates_v3 RENAME TO context_gc_candidates; + + CREATE INDEX context_refs_session + ON context_refs(session_id, created_at, ref_id); + CREATE INDEX context_refs_blob + ON context_refs(blob_id); + CREATE INDEX context_gc_candidates_eligible + ON context_gc_candidates(unreferenced_at, blob_id); + + CREATE TABLE context_file_deletions ( + locator BLOB PRIMARY KEY CHECK(length(locator) BETWEEN 1 AND 512), + size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0), + enqueued_at INTEGER NOT NULL CHECK(enqueued_at >= 0) + ); + + CREATE INDEX context_file_deletions_pending + ON context_file_deletions(enqueued_at, locator); +`; + +const REQUIRED_SCHEMA_OBJECTS = Object.freeze([ + ['table', 'context_blobs'], + ['table', 'context_refs'], + ['table', 'context_session_usage'], + ['table', 'context_store_usage'], + ['table', 'context_gc_candidates'], + ['table', 'context_file_deletions'], + ['index', 'context_refs_session'], + ['index', 'context_refs_blob'], + ['index', 'context_gc_candidates_eligible'], + ['index', 'context_file_deletions_pending'], +] as const); + +const REQUIRED_TABLE_COLUMNS = Object.freeze({ + context_blobs: ['blob_id', 'storage_kind', 'payload', 'size_bytes', 'created_at'], + context_refs: [ + 'ref_id', + 'session_id', + 'owner_kind', + 'owner_id', + 'blob_id', + 'media_type', + 'created_at', + ], + context_session_usage: ['session_id', 'reference_count', 'logical_bytes'], + context_store_usage: ['singleton', 'blob_count', 'physical_bytes'], + context_gc_candidates: ['blob_id', 'unreferenced_at'], + context_file_deletions: ['locator', 'size_bytes', 'enqueued_at'], +} as const); + +const REQUIRED_INDEX_COLUMNS = Object.freeze({ + context_refs_session: ['session_id', 'created_at', 'ref_id'], + context_refs_blob: ['blob_id'], + context_gc_candidates_eligible: ['unreferenced_at', 'blob_id'], + context_file_deletions_pending: ['enqueued_at', 'locator'], +} as const); + +export function configureSqliteContextOffloadDatabase(db: DatabaseSync): void { + db.exec(`PRAGMA busy_timeout = ${SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS}`); + // WAL initialization fixes the database header in a form where changing + // auto_vacuum from NONE is no longer accepted. Configure it first, while a + // brand-new dedicated database still has no application schema objects. + if (readSqliteContextOffloadSchemaVersion(db) === 0 && !hasApplicationSchemaObjects(db)) { + db.exec('PRAGMA auto_vacuum = INCREMENTAL'); + } + ensureWalJournalMode(db); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); +} + +export function migrateSqliteContextOffloadDatabase(db: DatabaseSync): void { + const observedVersion = readSqliteContextOffloadSchemaVersion(db); + if (observedVersion > SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) { + throw newerSchemaError(observedVersion); + } + if (observedVersion === SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) { + validateSchema(db); + return; + } + + db.exec('BEGIN IMMEDIATE'); + try { + let current = readSqliteContextOffloadSchemaVersion(db); + if (current > SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) throw newerSchemaError(current); + if (current === 0) { + if (hasApplicationSchemaObjects(db)) { + throw new Error('Unversioned context-offload SQLite schema is not supported'); + } + db.exec(INITIAL_SCHEMA); + db.exec(`PRAGMA user_version = ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`); + current = SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION; + } + if (current === 1) { + db.exec(SCHEMA_V2_MIGRATION); + db.exec('PRAGMA user_version = 2'); + current = 2; + } + if (current === 2) { + db.exec(SCHEMA_V3_MIGRATION); + db.exec(`PRAGMA user_version = ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`); + current = SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION; + } + validateSchema(db); + db.exec('COMMIT'); + } catch (error) { + rollback(db); + throw error; + } +} + +export function readSqliteContextOffloadSchemaVersion(db: DatabaseSync): number { + const row = retryWhileSqliteBusy( + () => db.prepare('PRAGMA user_version').get() as { user_version?: unknown } | undefined, + ); + const value = row?.user_version; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid context-offload SQLite schema version'); + } + return value; +} + +function validateSchema(db: DatabaseSync): void { + const autoVacuum = db.prepare('PRAGMA auto_vacuum').get() as + | { auto_vacuum?: unknown } + | undefined; + if (autoVacuum?.auto_vacuum !== 2) { + throw new Error('Incomplete context-offload SQLite schema: incremental auto-vacuum required'); + } + const readObject = db.prepare('SELECT type FROM sqlite_schema WHERE name = ?'); + for (const [type, name] of REQUIRED_SCHEMA_OBJECTS) { + const row = readObject.get(name) as { type?: unknown } | undefined; + if (row?.type !== type) { + throw new Error(`Incomplete context-offload SQLite schema: missing ${type} ${name}`); + } + } + for (const [table, requiredColumns] of Object.entries(REQUIRED_TABLE_COLUMNS)) { + const columns = new Set( + (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>).flatMap( + (row) => (typeof row.name === 'string' ? [row.name] : []), + ), + ); + for (const column of requiredColumns) { + if (!columns.has(column)) { + throw new Error( + `Incomplete context-offload SQLite schema: table ${table} is missing column ${column}`, + ); + } + } + } + for (const [index, requiredColumns] of Object.entries(REQUIRED_INDEX_COLUMNS)) { + const columns = ( + db.prepare(`PRAGMA index_info(${index})`).all() as Array<{ + seqno?: unknown; + name?: unknown; + }> + ) + .filter( + (row): row is { seqno: number; name: string } => + typeof row.seqno === 'number' && typeof row.name === 'string', + ) + .sort((left, right) => left.seqno - right.seqno) + .map((row) => row.name); + if (requiredColumns.some((column, position) => columns[position] !== column)) { + throw new Error( + `Incomplete context-offload SQLite schema: index ${index} has incompatible columns`, + ); + } + } + const usage = db + .prepare('SELECT blob_count, physical_bytes FROM context_store_usage WHERE singleton = 1') + .get() as { blob_count?: unknown; physical_bytes?: unknown } | undefined; + if (!isNonNegativeInteger(usage?.blob_count) || !isNonNegativeInteger(usage.physical_bytes)) { + throw new Error('Incomplete context-offload SQLite schema: missing store usage row'); + } +} + +function hasApplicationSchemaObjects(db: DatabaseSync): boolean { + const row = db + .prepare( + `SELECT 1 AS present FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' LIMIT 1`, + ) + .get() as { present?: unknown } | undefined; + return row?.present === 1; +} + +function ensureWalJournalMode(db: DatabaseSync): void { + const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS; + while (true) { + const journalMode = readJournalMode(db); + if (journalMode === 'wal' || journalMode === 'memory') return; + try { + db.exec('PRAGMA journal_mode = WAL'); + const configuredMode = readJournalMode(db); + if (configuredMode !== 'wal') { + throw new Error( + `Context-offload SQLite requires WAL journal mode, received ${configuredMode}`, + ); + } + return; + } catch (error) { + if (!isSqliteBusy(error) || Date.now() >= deadline) throw error; + Atomics.wait( + initializationRetryGate, + 0, + 0, + Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())), + ); + } + } +} + +function readJournalMode(db: DatabaseSync): string { + const row = retryWhileSqliteBusy( + () => db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined, + ); + if (typeof row?.journal_mode !== 'string') { + throw new Error('Invalid context-offload SQLite journal mode'); + } + return row.journal_mode.toLowerCase(); +} + +function retryWhileSqliteBusy(operation: () => T): T { + const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS; + while (true) { + try { + return operation(); + } catch (error) { + if (!isSqliteBusy(error) || Date.now() >= deadline) throw error; + Atomics.wait( + initializationRetryGate, + 0, + 0, + Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())), + ); + } + } +} + +function isSqliteBusy(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = 'code' in error ? String(error.code) : ''; + return code === 'SQLITE_BUSY' || /database (?:is )?(?:locked|busy)/i.test(error.message); +} + +function newerSchemaError(version: number): Error { + return new Error( + `Context-offload SQLite schema ${version} is newer than supported version ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`, + ); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the migration failure that triggered rollback. + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e14bda1726b70f3dfff599d0e584356e8c5bf9e4ae7236ab82d42137fcc13732.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e14bda1726b70f3dfff599d0e584356e8c5bf9e4ae7236ab82d42137fcc13732.source new file mode 100644 index 0000000000..4e3ca56bb5 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e14bda1726b70f3dfff599d0e584356e8c5bf9e4ae7236ab82d42137fcc13732.source @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, test } from 'node:test'; +import { createGitWorktreeChildExecutor } from '../git-worktree-child-executor.js'; +import { createGitRepositoryWithWorktree } from './fixtures/git-repository.js'; + +const execFileAsync = promisify(execFile); +const cleanup: string[] = []; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('Git worktree child executor', () => { + test('reports availability only when the source workspace is a Git project', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + const folder = join(root, 'folder'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + await writeFileAfterMkdir(folder, 'file.txt', 'plain\n'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + + assert.equal(await executor.isAvailable({ sourceCwd: repository }), true); + assert.equal(await executor.isAvailable({ sourceCwd: folder }), false); + assert.equal(await executor.isAvailable({ sourceCwd: join(root, 'missing') }), false); + }); + + test('provisions deterministic isolated worktrees for concurrent child leases', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + const [left, right] = await Promise.all([ + executor.provision({ + leaseId: `subagent_worktree_${'1'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + sourceProjectId: 'project-1', + }), + executor.provision({ + leaseId: `subagent_worktree_${'2'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + sourceProjectId: 'project-1', + }), + ]); + + assert.notEqual(left.worktreePath, right.worktreePath); + assert.notEqual(left.branch, right.branch); + assert.equal((await stat(left.worktreePath)).isDirectory(), true); + assert.equal((await stat(right.worktreePath)).isDirectory(), true); + assert.equal(await git(left.worktreePath, 'branch', '--show-current'), left.branch); + assert.equal(await git(right.worktreePath, 'branch', '--show-current'), right.branch); + assert.equal(left.baseCommit, await git(repository, 'rev-parse', 'HEAD')); + assert.equal(right.baseCommit, left.baseCommit); + + await writeFile(join(left.worktreePath, 'left.txt'), 'left\n', 'utf8'); + await writeFile(join(right.worktreePath, 'right.txt'), 'right\n', 'utf8'); + assert.match(await git(left.worktreePath, 'status', '--short'), /left\.txt/); + assert.match(await git(right.worktreePath, 'status', '--short'), /right\.txt/); + assert.equal(await git(repository, 'status', '--short'), ''); + }); + + test('reuses the durable branch/path binding across executor restart and child edits', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const storageRoot = join(root, 'storage'); + const request = { + leaseId: `subagent_worktree_${'3'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }; + const first = await createGitWorktreeChildExecutor({ storageRoot }).provision(request); + await writeFile(join(first.worktreePath, 'work.txt'), 'work\n', 'utf8'); + await git(first.worktreePath, 'switch', '-c', 'maka/issue-3-a-contract'); + + const restarted = createGitWorktreeChildExecutor({ storageRoot }); + const second = await restarted.provision(request); + assert.deepEqual(second, first); + await restarted.ensure(first); + assert.equal( + await git(first.worktreePath, 'branch', '--show-current'), + 'maka/issue-3-a-contract', + ); + assert.match(await git(first.worktreePath, 'status', '--short'), /work\.txt/); + + await git( + first.worktreePath, + 'config', + '--local', + `branch.${first.branch}.maka-worktree-lease`, + `subagent_worktree_${'f'.repeat(32)}`, + ); + await assert.rejects(restarted.ensure(first), /worktree lease changed/); + }); + + test('captures committed, tracked, and untracked changes as one base-relative patch', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + const binding = await executor.provision({ + leaseId: `subagent_worktree_${'6'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }); + + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'committed change\n', 'utf8'); + await writeFile(join(binding.worktreePath, '.gitignore'), 'forced.txt\n', 'utf8'); + await writeFile(join(binding.worktreePath, 'forced.txt'), 'forced committed change\n', 'utf8'); + await git(binding.worktreePath, 'add', 'tracked.txt', '.gitignore'); + await git(binding.worktreePath, 'add', '--force', 'forced.txt'); + await git( + binding.worktreePath, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '-m', + 'child commit', + ); + await writeFile(join(binding.worktreePath, 'pending.txt'), 'pending change\n', 'utf8'); + + const patch = Buffer.from(await executor.capturePatch(binding)).toString('utf8'); + assert.match(patch, /diff --git a\/tracked\.txt b\/tracked\.txt/); + assert.match(patch, /\+committed change/); + assert.match(patch, /diff --git a\/forced\.txt b\/forced\.txt/); + assert.match(patch, /\+forced committed change/); + assert.match(patch, /diff --git a\/pending\.txt b\/pending\.txt/); + assert.match(patch, /\+pending change/); + }); + + test('captures complete patches larger than the former 10 MiB process buffer', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + const binding = await executor.provision({ + leaseId: `subagent_worktree_${'b'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }); + const formerCeiling = 10 * 1024 * 1024; + await writeFile( + join(binding.worktreePath, 'large-generated.txt'), + 'x'.repeat(formerCeiling + 1024), + 'utf8', + ); + + const patch = await executor.capturePatch(binding); + + assert.ok(patch.byteLength > formerCeiling); + assert.match(Buffer.from(patch.subarray(0, 512)).toString('utf8'), /large-generated\.txt/); + }); + + test('retires only the Host lease branch after a child switches branches', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + const binding = await executor.provision({ + leaseId: `subagent_worktree_${'7'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }); + await git(binding.worktreePath, 'switch', '-c', 'child-owned-branch'); + await writeFile(join(binding.worktreePath, 'ignored.tmp'), 'discard me\n', 'utf8'); + + await executor.retire(binding); + await executor.retire(binding); + + await assert.rejects(stat(binding.worktreePath), { code: 'ENOENT' }); + assert.equal(await git(repository, 'branch', '--list', binding.branch), ''); + assert.equal( + await git(repository, 'branch', '--list', 'child-owned-branch'), + 'child-owned-branch', + ); + }); + + test('recovery preserves live bindings and retires orphaned worktrees', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + const storageRoot = join(root, 'storage'); + const executor = createGitWorktreeChildExecutor({ storageRoot }); + const live = await executor.provision({ + leaseId: `subagent_worktree_${'8'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }); + const orphan = await executor.provision({ + leaseId: `subagent_worktree_${'9'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }); + const partial = join(storageRoot, 'subagent-worktrees', 'a'.repeat(32)); + await mkdir(partial); + + await createGitWorktreeChildExecutor({ storageRoot }).recover([live]); + + assert.equal((await stat(live.worktreePath)).isDirectory(), true); + await assert.rejects(stat(orphan.worktreePath), { code: 'ENOENT' }); + await assert.rejects(stat(partial), { code: 'ENOENT' }); + assert.equal(await git(repository, 'branch', '--list', orphan.branch), ''); + }); + + test('fails closed when a fresh lease would omit uncommitted parent work', async () => { + const root = await temporaryRoot(); + const repository = join(root, 'repository'); + await createGitRepositoryWithWorktree(repository, join(root, 'existing-worktree'), 'existing'); + await writeFile(join(repository, 'dirty.txt'), 'dirty\n', 'utf8'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + + await assert.rejects( + executor.provision({ + leaseId: `subagent_worktree_${'4'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: repository, + }), + /no uncommitted changes/, + ); + }); + + test('rejects non-Git project roots', async () => { + const root = await temporaryRoot(); + const source = join(root, 'folder'); + await writeFileAfterMkdir(source, 'file.txt', 'plain\n'); + const executor = createGitWorktreeChildExecutor({ storageRoot: join(root, 'storage') }); + + await assert.rejects( + executor.provision({ + leaseId: `subagent_worktree_${'5'.repeat(32)}`, + sourceSessionId: 'parent-session', + sourceCwd: source, + }), + /requires a Git project/, + ); + }); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-worktree-child-')); + cleanup.push(root); + return root; +} + +async function writeFileAfterMkdir(root: string, name: string, contents: string): Promise { + await mkdir(root, { recursive: true }); + await writeFile(join(root, name), contents, 'utf8'); +} + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf8' }); + return stdout.trim(); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e152cc29e43dcab0cade60f82d040f00ddd840429a628d678b974093add045d6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e152cc29e43dcab0cade60f82d040f00ddd840429a628d678b974093add045d6.source new file mode 100644 index 0000000000..e3721279e4 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e152cc29e43dcab0cade60f82d040f00ddd840429a628d678b974093add045d6.source @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, +} from '@maka/core/session'; + +export interface SqliteSessionRolePredicate { + readonly sql: string; + readonly parameters: readonly string[]; +} + +/** + * The reserved Coordination identity remains non-ordinary even when its role + * metadata is corrupt or missing. This keeps damaged authority state out of + * ordinary catalogs and write paths until the Host can be repaired. + */ +export function sqliteOrdinarySessionRolePredicate(): SqliteSessionRolePredicate { + return { + sql: `( + metadata.session_id <> ? + AND json_type(metadata.payload_json, '$.role') IS NULL + )`, + parameters: [WORKHUB_COORDINATION_SESSION_ID], + }; +} + +/** Returns only rows that Runtime recovery may safely rebuild. */ +export function sqliteRecoverableSessionRolePredicate(): SqliteSessionRolePredicate { + const ordinary = sqliteOrdinarySessionRolePredicate(); + return { + sql: `( + ${ordinary.sql} + OR ( + metadata.session_id = ? + AND json_type(metadata.payload_json, '$.role') = 'text' + AND json_extract(metadata.payload_json, '$.role') = ? + ) + )`, + parameters: [ + ...ordinary.parameters, + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + ], + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e166b72f65fc9beed44105e9921f0717e60fba2368b9223fd5bf5208ad73cd1c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e166b72f65fc9beed44105e9921f0717e60fba2368b9223fd5bf5208ad73cd1c.source new file mode 100644 index 0000000000..eee51d310e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e166b72f65fc9beed44105e9921f0717e60fba2368b9223fd5bf5208ad73cd1c.source @@ -0,0 +1,609 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + decodeModelCallAttempt, + MODEL_CALL_ATTEMPT_EVENT_TYPE, + type ModelCallAttempt, + type ModelCallCoverage, +} from '@maka/core/model-call-attempt'; +import { + resolveUsageRange, + type ModelCallUsageBuckets, + type ModelCallUsageLogs, + type ModelCallUsageSummary, +} from '@maka/core/model-call-usage-projection'; +import { usageBucketKey } from '@maka/core/usage-stats/bucket-key'; +import type { + UsageBucket, + UsageGroupBy, + UsageLogRow, + UsageQuery, +} from '@maka/core/usage-stats/types'; +import type { DatabaseSync } from 'node:sqlite'; +import { + bucketGrouping, + CACHE_READ_TOKENS, + count, + countableFilter, + COVERAGE_SUMS, + PRICED_COST, + REQUEST_SUMS, + TOKEN_SUMS, + unreadableFilter, + type SqlFilter, +} from './model-call-usage-sql.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { MODEL_CALL_COLUMNS } from './sqlite-usage-schema.js'; +import type { ModelCallLedgerResult } from './usage-stores.js'; + +/** + * Materialization of the canonical model-call accounting ledger (#1679). + * + * The single durable authority is the AgentRun event stream: an attempt is + * committed there as `model_call_attempt_recorded` before anything reaches this + * table. Everything here is a projection of that stream, never an independent + * write, and the upsert key is `attemptId` so re-projecting is idempotent. + * + * The table exists because the AgentRun store answers "what happened in this + * run" and no Usage question is shaped that way. It may fall behind the stream + * — a failed upsert, a crash between the two — and that is recoverable: the + * authority still holds every record, so re-projecting the run restores it. + * + * That recovery covers live Sessions only. Deleting a Session drops its + * `core_agent_runs` rows and cascades both their events and this projection's + * checkpoints, while these rows are deliberately left standing — spend does not + * disappear from all-time totals because a conversation was deleted. For those + * rows the projection is the last copy, so nothing may rebuild this table by + * clearing it and replaying the stream. See + * `ConversationOperationalStateStore.purge`. + * + * A row holds one column per field a cost answer reads, and nothing else. + * Request shape and provider diagnostics are answered from the AgentRun stream; + * copied here they would make a row grow with the conversation rather than with + * spend. Because they are columns, a Usage total is a `SUM` this table computes + * — the reads below return answers, not records, so asking for an all-time + * total no longer means handing every call a workspace ever made to the caller. + * + * Recovery compares the AgentRun stream's durable sequence with this + * projection's applied-through checkpoint. There is no second "dirty" fact to + * race with the authority: any committed event beyond the checkpoint remains + * discoverable until it has been projected in the same transaction that + * advances the checkpoint. + * + * Deliberately separate from `usage_llm_calls`. That table is a frozen + * historical projection with no way to express `usageBasis` or `costBasis`, so + * writing canonical records into it would land unpriced spend as `costUsd: 0` — + * the failure this ledger exists to remove. + */ +export interface ModelCallLedgerReader { + /** + * Usage answers over the rows a query addresses, alongside the number of rows + * in that window whose pricing was lost before this table held columns. + * + * Those are real calls whose cost is now unknown: they are reported rather + * than dropped, because a total that silently omits them overstates what the + * ledger knows. One of them cannot fail the query (#1638). + */ + summary(query: UsageQuery, now: number): ModelCallLedgerResult; + buckets( + query: UsageQuery, + groupBy: UsageGroupBy, + now: number, + ): ModelCallLedgerResult; + logs( + query: UsageQuery, + now: number, + offset: number, + limit: number, + ): ModelCallLedgerResult; +} + +export interface CatchUpModelCallProjectionInput { + readonly sessionId?: string; + readonly runId?: string; + /** Bounds the number of lagging runs processed in one pass. */ + readonly limit?: number; + /** Bounds authority events processed for each run in one pass. */ + readonly eventsPerRun?: number; +} + +export interface CatchUpModelCallProjectionResult { + readonly changedSessionIds: readonly string[]; + readonly pendingRuns: number; + readonly unreadableEvents: number; +} + +export interface ModelCallLedgerWriter extends ModelCallLedgerReader { + /** Advances the read model from the AgentRun authority's durable sequence. */ + catchUpProjection( + input?: CatchUpModelCallProjectionInput, + ): Promise; +} + +export interface ModelCallLedger extends ModelCallLedgerWriter { + flush(): Promise; + close(): Promise; +} + +export class ModelCallLedgerClosedError extends Error { + constructor() { + super('Model call ledger is draining or closed'); + this.name = 'ModelCallLedgerClosedError'; + } +} + +export class ModelCallLedgerPublicationError extends Error { + readonly commitUnknown: boolean; + + constructor(commitUnknown: boolean, options?: ErrorOptions) { + super('Model call ledger publication failed', options); + this.name = 'ModelCallLedgerPublicationError'; + this.commitUnknown = commitUnknown; + } +} + +export function createSqliteModelCallLedger(workspaceRoot: string): ModelCallLedger { + return new SqliteModelCallLedger(workspaceRoot); +} + +class SqliteModelCallLedger implements ModelCallLedger { + readonly #lease: OperationalStateDatabaseLease; + #state: 'open' | 'draining' | 'closed' = 'open'; + #queue: Promise = Promise.resolve(); + #closePromise: Promise | undefined; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(workspaceRoot); + } + + private write(operation: () => T): Promise { + const accepted = this.#queue.then(() => { + try { + return this.#lease.transaction('write', operation); + } catch (cause) { + throw new ModelCallLedgerPublicationError(false, { cause }); + } + }); + this.#queue = accepted.then( + () => undefined, + () => undefined, + ); + return accepted; + } + + catchUpProjection( + input: CatchUpModelCallProjectionInput = {}, + ): Promise { + if (this.#state !== 'open') return Promise.reject(new ModelCallLedgerClosedError()); + if (input.runId !== undefined && input.sessionId === undefined) { + return Promise.reject(new Error('A run-scoped projection catch-up requires sessionId')); + } + const limit = positiveInteger(input.limit, 16, 'projection catch-up limit'); + const eventsPerRun = positiveInteger( + input.eventsPerRun, + 512, + 'projection catch-up event limit', + ); + return this.write(() => + catchUpModelCallProjection(this.#lease.database, input, limit, eventsPerRun), + ); + } + + summary(query: UsageQuery, now: number): ModelCallLedgerResult { + const db = this.#open(); + const range = resolveUsageRange(query.range, now); + const filter = countableFilter(query, range); + const row = db + .prepare( + `SELECT ${REQUEST_SUMS}, ${TOKEN_SUMS}, ${COVERAGE_SUMS} + FROM usage_model_call_attempts WHERE ${filter.sql}`, + ) + .get(...filter.parameters) as Record | undefined; + return { + projection: { + range, + totalRequests: count(row?.totalRequests), + totalCostUsd: count(row?.totalCostUsd), + totalDurationMs: count(row?.totalDurationMs), + totalTokens: readTokens(row), + cacheHitRequests: count(row?.cacheHitRequests), + cacheCreateRequests: count(row?.cacheCreateRequests), + errorRequests: count(row?.errorRequests), + coverage: readCoverage(row), + }, + unreadableRecords: this.#unreadable(query, range), + }; + } + + buckets( + query: UsageQuery, + groupBy: UsageGroupBy, + now: number, + ): ModelCallLedgerResult { + const db = this.#open(); + const range = resolveUsageRange(query.range, now); + const filter = countableFilter(query, range); + const rows = db + .prepare( + `SELECT MIN(provider_id) AS providerId, MIN(model_id) AS modelId, + MIN(completed_at) AS ts, COUNT(*) AS requests, + SUM(${PRICED_COST}) AS costUsd, SUM(latency_ms) AS latency, + SUM(status = 'failed') AS errors, ${TOKEN_SUMS} + FROM usage_model_call_attempts WHERE ${filter.sql} + GROUP BY ${bucketGrouping(groupBy)}`, + ) + .all(...filter.parameters) as Array>; + const buckets = rows + .map((row) => { + const requests = count(row.requests); + const tokens = readTokens(row); + const key = usageBucketKey( + { + providerId: String(row.providerId ?? ''), + modelId: String(row.modelId ?? ''), + ts: count(row.ts), + }, + groupBy, + ); + return { + key, + label: key, + requests, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheMissTokens: tokens.cacheMiss, + cacheReadTokens: tokens.cacheRead, + cacheWriteTokens: tokens.cacheWrite, + reasoningTokens: tokens.reasoning, + totalTokens: tokens.total, + costUsd: count(row.costUsd), + avgLatencyMs: requests === 0 ? 0 : count(row.latency) / requests, + errorRate: requests === 0 ? 0 : count(row.errors) / requests, + } satisfies UsageBucket; + }) + .sort((left, right) => right.requests - left.requests); + return { + projection: { buckets, coverage: this.#coverage(filter) }, + unreadableRecords: this.#unreadable(query, range), + }; + } + + logs( + query: UsageQuery, + now: number, + offset: number, + limit: number, + ): ModelCallLedgerResult { + const db = this.#open(); + const range = resolveUsageRange(query.range, now); + const filter = countableFilter(query, range); + const rows = db + .prepare( + `SELECT attempt_id, completed_at, call_kind, logical_call_id, connection_slug, + provider_id, model_id, cost_basis, cost_usd, latency_ms, status, error_class, + session_id, turn_id, + COALESCE(input_tokens, 0) AS input, + COALESCE(output_tokens, 0) AS output, + COALESCE(cache_miss_input_tokens, 0) AS cacheMiss, + ${CACHE_READ_TOKENS} AS cacheRead, + COALESCE(cache_write_input_tokens, 0) AS cacheWrite, + COALESCE(reasoning_tokens, 0) AS reasoning + FROM usage_model_call_attempts WHERE ${filter.sql} + ORDER BY completed_at DESC, attempt_id DESC + LIMIT ? OFFSET ?`, + ) + .all(...filter.parameters, limit, offset) as Array>; + const coverage = this.#coverage(filter); + return { + projection: { rows: rows.map(toUsageLogRow), total: coverage.attempts, coverage }, + unreadableRecords: this.#unreadable(query, range), + }; + } + + #open(): DatabaseSync { + if (this.#state !== 'open') throw new ModelCallLedgerClosedError(); + return this.#lease.database; + } + + #coverage(filter: SqlFilter): ModelCallCoverage { + const row = this.#lease.database + .prepare(`SELECT ${COVERAGE_SUMS} FROM usage_model_call_attempts WHERE ${filter.sql}`) + .get(...filter.parameters) as Record | undefined; + return readCoverage(row); + } + + #unreadable(query: UsageQuery, range: { from: number; to: number }): number { + const filter = unreadableFilter(query, range); + return count( + this.#lease.database + .prepare(`SELECT COUNT(*) AS unreadable FROM usage_model_call_attempts WHERE ${filter.sql}`) + .get(...filter.parameters)?.unreadable, + ); + } + + async flush(): Promise { + await this.#queue; + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.#state = 'draining'; + this.#closePromise = this.#queue + .catch(() => undefined) + .finally(() => { + this.#state = 'closed'; + this.#lease.close(); + }); + return this.#closePromise; + } +} + +function readTokens(row: Record | undefined): { + input: number; + output: number; + cacheMiss: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + total: number; +} { + return { + input: count(row?.input), + output: count(row?.output), + cacheMiss: count(row?.cacheMiss), + cacheRead: count(row?.cacheRead), + cacheWrite: count(row?.cacheWrite), + reasoning: count(row?.reasoning), + total: count(row?.total), + }; +} + +function readCoverage(row: Record | undefined): ModelCallCoverage { + return { + attempts: count(row?.attempts), + pricedAttempts: count(row?.pricedAttempts), + unpricedAttempts: count(row?.unpricedAttempts), + usageReportedAttempts: count(row?.usageReportedAttempts), + usagePartialAttempts: count(row?.usagePartialAttempts), + usageMissingAttempts: count(row?.usageMissingAttempts), + }; +} + +function toUsageLogRow(row: Record): UsageLogRow { + const costBasis = row.cost_basis as UsageLogRow['costBasis']; + return { + id: String(row.attempt_id), + ts: count(row.completed_at), + callKind: row.call_kind as UsageLogRow['callKind'], + callId: String(row.logical_call_id), + ...(row.connection_slug === null ? {} : { connectionSlug: String(row.connection_slug) }), + providerId: String(row.provider_id), + modelId: String(row.model_id), + inputTokens: count(row.input), + outputTokens: count(row.output), + cacheMissTokens: count(row.cacheMiss), + cacheReadTokens: count(row.cacheRead), + cacheWriteTokens: count(row.cacheWrite), + reasoningTokens: count(row.reasoning), + totalTokens: count(row.input) + count(row.output), + // A row keeps its basis, not just its number. Collapsing an unpriced call + // to 0 here would reproduce, per row, exactly the ambiguity the coverage + // breakdown removes from the totals. + ...(costBasis === 'priced' ? { costUsd: count(row.cost_usd) } : {}), + costBasis, + latencyMs: count(row.latency_ms), + status: row.status === 'completed' ? 'success' : row.status === 'failed' ? 'error' : 'aborted', + ...(row.error_class === null ? {} : { errorClass: String(row.error_class) }), + sessionId: String(row.session_id), + turnId: String(row.turn_id), + }; +} + +function positiveInteger(value: number | undefined, fallback: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) throw new Error(`Invalid ${label}`); + return resolved; +} + +const MODEL_CALL_UPSERT = ` + INSERT INTO usage_model_call_attempts(${MODEL_CALL_COLUMNS.join(', ')}) + VALUES (${MODEL_CALL_COLUMNS.map(() => '?').join(', ')}) + ON CONFLICT(attempt_id) DO UPDATE SET + ${MODEL_CALL_COLUMNS.filter((column) => column !== 'attempt_id') + .map((column) => `${column} = excluded.${column}`) + .join(', ')} +`; + +/** + * The attempt's pricing fields, in column order. + * + * Keyed by column so the binding list cannot drift from the table: a column + * added to `MODEL_CALL_COLUMNS` without a value here is a compile error. + */ +function bindModelCallAttempt(attempt: ModelCallAttempt): (string | number | null)[] { + const values: Record<(typeof MODEL_CALL_COLUMNS)[number], string | number | null> = { + attempt_id: attempt.attemptId, + completed_at: attempt.completedAt, + session_id: attempt.sessionId, + logical_call_id: attempt.logicalCallId, + turn_id: attempt.turnId, + call_kind: attempt.callKind, + connection_slug: attempt.connectionSlug ?? null, + provider_id: attempt.providerId, + model_id: attempt.modelId, + latency_ms: attempt.latencyMs, + status: attempt.status, + error_class: attempt.errorClass ?? null, + usage_basis: attempt.usageBasis, + input_tokens: attempt.inputTokens ?? null, + output_tokens: attempt.outputTokens ?? null, + cache_read_input_tokens: attempt.cacheReadInputTokens ?? null, + cache_miss_input_tokens: attempt.cacheMissInputTokens ?? null, + cache_write_input_tokens: attempt.cacheWriteInputTokens ?? null, + reasoning_tokens: attempt.reasoningTokens ?? null, + cost_basis: attempt.costBasis, + cost_usd: attempt.costUsd ?? null, + }; + return MODEL_CALL_COLUMNS.map((column) => values[column]); +} + +function writeModelCallAttempt(db: DatabaseSync, attempt: ModelCallAttempt): void { + db.prepare(MODEL_CALL_UPSERT).run(...bindModelCallAttempt(attempt)); +} + +interface LaggingRunRow { + readonly session_id: string; + readonly run_id: string; + readonly high_water: number; + readonly applied_through: number; +} + +function catchUpModelCallProjection( + db: DatabaseSync, + input: CatchUpModelCallProjectionInput, + limit: number, + eventsPerRun: number, +): CatchUpModelCallProjectionResult { + const scope = projectionScope(input); + const lagging = db + .prepare(` + WITH source AS ( + SELECT session_id, run_id, latest_model_call_sequence AS high_water + FROM core_agent_runs + WHERE latest_model_call_sequence IS NOT NULL${scope.sourceWhere} + ) + SELECT source.session_id, source.run_id, source.high_water, + COALESCE(checkpoint.applied_through_sequence, -1) AS applied_through + FROM source + LEFT JOIN usage_model_call_projection_checkpoints AS checkpoint + ON checkpoint.session_id = source.session_id + AND checkpoint.run_id = source.run_id + WHERE source.high_water > COALESCE(checkpoint.applied_through_sequence, -1) + ORDER BY source.session_id, source.run_id + LIMIT ? + `) + .all(...scope.parameters, limit) as unknown as LaggingRunRow[]; + + const changedSessionIds = new Set(); + for (const run of lagging) { + const rows = db + .prepare(` + SELECT sequence, record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? AND event_type = ? + AND sequence > ? AND sequence <= ? + ORDER BY sequence ASC + LIMIT ? + `) + .all( + run.session_id, + run.run_id, + MODEL_CALL_ATTEMPT_EVENT_TYPE, + run.applied_through, + run.high_water, + eventsPerRun, + ) as Array<{ sequence: number; record_json: string }>; + if (rows.length === 0) continue; + + let unreadableEvents = 0; + for (const row of rows) { + let attempt: ModelCallAttempt; + try { + const event = JSON.parse(row.record_json) as { readonly data?: unknown }; + attempt = decodeModelCallAttempt(event.data); + if (attempt.sessionId !== run.session_id || attempt.runId !== run.run_id) { + throw new Error('Model-call attempt identity disagrees with its AgentRun envelope'); + } + } catch { + unreadableEvents += 1; + continue; + } + // Projection storage failures must roll the transaction back. Treating + // one as corrupt authority would advance the checkpoint past a valid, + // still-unprojected billed call. + writeModelCallAttempt(db, attempt); + } + const appliedThrough = rows.at(-1)?.sequence; + if (appliedThrough === undefined) continue; + db.prepare(` + INSERT INTO usage_model_call_projection_checkpoints( + session_id, run_id, applied_through_sequence, unreadable_events + ) VALUES (?, ?, ?, ?) + ON CONFLICT(session_id, run_id) DO UPDATE SET + applied_through_sequence = excluded.applied_through_sequence, + unreadable_events = usage_model_call_projection_checkpoints.unreadable_events + + excluded.unreadable_events + `).run(run.session_id, run.run_id, appliedThrough, unreadableEvents); + changedSessionIds.add(run.session_id); + } + + const pendingRuns = Number( + db + .prepare(` + WITH source AS ( + SELECT session_id, run_id, latest_model_call_sequence AS high_water + FROM core_agent_runs + WHERE latest_model_call_sequence IS NOT NULL${scope.sourceWhere} + ) + SELECT COUNT(*) AS count + FROM source + LEFT JOIN usage_model_call_projection_checkpoints AS checkpoint + ON checkpoint.session_id = source.session_id + AND checkpoint.run_id = source.run_id + WHERE source.high_water > COALESCE(checkpoint.applied_through_sequence, -1) + `) + .get(...scope.parameters)?.count ?? 0, + ); + const unreadableEvents = Number( + db + .prepare(` + SELECT COALESCE(SUM(unreadable_events), 0) AS count + FROM usage_model_call_projection_checkpoints + WHERE 1 = 1${scope.checkpointWhere} + `) + .get(...scope.parameters)?.count ?? 0, + ); + return { changedSessionIds: [...changedSessionIds], pendingRuns, unreadableEvents }; +} + +function projectionScope(input: CatchUpModelCallProjectionInput): { + readonly sourceWhere: string; + readonly checkpointWhere: string; + readonly parameters: readonly string[]; +} { + if (input.runId !== undefined) { + return { + sourceWhere: ' AND session_id = ? AND run_id = ?', + checkpointWhere: ' AND session_id = ? AND run_id = ?', + parameters: [input.sessionId!, input.runId], + }; + } + if (input.sessionId !== undefined) { + return { + sourceWhere: ' AND session_id = ?', + checkpointWhere: ' AND session_id = ?', + parameters: [input.sessionId], + }; + } + return { sourceWhere: '', checkpointWhere: '', parameters: [] }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e4057e840c1c735ab8a04a2bc19e27e1e7fb3100f9d3311cf257e961b8cc9180.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e4057e840c1c735ab8a04a2bc19e27e1e7fb3100f9d3311cf257e961b8cc9180.source new file mode 100644 index 0000000000..9fb2fc4e24 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e4057e840c1c735ab8a04a2bc19e27e1e7fb3100f9d3311cf257e961b8cc9180.source @@ -0,0 +1,541 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// opencode sessions as Maka Sessions. +// +// State lives in one SQLite database, `~/.local/share/opencode/opencode.db`, +// which the CLI itself will name (`opencode db path`). Earlier releases wrote +// a `storage/session/{info,message,part}` JSON tree; that layout is gone, so +// this reads the database and nothing else. Verified against 1.18.21. +// +// A conversation is three tables. `session` holds identity and `directory`, +// which is the cwd a project-scoped query reads. `message` and `part` each +// keep their payload in an opaque `data` JSON column — the schema names the +// container, the column names the shape. +// +// Turn state is derivable here, unlike a Claude Code transcript: every +// assistant message records `time.completed`, and `finish` is `stop` on a +// closing step, `tool-calls` on an intermediate one, and absent on a message +// that was aborted, which also carries `error.name`. +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { externalSessionMatchesQuery } from '@maka/core/external-session'; +import type { + ExternalMakaSession, + ExternalSessionAdapter, + ExternalSessionQuery, + ExternalSessionSummary, +} from '@maka/core/external-session'; +import { sanitizeForeignTitle } from '@maka/core/foreign-session'; +import type { StoredMessage } from '@maka/core/session'; + +export const OPENCODE_SESSION_ADAPTER_ID = 'opencode'; + +const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot'; + +/** Guards the value interpolated into no SQL, but read back out of one. */ +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; + +export interface OpenCodeSessionAdapterOptions { + /** Overrides `~/.local/share/opencode`. */ + opencodeHome?: string; +} + +interface SessionRow { + readonly id: string; + readonly title: string; + readonly directory: string; + readonly timeCreated?: number; + readonly timeUpdated?: number; + readonly archived: boolean; + readonly parentId?: string; +} + +interface MessageRow { + readonly id: string; + readonly timeCreated: number; + readonly data: Record; +} + +interface PartRow { + readonly messageId: string; + readonly data: Record; +} + +export class OpenCodeSessionAdapter implements ExternalSessionAdapter { + readonly id = OPENCODE_SESSION_ADAPTER_ID; + readonly #home: string; + + constructor(options: OpenCodeSessionAdapterOptions = {}) { + this.#home = options.opencodeHome ?? join(homedir(), '.local', 'share', 'opencode'); + } + + async detect(): Promise { + return existsSync(this.#databasePath()); + } + + async listSessions(query?: ExternalSessionQuery): Promise { + const rows = await this.#readSessions(); + const summaries: ExternalSessionSummary[] = []; + for (const row of rows) { + // A child session is one operator's leg of a parent conversation, not a + // conversation a user started. Listing it offers an import of half a + // dialogue whose other half is a separate entry. + if (row.parentId !== undefined) continue; + const summary = toSummary(row); + if (externalSessionMatchesQuery(summary, query)) summaries.push(summary); + } + summaries.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0)); + return summaries; + } + + async readSession(sessionId: string): Promise { + if (!SESSION_ID_PATTERN.test(sessionId)) { + throw new Error(`opencode session id is not usable: ${sessionId}`); + } + const rows = await this.#readSessions(); + const row = rows.find((candidate) => candidate.id === sessionId); + if (!row) throw new Error(`opencode session not found: ${sessionId}`); + if (row.parentId !== undefined) { + throw new Error(`opencode session is a child of another session: ${sessionId}`); + } + const { messages, parts } = await this.#readTranscript(sessionId); + return { + sourceSessionId: sessionId, + metadata: { name: row.title || sessionId, cwd: row.directory }, + messages: convertTranscript(sessionId, messages, parts), + }; + } + + #databasePath(): string { + return join(this.#home, 'opencode.db'); + } + + /** + * Opens the database and runs one read. + * + * Failure is reported, not flattened into an empty result. An unreadable + * database and an opencode install with no sessions are different facts, and + * a caller that cannot tell them apart reports the wrong one: "no sessions + * here" for a database that is locked, corrupt, or written by a version + * whose tables this does not recognise. + */ + async #withDatabase(read: (db: OpenCodeDatabase) => T): Promise { + const path = this.#databasePath(); + let sqlite: typeof import('node:sqlite'); + try { + sqlite = await import('node:sqlite'); + } catch (cause) { + throw new Error('opencode sessions need node:sqlite, which is unavailable', { cause }); + } + let db: OpenCodeDatabase; + try { + db = new sqlite.DatabaseSync(path, { readOnly: true }) as OpenCodeDatabase; + } catch (cause) { + throw new Error(`opencode database could not be opened: ${path}`, { cause }); + } + try { + return read(db); + } catch (cause) { + throw new Error(`opencode database could not be read: ${path}`, { cause }); + } finally { + try { + db.close(); + } catch { + // A close that fails leaves nothing for a reader to do; the process + // releases the handle either way, and throwing here would replace a + // usable result with an error about cleanup. + } + } + } + + async #readSessions(): Promise { + // Discovery is allowed to come up empty — the catalog lists whatever + // sources are present, and an opencode that was installed but never used + // is a normal state rather than a failure to report. + if (!existsSync(this.#databasePath())) return []; + return await this.#withDatabase((db) => { + const columns = tableColumns(db, 'session'); + if (!columns.has('id') || !columns.has('directory')) { + throw new Error('opencode `session` table does not carry `id` and `directory`'); + } + const selected = [ + 'id', + 'title', + 'directory', + 'time_created', + 'time_updated', + 'time_archived', + 'parent_id', + ].filter((column) => columns.has(column)); + const raw = db.prepare(`SELECT ${selected.join(', ')} FROM session`).all(); + return raw.map(toSessionRow).filter((row): row is SessionRow => row !== undefined); + }); + } + + async #readTranscript( + sessionId: string, + ): Promise<{ messages: readonly MessageRow[]; parts: readonly PartRow[] }> { + return await this.#withDatabase((db) => { + // A row that will not decode is not skipped. Dropping one silently + // yields a transcript missing a message or a part while the import + // reports success — a history that reads as complete and is not. A + // selected import either carries what the session recorded or fails. + const messages = db + .prepare('SELECT id, time_created, data FROM message WHERE session_id = ?') + .all(sessionId) + .map((row, index) => requireRow(toMessageRow(row), 'message', index)); + // Ordered by the message they belong to and then by their own id, which + // is how the writer orders them; `time_created` ties within one step. + const parts = db + .prepare('SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id') + .all(sessionId) + .map((row, index) => requireRow(toPartRow(row), 'part', index)); + return { messages, parts }; + }); + } +} + +interface OpenCodeDatabase { + prepare(sql: string): { all(...params: unknown[]): unknown[] }; + close(): void; +} + +function tableColumns(db: OpenCodeDatabase, table: string): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name?: unknown }[]; + return new Set( + rows.map((column) => (typeof column.name === 'string' ? column.name : '')).filter(Boolean), + ); +} + +function toSummary(row: SessionRow): ExternalSessionSummary { + return { + id: row.id, + name: sanitizeForeignTitle(row.title) || row.id, + cwd: row.directory, + ...(row.timeCreated !== undefined ? { createdAt: row.timeCreated } : {}), + ...(row.timeUpdated !== undefined ? { updatedAt: row.timeUpdated } : {}), + ...(row.archived ? { archived: true } : {}), + }; +} + +/** + * Turns one opencode conversation into Maka messages. + * + * Exported for the fixture tests, which exercise the mapping without a + * database: the conversion is where the source format is interpreted, and it + * is the part worth pinning. + */ +export function convertTranscript( + sessionId: string, + messages: readonly MessageRow[], + parts: readonly PartRow[], +): readonly StoredMessage[] { + const partsByMessage = new Map[]>(); + for (const part of parts) { + const existing = partsByMessage.get(part.messageId); + if (existing) existing.push(part.data); + else partsByMessage.set(part.messageId, [part.data]); + } + + const ordered = [...messages].sort((left, right) => + left.timeCreated === right.timeCreated + ? left.id.localeCompare(right.id) + : left.timeCreated - right.timeCreated, + ); + + const out: StoredMessage[] = []; + let sequence = 0; + const id = (kind: string): string => `opencode:${sessionId}:${kind}:${sequence++}`; + let turnSequence = 0; + + interface Turn { + turnId: string; + lastTs: number; + aborted: boolean; + closed: boolean; + errorName?: string; + } + let turn: Turn | undefined; + + const closeTurn = (): void => { + if (!turn) return; + if (turn.errorName !== undefined && !turn.aborted) { + out.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'failed', + errorClass: 'opencode_error', + }); + } else if (turn.aborted) { + out.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE, + }); + } else if (turn.closed) { + out.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'completed', + }); + } else { + // A turn whose last assistant step asked for tools and never came back: + // the run stopped between a call and its answer. Recording it as + // completed would assert a reply the session never produced. + out.push({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE, + }); + } + turn = undefined; + }; + + for (const message of ordered) { + const data = message.data; + const role = stringOf(data.role); + const ts = + numberOf((data.time as Record | undefined)?.created) ?? message.timeCreated; + const messageParts = partsByMessage.get(message.id) ?? []; + + if (role === 'user') { + // A user message closes whatever came before it either way: it is the + // boundary, whether or not it carries a prompt this import can use. + closeTurn(); + const text = messageParts + .filter((part) => stringOf(part.type) === 'text') + .map((part) => stringOf(part.text) ?? '') + .filter((part) => part.length > 0) + .join('\n\n'); + // No text part means no prompt to import. Opening a turn for it would + // produce a `turn_state` describing a turn that holds no messages — + // a terminal verdict on a conversation that is not there. An assistant + // message that follows opens its own turn. + if (text.length === 0) continue; + turn = { + turnId: `opencode:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + aborted: false, + closed: false, + }; + out.push({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text }); + continue; + } + + if (role !== 'assistant') continue; + + if (!turn) { + // An assistant message with no preceding user message: a resumed + // session whose opening prompt is not in this transcript. Give it a turn + // rather than dropping the content. + turn = { + turnId: `opencode:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + aborted: false, + closed: false, + }; + } + turn.lastTs = Math.max(turn.lastTs, ts); + + const errorName = stringOf((data.error as Record | undefined)?.name); + if (errorName !== undefined) { + if (errorName === 'MessageAbortedError') turn.aborted = true; + else turn.errorName = errorName; + } + const finish = stringOf(data.finish); + // `stop` is the only finish that closes a turn. `tool-calls` means the + // step handed off to a tool and another assistant message follows. + if (finish === 'stop') turn.closed = true; + else if (finish !== undefined) turn.closed = false; + + const modelId = stringOf(data.modelID) ?? 'opencode'; + + // Parts are walked in the order the session recorded them rather than + // bucketed by type. opencode accepts `text` before `reasoning`, and its + // own replay keeps that order; emitting all reasoning first would move a + // model's thinking across text it actually wrote after. + for (const part of messageParts) { + const kind = stringOf(part.type); + + if (kind === 'reasoning') { + const thinking = stringOf(part.text); + if (thinking === undefined) continue; + out.push({ + type: 'assistant', + id: id('thinking'), + turnId: turn.turnId, + ts, + text: '', + thinking: { text: thinking }, + contentOrder: ['thinking'], + modelId, + }); + continue; + } + + if (kind === 'text') { + const text = stringOf(part.text); + if (text === undefined) continue; + out.push({ + type: 'assistant', + id: id('assistant'), + turnId: turn.turnId, + ts, + text, + contentOrder: ['text'], + modelId, + }); + continue; + } + + if (kind !== 'tool') continue; + const callId = stringOf(part.callID); + // A call with no id cannot be paired with its result. Minting one + // produces a row guaranteed not to match anything, which reads as a + // detached call rather than an absent one. + if (callId === undefined) continue; + const state = asRecord(part.state); + const status = stringOf(state?.status); + out.push({ + type: 'tool_call', + id: callId, + turnId: turn.turnId, + ts, + toolName: stringOf(part.tool) ?? 'unknown', + args: asRecord(state?.input) ?? {}, + }); + // `completed` and `error` are both terminal: opencode records a failed + // call as `{ status: 'error', error: }` and replays it as an + // errored output. Dropping the failure would leave a call with no + // answer inside a turn a later `finish: "stop"` marks completed — a + // transcript asserting the tool never replied when it replied with a + // failure. + // + // `pending` and `running` are the calls that genuinely had no answer + // when the session was written, and they get no result. + if (status === 'completed') { + out.push({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: false, + content: { kind: 'text', text: stringOf(state?.output) ?? '' }, + }); + continue; + } + if (status === 'error') { + out.push({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: true, + content: { kind: 'text', text: stringOf(state?.error) ?? 'opencode tool call failed' }, + }); + } + } + } + + closeTurn(); + return out; +} + +function requireRow(row: T | undefined, table: string, index: number): T { + if (row === undefined) { + throw new Error(`opencode \`${table}\` row ${index} could not be decoded`); + } + return row; +} + +function toSessionRow(value: unknown): SessionRow | undefined { + const row = asRecord(value); + const id = stringOf(row?.id); + if (id === undefined) return undefined; + const directory = stringOf(row?.directory) ?? ''; + const parentId = stringOf(row?.parent_id); + return { + id, + title: stringOf(row?.title) ?? '', + directory, + ...(numberOf(row?.time_created) !== undefined + ? { timeCreated: numberOf(row?.time_created) } + : {}), + ...(numberOf(row?.time_updated) !== undefined + ? { timeUpdated: numberOf(row?.time_updated) } + : {}), + archived: numberOf(row?.time_archived) !== undefined, + ...(parentId !== undefined ? { parentId } : {}), + }; +} + +function toMessageRow(value: unknown): MessageRow | undefined { + const row = asRecord(value); + const id = stringOf(row?.id); + const data = parseJsonRecord(row?.data); + if (id === undefined || data === undefined) return undefined; + return { id, timeCreated: numberOf(row?.time_created) ?? 0, data }; +} + +function toPartRow(value: unknown): PartRow | undefined { + const row = asRecord(value); + const messageId = stringOf(row?.message_id); + const data = parseJsonRecord(row?.data); + if (messageId === undefined || data === undefined) return undefined; + return { messageId, data }; +} + +function parseJsonRecord(value: unknown): Record | undefined { + if (typeof value !== 'string') return undefined; + try { + return asRecord(JSON.parse(value)); + } catch { + return undefined; + } +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringOf(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function numberOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e42e7554c8d4fa1ba09af2eb501fe5db726d274558343d52181c53c6293280d0.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e42e7554c8d4fa1ba09af2eb501fe5db726d274558343d52181c53c6293280d0.source new file mode 100644 index 0000000000..db76af5688 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e42e7554c8d4fa1ba09af2eb501fe5db726d274558343d52181c53c6293280d0.source @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UsageGroupBy, UsageQuery } from '@maka/core/usage-stats/types'; + +/** + * The Usage aggregation, expressed over the canonical ledger's columns. + * + * These fragments are the SQL half of rules whose vocabulary lives in + * `@maka/core`. Each one names the function it mirrors; change them together. + */ + +/** Mirrors `clampCacheReadTokens`: cache reads cannot exceed the prompt they came from. */ +export const CACHE_READ_TOKENS = ` + CASE WHEN input_tokens IS NULL + THEN COALESCE(cache_read_input_tokens, 0) + ELSE MIN(COALESCE(cache_read_input_tokens, 0), input_tokens) + END`; + +/** + * Unpriced records contribute nothing rather than zero. What they cost is + * reported through coverage instead, so a total never claims a call was free + * when the price was simply never resolved. + */ +export const PRICED_COST = `CASE WHEN cost_basis = 'priced' THEN COALESCE(cost_usd, 0) ELSE 0 END`; + +/** Mirrors `usageStatusForAttempt`: only a provider failure is an error. */ +const ERROR_ROW = `status = 'failed'`; + +export const TOKEN_SUMS = ` + SUM(COALESCE(input_tokens, 0)) AS input, + SUM(COALESCE(output_tokens, 0)) AS output, + SUM(COALESCE(cache_miss_input_tokens, 0)) AS cacheMiss, + SUM(${CACHE_READ_TOKENS}) AS cacheRead, + SUM(COALESCE(cache_write_input_tokens, 0)) AS cacheWrite, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning, + SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)) AS total`; + +export const COVERAGE_SUMS = ` + COUNT(*) AS attempts, + SUM(cost_basis = 'priced') AS pricedAttempts, + SUM(cost_basis = 'unpriced') AS unpricedAttempts, + SUM(usage_basis = 'reported') AS usageReportedAttempts, + SUM(usage_basis = 'partial') AS usagePartialAttempts, + SUM(usage_basis = 'missing') AS usageMissingAttempts`; + +export const REQUEST_SUMS = ` + COUNT(*) AS totalRequests, + SUM(${PRICED_COST}) AS totalCostUsd, + SUM(latency_ms) AS totalDurationMs, + SUM((${CACHE_READ_TOKENS}) > 0) AS cacheHitRequests, + SUM(COALESCE(cache_write_input_tokens, 0) > 0) AS cacheCreateRequests, + SUM(${ERROR_ROW}) AS errorRequests`; + +export interface SqlFilter { + readonly sql: string; + readonly parameters: readonly (string | number)[]; +} + +/** + * Rows a query addresses that can be counted. + * + * A tombstone — a row whose stored form was damaged before the ledger held + * columns — matches no filter and is excluded here; {@link unreadableFilter} + * counts it instead. + */ +export function countableFilter( + query: UsageQuery, + range: { readonly from: number; readonly to: number }, +): SqlFilter { + const clauses = ['cost_basis IS NOT NULL', 'completed_at >= ?', 'completed_at <= ?']; + const parameters: (string | number)[] = [range.from, range.to]; + const equals = (column: string, value: string | undefined) => { + if (value === undefined) return; + clauses.push(`${column} = ?`); + parameters.push(value); + }; + equals('session_id', query.sessionId); + equals('provider_id', query.providerId); + equals('model_id', query.modelId); + equals('connection_slug', query.connectionSlug); + if (query.status !== undefined && query.status !== 'all') { + // `interrupted` joins `aborted`: both mean the call stopped short without + // the provider reporting a failure. + if (query.status === 'success') clauses.push(`status = 'completed'`); + else if (query.status === 'error') clauses.push(ERROR_ROW); + else clauses.push(`status NOT IN ('completed', 'failed')`); + } + return { sql: clauses.join(' AND '), parameters }; +} + +/** + * Rows a query addresses whose pricing was lost. + * + * Scoped by window and Session only — the columns a tombstone keeps. Narrowing + * it by provider or status would drop the row from the report on the strength + * of a field the row no longer has, which is how a total quietly stops + * mentioning spend it cannot account for. + */ +export function unreadableFilter( + query: UsageQuery, + range: { readonly from: number; readonly to: number }, +): SqlFilter { + const clauses = ['cost_basis IS NULL', 'completed_at >= ?', 'completed_at <= ?']; + const parameters: (string | number)[] = [range.from, range.to]; + if (query.sessionId !== undefined) { + clauses.push('session_id = ?'); + parameters.push(query.sessionId); + } + return { sql: clauses.join(' AND '), parameters }; +} + +/** + * How SQL groups rows for a bucket query. + * + * SQLite decides only which rows belong together; the key string itself is + * still built by `usageBucketKey`, so both Usage sources keep deriving it from + * one place. `MIN(completed_at)` gives the time bucket a timestamp to name + * itself from. + */ +export function bucketGrouping(groupBy: UsageGroupBy): string { + switch (groupBy) { + case 'provider': + return 'provider_id'; + case 'model': + return 'provider_id, model_id'; + case 'day': + return `strftime('%Y-%m-%d', completed_at / 1000, 'unixepoch')`; + case 'hour': + return `strftime('%Y-%m-%dT%H', completed_at / 1000, 'unixepoch')`; + case 'tool': + // Tool invocations live in their own ledger; nothing here describes them. + return `''`; + } +} + +/** SQL aggregates arrive as `null` for an empty set and as bigint-safe numbers. */ +export function count(value: unknown): number { + return Number(value ?? 0); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e530215828585c8b6096416df5b9c6e5418bbbca5e173b6832ae06ab5cde9c8e.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e530215828585c8b6096416df5b9c6e5418bbbca5e173b6832ae06ab5cde9c8e.source new file mode 100644 index 0000000000..8ed7a4e9b6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e530215828585c8b6096416df5b9c6e5418bbbca5e173b6832ae06ab5cde9c8e.source @@ -0,0 +1,835 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { + PLAN_MAX_FILES_PER_STEP, + PLAN_MAX_RISKS, + PLAN_MAX_STEPS, + PLAN_LIFECYCLE_REASON_MAX_BYTES, + PLAN_PROJECTION_ITEM_MAX_BYTES, + PLAN_STEP_TITLE_MAX_CHARS, + PlanConflictError, + activePlanExecution, + type AbandonPlanProposalInput, + emptyPlanSessionState, + latestPlanProposal, + type ApprovePlanProposalInput, + type CancelPlanExecutionInput, + type PlanEvent, + type PlanExecution, + type PlanExecutionStep, + type PlanMutationResult, + type PlanProposal, + type PlanSessionState, + type PlanStepDefinition, + type PlanStore, + type RequestPlanRevisionInput, + type SubmitPlanProposalInput, + type UpdatePlanExecutionInput, + isCanonicalPlanEntityId, + isPlanProposalLifecycleAdmissible, + isPlanTextWithinLimit, + planEncodedByteLength, + worstCasePlanExecution, +} from '@maka/core/plan'; +import { chainWrite } from './write-queue.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; + +export interface CreatePlanStoreOptions { + newId?: () => string; + now?: () => number; +} + +export interface SqlitePlanStore extends PlanStore { + ready(): Promise; + purgeSessionState(sessionId: string): Promise; + close(): void; +} + +export type CreateSqlitePlanStoreOptions = CreatePlanStoreOptions; + +export function createSqlitePlanStore( + workspaceRoot: string, + options: CreateSqlitePlanStoreOptions = {}, +): SqlitePlanStore { + return new SqlitePlanStoreImpl(workspaceRoot, options); +} + +class SqlitePlanStoreImpl implements SqlitePlanStore { + readonly #lease: OperationalStateDatabaseLease; + private readonly queues = new Map>(); + private readonly newId: () => string; + private readonly now: () => number; + + constructor(workspaceRoot: string, options: CreatePlanStoreOptions) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + this.newId = options.newId ?? randomUUID; + this.now = options.now ?? Date.now; + } + + ready(): Promise { + return Promise.resolve(); + } + + close(): void { + this.#lease.close(); + } + + async readState(sessionId: string): Promise { + return (await this.readLedger(sessionId)).state; + } + + async readOperationReceipt( + sessionId: string, + operationId: string, + operationInput: unknown, + ): Promise { + assertSafeId(sessionId); + assertSafeId(operationId); + const fingerprint = operationFingerprint(operationInput); + let receipt: PlanEvent | undefined; + await chainWrite(this.queues, sessionId, async () => { + receipt = reconcileOperationReceipt( + (await this.readLedger(sessionId)).events, + operationId, + fingerprint, + ); + }); + return receipt; + } + + async submitProposal(input: SubmitPlanProposalInput): Promise { + return this.mutate(input.sessionId, input.operationId, input, async (state) => { + requiredId(input.turnId, 'Plan turn id'); + if (input.sourceExecutionId) { + requiredId(input.sourceExecutionId, 'Source Plan execution id'); + } + const title = requiredText(input.title, 'Plan title'); + const steps = normalizeDefinitions(input.steps); + const overview = optionalText(input.overview, 'Plan overview'); + if (input.risks && input.risks.length > PLAN_MAX_RISKS) { + throw new PlanConflictError(`A plan may contain at most ${PLAN_MAX_RISKS} risks`); + } + const risks = + input.risks && input.risks.length > 0 + ? input.risks.map((risk) => requiredText(risk, 'Plan risk')) + : undefined; + if (!isPlanProposalLifecycleAdmissible({ title, overview, steps, risks })) { + throw new PlanConflictError( + 'Plan proposal cannot fit the projection item limit across its execution lifecycle', + ); + } + const latest = latestPlanProposal(state); + if (state.activeExecutionId) { + throw new PlanConflictError('Cannot submit a new proposal while a plan is executing'); + } + const sourceExecution = input.sourceExecutionId + ? executionById(state, input.sourceExecutionId) + : undefined; + if (sourceExecution && sourceExecution.status !== 'interrupted') { + throw new PlanConflictError('Only an interrupted execution can be replanned'); + } + const revisesLatest = + latest !== undefined && + (latest.status !== 'approved' || sourceExecution?.proposalId === latest.proposalId); + const planId = revisesLatest ? latest.planId : requiredId(this.newId(), 'Plan id'); + const proposalId = requiredId(this.newId(), 'Plan proposal id'); + const submittedAt = this.now(); + const proposal: PlanProposal = { + planId, + proposalId, + sessionId: input.sessionId, + turnId: input.turnId, + revision: revisesLatest ? latest.revision + 1 : 1, + ...(revisesLatest ? { supersedesProposalId: latest.proposalId } : {}), + ...(sourceExecution ? { sourceExecutionId: sourceExecution.executionId } : {}), + title, + ...(overview ? { overview } : {}), + steps, + ...(risks ? { risks } : {}), + status: 'pending_approval', + submittedAt, + }; + return { + type: 'plan_submitted', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: submittedAt, + storeVersion: state.storeVersion + 1, + proposal, + }; + }); + } + + async requestRevision(input: RequestPlanRevisionInput): Promise { + return this.mutate(input.sessionId, input.operationId, input, async (state) => { + const proposal = proposalById(state, input.proposalId); + if (proposal.status === 'stale') { + throw new PlanConflictError('Plan proposal is already stale'); + } + if (proposal.status === 'approved') { + throw new PlanConflictError('An approved plan proposal cannot be revised'); + } + if (state.latestProposalId !== proposal.proposalId) { + throw new PlanConflictError('Only the latest plan proposal can be revised'); + } + return { + type: 'plan_revision_requested', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + }; + }); + } + + async abandonProposal(input: AbandonPlanProposalInput): Promise { + return this.mutate(input.sessionId, input.operationId, input, async (state) => { + const proposal = proposalById(state, input.proposalId); + if ( + proposal.status !== 'pending_approval' || + state.latestProposalId !== proposal.proposalId + ) { + throw new PlanConflictError('Only the latest pending plan proposal can be abandoned'); + } + return { + type: 'plan_abandoned', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + reason: requiredText(input.reason, 'Plan abandonment reason'), + }; + }); + } + + async approveProposal(input: ApprovePlanProposalInput): Promise { + let duplicate: PlanMutationResult | undefined; + const result = await this.mutateOptional( + input.sessionId, + input.operationId, + input, + async (state, events) => { + const proposal = proposalById(state, input.proposalId); + if (proposal.revision !== input.expectedRevision) { + throw new PlanConflictError('Plan proposal revision does not match'); + } + if (proposal.status === 'approved') { + if (input.operationId) { + throw new PlanConflictError('Plan proposal was already approved by another operation'); + } + const prior = [...events] + .reverse() + .find( + (event): event is Extract => + event.type === 'plan_approved' && event.proposalId === proposal.proposalId, + ); + if (!prior) throw new PlanConflictError('Approved plan execution is missing'); + duplicate = { event: prior, state }; + return null; + } + if ( + input.expectedStoreVersion !== undefined && + state.storeVersion !== input.expectedStoreVersion + ) { + throw new PlanConflictError('Plan state changed before approval'); + } + if ( + proposal.status !== 'pending_approval' || + state.latestProposalId !== proposal.proposalId + ) { + throw new PlanConflictError('Only the latest pending plan proposal can be approved'); + } + if (state.activeExecutionId) { + throw new PlanConflictError('This session already has an active plan execution'); + } + if (proposal.sourceExecutionId) { + const sourceExecution = executionById(state, proposal.sourceExecutionId); + if (sourceExecution.status !== 'interrupted') { + throw new PlanConflictError('The execution being replanned is no longer interrupted'); + } + } + const startedAt = this.now(); + const execution: PlanExecution = { + executionId: requiredId(this.newId(), 'Plan execution id'), + planId: proposal.planId, + proposalId: proposal.proposalId, + sessionId: input.sessionId, + status: 'active', + steps: proposal.steps.map((step) => ({ + ...structuredClone(step), + status: 'pending', + updatedAt: startedAt, + })), + startedAt, + updatedAt: startedAt, + }; + return { + type: 'plan_approved', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: startedAt, + storeVersion: state.storeVersion + 1, + proposalId: proposal.proposalId, + execution, + }; + }, + ); + if (duplicate) return duplicate; + if (!result) throw new Error('Plan approval completed without a result'); + return result; + } + + async updateExecution(input: UpdatePlanExecutionInput): Promise { + return this.mutate(input.sessionId, input.operationId, input, async (state) => { + const execution = requireActiveExecution(state, input.executionId); + const steps = mergeExecutionSteps(execution, input.steps, this.now()); + const explanation = optionalText(input.explanation, 'Plan progress explanation'); + const completed = steps.every( + (step) => step.status === 'completed' || step.status === 'skipped', + ); + return completed + ? { + type: 'plan_execution_completed', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + steps, + } + : { + type: 'plan_progress_updated', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + steps, + ...(explanation ? { explanation } : {}), + }; + }); + } + + async cancelExecution(input: CancelPlanExecutionInput): Promise { + return this.mutate(input.sessionId, input.operationId, input, async (state) => { + const execution = requireCancellableExecution(state, input.executionId); + return { + type: 'plan_execution_cancelled', + id: input.operationId ?? this.newId(), + sessionId: input.sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId: execution.executionId, + reason: requiredText( + input.reason, + 'Plan cancellation reason', + PLAN_LIFECYCLE_REASON_MAX_BYTES, + ), + }; + }); + } + + async interruptActiveExecution( + sessionId: string, + reason: string, + operationId?: string, + ): Promise { + return this.mutateOptional(sessionId, operationId, { sessionId, reason }, async (fresh) => { + const execution = activePlanExecution(fresh); + if (!execution) return null; + return { + type: 'plan_execution_interrupted', + id: operationId ?? this.newId(), + sessionId, + ts: this.now(), + storeVersion: fresh.storeVersion + 1, + executionId: execution.executionId, + reason: requiredText(reason, 'Plan interruption reason', PLAN_LIFECYCLE_REASON_MAX_BYTES), + }; + }); + } + + async resumeExecution( + sessionId: string, + executionId: string, + operationId?: string, + ): Promise { + return this.mutate(sessionId, operationId, { sessionId, executionId }, async (state) => { + if (state.activeExecutionId) { + throw new PlanConflictError('This session already has an active plan execution'); + } + const execution = executionById(state, executionId); + if (execution.status !== 'interrupted') { + throw new PlanConflictError('Only an interrupted plan execution can be resumed'); + } + return { + type: 'plan_execution_resumed', + id: operationId ?? this.newId(), + sessionId, + ts: this.now(), + storeVersion: state.storeVersion + 1, + executionId, + }; + }); + } + + private async mutate( + sessionId: string, + operationId: string | undefined, + operationInput: unknown, + build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise, + ): Promise { + const result = await this.mutateOptional(sessionId, operationId, operationInput, build); + if (!result) throw new Error('Plan mutation completed without a result'); + return result; + } + + private async mutateOptional( + sessionId: string, + operationId: string | undefined, + operationInput: unknown, + build: (state: PlanSessionState, events: readonly PlanEvent[]) => Promise, + ): Promise { + assertSafeId(sessionId); + if (operationId !== undefined) assertSafeId(operationId); + const fingerprint = operationId ? operationFingerprint(operationInput) : undefined; + let result: PlanMutationResult | null = null; + await chainWrite(this.queues, sessionId, async () => { + const ledger = await this.readLedger(sessionId); + if (operationId) { + const existing = reconcileOperationReceipt(ledger.events, operationId, fingerprint!); + if (existing) { + result = { + event: existing, + state: stateThroughEvent(sessionId, ledger.events, existing.id), + }; + return; + } + } + const event = await build(ledger.state, ledger.events); + if (!event) return; + if (fingerprint) event.operationFingerprint = fingerprint; + const state = applyPlanEvent(ledger.state, event); + assertPlanProjectionWithinLimit(state); + await this.appendCanonicalEvent(event); + result = { event, state }; + }); + return result; + } + + async purgeSessionState(sessionId: string): Promise { + assertSafeId(sessionId); + await chainWrite(this.queues, sessionId, async () => { + this.#lease.transaction('write', () => { + this.#lease.database + .prepare('DELETE FROM workflow_plan_events WHERE session_id = ?') + .run(sessionId); + }); + }); + } + + private async readLedger( + sessionId: string, + ): Promise<{ events: PlanEvent[]; state: PlanSessionState }> { + assertSafeId(sessionId); + return readSqlitePlanLedger(this.#lease.database, sessionId); + } + + private async appendCanonicalEvent(event: PlanEvent): Promise { + this.#lease.transaction('write', () => { + insertPlanEvent(this.#lease.database, event); + }); + } +} + +function operationFingerprint(input: unknown): string { + const normalized = omitOperationId(structuredClone(input)); + return `sha256:${createHash('sha256').update(stableJson(normalized)).digest('hex')}`; +} + +function reconcileOperationReceipt( + events: readonly PlanEvent[], + operationId: string, + fingerprint: string, +): PlanEvent | undefined { + const existing = events.find((event) => event.id === operationId); + if (!existing) return undefined; + if (existing.operationFingerprint !== fingerprint) { + throw new PlanConflictError('Plan operation identity was reused with different input'); + } + return existing; +} + +function stateThroughEvent( + sessionId: string, + events: readonly PlanEvent[], + eventId: string, +): PlanSessionState { + let state = emptyPlanSessionState(sessionId); + for (const event of events) { + state = applyPlanEvent(state, event); + if (event.id === eventId) return state; + } + throw new Error('Plan operation receipt is missing from its ledger'); +} + +function omitOperationId(input: unknown): unknown { + if (!input || typeof input !== 'object' || Array.isArray(input)) return input; + const { operationId: _operationId, ...rest } = input as Record; + return rest; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function readSqlitePlanLedger( + database: DatabaseSync, + sessionId: string, +): { events: PlanEvent[]; state: PlanSessionState } { + assertSafeId(sessionId); + const rows = database + .prepare(` + SELECT record_json + FROM workflow_plan_events + WHERE session_id = ? + ORDER BY sequence + `) + .all(sessionId) as Array<{ record_json?: unknown }>; + const persistedEvents = rows.map((row, index) => { + if (typeof row.record_json !== 'string') { + throw new Error(`Invalid SQLite Plan event at sequence ${index}`); + } + return decodePlanEvent(JSON.parse(row.record_json), sessionId); + }); + let state = emptyPlanSessionState(sessionId); + for (const persistedEvent of persistedEvents) { + state = applyPlanEvent(state, persistedEvent); + assertPlanProjectionWithinLimit(state); + } + return { events: persistedEvents, state }; +} + +function insertPlanEvent(database: DatabaseSync, event: PlanEvent): void { + const row = database + .prepare(` + SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence + FROM workflow_plan_events + WHERE session_id = ? + `) + .get(event.sessionId) as { sequence?: unknown }; + if (typeof row.sequence !== 'number' || !Number.isSafeInteger(row.sequence)) { + throw new Error('Invalid next Plan event sequence'); + } + database + .prepare(` + INSERT INTO workflow_plan_events( + session_id, sequence, event_id, store_version, record_json + ) VALUES (?, ?, ?, ?, ?) + `) + .run(event.sessionId, row.sequence, event.id, event.storeVersion, JSON.stringify(event)); +} + +export function applyPlanEvent(state: PlanSessionState, event: PlanEvent): PlanSessionState { + if (event.sessionId !== state.sessionId) throw new Error('Plan event session mismatch'); + if (event.storeVersion !== state.storeVersion + 1) { + throw new Error('Plan event storeVersion is not contiguous'); + } + const next = structuredClone(state); + next.storeVersion = event.storeVersion; + switch (event.type) { + case 'plan_submitted': { + const prior = latestPlanProposal(next); + if (prior && prior.status === 'pending_approval') prior.status = 'stale'; + next.proposals.push(structuredClone(event.proposal)); + next.latestProposalId = event.proposal.proposalId; + break; + } + case 'plan_revision_requested': + proposalById(next, event.proposalId).status = 'stale'; + break; + case 'plan_abandoned': + proposalById(next, event.proposalId).status = 'stale'; + break; + case 'plan_approved': { + const proposal = proposalById(next, event.proposalId); + proposal.status = 'approved'; + if (proposal.sourceExecutionId) { + const sourceExecution = executionById(next, proposal.sourceExecutionId); + sourceExecution.status = 'cancelled'; + sourceExecution.updatedAt = event.ts; + sourceExecution.cancelledAt = event.ts; + sourceExecution.cancelReason = `Replanned by proposal ${proposal.proposalId}`; + delete sourceExecution.interruptedAt; + delete sourceExecution.interruptionReason; + } + next.executions.push(structuredClone(event.execution)); + next.activeExecutionId = event.execution.executionId; + break; + } + case 'plan_progress_updated': { + const execution = executionById(next, event.executionId); + execution.steps = structuredClone(event.steps); + execution.updatedAt = event.ts; + break; + } + case 'plan_execution_completed': { + const execution = executionById(next, event.executionId); + execution.steps = structuredClone(event.steps); + execution.status = 'completed'; + execution.updatedAt = event.ts; + execution.completedAt = event.ts; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_cancelled': { + const execution = executionById(next, event.executionId); + execution.status = 'cancelled'; + execution.updatedAt = event.ts; + execution.cancelledAt = event.ts; + execution.cancelReason = event.reason; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_interrupted': { + const execution = executionById(next, event.executionId); + execution.status = 'interrupted'; + execution.updatedAt = event.ts; + execution.interruptedAt = event.ts; + execution.interruptionReason = event.reason; + if (next.activeExecutionId === execution.executionId) delete next.activeExecutionId; + break; + } + case 'plan_execution_resumed': { + const execution = executionById(next, event.executionId); + execution.status = 'active'; + execution.updatedAt = event.ts; + delete execution.interruptedAt; + delete execution.interruptionReason; + next.activeExecutionId = execution.executionId; + break; + } + } + return next; +} + +function mergeExecutionSteps( + execution: PlanExecution, + updates: UpdatePlanExecutionInput['steps'], + now: number, +): PlanExecutionStep[] { + if (updates.length !== execution.steps.length) { + throw new PlanConflictError('update_plan must include every execution step'); + } + const byId = new Map(updates.map((step) => [step.id, step])); + if (byId.size !== updates.length) throw new PlanConflictError('Plan step ids must be unique'); + const merged = execution.steps.map((step) => { + const update = byId.get(step.id); + if (!update) throw new PlanConflictError(`Plan step ${step.id} is missing`); + if ( + (step.status === 'completed' || step.status === 'skipped') && + update.status !== step.status + ) { + throw new PlanConflictError(`Terminal plan step ${step.id} cannot be reopened`); + } + const note = optionalText(update.note, 'Plan step note'); + return { + ...structuredClone(step), + status: update.status, + ...(note ? { note } : {}), + updatedAt: now, + }; + }); + if (merged.filter((step) => step.status === 'in_progress').length > 1) { + throw new PlanConflictError('Only one plan step may be in progress'); + } + return merged; +} + +function normalizeDefinitions(steps: PlanStepDefinition[]): PlanStepDefinition[] { + if (!Array.isArray(steps) || steps.length === 0 || steps.length > PLAN_MAX_STEPS) { + throw new PlanConflictError(`A plan must contain between 1 and ${PLAN_MAX_STEPS} steps`); + } + const normalized = steps.map((step, index) => { + if (step.files && step.files.length > PLAN_MAX_FILES_PER_STEP) { + throw new PlanConflictError( + `A Plan step may reference at most ${PLAN_MAX_FILES_PER_STEP} files`, + ); + } + return { + id: requiredId(optionalText(step.id, 'Plan step id') ?? `step-${index + 1}`, 'Plan step id'), + title: requiredPlainText(step.title, 'Plan step title', PLAN_STEP_TITLE_MAX_CHARS), + description: requiredPlainText(step.description, 'Plan step description'), + ...(step.files && step.files.length > 0 + ? { files: step.files.map((file) => requiredText(file, 'Plan step file')) } + : {}), + ...(step.complexity ? { complexity: step.complexity } : {}), + }; + }); + if (new Set(normalized.map((step) => step.id)).size !== normalized.length) { + throw new PlanConflictError('Plan step ids must be unique'); + } + return normalized; +} + +function proposalById(state: PlanSessionState, proposalId: string): PlanProposal { + const proposal = state.proposals.find((candidate) => candidate.proposalId === proposalId); + if (!proposal) throw new PlanConflictError(`Unknown plan proposal: ${proposalId}`); + return proposal; +} + +function executionById(state: PlanSessionState, executionId: string): PlanExecution { + const execution = state.executions.find((candidate) => candidate.executionId === executionId); + if (!execution) throw new PlanConflictError(`Unknown plan execution: ${executionId}`); + return execution; +} + +function requireActiveExecution(state: PlanSessionState, executionId: string): PlanExecution { + if (state.activeExecutionId !== executionId) { + throw new PlanConflictError('The plan tool is bound to a stale execution'); + } + const execution = executionById(state, executionId); + if (execution.status !== 'active') { + throw new PlanConflictError('Plan execution is not active'); + } + return execution; +} + +function requireCancellableExecution(state: PlanSessionState, executionId: string): PlanExecution { + const execution = executionById(state, executionId); + if (execution.status !== 'active' && execution.status !== 'interrupted') { + throw new PlanConflictError('Plan execution cannot be cancelled'); + } + if (execution.status === 'active' && state.activeExecutionId !== executionId) { + throw new PlanConflictError('The plan tool is bound to a stale execution'); + } + return execution; +} + +function requiredText(value: string, label: string, maxBytes?: number): string { + const normalized = value.trim(); + if (!normalized) throw new PlanConflictError(`${label} cannot be empty`); + if (!isPlanTextWithinLimit(normalized, maxBytes)) { + throw new PlanConflictError(`${label} exceeds the Plan text limit`); + } + return normalized; +} + +function requiredPlainText(value: string, label: string, maxLength?: number): string { + const normalized = requiredText(value, label); + if (maxLength !== undefined && normalized.length > maxLength) { + throw new PlanConflictError(`${label} must be ${maxLength} characters or fewer`); + } + if ( + /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~)|!?(?:\[[^\]\n]+\]\([^)\n]+\))|(?:\*\*|__|`)/.test( + normalized, + ) + ) { + throw new PlanConflictError(`${label} must be plain text without Markdown formatting`); + } + return normalized; +} + +function optionalText(value: string | undefined, label: string): string | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + if (!isPlanTextWithinLimit(normalized)) { + throw new PlanConflictError(`${label} exceeds the Plan text limit`); + } + return normalized; +} + +function assertSafeId(value: string): void { + if (!isCanonicalPlanEntityId(value)) throw new Error('Invalid session id'); +} + +function requiredId(value: string, label: string): string { + if (!isCanonicalPlanEntityId(value)) { + throw new PlanConflictError(`${label} must be a canonical entity id`); + } + return value; +} + +function assertPlanProjectionWithinLimit(state: PlanSessionState): void { + for (const proposal of state.proposals) { + if (planEncodedByteLength({ kind: 'proposal', proposal }) > PLAN_PROJECTION_ITEM_MAX_BYTES) { + throw new PlanConflictError('Plan proposal exceeds the projection item limit'); + } + } + for (const execution of state.executions) { + if (planEncodedByteLength({ kind: 'execution', execution }) > PLAN_PROJECTION_ITEM_MAX_BYTES) { + throw new PlanConflictError('Plan execution exceeds the projection item limit'); + } + const worst = worstCasePlanExecution(execution, execution.executionId, Number.MAX_SAFE_INTEGER); + if ( + (execution.status === 'active' || execution.status === 'interrupted') && + planEncodedByteLength({ kind: 'execution', execution: worst }) > + PLAN_PROJECTION_ITEM_MAX_BYTES + ) { + throw new PlanConflictError('Plan execution cannot fit its terminal lifecycle projection'); + } + } +} + +function decodePlanEvent(value: unknown, sessionId: string): PlanEvent { + if (!value || typeof value !== 'object') throw new Error('Plan event must be an object'); + const event = value as Partial; + if ( + typeof event.id !== 'string' || + (event.operationFingerprint !== undefined && + (typeof event.operationFingerprint !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(event.operationFingerprint))) || + event.sessionId !== sessionId || + typeof event.ts !== 'number' || + !Number.isFinite(event.ts) || + typeof event.storeVersion !== 'number' || + !Number.isSafeInteger(event.storeVersion) || + event.storeVersion < 1 || + ![ + 'plan_submitted', + 'plan_revision_requested', + 'plan_abandoned', + 'plan_approved', + 'plan_progress_updated', + 'plan_execution_completed', + 'plan_execution_cancelled', + 'plan_execution_interrupted', + 'plan_execution_resumed', + ].includes(String(event.type)) + ) { + throw new Error('Invalid Plan event envelope'); + } + return value as PlanEvent; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e60d2275924c8653218639b9ff6f9fbf9bb66931d64d0d83891954e0e09fe5d5.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e60d2275924c8653218639b9ff6f9fbf9bb66931d64d0d83891954e0e09fe5d5.source new file mode 100644 index 0000000000..033d3e658e --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e60d2275924c8653218639b9ff6f9fbf9bb66931d64d0d83891954e0e09fe5d5.source @@ -0,0 +1,1447 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, realpathSync } from 'node:fs'; +import { link, lstat, mkdir, open, realpath, unlink } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { + CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS, + type ContextOffloadCopyResult, + type ContextOffloadGarbageCollectionResult, + type ContextOffloadLimits, + type ContextOffloadOwner, + type ContextOffloadPutResult, + type ContextOffloadReadResult, + type ContextOffloadRecord, + type ContextOffloadRetirementResult, + type ContextOffloadStore, + type ContextOffloadUsage, +} from '@maka/core/context-offload'; +import { + configureSqliteContextOffloadDatabase, + migrateSqliteContextOffloadDatabase, +} from './sqlite-context-offload-schema.js'; +import { + readStableBoundedFile, + syncDirectory, + syncDirectoryChain, + syncFile, +} from './stable-storage.js'; + +const MAX_MEDIA_TYPE_CODE_POINTS = 256; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const MANAGED_FILE_LOCATOR_PATTERN = /^sha256\/([0-9a-f]{2})\/([0-9a-f]{64})$/; +const require = createRequire(import.meta.url); + +export const CONTEXT_OFFLOAD_DATABASE_NAME = 'context-offload.sqlite'; +export const CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME = 'context-offload-values'; + +export type SqliteContextOffloadStoreFailpoint = + | 'after_blob_insert' + | 'after_ref_insert' + | 'after_managed_file_staging' + | 'after_managed_file_publish' + | 'after_gc_blob_delete'; + +export interface SqliteContextOffloadStoreOptions { + readonly limits: ContextOffloadLimits; + readonly now?: () => number; + readonly idFactory?: () => string; + readonly failpoint?: (point: SqliteContextOffloadStoreFailpoint) => void; + readonly onUnavailable?: (error: unknown) => void; +} + +interface ContextReferenceRow { + ref_id: unknown; + session_id: unknown; + owner_kind: unknown; + owner_id: unknown; + blob_id: unknown; + size_bytes: unknown; + media_type: unknown; + created_at: unknown; + storage_kind?: unknown; + payload?: unknown; +} + +interface ContextBlobRow { + storage_kind: unknown; + payload: unknown; + size_bytes: unknown; +} + +interface SessionUsageRow { + reference_count: unknown; + logical_bytes: unknown; +} + +interface StoreUsageRow { + blob_count: unknown; + physical_bytes: unknown; +} + +interface GarbageCandidateRow { + blob_id: unknown; + size_bytes: unknown; + storage_kind: unknown; +} + +interface ManagedFilePublication { + readonly locator: string; +} + +type ContextBlobStorageKind = 'inline' | 'managed_file'; + +type PreparedContextRead = + | ContextOffloadReadResult + | { + readonly kind: 'managed_file'; + readonly record: ContextOffloadRecord; + readonly locator: string; + }; + +/** Low-level implementation; production callers must use the Storage Root authority facade. */ +export class SqliteContextOffloadStore implements ContextOffloadStore { + readonly #database: DatabaseSync; + readonly #limits: ContextOffloadLimits; + readonly #now: () => number; + readonly #idFactory: () => string; + readonly #failpoint?: (point: SqliteContextOffloadStoreFailpoint) => void; + readonly #onUnavailable?: (error: unknown) => void; + readonly #storageRoot: string | undefined; + readonly #valueRoot: string | undefined; + #managedValueMutationTail: Promise = Promise.resolve(); + #closed = false; + + constructor(path: string, options: SqliteContextOffloadStoreOptions) { + if (!path) throw new Error('Context-offload SQLite path is required'); + this.#limits = validateLimits(options.limits); + this.#now = options.now ?? Date.now; + this.#idFactory = options.idFactory ?? randomUUID; + this.#failpoint = options.failpoint; + this.#onUnavailable = options.onUnavailable; + const storageRoot = path === ':memory:' ? undefined : dirname(path); + if (storageRoot) mkdirSync(storageRoot, { recursive: true }); + this.#storageRoot = storageRoot ? realpathSync(storageRoot) : undefined; + this.#valueRoot = this.#storageRoot + ? join(this.#storageRoot, CONTEXT_OFFLOAD_VALUES_DIRECTORY_NAME) + : undefined; + const Database = loadDatabaseSync(); + this.#database = new Database(path); + try { + configureSqliteContextOffloadDatabase(this.#database); + migrateSqliteContextOffloadDatabase(this.#database); + } catch (error) { + this.#database.close(); + this.#closed = true; + throw error; + } + } + + async put(input: { + readonly sessionId: string; + readonly owner: ContextOffloadOwner; + readonly bytes: Uint8Array; + readonly mediaType: string; + readonly expectedSha256?: string; + }): Promise { + assertBoundedIdentity(input.sessionId, 'Session id'); + assertOwner(input.owner); + assertBoundedText(input.mediaType, MAX_MEDIA_TYPE_CODE_POINTS, 'Context media type'); + if (!(input.bytes instanceof Uint8Array)) { + throw new Error('Context bytes must be a Uint8Array'); + } + if (input.expectedSha256 !== undefined && !SHA256_PATTERN.test(input.expectedSha256)) { + throw new Error('Expected context SHA-256 must be canonical lowercase hexadecimal'); + } + if (input.bytes.byteLength > this.#limits.ownerMaxBytes[input.owner.kind]) { + return { ok: false, reason: 'too_large' }; + } + + // Snapshot caller-owned bytes before crossing the asynchronous interface. + const bytes = new Uint8Array(input.bytes); + const blobId = createHash('sha256').update(bytes).digest('hex'); + if (input.expectedSha256 !== undefined && input.expectedSha256 !== blobId) { + return { ok: false, reason: 'identity_conflict' }; + } + + const operation = async (): Promise => { + let publication: ManagedFilePublication | undefined; + let deletionIntentLocator: string | undefined; + try { + this.#assertOpen(); + const existingStorageKind = this.#readBlobStorageKind(blobId); + const storageKind = + preferredStorageKind(input.owner) === 'managed_file' || + existingStorageKind === 'managed_file' + ? 'managed_file' + : 'inline'; + if (storageKind === 'managed_file') { + const locator = managedFileLocator(blobId); + this.#recordManagedFileDeletionIntent(locator, bytes.byteLength); + deletionIntentLocator = locator; + publication = await this.#publishManagedFile(locator, blobId, bytes); + this.#failpoint?.('after_managed_file_publish'); + } + const result = this.#writeTransaction(() => + this.#put({ ...input, bytes, blobId, storageKind, publication }), + ); + if (!result.ok && publication) { + await this.#drainFileDeletion(publication.locator).catch(() => undefined); + } + return result; + } catch (error) { + if (deletionIntentLocator) { + await this.#drainFileDeletion(deletionIntentLocator).catch(() => undefined); + } + this.#onUnavailable?.(error); + return { ok: false, reason: 'unavailable' }; + } + }; + return this.#runManagedValueMutation(operation); + } + + async read(input: { + readonly sessionId: string; + readonly refId: string; + readonly maxBytes: number; + }): Promise { + assertBoundedIdentity(input.sessionId, 'Session id'); + assertBoundedIdentity(input.refId, 'Context reference id'); + assertNonNegativeSafeInteger(input.maxBytes, 'Context read byte limit'); + try { + this.#assertOpen(); + const prepared = this.#readTransaction(() => this.#prepareRead(input)); + if ('ok' in prepared) return prepared; + return await this.#readManagedFile(prepared); + } catch (error) { + this.#onUnavailable?.(error); + return { ok: false, reason: 'unavailable' }; + } + } + + async releaseReference(input: { + readonly sessionId: string; + readonly refId: string; + }): Promise { + assertBoundedIdentity(input.sessionId, 'Session id'); + assertBoundedIdentity(input.refId, 'Context reference id'); + this.#assertOpen(); + this.#writeTransaction(() => { + const row = this.#database + .prepare( + `SELECT r.session_id, r.blob_id, b.size_bytes + FROM context_refs r + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.ref_id = ?`, + ) + .get(input.refId) as + | { session_id?: unknown; blob_id?: unknown; size_bytes?: unknown } + | undefined; + if (!row || row.session_id !== input.sessionId) return; + if (!isNonNegativeSafeInteger(row.size_bytes)) { + throw new Error('Invalid context reference size'); + } + const blobId = decodeBlobId(row.blob_id); + if (!blobId) throw new Error('Invalid context reference blob identity'); + const deleted = this.#database + .prepare('DELETE FROM context_refs WHERE session_id = ? AND ref_id = ?') + .run(input.sessionId, input.refId); + if (deleted.changes !== 1) return; + this.#database + .prepare( + `UPDATE context_session_usage + SET reference_count = reference_count - 1, + logical_bytes = logical_bytes - ? + WHERE session_id = ?`, + ) + .run(row.size_bytes, input.sessionId); + this.#database + .prepare( + `DELETE FROM context_session_usage + WHERE session_id = ? AND reference_count = 0 AND logical_bytes = 0`, + ) + .run(input.sessionId); + this.#markBlobUnreferencedIfEligible(blobId, this.#readNow()); + }); + } + + async copyReferences(input: { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly references: readonly { + readonly sourceRefId: string; + readonly targetOwner: ContextOffloadOwner; + }[]; + }): Promise { + assertBoundedIdentity(input.sourceSessionId, 'Source Session id'); + assertBoundedIdentity(input.targetSessionId, 'Target Session id'); + const references = input.references.map((reference) => { + assertBoundedIdentity(reference.sourceRefId, 'Source context reference id'); + assertOwner(reference.targetOwner); + return Object.freeze({ + sourceRefId: reference.sourceRefId, + targetOwner: Object.freeze({ ...reference.targetOwner }), + }); + }); + try { + this.#assertOpen(); + return this.#writeTransaction(() => + this.#copyReferences({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + references, + }), + ); + } catch (error) { + this.#onUnavailable?.(error); + return { ok: false, reason: 'unavailable' }; + } + } + + async retireSession(sessionId: string): Promise { + assertBoundedIdentity(sessionId, 'Session id'); + this.#assertOpen(); + return this.#writeTransaction(() => { + const rows = this.#database + .prepare( + `SELECT r.blob_id, b.size_bytes + FROM context_refs r INDEXED BY context_refs_session + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.session_id = ?`, + ) + .all(sessionId) as unknown as Array<{ blob_id?: unknown; size_bytes?: unknown }>; + let releasedLogicalBytes = 0; + const blobIds = new Map(); + for (const row of rows) { + const blobId = decodeBlobId(row.blob_id); + if (!blobId || !isNonNegativeSafeInteger(row.size_bytes)) { + throw new Error('Invalid retiring context reference'); + } + releasedLogicalBytes = addSafeInteger( + releasedLogicalBytes, + row.size_bytes, + 'Retired context logical bytes', + ); + blobIds.set(Buffer.from(blobId).toString('hex'), blobId); + } + const usage = this.#readSessionUsage(sessionId); + if ( + readNonNegativeInteger(usage.reference_count, 'Session reference count') !== rows.length || + readNonNegativeInteger(usage.logical_bytes, 'Session logical bytes') !== + releasedLogicalBytes + ) { + throw new Error('Context Session usage is inconsistent with retiring references'); + } + const deleted = this.#database + .prepare('DELETE FROM context_refs WHERE session_id = ?') + .run(sessionId); + if (deleted.changes !== rows.length) { + throw new Error('Context Session retirement deleted an unexpected reference count'); + } + this.#database + .prepare('DELETE FROM context_session_usage WHERE session_id = ?') + .run(sessionId); + const unreferencedAt = this.#readNow(); + for (const blobId of blobIds.values()) { + this.#markBlobUnreferencedIfEligible(blobId, unreferencedAt); + } + return { + releasedReferences: rows.length, + releasedLogicalBytes, + }; + }); + } + + async collectGarbage(input: { + readonly olderThan: number; + readonly maxBlobs: number; + readonly maxBytes: number; + }): Promise { + assertNonNegativeSafeInteger(input.olderThan, 'Context garbage watermark'); + assertPositiveSafeInteger(input.maxBlobs, 'Context garbage blob limit'); + assertPositiveSafeInteger(input.maxBytes, 'Context garbage byte limit'); + if (input.maxBlobs === Number.MAX_SAFE_INTEGER) { + throw new Error('Context garbage blob limit is too large'); + } + return this.#runManagedValueMutation(async () => { + this.#assertOpen(); + if (this.#hasPendingFileDeletions()) { + await this.#drainPendingFileDeletions(input.maxBlobs, input.maxBytes); + return { + deletedBlobs: 0, + deletedBytes: 0, + hasMore: this.#hasPendingFileDeletions() || this.#hasEligibleGarbage(input.olderThan), + }; + } + const collected = this.#writeTransaction(() => { + const rows = this.#database + .prepare( + `SELECT c.blob_id, b.size_bytes, b.storage_kind + FROM context_gc_candidates c INDEXED BY context_gc_candidates_eligible + JOIN context_blobs b ON b.blob_id = c.blob_id + WHERE c.unreferenced_at < ? + ORDER BY c.unreferenced_at, c.blob_id + LIMIT ?`, + ) + .all(input.olderThan, input.maxBlobs + 1) as unknown as GarbageCandidateRow[]; + const selected: Array<{ + readonly blobId: Uint8Array; + readonly sizeBytes: number; + readonly managedLocator?: string; + }> = []; + let deletedBytes = 0; + let inlineDeletedBytes = 0; + // Admit by metadata before SQLite materializes legacy inline BLOBs. + const readValue = this.#database.prepare( + 'SELECT storage_kind, size_bytes, payload FROM context_blobs WHERE blob_id = ?', + ); + for (const row of rows) { + if (selected.length === input.maxBlobs) break; + const blobId = decodeBlobId(row.blob_id); + if (!blobId || !isNonNegativeSafeInteger(row.size_bytes)) { + throw new Error('Invalid context garbage candidate'); + } + if (exceedsLimit(deletedBytes, row.size_bytes, input.maxBytes)) { + if (selected.length === 0) { + throw new Error( + `Context garbage byte limit ${input.maxBytes} cannot fit eligible blob of ${row.size_bytes} bytes`, + ); + } + break; + } + const stored = readValue.get(blobId) as unknown as ContextBlobRow | undefined; + if ( + !stored || + stored.size_bytes !== row.size_bytes || + stored.storage_kind !== row.storage_kind + ) { + throw new Error('Invalid context garbage candidate metadata'); + } + const value = decodeBlobValue(stored, Buffer.from(blobId).toString('hex')); + if (!value) throw new Error('Invalid context garbage candidate value'); + deletedBytes = addSafeInteger(deletedBytes, row.size_bytes, 'Collected context bytes'); + selected.push({ + blobId, + sizeBytes: row.size_bytes, + ...(value.kind === 'managed_file' ? { managedLocator: value.locator } : {}), + }); + if (value.kind === 'inline') { + inlineDeletedBytes = addSafeInteger( + inlineDeletedBytes, + row.size_bytes, + 'Collected inline context bytes', + ); + } + } + const deleteBlob = this.#database.prepare( + `DELETE FROM context_blobs + WHERE blob_id = ? + AND NOT EXISTS (SELECT 1 FROM context_refs WHERE blob_id = ?)`, + ); + const enqueueFileDeletion = this.#database.prepare( + `INSERT INTO context_file_deletions(locator, size_bytes, enqueued_at) + VALUES (?, ?, ?) + ON CONFLICT(locator) DO NOTHING`, + ); + for (const selectedBlob of selected) { + if (selectedBlob.managedLocator) { + enqueueFileDeletion.run( + Buffer.from(selectedBlob.managedLocator, 'utf8'), + selectedBlob.sizeBytes, + this.#readNow(), + ); + } + const deleted = deleteBlob.run(selectedBlob.blobId, selectedBlob.blobId); + if (deleted.changes !== 1) { + throw new Error('Context garbage candidate is still referenced or missing'); + } + this.#failpoint?.('after_gc_blob_delete'); + } + if (selected.length > 0) { + const updated = this.#database + .prepare( + `UPDATE context_store_usage + SET blob_count = blob_count - ?, physical_bytes = physical_bytes - ? + WHERE singleton = 1`, + ) + .run(selected.length, inlineDeletedBytes); + if (updated.changes !== 1) throw new Error('Missing context store usage row'); + } + return { + deletedBlobs: selected.length, + deletedBytes, + hasMore: rows.length > selected.length, + }; + }); + await this.#drainPendingFileDeletions(input.maxBlobs, input.maxBytes); + return { + ...collected, + hasMore: collected.hasMore || this.#hasPendingFileDeletions(), + }; + }); + } + + async usage(sessionId?: string): Promise { + if (sessionId !== undefined) assertBoundedIdentity(sessionId, 'Session id'); + this.#assertOpen(); + return this.#readTransaction(() => { + const storeUsage = this.#readStoreUsage(); + if (sessionId === undefined) { + const row = this.#database + .prepare( + `SELECT COALESCE(SUM(reference_count), 0) AS reference_count, + COALESCE(SUM(logical_bytes), 0) AS logical_bytes + FROM context_session_usage`, + ) + .get() as unknown as SessionUsageRow; + return usageFromRows(row, storeUsage); + } + const row = this.#readSessionUsage(sessionId); + return usageFromRows(row, storeUsage); + }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#database.close(); + } + + #readBlobStorageKind(blobId: string): ContextBlobStorageKind | undefined { + const row = this.#database + .prepare('SELECT storage_kind FROM context_blobs WHERE blob_id = ?') + .get(Buffer.from(blobId, 'hex')) as { storage_kind?: unknown } | undefined; + if (!row) return undefined; + if (row.storage_kind !== 'inline' && row.storage_kind !== 'managed_file') { + throw new Error('Invalid context blob storage kind'); + } + return row.storage_kind; + } + + #put(input: { + readonly sessionId: string; + readonly owner: ContextOffloadOwner; + readonly bytes: Uint8Array; + readonly mediaType: string; + readonly blobId: string; + readonly storageKind: ContextBlobStorageKind; + readonly publication?: ManagedFilePublication; + }): ContextOffloadPutResult { + const existingReference = this.#readReferenceByOwner(input.sessionId, input.owner); + if (existingReference) { + if ( + existingReference.blobId !== input.blobId || + existingReference.mediaType !== input.mediaType + ) { + return { ok: false, reason: 'identity_conflict' }; + } + } + + const blobIdBytes = Buffer.from(input.blobId, 'hex'); + const existingBlob = this.#database + .prepare('SELECT storage_kind, payload, size_bytes FROM context_blobs WHERE blob_id = ?') + .get(blobIdBytes) as ContextBlobRow | undefined; + if (existingBlob) { + if (!blobMatchesInput(existingBlob, input.blobId, input.bytes)) { + throw new Error(`Context blob identity is inconsistent: ${input.blobId}`); + } + } + + if (input.storageKind === 'managed_file' && !input.publication) { + throw new Error('Managed context value was not durably published'); + } + if (existingReference) { + this.#cancelPendingFileDeletion(input.publication, input.bytes.byteLength); + this.#promoteToManagedFile(existingBlob, input, blobIdBytes); + return { ok: true, record: existingReference }; + } + + const sessionUsage = this.#readSessionUsage(input.sessionId); + const logicalBytes = readNonNegativeInteger( + sessionUsage.logical_bytes, + 'Session logical bytes', + ); + if (exceedsLimit(logicalBytes, input.bytes.byteLength, this.#limits.sessionLogicalBytes)) { + return { ok: false, reason: 'session_quota_exceeded' }; + } + if (!existingBlob) { + const physicalBytes = readNonNegativeInteger( + this.#readStoreUsage().physical_bytes, + 'Workspace physical bytes', + ); + const exceedsWorkspaceQuota = + input.storageKind === 'managed_file' + ? physicalBytes > this.#limits.workspacePhysicalBytes + : exceedsLimit( + physicalBytes, + input.bytes.byteLength, + this.#limits.workspacePhysicalBytes, + ); + if (exceedsWorkspaceQuota) { + return { ok: false, reason: 'workspace_quota_exceeded' }; + } + } + this.#cancelPendingFileDeletion(input.publication, input.bytes.byteLength); + this.#promoteToManagedFile(existingBlob, input, blobIdBytes); + + const createdAt = this.#readNow(); + const refId = this.#idFactory(); + assertBoundedIdentity(refId, 'Context reference id'); + if (!existingBlob) { + this.#database + .prepare( + `INSERT INTO context_blobs(blob_id, storage_kind, payload, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + blobIdBytes, + input.storageKind, + input.storageKind === 'inline' + ? input.bytes + : Buffer.from(input.publication?.locator ?? '', 'utf8'), + input.bytes.byteLength, + createdAt, + ); + this.#database + .prepare( + `UPDATE context_store_usage + SET blob_count = blob_count + 1, + physical_bytes = physical_bytes + ? + WHERE singleton = 1`, + ) + .run(input.bytes.byteLength); + this.#failpoint?.('after_blob_insert'); + } + this.#database + .prepare( + `INSERT INTO context_refs( + ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + refId, + input.sessionId, + input.owner.kind, + input.owner.ownerId, + blobIdBytes, + input.mediaType, + createdAt, + ); + this.#database + .prepare( + `INSERT INTO context_session_usage(session_id, reference_count, logical_bytes) + VALUES (?, 1, ?) + ON CONFLICT(session_id) DO UPDATE SET + reference_count = reference_count + 1, + logical_bytes = logical_bytes + excluded.logical_bytes`, + ) + .run(input.sessionId, input.bytes.byteLength); + this.#database.prepare('DELETE FROM context_gc_candidates WHERE blob_id = ?').run(blobIdBytes); + this.#failpoint?.('after_ref_insert'); + return { + ok: true, + record: { + refId, + sessionId: input.sessionId, + owner: { ...input.owner }, + blobId: input.blobId, + sizeBytes: input.bytes.byteLength, + mediaType: input.mediaType, + createdAt, + }, + }; + } + + #promoteToManagedFile( + existingBlob: ContextBlobRow | undefined, + input: { + readonly storageKind: ContextBlobStorageKind; + readonly publication?: ManagedFilePublication; + }, + blobId: Uint8Array, + ): void { + if (existingBlob?.storage_kind !== 'inline' || input.storageKind !== 'managed_file') return; + this.#database + .prepare( + `UPDATE context_blobs + SET storage_kind = 'managed_file', payload = ? + WHERE blob_id = ? AND storage_kind = 'inline'`, + ) + .run(Buffer.from(input.publication?.locator ?? '', 'utf8'), blobId); + } + + #cancelPendingFileDeletion( + publication: ManagedFilePublication | undefined, + sizeBytes: number, + ): void { + if (!publication) return; + const locator = Buffer.from(publication.locator, 'utf8'); + const row = this.#database + .prepare('SELECT size_bytes FROM context_file_deletions WHERE locator = ?') + .get(locator) as { size_bytes?: unknown } | undefined; + if (!row) return; + if (row.size_bytes !== sizeBytes) { + throw new Error('Pending context file deletion has an inconsistent size'); + } + const deleted = this.#database + .prepare('DELETE FROM context_file_deletions WHERE locator = ?') + .run(locator); + if (deleted.changes !== 1) throw new Error('Pending context file deletion disappeared'); + this.#releasePendingFileBytes(sizeBytes); + } + + #copyReferences(input: { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly references: readonly { + readonly sourceRefId: string; + readonly targetOwner: ContextOffloadOwner; + }[]; + }): ContextOffloadCopyResult { + const createdAt = this.#readNow(); + const pendingByOwner = new Map< + string, + { + readonly refId: string; + readonly owner: ContextOffloadOwner; + readonly blobId: string; + readonly sizeBytes: number; + readonly mediaType: string; + } + >(); + const copied: Array<{ sourceRefId: string; targetRefId: string }> = []; + let addedLogicalBytes = 0; + + for (const reference of input.references) { + const sourceRow = this.#database + .prepare( + `SELECT r.ref_id, r.session_id, r.owner_kind, r.owner_id, r.blob_id, + b.size_bytes, r.media_type, r.created_at + FROM context_refs r + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.session_id = ? AND r.ref_id = ?`, + ) + .get(input.sourceSessionId, reference.sourceRefId) as ContextReferenceRow | undefined; + if (!sourceRow) return { ok: false, reason: 'not_found' }; + const source = decodeReferenceRow(sourceRow); + if (!source) throw new Error('Invalid source context reference'); + + const ownerKey = `${reference.targetOwner.kind}\0${reference.targetOwner.ownerId}`; + const pending = pendingByOwner.get(ownerKey); + if (pending) { + if (pending.blobId !== source.blobId || pending.mediaType !== source.mediaType) { + return { ok: false, reason: 'identity_conflict' }; + } + copied.push({ sourceRefId: reference.sourceRefId, targetRefId: pending.refId }); + continue; + } + + const existing = this.#readReferenceByOwner(input.targetSessionId, reference.targetOwner); + if (existing) { + if (existing.blobId !== source.blobId || existing.mediaType !== source.mediaType) { + return { ok: false, reason: 'identity_conflict' }; + } + pendingByOwner.set(ownerKey, { + refId: existing.refId, + owner: reference.targetOwner, + blobId: existing.blobId, + sizeBytes: existing.sizeBytes, + mediaType: existing.mediaType, + }); + copied.push({ sourceRefId: reference.sourceRefId, targetRefId: existing.refId }); + continue; + } + + const refId = this.#idFactory(); + assertBoundedIdentity(refId, 'Context reference id'); + pendingByOwner.set(ownerKey, { + refId, + owner: reference.targetOwner, + blobId: source.blobId, + sizeBytes: source.sizeBytes, + mediaType: source.mediaType, + }); + addedLogicalBytes = addSafeInteger( + addedLogicalBytes, + source.sizeBytes, + 'Copied context logical bytes', + ); + copied.push({ sourceRefId: reference.sourceRefId, targetRefId: refId }); + } + + const targetUsage = this.#readSessionUsage(input.targetSessionId); + const currentLogicalBytes = readNonNegativeInteger( + targetUsage.logical_bytes, + 'Target Session logical bytes', + ); + if (exceedsLimit(currentLogicalBytes, addedLogicalBytes, this.#limits.sessionLogicalBytes)) { + return { ok: false, reason: 'session_quota_exceeded' }; + } + + const newReferences = [...pendingByOwner.values()].filter( + (reference) => !this.#readReferenceByOwner(input.targetSessionId, reference.owner), + ); + const insertReference = this.#database.prepare( + `INSERT INTO context_refs( + ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + const clearCandidate = this.#database.prepare( + 'DELETE FROM context_gc_candidates WHERE blob_id = ?', + ); + for (const reference of newReferences) { + const blobId = Buffer.from(reference.blobId, 'hex'); + insertReference.run( + reference.refId, + input.targetSessionId, + reference.owner.kind, + reference.owner.ownerId, + blobId, + reference.mediaType, + createdAt, + ); + clearCandidate.run(blobId); + } + if (newReferences.length > 0) { + this.#database + .prepare( + `INSERT INTO context_session_usage(session_id, reference_count, logical_bytes) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + reference_count = reference_count + excluded.reference_count, + logical_bytes = logical_bytes + excluded.logical_bytes`, + ) + .run(input.targetSessionId, newReferences.length, addedLogicalBytes); + } + return { ok: true, copied }; + } + + #prepareRead(input: { + readonly sessionId: string; + readonly refId: string; + readonly maxBytes: number; + }): PreparedContextRead { + const row = this.#database + .prepare( + `SELECT r.ref_id, r.session_id, r.owner_kind, r.owner_id, r.blob_id, + b.size_bytes, b.storage_kind, b.payload, r.media_type, r.created_at + FROM context_refs r + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.ref_id = ?`, + ) + .get(input.refId) as (ContextReferenceRow & ContextBlobRow) | undefined; + if (!row) return { ok: false, reason: 'not_found' }; + const record = decodeReferenceRow(row); + if (!record) return { ok: false, reason: 'corrupt' }; + if (record.sessionId !== input.sessionId) return { ok: false, reason: 'session_mismatch' }; + if ( + record.sizeBytes > input.maxBytes || + record.sizeBytes > this.#limits.ownerMaxBytes[record.owner.kind] + ) { + return { ok: false, reason: 'too_large' }; + } + const value = decodeBlobValue(row, record.blobId); + if (!value || row.size_bytes !== record.sizeBytes) { + return { ok: false, reason: 'corrupt' }; + } + if (value.kind === 'managed_file') { + return { kind: 'managed_file', record, locator: value.locator }; + } + if (createHash('sha256').update(value.bytes).digest('hex') !== record.blobId) { + return { ok: false, reason: 'corrupt' }; + } + return { ok: true, record, bytes: value.bytes }; + } + + #readReferenceByOwner( + sessionId: string, + owner: ContextOffloadOwner, + ): ContextOffloadRecord | undefined { + const row = this.#database + .prepare( + `SELECT r.ref_id, r.session_id, r.owner_kind, r.owner_id, r.blob_id, + b.size_bytes, r.media_type, r.created_at + FROM context_refs r + JOIN context_blobs b ON b.blob_id = r.blob_id + WHERE r.session_id = ? AND r.owner_kind = ? AND r.owner_id = ?`, + ) + .get(sessionId, owner.kind, owner.ownerId) as ContextReferenceRow | undefined; + if (!row) return undefined; + const record = decodeReferenceRow(row); + if (!record) throw new Error('Invalid context reference row'); + return record; + } + + async #readManagedFile(input: { + readonly record: ContextOffloadRecord; + readonly locator: string; + }): Promise { + let bytes: Uint8Array; + try { + const path = this.#managedFilePath(input.locator, input.record.blobId); + await this.#assertManagedDirectory(dirname(path)); + bytes = await readStableBoundedFile({ + path, + maxBytes: input.record.sizeBytes, + invalidFile: () => new InvalidManagedContextFileError(), + }); + } catch (error) { + if (error instanceof InvalidManagedContextFileError || isNodeError(error, 'ENOENT')) { + return { ok: false, reason: 'corrupt' }; + } + throw error; + } + if ( + bytes.byteLength !== input.record.sizeBytes || + createHash('sha256').update(bytes).digest('hex') !== input.record.blobId + ) { + return { ok: false, reason: 'corrupt' }; + } + return { ok: true, record: input.record, bytes: new Uint8Array(bytes) }; + } + + async #publishManagedFile( + locator: string, + blobId: string, + bytes: Uint8Array, + ): Promise { + const target = this.#managedFilePath(locator, blobId); + const targetDirectory = dirname(target); + const storageRoot = this.#storageRoot; + if (!storageRoot) throw new Error('Managed context files require a durable Storage Root'); + await this.#ensureManagedDirectory(targetDirectory); + const temporary = managedFileStagingPath(target, blobId); + let handle: Awaited> | undefined; + try { + await unlink(temporary).then( + () => syncDirectory(targetDirectory), + (error: unknown) => { + if (!isNodeError(error, 'ENOENT')) throw error; + }, + ); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + this.#failpoint?.('after_managed_file_staging'); + try { + await link(temporary, target); + await this.#assertManagedDirectory(targetDirectory); + await syncDirectoryChain(targetDirectory, storageRoot); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + await this.#verifyManagedFile(target, blobId, bytes.byteLength); + await syncFile(target); + await this.#assertManagedDirectory(targetDirectory); + await syncDirectoryChain(targetDirectory, storageRoot); + } + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).then( + () => syncDirectory(targetDirectory), + (error: unknown) => { + if (!isNodeError(error, 'ENOENT')) throw error; + }, + ); + } + return { locator }; + } + + async #verifyManagedFile(path: string, blobId: string, sizeBytes: number): Promise { + await this.#assertManagedDirectory(dirname(path)); + const bytes = await readStableBoundedFile({ + path, + maxBytes: sizeBytes, + invalidFile: () => new InvalidManagedContextFileError(), + }); + if ( + bytes.byteLength !== sizeBytes || + createHash('sha256').update(bytes).digest('hex') !== blobId + ) { + throw new InvalidManagedContextFileError(); + } + } + + #recordManagedFileDeletionIntent(locator: string, sizeBytes: number): void { + const locatorBytes = Buffer.from(locator, 'utf8'); + this.#writeTransaction(() => { + const inserted = this.#database + .prepare( + `INSERT INTO context_file_deletions(locator, size_bytes, enqueued_at) + VALUES (?, ?, ?) + ON CONFLICT(locator) DO NOTHING`, + ) + .run(locatorBytes, sizeBytes, this.#readNow()); + if (inserted.changes === 1) { + const updated = this.#database + .prepare( + `UPDATE context_store_usage + SET physical_bytes = physical_bytes + ? + WHERE singleton = 1`, + ) + .run(sizeBytes); + if (updated.changes !== 1) throw new Error('Missing context store usage row'); + } + const row = this.#database + .prepare('SELECT size_bytes FROM context_file_deletions WHERE locator = ?') + .get(locatorBytes) as { size_bytes?: unknown } | undefined; + if (row?.size_bytes !== sizeBytes) { + throw new Error('Pending context file deletion has an inconsistent size'); + } + }); + } + + async #drainPendingFileDeletions(limit: number, maxBytes: number): Promise { + const rows = this.#database + .prepare( + 'SELECT locator, size_bytes FROM context_file_deletions ORDER BY enqueued_at, locator LIMIT ?', + ) + .all(limit) as Array<{ locator?: unknown; size_bytes: number }>; + let bytes = 0; + for (const row of rows) { + const size = readNonNegativeInteger(row.size_bytes, 'Pending context file deletion bytes'); + if (exceedsLimit(bytes, size, maxBytes)) { + if (bytes === 0) throw new Error('Context garbage byte limit cannot fit pending file'); + break; + } + bytes += size; + const locator = decodeManagedFileLocator(row.locator); + if (!locator) throw new Error('Invalid pending context file deletion locator'); + await this.#drainFileDeletion(locator); + } + } + + #hasPendingFileDeletions(): boolean { + return Boolean( + this.#database.prepare('SELECT 1 AS present FROM context_file_deletions LIMIT 1').get(), + ); + } + + #hasEligibleGarbage(olderThan: number): boolean { + return Boolean( + this.#database + .prepare( + `SELECT 1 AS present + FROM context_gc_candidates INDEXED BY context_gc_candidates_eligible + WHERE unreferenced_at < ? + LIMIT 1`, + ) + .get(olderThan), + ); + } + + async #drainFileDeletion(locator: string): Promise { + const locatorBytes = Buffer.from(locator, 'utf8'); + const live = this.#database + .prepare( + `SELECT 1 AS present FROM context_blobs + WHERE storage_kind = 'managed_file' AND payload = ? LIMIT 1`, + ) + .get(locatorBytes) as { present?: unknown } | undefined; + await this.#deleteManagedFile(locator, live?.present !== 1); + this.#writeTransaction(() => { + const row = this.#database + .prepare('SELECT size_bytes FROM context_file_deletions WHERE locator = ?') + .get(locatorBytes) as { size_bytes?: unknown } | undefined; + if (!row) return; + const sizeBytes = readNonNegativeInteger( + row.size_bytes, + 'Pending context file deletion bytes', + ); + const deleted = this.#database + .prepare('DELETE FROM context_file_deletions WHERE locator = ?') + .run(locatorBytes); + if (deleted.changes !== 1) throw new Error('Pending context file deletion disappeared'); + this.#releasePendingFileBytes(sizeBytes); + }); + } + + #releasePendingFileBytes(sizeBytes: number): void { + const updated = this.#database + .prepare( + `UPDATE context_store_usage + SET physical_bytes = physical_bytes - ? + WHERE singleton = 1 AND physical_bytes >= ?`, + ) + .run(sizeBytes, sizeBytes); + if (updated.changes !== 1) throw new Error('Context physical byte accounting underflow'); + } + + async #deleteManagedFile(locator: string, deleteTarget: boolean): Promise { + const path = this.#managedFilePath(locator); + const blobId = MANAGED_FILE_LOCATOR_PATTERN.exec(locator)?.[2]; + if (!blobId) throw new InvalidManagedContextFileError(); + const staging = managedFileStagingPath(path, blobId); + try { + await this.#assertManagedDirectory(dirname(path)); + let deleted = false; + for (const candidate of deleteTarget ? [path, staging] : [staging]) { + await unlink(candidate).then( + () => { + deleted = true; + }, + (error: unknown) => { + if (!isNodeError(error, 'ENOENT')) throw error; + }, + ); + } + if (deleted) await syncDirectory(dirname(path)); + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + } + } + + async #ensureManagedDirectory(directory: string): Promise { + for (const path of this.#managedDirectoryChain(directory)) { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + } + await this.#assertManagedDirectoryEntry(path); + } + } + + async #assertManagedDirectory(directory: string): Promise { + for (const path of this.#managedDirectoryChain(directory)) { + await this.#assertManagedDirectoryEntry(path); + } + } + + #managedDirectoryChain(directory: string): readonly string[] { + const valueRoot = this.#valueRoot; + if (!valueRoot) throw new Error('Managed context files require a durable Storage Root'); + const shaRoot = join(valueRoot, 'sha256'); + const shard = relative(shaRoot, directory); + if (!/^[0-9a-f]{2}$/u.test(shard) || isAbsolute(shard) || shard.includes(sep)) { + throw new InvalidManagedContextFileError(); + } + return [valueRoot, shaRoot, directory]; + } + + async #assertManagedDirectoryEntry(path: string): Promise { + const storageRoot = this.#storageRoot; + if (!storageRoot) throw new Error('Managed context files require a durable Storage Root'); + let entry: Awaited>; + let resolved: string; + try { + [entry, resolved] = await Promise.all([lstat(path), realpath(path)]); + } catch (error) { + if (isNodeError(error, 'ENOTDIR')) { + throw new InvalidManagedContextFileError(); + } + throw error; + } + const fromRoot = relative(storageRoot, resolved); + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new InvalidManagedContextFileError(); + } + } + + #managedFilePath(locator: string, expectedBlobId?: string): string { + const valueRoot = this.#valueRoot; + if (!valueRoot) throw new Error('Managed context files require a durable Storage Root'); + const match = MANAGED_FILE_LOCATOR_PATTERN.exec(locator); + const blobId = match?.[2]; + if (!match || !blobId || match[1] !== blobId.slice(0, 2)) { + throw new InvalidManagedContextFileError(); + } + if (expectedBlobId !== undefined && blobId !== expectedBlobId) { + throw new InvalidManagedContextFileError(); + } + return join(valueRoot, 'sha256', match[1], blobId); + } + + #runManagedValueMutation(operation: () => Promise): Promise { + const pending = this.#managedValueMutationTail.then(operation, operation); + this.#managedValueMutationTail = pending.then( + () => undefined, + () => undefined, + ); + return pending; + } + + #markBlobUnreferencedIfEligible(blobId: Uint8Array, unreferencedAt: number): void { + this.#database + .prepare( + `INSERT INTO context_gc_candidates(blob_id, unreferenced_at) + SELECT ?, ? + WHERE NOT EXISTS (SELECT 1 FROM context_refs WHERE blob_id = ?) + ON CONFLICT(blob_id) DO NOTHING`, + ) + .run(blobId, unreferencedAt, blobId); + } + + #readNow(): number { + const now = this.#now(); + assertNonNegativeSafeInteger(now, 'Context timestamp'); + return now; + } + + #readSessionUsage(sessionId: string): SessionUsageRow { + return ( + (this.#database + .prepare( + `SELECT reference_count, logical_bytes + FROM context_session_usage WHERE session_id = ?`, + ) + .get(sessionId) as SessionUsageRow | undefined) ?? { + reference_count: 0, + logical_bytes: 0, + } + ); + } + + #readStoreUsage(): StoreUsageRow { + const row = this.#database + .prepare( + `SELECT blob_count, physical_bytes + FROM context_store_usage WHERE singleton = 1`, + ) + .get() as StoreUsageRow | undefined; + if (!row) throw new Error('Missing context store usage row'); + readNonNegativeInteger(row.blob_count, 'Workspace blob count'); + readNonNegativeInteger(row.physical_bytes, 'Workspace physical bytes'); + return row; + } + + #writeTransaction(operation: () => T): T { + this.#database.exec('BEGIN IMMEDIATE'); + try { + const result = operation(); + this.#database.exec('COMMIT'); + return result; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + #readTransaction(operation: () => T): T { + this.#database.exec('BEGIN'); + try { + const result = operation(); + this.#database.exec('COMMIT'); + return result; + } catch (error) { + rollback(this.#database); + throw error; + } + } + + #assertOpen(): void { + if (this.#closed) throw new Error('SQLite Context Offload Store is closed'); + } +} + +function decodeReferenceRow(row: ContextReferenceRow): ContextOffloadRecord | undefined { + if ( + typeof row.ref_id !== 'string' || + typeof row.session_id !== 'string' || + !isOwnerKind(row.owner_kind) || + typeof row.owner_id !== 'string' || + typeof row.media_type !== 'string' || + !isNonNegativeSafeInteger(row.size_bytes) || + !isNonNegativeSafeInteger(row.created_at) + ) { + return undefined; + } + const blobIdBytes = decodeBytes(row.blob_id); + if (!blobIdBytes || blobIdBytes.byteLength !== 32) return undefined; + return { + refId: row.ref_id, + sessionId: row.session_id, + owner: { kind: row.owner_kind, ownerId: row.owner_id }, + blobId: Buffer.from(blobIdBytes).toString('hex'), + sizeBytes: row.size_bytes, + mediaType: row.media_type, + createdAt: row.created_at, + }; +} + +type DecodedBlobValue = + | { readonly kind: 'inline'; readonly bytes: Uint8Array } + | { readonly kind: 'managed_file'; readonly locator: string }; + +function decodeBlobValue( + row: ContextBlobRow, + expectedBlobId?: string, +): DecodedBlobValue | undefined { + if (!isNonNegativeSafeInteger(row.size_bytes)) return undefined; + if (row.storage_kind === 'inline') { + const bytes = decodeBytes(row.payload); + return bytes?.byteLength === row.size_bytes ? { kind: 'inline', bytes } : undefined; + } + if (row.storage_kind !== 'managed_file') return undefined; + const locator = decodeManagedFileLocator(row.payload); + if (!locator) return undefined; + const blobId = MANAGED_FILE_LOCATOR_PATTERN.exec(locator)?.[2]; + if (expectedBlobId !== undefined && blobId !== expectedBlobId) return undefined; + return { kind: 'managed_file', locator }; +} + +function blobMatchesInput(row: ContextBlobRow, blobId: string, expectedBytes: Uint8Array): boolean { + if ( + row.size_bytes !== expectedBytes.byteLength || + createHash('sha256').update(expectedBytes).digest('hex') !== blobId + ) { + return false; + } + const value = decodeBlobValue(row, blobId); + return ( + value !== undefined && + (value.kind === 'managed_file' || + createHash('sha256').update(value.bytes).digest('hex') === blobId) + ); +} + +function decodeBytes(value: unknown): Uint8Array | undefined { + return value instanceof Uint8Array ? new Uint8Array(value) : undefined; +} + +function decodeBlobId(value: unknown): Uint8Array | undefined { + const bytes = decodeBytes(value); + return bytes?.byteLength === 32 ? bytes : undefined; +} + +function managedFileLocator(blobId: string): string { + if (!SHA256_PATTERN.test(blobId)) throw new Error('Invalid managed context blob identity'); + return `sha256/${blobId.slice(0, 2)}/${blobId}`; +} + +function decodeManagedFileLocator(value: unknown): string | undefined { + const bytes = decodeBytes(value); + if (!bytes || bytes.byteLength === 0 || bytes.byteLength > 512) return undefined; + const locator = Buffer.from(bytes).toString('utf8'); + if (!Buffer.from(locator, 'utf8').equals(Buffer.from(bytes))) return undefined; + const match = MANAGED_FILE_LOCATOR_PATTERN.exec(locator); + const blobId = match?.[2]; + return match && blobId && match[1] === blobId.slice(0, 2) ? locator : undefined; +} + +function usageFromRows(session: SessionUsageRow, store: StoreUsageRow): ContextOffloadUsage { + return { + references: readNonNegativeInteger(session.reference_count, 'Context reference count'), + logicalBytes: readNonNegativeInteger(session.logical_bytes, 'Context logical bytes'), + physicalBytes: readNonNegativeInteger(store.physical_bytes, 'Context physical bytes'), + }; +} + +function validateLimits(limits: ContextOffloadLimits): ContextOffloadLimits { + const ownerMaxBytes = { + read_image_snapshot: limits.ownerMaxBytes?.read_image_snapshot, + tool_result_archive: limits.ownerMaxBytes?.tool_result_archive, + }; + assertNonNegativeSafeInteger(ownerMaxBytes.read_image_snapshot, 'Read image snapshot byte limit'); + assertNonNegativeSafeInteger(ownerMaxBytes.tool_result_archive, 'Tool Result archive byte limit'); + assertNonNegativeSafeInteger(limits.sessionLogicalBytes, 'Session context quota'); + assertNonNegativeSafeInteger(limits.workspacePhysicalBytes, 'Workspace context quota'); + return Object.freeze({ + ownerMaxBytes: Object.freeze(ownerMaxBytes), + sessionLogicalBytes: limits.sessionLogicalBytes, + workspacePhysicalBytes: limits.workspacePhysicalBytes, + }); +} + +function assertOwner(owner: ContextOffloadOwner): void { + if (!isOwnerKind(owner.kind)) throw new Error(`Unsupported context owner: ${String(owner.kind)}`); + assertBoundedIdentity(owner.ownerId, 'Context owner id'); +} + +function preferredStorageKind(owner: ContextOffloadOwner): ContextBlobStorageKind { + return owner.kind === 'read_image_snapshot' ? 'managed_file' : 'inline'; +} + +function managedFileStagingPath(target: string, blobId: string): string { + return join(dirname(target), `.${blobId}.publish.tmp`); +} + +function isOwnerKind(value: unknown): value is ContextOffloadOwner['kind'] { + return value === 'read_image_snapshot' || value === 'tool_result_archive'; +} + +function assertBoundedIdentity(value: string, label: string): void { + assertBoundedText(value, CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS, label); +} + +function assertBoundedText(value: string, maxCodePoints: number, label: string): void { + if (typeof value !== 'string' || value.length === 0 || [...value].length > maxCodePoints) { + throw new Error(`${label} must be a non-empty string of at most ${maxCodePoints} code points`); + } +} + +function assertNonNegativeSafeInteger(value: number, label: string): void { + if (!isNonNegativeSafeInteger(value)) { + throw new Error(`${label} must be a non-negative safe integer`); + } +} + +function assertPositiveSafeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive safe integer`); + } +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function readNonNegativeInteger(value: unknown, label: string): number { + if (!isNonNegativeSafeInteger(value)) throw new Error(`Invalid ${label}`); + return value; +} + +function exceedsLimit(current: number, added: number, limit: number): boolean { + return current > limit - added; +} + +function addSafeInteger(left: number, right: number, label: string): number { + const result = left + right; + if (!Number.isSafeInteger(result) || result < 0) throw new Error(`Invalid ${label}`); + return result; +} + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; +} + +function rollback(database: DatabaseSync): void { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the operation failure that triggered rollback. + } +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +class InvalidManagedContextFileError extends Error {} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e68073e26267c23a56e7b2a36443b1ef05e3cce496a048e338d90ddbf8262784.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e68073e26267c23a56e7b2a36443b1ef05e3cce496a048e338d90ddbf8262784.source new file mode 100644 index 0000000000..2e0cb2e04b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e68073e26267c23a56e7b2a36443b1ef05e3cce496a048e338d90ddbf8262784.source @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + assertStorageRootLease, + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootLease, +} from './root-authority.js'; +import { + invalidMemoryDocument, + isRevision, + memoryBundleIoFailed, + type CommitMemoryBundleInput, + MemoryBundleBackupRevisionConflictError, + MemoryBundleBackupNotFoundError, + type MemoryBackupSnapshot, + type MemoryBundleMutationResult, + MemoryBundleRevisionConflictError, + type MemoryBundleSnapshot, + MemoryBundleStoreError, + type RestoreMemoryBackupInput, +} from './memory-bundle-model.js'; +import { + commitMemoryBundle, + readMemoryBackups, + readMemoryBundle, + recoverMemoryBundle, + restoreMemoryBackup, +} from './memory-bundle-io.js'; +import { SerializedOperationLane } from './serialized-operation-lane.js'; + +export { + MEMORY_DOCUMENT_MAX_BYTES, + MemoryBundleBackupRevisionConflictError, + MemoryBundleBackupNotFoundError, + MemoryBundleRevisionConflictError, + MemoryBundleStoreError, +} from './memory-bundle-model.js'; +export type { + CommitMemoryBundleInput, + MemoryBackupKind, + MemoryBackupSnapshot, + MemoryBundleMutationResult, + MemoryBundleSnapshot, + MemoryBundleStoreErrorCode, + MemoryDocumentName, + MemoryRevision, + MemoryDocumentSnapshot, + RestoreMemoryBackupInput, +} from './memory-bundle-model.js'; + +const readerBrand: unique symbol = Symbol('InteractiveMemoryBundleStoreReader'); +const writerBrand: unique symbol = Symbol('InteractiveMemoryBundleStoreWriter'); + +export interface InteractiveMemoryBundleStoreReader { + readonly kind: 'interactive'; + readonly access: 'read'; + readonly [readerBrand]: true; + read(): Promise; + listBackups(): Promise; +} + +export interface InteractiveMemoryBundleStoreWriter { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + read(): Promise; + listBackups(): Promise; + commit(input: CommitMemoryBundleInput): Promise; + restoreBackup(input: RestoreMemoryBackupInput): Promise; +} + +const readers = new WeakSet(); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export function authenticateInteractiveMemoryBundleStoreReader( + store: InteractiveMemoryBundleStoreReader, +): InteractiveMemoryBundleStoreReader { + if (!readers.has(store)) throw invalidFacade('read'); + return store; +} + +export function authenticateInteractiveMemoryBundleStoreWriter( + store: InteractiveMemoryBundleStoreWriter, +): InteractiveMemoryBundleStoreWriter { + if (!writers.has(store)) throw invalidFacade('write'); + return store; +} + +export async function openInteractiveMemoryBundleStoreForRead( + lease: StorageRootLease<'interactive', 'read'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'read'); + const facade = Object.freeze({ + kind: 'interactive' as const, + access: 'read' as const, + [readerBrand]: true as const, + read: () => + runMemoryStoreOperation(() => + runWithStorageRootLease(lease, 'interactive', 'read', (root) => readMemoryBundle(root)), + ), + listBackups: () => + runMemoryStoreOperation(() => + runWithStorageRootLease(lease, 'interactive', 'read', (root) => readMemoryBackups(root)), + ), + }); + readers.add(facade); + return facade; +} + +export async function openInteractiveMemoryBundleStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + await assertStorageRootLease(lease, 'interactive', 'write'); + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + + const coordinator = new MemoryBundleCoordinator((operation: (root: string) => Promise) => + runWithStorageRootLease(lease, 'interactive', 'write', operation), + ); + const pending = Promise.resolve().then(async () => { + await coordinator.recoverForWrite(); + await assertStorageRootLease(lease, 'interactive', 'write'); + const recoveredExisting = writerByLease.get(lease); + if (recoveredExisting) return recoveredExisting; + const facade = Object.freeze({ + kind: 'interactive' as const, + access: 'write' as const, + [writerBrand]: true as const, + read: () => coordinator.read(), + listBackups: () => coordinator.listBackups(), + commit: (input: CommitMemoryBundleInput) => coordinator.commit(admitCommitInput(input)), + restoreBackup: (input: RestoreMemoryBackupInput) => + coordinator.restoreBackup(admitRestoreInput(input)), + }); + writers.add(facade); + writerByLease.set(lease, facade); + return facade; + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) { + writerOpeningByLease.delete(lease); + } + } +} + +type RootExecutor = (operation: (root: string) => Promise) => Promise; + +class MemoryBundleCoordinator { + private readonly lane: SerializedOperationLane; + + constructor(execute: RootExecutor) { + this.lane = new SerializedOperationLane(execute); + } + + recoverForWrite(): Promise { + return this.inLane((root) => recoverMemoryBundle(root)); + } + + read(): Promise { + return this.inLane((root) => readMemoryBundle(root)); + } + + listBackups(): Promise { + return this.inLane((root) => readMemoryBackups(root)); + } + + commit(input: CommitMemoryBundleInput): Promise { + return this.inLane((root) => commitMemoryBundle(root, input)); + } + + restoreBackup(input: RestoreMemoryBackupInput): Promise { + return this.inLane((root) => restoreMemoryBackup(root, input)); + } + + private inLane(operation: (root: string) => Promise): Promise { + return this.lane.run(operation).catch(rethrowMemoryStoreError); + } +} + +function admitCommitInput(input: CommitMemoryBundleInput): CommitMemoryBundleInput { + if (!isRevision(input.expectedRevision)) { + throw invalidMemoryDocument('Expected Memory bundle revision must be SHA-256'); + } + return { + expectedRevision: input.expectedRevision, + memory: Uint8Array.from(input.memory), + pending: input.pending === null ? null : Uint8Array.from(input.pending), + ...(input.backup === undefined ? {} : { backup: requireCommitBackupKind(input.backup) }), + }; +} + +function admitRestoreInput(input: RestoreMemoryBackupInput): RestoreMemoryBackupInput { + if (!isRevision(input.expectedRevision)) { + throw invalidMemoryDocument('Expected Memory bundle revision must be SHA-256'); + } + if (!isRevision(input.expectedBackupRevision)) { + throw invalidMemoryDocument('Expected Memory backup revision must be SHA-256'); + } + if (input.kind !== 'save' && input.kind !== 'reset' && input.kind !== 'restore') { + throw invalidMemoryDocument('Memory backup kind is invalid'); + } + return { + expectedRevision: input.expectedRevision, + expectedBackupRevision: input.expectedBackupRevision, + kind: input.kind, + }; +} + +function requireCommitBackupKind(input: unknown): 'save' | 'reset' { + if (input !== 'save' && input !== 'reset') { + throw invalidMemoryDocument('Memory commit backup kind is invalid'); + } + return input; +} + +function runMemoryStoreOperation(operation: () => Promise): Promise { + return operation().catch(rethrowMemoryStoreError); +} + +function rethrowMemoryStoreError(error: unknown): never { + if ( + error instanceof MemoryBundleStoreError || + error instanceof MemoryBundleRevisionConflictError || + error instanceof MemoryBundleBackupRevisionConflictError || + error instanceof MemoryBundleBackupNotFoundError || + error instanceof StorageRootAuthorityError + ) { + throw error; + } + if (isNodeError(error)) { + throw memoryBundleIoFailed('Memory bundle I/O failed', error); + } + throw error; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as NodeJS.ErrnoException).code === 'string' + ); +} + +function invalidFacade(access: 'read' | 'write'): StorageRootAuthorityError { + return new StorageRootAuthorityError( + 'invalid_lease', + `Expected authentic interactive ${access} Memory bundle store`, + ); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6b133b580f3ec4dce18a252f6253f2f75a0b5e997318cb417fe01f4c0dd0ded.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6b133b580f3ec4dce18a252f6253f2f75a0b5e997318cb417fe01f4c0dd0ded.source new file mode 100644 index 0000000000..a64b102e8b --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6b133b580f3ec4dce18a252f6253f2f75a0b5e997318cb417fe01f4c0dd0ded.source @@ -0,0 +1,921 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + type WorkHubDelegationAssignedMessage, + type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationSupersededMessage, +} from '@maka/core/session'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { createSessionStore, isSessionNotFoundError } from '../session-store.js'; + +test('atomically commits one WorkHub assignment and target admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const request = assignmentRequest('action-one', target.id, 'Payments', 'target-turn'); + const first = await store.assignWorkHubMessage(request); + const replay = await store.assignWorkHubMessage({ + ...request, + assignment: { + ...request.assignment, + ts: request.assignment.ts + 10, + targetTurnId: 'recomputed-turn', + targetSessionName: 'Recomputed name', + }, + admission: { + ...request.admission, + turnId: 'recomputed-turn', + runId: 'recomputed-run', + }, + }); + + assert.equal(first.kind, 'assigned'); + assert.equal(replay.kind, 'existing'); + assert.equal(replay.assignment.targetTurnId, 'target-turn'); + assert.deepEqual( + await store.readMessageAdmission(target.id, request.admission.messageId), + request.admission, + ); + assert.deepEqual( + (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( + (message) => message.type === 'workhub_coordination', + ), + [request.assignment], + ); + assert.deepEqual(await store.readActiveWorkHubAssignmentsByTarget([target.id]), [ + request.assignment, + ]); + const coordination = await store.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); + assert.equal(coordination.lastMessageAt, request.assignment.ts); + await handOffToRootTurn(store, root, request); + const replayAfterConsumption = await store.assignWorkHubMessage(request); + assert.equal(replayAfterConsumption.kind, 'existing'); + assert.deepEqual(replayAfterConsumption.assignment, request.assignment); + assert.deepEqual(await store.readActiveWorkHubAssignmentsByTarget([target.id]), [ + request.assignment, + ]); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('atomically binds delegated text and copied attachments while preserving source authority', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-delegated-content-')); + let store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const base = assignmentRequest('delegated-content', target.id, 'Payments', 'target-turn'); + const sourceAttachment = { + kind: 'other' as const, + name: 'requirements.txt', + mimeType: 'text/plain', + bytes: 12, + ref: { + kind: 'session_file' as const, + sessionId: WORKHUB_COORDINATION_SESSION_ID, + relativePath: 'source-file', + }, + }; + const targetAttachment = { + ...sourceAttachment, + ref: { kind: 'session_file' as const, sessionId: target.id, relativePath: 'copied-file' }, + }; + const content = normalizeMessageContent({ + text: 'Fix the payment retry state', + attachments: [targetAttachment], + }); + const request = { + ...base, + assignment: { + ...base.assignment, + userText: 'Continue Payments and explain the result here', + delegationText: content.text, + attachments: [sourceAttachment], + targetAttachments: [targetAttachment], + }, + admission: { + ...base.admission, + content, + submittedContentDigest: messageContentDigest(content), + }, + }; + const wrongContent = normalizeMessageContent({ + text: request.assignment.userText, + attachments: [targetAttachment], + }); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + admission: { + ...request.admission, + content: wrongContent, + submittedContentDigest: messageContentDigest(wrongContent), + }, + }), + /Invalid WorkHub assignment identity/, + ); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + assignment: { ...request.assignment, targetAttachments: [sourceAttachment] }, + }), + /Invalid WorkHub assignment identity/, + ); + assert.equal(await store.readWorkHubAssignment(request.assignment.actionId), undefined); + assert.equal( + await store.readMessageAdmission(target.id, request.admission.messageId), + undefined, + ); + assert.equal((await store.assignWorkHubMessage(request)).kind, 'assigned'); + assert.deepEqual( + (await store.readMessageAdmission(target.id, request.admission.messageId))?.content, + content, + ); + await store.close?.(); + store = createSessionStore(root); + assert.deepEqual((await store.assignWorkHubMessage(request)).assignment, request.assignment); + const changed = normalizeMessageContent({ ...content, text: 'Different delegated work' }); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + assignment: { ...request.assignment, delegationText: changed.text }, + admission: { + ...request.admission, + content: changed, + submittedContentDigest: messageContentDigest(changed), + }, + }), + /different assignment/, + ); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + assignment: { + ...request.assignment, + attachments: [ + { + ...sourceAttachment, + ref: { ...sourceAttachment.ref, relativePath: 'another-source' }, + }, + ], + }, + }), + /different assignment/, + ); + assert.deepEqual( + await store.readWorkHubAssignment(request.assignment.actionId), + request.assignment, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('scans every target Message lifecycle once and preserves Coordination order', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-target-linkage-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const unrelated = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const oldest = assignmentRequest('target-oldest', target.id, 'Payments', 'oldest-turn'); + const middle = assignmentRequest('target-middle', target.id, 'Payments', 'middle-turn'); + const newest = assignmentRequest('target-newest', target.id, 'Payments', 'newest-turn'); + await store.assignWorkHubMessage(oldest); + await store.assignWorkHubMessage( + assignmentRequest('unrelated-action', unrelated.id, 'Login', 'unrelated-turn'), + ); + await store.assignWorkHubMessage(middle); + await handOffToRootTurn(store, root, middle); + await store.assignWorkHubMessage(newest); + assert.equal( + await store.claimMessageAdmissionCancellation( + target.id, + newest.admission.messageId, + 'newest-cancellation-claim', + ), + 'cancelled_by_claim', + ); + + assert.deepEqual(await store.readActiveWorkHubAssignmentsByTarget([target.id]), [ + newest.assignment, + middle.assignment, + oldest.assignment, + ]); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('keeps target assignments reachable when their Message lifecycle changes', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-target-linkage-transition-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const requests = ['transition-first', 'transition-second', 'transition-third'] + .map((actionId) => assignmentRequest(actionId, target.id, target.name, `${actionId}-turn`)) + .sort((left, right) => left.admission.messageId.localeCompare(right.admission.messageId)); + for (const request of requests) await store.assignWorkHubMessage(request); + + await handOffToRootTurn(store, root, requests[1]!); + assert.equal( + await store.claimMessageAdmissionCancellation( + target.id, + requests[0]!.admission.messageId, + 'transition-cancellation-claim', + ), + 'cancelled_by_claim', + ); + + assert.deepEqual( + (await store.readActiveWorkHubAssignmentsByTarget([target.id])).map( + ({ actionId }) => actionId, + ), + [...requests].reverse().map(({ assignment }) => assignment.actionId), + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('ignores ordinary WorkHub-shaped Message ids without hiding a real linkage', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-target-linkage-namespace-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const unrelated = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const real = assignmentRequest('real-target-action', target.id, target.name, 'real-turn'); + const other = assignmentRequest( + 'other-target-action', + unrelated.id, + unrelated.name, + 'other-turn', + ); + await store.assignWorkHubMessage(real); + await store.assignWorkHubMessage(other); + const ordinaryContent = normalizeMessageContent({ text: 'An ordinary pending Message' }); + const ordinaryIds = [ + ...Array.from( + { length: 33 }, + (_, index) => `whm_${(index + 1).toString(16).padStart(48, '0')}`, + ), + other.admission.messageId, + ]; + assert.equal( + ordinaryIds.slice(0, -1).every((id) => id < real.admission.messageId), + true, + ); + for (const [index, messageId] of ordinaryIds.entries()) { + await store.commitMessageAdmission({ + sessionId: target.id, + turnId: `ordinary-turn-${index}`, + runId: `ordinary-run-${index}`, + messageId, + content: ordinaryContent, + submittedContentDigest: messageContentDigest(ordinaryContent), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: index, + }); + } + + assert.deepEqual( + (await store.readActiveWorkHubAssignmentsByTarget([target.id])).map( + ({ actionId }) => actionId, + ), + [real.assignment.actionId], + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('retires a link on every terminal record and on no other outcome', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-terminal-matrix-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const requests = Object.fromEntries( + ['plain', 'superseded', 'aborted', 'stopped', 'not-owned'].map((actionId) => [ + actionId, + assignmentRequest(actionId, target.id, 'Payments', `${actionId}-turn`), + ]), + ) as Record<'plain' | 'superseded' | 'aborted' | 'stopped' | 'not-owned', AssignmentRequest>; + for (const request of Object.values(requests)) await store.assignWorkHubMessage(request); + + const superseded = requests.superseded.assignment; + const aborted = requests.aborted.assignment; + const stopped = requests.stopped.assignment; + const notOwned = requests['not-owned'].assignment; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${terminalSuffix(superseded.delegationId)}`, + turnId: 'terminal-matrix', + ts: 20, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: 'terminal-matrix-supersede', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + coordinationTurnId: 'terminal-matrix', + supersededActionId: superseded.actionId, + supersededDelegationId: superseded.delegationId, + replacementDelegationId: 'whd_terminal_matrix_replacement', + }; + const replacementAbort: WorkHubDelegationReplacementAbortedMessage = { + type: 'workhub_coordination', + id: `whb_${terminalSuffix(aborted.delegationId)}`, + turnId: 'terminal-matrix', + ts: 21, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: 'terminal-matrix-abort', + actionFingerprint: `sha256:${'e'.repeat(64)}`, + coordinationTurnId: 'terminal-matrix', + abortedActionId: aborted.actionId, + abortedDelegationId: aborted.delegationId, + targetSessionId: target.id, + reason: 'target_unavailable', + }; + const stopResolution = ( + assignment: WorkHubDelegationAssignedMessage, + outcome: WorkHubDelegationStopResolvedMessage['outcome'], + ts: number, + ): WorkHubDelegationStopResolvedMessage => ({ + type: 'workhub_coordination', + id: `whz_${terminalSuffix(assignment.delegationId)}`, + turnId: 'terminal-matrix', + ts, + schemaVersion: 3, + kind: 'delegation_stop_resolved', + actionId: `terminal-matrix-stop-${outcome}`, + actionFingerprint: `sha256:${'f'.repeat(64)}`, + coordinationTurnId: 'terminal-matrix', + stopsActionId: assignment.actionId, + stopsDelegationId: assignment.delegationId, + targetSessionId: target.id, + targetTurnId: assignment.targetTurnId, + outcome, + }); + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ + supersession, + replacementAbort, + stopResolution(stopped, 'stop_delivered', 22), + // `not_owned` means WorkHub never held the work, so the link survives. + stopResolution(notOwned, 'not_owned', 23), + ]); + + assert.deepEqual( + (await store.readActiveWorkHubAssignmentsByTarget([target.id])).map( + ({ actionId }) => actionId, + ), + ['not-owned', 'plain'], + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('rolls create_new Session back when assignment validation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-create-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const request = assignmentRequest( + 'create-action', + 'created-target', + 'Wrong name', + 'target-turn', + ); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + assignment: { + ...request.assignment, + disposition: 'create_new', + create: { + title: 'Actual name', + workspace: { kind: 'host_path', path: root }, + }, + }, + create: { + sessionId: 'created-target', + requestFingerprint: `sha256:${'b'.repeat(64)}`, + input: { + cwd: root, + name: 'Actual name', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }, + }, + }), + /display identity changed/u, + ); + await assert.rejects(store.readHeaderSnapshot('created-target'), (error) => + isSessionNotFoundError(error), + ); + assert.deepEqual(await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), []); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a stale display identity for a new delegation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stale-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const request = assignmentRequest('stale-action', target.id, 'Old name', 'target-turn'); + + await assert.rejects(store.assignWorkHubMessage(request), /display identity changed/u); + assert.equal(await store.readWorkHubAssignment(request.assignment.actionId), undefined); + assert.equal( + await store.readMessageAdmission(target.id, request.assignment.targetMessageId), + undefined, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('atomically commits a replacement assignment with the old-link supersession', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-replacement-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login before rename', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('original-action', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + + const base = assignmentRequest( + 'replacement-action', + destination.id, + 'Login before rename', + 'destination-turn', + ); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + await store.rename(destination.id, 'Login'); + + const committed = await store.assignWorkHubMessage({ + ...base, + assignment, + supersession, + }); + const committedAssignment = { ...assignment, targetSessionName: 'Login' }; + + assert.equal(committed.kind, 'assigned'); + assert.deepEqual(committed.assignment, committedAssignment); + assert.deepEqual( + await store.readWorkHubSupersession(original.assignment.delegationId), + supersession, + ); + assert.deepEqual(await store.readWorkHubAssignment(assignment.actionId), committedAssignment); + assert.deepEqual( + await store.readMessageAdmission(destination.id, assignment.targetMessageId), + base.admission, + ); + assert.deepEqual( + (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) + .filter((message) => message.type === 'workhub_coordination') + .map((message) => message.kind), + ['delegation_assigned', 'delegation_assigned', 'delegation_superseded'], + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('an aborted replacement cannot later commit a supersession', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-aborted-replacement-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('original-aborted', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + const base = assignmentRequest( + 'replacement-after-abort', + destination.id, + 'Login', + 'destination-turn', + ); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + const abort: WorkHubDelegationReplacementAbortedMessage = { + type: 'workhub_coordination', + id: `whb_${createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48)}`, + turnId: assignment.actionId, + ts: assignment.ts - 1, + schemaVersion: 2, + kind: 'delegation_replacement_aborted', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + abortedActionId: original.assignment.actionId, + abortedDelegationId: original.assignment.delegationId, + targetSessionId: destination.id, + reason: 'target_unavailable', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [abort]); + + await assert.rejects( + store.assignWorkHubMessage({ ...base, assignment, supersession }), + /replacement is aborted/u, + ); + assert.equal(await store.readWorkHubAssignment(assignment.actionId), undefined); + assert.equal(await store.readWorkHubSupersession(original.assignment.delegationId), undefined); + assert.equal( + await store.readMessageAdmission(destination.id, assignment.targetMessageId), + undefined, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('an unresolved stop claim blocks replacement while not_owned releases the link', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-arbitration-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('stop-source', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + const delegationSuffix = createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48); + const request: WorkHubDelegationStopRequestedMessage = { + type: 'workhub_coordination', + id: `whq_${delegationSuffix}`, + turnId: 'stop-action', + ts: 11, + schemaVersion: 3, + kind: 'delegation_stop_requested', + actionId: 'stop-action', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + coordinationTurnId: 'stop-action', + stopsActionId: original.assignment.actionId, + stopsDelegationId: original.assignment.delegationId, + targetSessionId: source.id, + targetMessageId: original.assignment.targetMessageId, + targetSessionName: 'Payments', + userText: 'Stop Payments', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [request]); + assert.deepEqual(await store.readWorkHubStopRequest(original.assignment.delegationId), request); + + const base = assignmentRequest('after-stop', destination.id, 'Login', 'destination-turn'); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${delegationSuffix}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + await assert.rejects( + store.assignWorkHubMessage({ ...base, assignment, supersession }), + /stop claim/u, + ); + + const resolution: WorkHubDelegationStopResolvedMessage = { + type: 'workhub_coordination', + id: `whz_${delegationSuffix}`, + turnId: 'stop-action', + ts: 12, + schemaVersion: 3, + kind: 'delegation_stop_resolved', + actionId: 'stop-action', + actionFingerprint: request.actionFingerprint, + coordinationTurnId: 'stop-action', + stopsActionId: original.assignment.actionId, + stopsDelegationId: original.assignment.delegationId, + targetSessionId: source.id, + targetTurnId: 'shared-turn', + outcome: 'not_owned', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [resolution]); + assert.deepEqual( + await store.readWorkHubStopResolution(original.assignment.delegationId), + resolution, + ); + assert.equal( + (await store.assignWorkHubMessage({ ...base, assignment, supersession })).kind, + 'assigned', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +async function createCoordinationSession( + store: ReturnType, + root: string, +): Promise { + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: root, + name: 'WorkHub', + role: WORKHUB_COORDINATION_SESSION_ROLE, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'explore', + toolProfile: 'workhub-coordination-v1', + }, + }); +} + +function terminalSuffix(delegationId: string): string { + return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); +} + +/** + * Hand a Message off the way a Turn does: the Root admission that consumed it + * is what keeps its identity durable once the pending admission is retired. + */ +async function handOffToRootTurn( + store: ReturnType, + root: string, + request: AssignmentRequest, +): Promise { + const runStore = createSqliteAgentRunStore(root); + try { + await runStore.admitRootTurn({ + sessionId: request.admission.sessionId, + turnId: request.admission.turnId, + proposedRunId: request.admission.runId, + proposedUserMessageId: request.admission.messageId, + execution: { + kind: 'external_message', + inputDigest: request.admission.submittedContentDigest, + }, + previousRootTurnId: null, + normalizedInput: request.admission.content, + sourceMessages: [ + { + messageId: request.admission.messageId, + content: request.admission.content, + submittedContentDigest: request.admission.submittedContentDigest, + placement: request.admission.placement, + disposition: request.admission.disposition, + }, + ], + admittedAt: request.admission.admittedAt, + }); + } finally { + runStore.close?.(); + } + await store.markMessagesHandedOff({ + sessionId: request.admission.sessionId, + messageIds: [request.admission.messageId], + turnId: request.admission.turnId, + }); +} + +type AssignmentRequest = ReturnType; + +function assignmentRequest( + actionId: string, + targetSessionId: string, + targetSessionName: string, + targetTurnId: string, +) { + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const content = normalizeMessageContent({ text: 'Continue payment work' }); + const assignment: WorkHubDelegationAssignedMessage = { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: actionId, + ts: 10, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId, + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: actionId, + targetSessionId, + targetSessionName, + targetTurnId, + targetMessageId: `whm_${suffix}`, + delegationId: `whd_${suffix}`, + disposition: 'delegate_existing', + userText: content.text, + }; + return { + assignment, + admission: { + sessionId: targetSessionId, + turnId: targetTurnId, + runId: `whr_${suffix}`, + messageId: assignment.targetMessageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e0a6d63839ed6c44194936e1354fcab570887ddc2abcfe3d8f244e9202d19f.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e0a6d63839ed6c44194936e1354fcab570887ddc2abcfe3d8f244e9202d19f.source new file mode 100644 index 0000000000..421650c210 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e0a6d63839ed6c44194936e1354fcab570887ddc2abcfe3d8f244e9202d19f.source @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + modelCallAttempt as attempt, + MODEL_CALL_NOW as NOW, + withProjectedAttempts, +} from './fixtures/model-call-attempt.js'; + +const ALL = { range: 'all' } as const; + +describe('Usage answers over the canonical ledger', () => { + test('a total never counts unpriced spend as zero, and says so in coverage', async () => { + // The frozen pre-cutover table had nowhere to record "we could not price + // this", so it wrote 0 and unpriced spend looked free. The total here + // excludes it and the coverage reports it instead. + await withProjectedAttempts( + [ + attempt({ attemptId: 'a', costUsd: 0.004 }), + attempt({ attemptId: 'b', costBasis: 'unpriced', costUsd: undefined }), + ], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(Math.round(projection.totalCostUsd * 1000) / 1000, 0.004); + assert.equal(projection.totalRequests, 2); + assert.equal(projection.coverage.pricedAttempts, 1); + assert.equal(projection.coverage.unpricedAttempts, 1); + }, + ); + }); + + test('a genuinely free call is counted as priced, and reads apart from an unpriced one', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'free', logicalCallId: 'free', costUsd: 0 }), + attempt({ + attemptId: 'unknown', + logicalCallId: 'unknown', + costBasis: 'unpriced', + costUsd: undefined, + }), + ], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(projection.totalCostUsd, 0); + assert.equal(projection.coverage.pricedAttempts, 1); + assert.equal(projection.coverage.unpricedAttempts, 1); + + // The page-level coverage says how many rows were unpriced but not which + // ones, so a log row has to carry its own basis. + const rows = ledger.logs(ALL, NOW, 0, 10).projection.rows; + const free = rows.find((row) => row.id === 'free'); + const unknown = rows.find((row) => row.id === 'unknown'); + assert.equal(free?.costBasis, 'priced'); + assert.equal(free?.costUsd, 0); + assert.equal(unknown?.costBasis, 'unpriced'); + assert.equal(Object.hasOwn(unknown ?? {}, 'costUsd'), false); + }, + ); + }); + + test('usage-missing records are reported separately from unpriced ones', async () => { + await withProjectedAttempts( + [ + attempt({ + attemptId: 'no-usage', + status: 'failed', + usageBasis: 'missing', + inputTokens: undefined, + outputTokens: undefined, + costBasis: 'unpriced', + costUsd: undefined, + }), + ], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(projection.coverage.usageMissingAttempts, 1); + assert.equal(projection.coverage.unpricedAttempts, 1); + assert.equal(projection.totalTokens.total, 0); + }, + ); + }); + + test('one malformed cache reading cannot inflate the cache total', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'malformed-cache', inputTokens: 100, cacheReadInputTokens: 200 }), + attempt({ attemptId: 'cache-miss', inputTokens: 100, cacheReadInputTokens: 0 }), + ], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(projection.totalTokens.input, 200); + assert.equal(projection.totalTokens.cacheRead, 100); + }, + ); + }); + + test('provider cache-only evidence survives without inventing an input total', async () => { + await withProjectedAttempts( + [ + attempt({ + attemptId: 'cache-only', + usageBasis: 'partial', + inputTokens: undefined, + outputTokens: undefined, + cacheReadInputTokens: 10, + }), + ], + async (ledger) => { + const summary = ledger.summary(ALL, NOW).projection; + assert.equal(summary.totalTokens.input, 0); + assert.equal(summary.totalTokens.cacheRead, 10); + assert.equal(summary.cacheHitRequests, 1); + assert.equal(summary.coverage.usagePartialAttempts, 1); + + const bucket = ledger.buckets(ALL, 'provider', NOW).projection.buckets[0]; + assert.equal(bucket?.inputTokens, 0); + assert.equal(bucket?.cacheReadTokens, 10); + + const log = ledger.logs(ALL, NOW, 0, 10).projection.rows[0]; + assert.equal(log?.inputTokens, 0); + assert.equal(log?.cacheReadTokens, 10); + }, + ); + }); + + test('the summary sums recorded call time over the rows it counts', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'a', logicalCallId: 'a', latencyMs: 1_200 }), + attempt({ attemptId: 'b', logicalCallId: 'b', latencyMs: 300 }), + ], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(projection.totalDurationMs, 1_500); + assert.equal(projection.totalRequests, 2); + }, + ); + }); + + test('filters by Session, window, provider, model, and status', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'recent' }), + attempt({ + attemptId: 'old', + startedAt: NOW - 40 * 86_400_000 - 1, + completedAt: NOW - 40 * 86_400_000, + }), + attempt({ attemptId: 'other-provider', providerId: 'openai', modelId: 'gpt-x' }), + attempt({ attemptId: 'failed', status: 'failed' }), + attempt({ attemptId: 'other-session', sessionId: 'session-2', runId: 'run-2' }), + ], + async (ledger) => { + const requests = (query: Parameters[0]) => + ledger.summary(query, NOW).projection.totalRequests; + assert.equal(requests({ range: '24h' }), 4); + assert.equal(requests({ range: 'all', sessionId: 'session-1' }), 4); + assert.equal(requests({ range: 'all', providerId: 'openai' }), 1); + assert.equal(requests({ range: 'all', modelId: 'claude-opus-5' }), 4); + assert.equal(requests({ range: 'all', status: 'error' }), 1); + assert.equal(requests({ range: 'all', status: 'all' }), 5); + }, + ); + }); + + test('interrupted counts as aborted, not as an error', async () => { + // Collapsing a cut-short call into `error` would inflate the error rate + // with user cancellations. + await withProjectedAttempts( + [attempt({ attemptId: 'cut', status: 'interrupted' })], + async (ledger) => { + assert.equal(ledger.summary(ALL, NOW).projection.errorRequests, 0); + assert.equal( + ledger.summary({ range: 'all', status: 'aborted' }, NOW).projection.totalRequests, + 1, + ); + }, + ); + }); + + test('buckets group by provider and by model, excluding unpriced cost', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'a', costUsd: 0.004 }), + attempt({ attemptId: 'b', costUsd: 0.006 }), + attempt({ + attemptId: 'c', + providerId: 'openai', + modelId: 'gpt-x', + costBasis: 'unpriced', + costUsd: undefined, + }), + ], + async (ledger) => { + const byProvider = ledger.buckets(ALL, 'provider', NOW).projection.buckets; + assert.deepEqual( + byProvider.map((bucket) => [bucket.key, bucket.requests]), + [ + ['anthropic', 2], + ['openai', 1], + ], + ); + assert.equal(Math.round((byProvider[0]?.costUsd ?? 0) * 1000) / 1000, 0.01); + assert.equal(byProvider[1]?.costUsd, 0); + + const byModel = ledger.buckets(ALL, 'model', NOW).projection.buckets; + assert.equal(byModel.length, 2); + assert.ok(byModel.some((bucket) => bucket.key === 'anthropic:claude-opus-5')); + }, + ); + }); + + test('time buckets are named by the same key both Usage sources derive', async () => { + // SQLite decides only which rows group together; the key is still built by + // `usageBucketKey`. If the two disagreed about where a day starts, one day + // would silently split into two buckets rather than fail. + const midnight = Date.parse('2025-03-04T00:00:00.000Z'); + await withProjectedAttempts( + [ + attempt({ attemptId: 'first', startedAt: midnight - 1, completedAt: midnight }), + attempt({ attemptId: 'last', startedAt: midnight, completedAt: midnight + 86_399_999 }), + attempt({ attemptId: 'next-day', startedAt: midnight, completedAt: midnight + 86_400_000 }), + ], + async (ledger) => { + const byDay = ledger.buckets({ range: 'all' }, 'day', NOW).projection.buckets; + assert.deepEqual( + [...byDay] + .sort((left, right) => left.key.localeCompare(right.key)) + .map((b) => [b.key, b.requests]), + [ + ['2025-03-04', 2], + ['2025-03-05', 1], + ], + ); + }, + ); + }); + + test('logs page newest first and carry coverage for the whole match', async () => { + await withProjectedAttempts( + [ + attempt({ attemptId: 'older', startedAt: NOW - 3_500, completedAt: NOW - 3_000 }), + attempt({ attemptId: 'newer', startedAt: NOW - 1_500, completedAt: NOW - 1_000 }), + attempt({ + attemptId: 'unpriced', + startedAt: NOW - 2_500, + completedAt: NOW - 2_000, + costBasis: 'unpriced', + costUsd: undefined, + }), + ], + async (ledger) => { + const page = ledger.logs(ALL, NOW, 0, 2).projection; + assert.deepEqual( + page.rows.map((row) => row.id), + ['newer', 'unpriced'], + ); + assert.equal(page.total, 3); + // Coverage describes every matching record, not just the returned page. + assert.equal(page.coverage.attempts, 3); + assert.equal(page.coverage.unpricedAttempts, 1); + }, + ); + }); + + test('a replayed attemptId is one call, not two', async () => { + await withProjectedAttempts( + [attempt({ attemptId: 'dup' }), attempt({ attemptId: 'dup', costUsd: 0.004 })], + async (ledger) => { + const { projection } = ledger.summary(ALL, NOW); + assert.equal(projection.totalRequests, 1); + assert.equal(Math.round(projection.totalCostUsd * 1000) / 1000, 0.004); + }, + ); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e64cb168b4524bf0335686f4417f268f5a83d5301f3b44e0d6011bfe48c2e9.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e64cb168b4524bf0335686f4417f268f5a83d5301f3b44e0d6011bfe48c2e9.source new file mode 100644 index 0000000000..7340c46514 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e6e64cb168b4524bf0335686f4417f268f5a83d5301f3b44e0d6011bfe48c2e9.source @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createManagedDependencyEnvironmentAuthority, + createManagedDependencyEnvironmentProducerCapability, +} from '../../managed-dependency-environment.js'; + +const storageRoot = process.env.MAKA_DEPENDENCY_OWNER_ROOT; +if (!storageRoot) throw new Error('Missing dependency owner fixture root'); + +const producerCapability = createManagedDependencyEnvironmentProducerCapability( + `sha256:${'a'.repeat(64)}`, +); +await createManagedDependencyEnvironmentAuthority({ + storageRoot, + producer: { + capability: producerCapability, + packageManagerName: 'npm', + packageManagerVersion: '11.12.1', + nodeRuntime: { + version: '24.7.0', + abi: '137', + platform: process.platform, + arch: process.arch, + }, + async provision() {}, + }, +}); +process.stdout.write('READY\n'); +setInterval(() => undefined, 1_000); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e7aede64028e5ba0465f4ff125dd3d8033eb58f925f2b4b7d0643cd00e30f853.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e7aede64028e5ba0465f4ff125dd3d8033eb58f925f2b4b7d0643cd00e30f853.source new file mode 100644 index 0000000000..db998487b6 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e7aede64028e5ba0465f4ff125dd3d8033eb58f925f2b4b7d0643cd00e30f853.source @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { lstat } from 'node:fs/promises'; +import { join, parse } from 'node:path'; + +export async function hasEnclosingGitEntry(path: string): Promise { + let current = path; + while (true) { + try { + await lstat(join(current, '.git')); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + } + const parent = parse(current).dir; + if (parent === current) return false; + current = parent; + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e8738d4236d6e49c159537af9d06f5c917d2f57be87d69d43b01d85467df96b9.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e8738d4236d6e49c159537af9d06f5c917d2f57be87d69d43b01d85467df96b9.source new file mode 100644 index 0000000000..cd7d528b99 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e8738d4236d6e49c159537af9d06f5c917d2f57be87d69d43b01d85467df96b9.source @@ -0,0 +1,1679 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { + createSqliteSessionMetadataStore, + type SessionConfigurationMetadataUpdate, + type SessionCatalogRevisionState, + type SessionMetadataRecord, + type SessionRemovalProbe, + SessionMetadataVersionConflictError, + type SqliteSessionMetadataStore, + type StableSessionCreateProbe, + type VersionedSessionIdentity, +} from './sqlite-session-metadata-store.js'; +import { isDiscardableConversationCopy } from './session-conversation-copy.js'; +import { + acquireOperationalStateDatabase, + OPERATIONAL_STATE_DATABASE_NAME, +} from './operational-state-store.js'; +import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/session-name'; +import { + decodeCanonicalMessage, + deriveTurnRecords, + isSessionBlockedReason, + isSessionConversationCopy, + isSubagentSessionParent, + isSubagentSessionRuntime, + isSubagentSessionSpawn, + isSessionStatus, + isWorkHubCoordinationSessionId, + subagentSessionRuntimeSummary, + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, +} from '@maka/core/session'; +import { isCollaborationMode } from '@maka/core/collaboration'; +import { isOrchestrationMode } from '@maka/core/orchestration'; +import { decodePersistedPermissionMode, isPermissionMode } from '@maka/core/permission'; +import type { PersistedValue } from '@maka/core/persisted-value'; +import { isSubagentWorkspaceBinding } from '@maka/core/subagent-workspace'; +import { WORKSPACE_AUTHORITY_SESSION_ID } from '@maka/core/workspace-version-authority'; +import type { + AgentGraphOperatorProvisionRequest, + AgentGraphOperatorProvisionResult, +} from '@maka/core/agent-graph-topology'; + +import type { + CreateSandboxBoundaryRequest, + ExecutionBoundary, + SandboxBoundaryRequest, + SandboxBoundarySettlement, + SettleSandboxBoundaryRequest, +} from '@maka/core/sandbox-boundary'; + +import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs'; + +import { + isSessionToolProfile, + type SessionHeader, + type SessionHeaderPatch, + type SessionConversationCopy, + type SessionExternalOrigin, + type SessionSummary, + type SessionRole, + type StoredMessage, + type TurnRecord, + type TurnStateMessage, + type AssistantMessage, + type UserMessage, + type WorkHubDelegationAssignedMessage, + type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationReplacementRequestedMessage, + type WorkHubActionClaim, + type WorkHubActionClaimOutcome, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationSupersededMessage, +} from '@maka/core/session'; +import type { + MarkMessagesHandedOffInput, + MessageAdmissionStore, + PendingMessageAdmission, +} from './message-admission-store.js'; +import { projectSessionCatalogMessages } from './session-message-projection.js'; +export { projectSessionCatalogMessages }; + +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +export function isSafeSessionId(sessionId: string): boolean { + return SESSION_ID_PATTERN.test(sessionId); +} + +export function assertSafeSessionId(sessionId: string): void { + if (!isSafeSessionId(sessionId)) throw new Error(`Invalid Session id: ${sessionId}`); +} +export class SessionNotFoundError extends Error { + readonly name = 'SessionNotFoundError'; + readonly code = 'session_not_found'; + + constructor(readonly sessionId: string) { + super(`Session metadata not found: ${sessionId}`); + } +} + +export function isSessionNotFoundError(error: unknown): error is SessionNotFoundError { + return error instanceof SessionNotFoundError; +} + +export interface SessionHeaderSnapshot { + readonly header: SessionHeader; + readonly revision: number; + readonly committedAt: number; +} + +export type ProbeSessionRemovalResult = + | { readonly kind: 'present'; readonly record: SessionHeaderSnapshot } + | { readonly kind: 'removed' } + | { readonly kind: 'absent' }; + +export interface SessionCatalogRecord extends SessionHeaderSnapshot { + readonly activityAt: number; + readonly summary: SessionSummary; +} + +export interface SessionCatalogPageCursor { + readonly activityAt: number; + readonly sessionId: string; +} + +export const EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS = 256; +export const EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS = 16; + +export interface ExternalSessionImportLookupResult { + readonly sourceSessionId: string; + readonly livePublishedImportCount: number; + readonly recentSessionIds: readonly string[]; +} + +export type SessionCatalogPageResult = + | { + readonly kind: 'page'; + readonly revision: `sha256:${string}`; + readonly records: readonly SessionCatalogRecord[]; + readonly hasMore: boolean; + } + | { + readonly kind: 'revision_changed'; + readonly expectedRevision: `sha256:${string}`; + readonly actualRevision: `sha256:${string}`; + }; + +export interface CreateStableSessionRequest { + readonly sessionId: string; + readonly requestFingerprint: string; + readonly input: StableSessionCreateInput; +} + +export interface WorkHubMessageAssignmentRequest { + readonly assignment: WorkHubDelegationAssignedMessage; + readonly admission: PendingMessageAdmission; + /** Present exactly when this assignment atomically supersedes an earlier link. */ + readonly supersession?: WorkHubDelegationSupersededMessage; + /** Present exactly when the assignment creates its target Session. */ + readonly create?: CreateStableSessionRequest; +} + +export interface WorkHubMessageAssignmentResult { + readonly kind: 'assigned' | 'existing'; + readonly targetCreated: boolean; + readonly assignment: WorkHubDelegationAssignedMessage; +} + +export type StableSessionCreateInput = CreateSessionInput & { + readonly conversationCopy?: SessionConversationCopy; + readonly role?: SessionRole; +}; + +export type CreateStableSessionResult = + | { readonly kind: 'created'; readonly record: SessionHeaderSnapshot } + | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } + | { + readonly kind: 'conflict'; + readonly reason: 'identity_mismatch' | 'removed'; + }; + +export type ProbeStableSessionCreateResult = + | { readonly kind: 'absent' } + | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } + | { + readonly kind: 'conflict'; + readonly reason: 'identity_mismatch' | 'removed'; + }; + +export type UpdateSessionConfigurationRequest = SessionConfigurationMetadataUpdate; + +export interface SessionTranscriptStorageFragment { + readonly sequence: number; + readonly byteOffset: number; + readonly totalBytes: number; + readonly payloadDigest: `sha256:${string}` | null; + readonly data: Buffer; +} + +export interface SessionTranscriptMessageLookupRequest { + readonly messageIds: readonly string[]; + readonly throughSequence: number | null; + readonly maxBytes: number; + readonly maxMessages: number; +} + +/** + * One page of a Session's legacy rows, for the converter that lifts them onto + * the ledger and for the WorkHub Coordination Session, whose transcript no run + * produces and so has no ledger to read. + */ +export interface SessionMessageScanRequest { + /** Exclusive lower bound; omit to start at the first row. */ + readonly afterSequence?: number; + /** + * Walk towards older rows instead, from this exclusive upper bound. Records + * then come back newest first, so the byte budget truncates at the older end, + * which is the end the walk is heading for. Pass at most one bound. + */ + readonly beforeSequence?: number; + readonly maxStoredBytes: number; + readonly maxMessages: number; +} + +export interface SessionMessageScanRecord { + readonly sequence: number; + readonly message: StoredMessage; +} + +export interface SessionMessageScanPage { + readonly records: readonly SessionMessageScanRecord[]; + /** + * The Session's last legacy sequence. It rides along with every page so the + * converter can place a turn relative to the whole transcript without a read + * that is proportional to it. + */ + readonly highWaterSequence: number | null; +} + +export interface SessionTranscriptPageRequest { + readonly direction: 'older' | 'newer'; + /** Inclusive durable high-water mark. Omit only for the first read. */ + readonly throughSequence?: number | null; + /** Inclusive sequence position for this read. Defaults to the watermark edge. */ + readonly position?: number; + /** Continuation byte offset within position. */ + readonly byteOffset?: number; + readonly maxBytes: number; + readonly maxMessages: number; +} + +export interface SessionTranscriptStoragePage { + readonly throughSequence: number | null; + /** Returned in traversal order for the requested direction. */ + readonly fragments: readonly SessionTranscriptStorageFragment[]; + readonly rawBytes: number; + readonly next: { + readonly position: number; + readonly byteOffset: number | null; + } | null; +} + +export interface SessionTranscriptRecordScanRequest { + readonly direction: 'older' | 'newer'; + readonly throughSequence?: number | null; + readonly position?: number; + readonly maxStoredBytes: number; + readonly maxMessages: number; +} + +export interface SessionTranscriptRecordScanPage { + readonly throughSequence: number | null; + readonly records: readonly { readonly sequence: number; readonly message: StoredMessage }[]; + readonly nextPosition: number | null; +} + +export interface SessionTurnContribution { + readonly turnId: string; + readonly firstSequence: number; + readonly latestState: { + readonly sequence: number; + readonly message: TurnStateMessage; + } | null; + readonly userPromptPreview: string | null; +} + +export interface SessionTurnContributionPage { + readonly throughSequence: number | null; + readonly contributions: readonly SessionTurnContribution[]; + readonly nextPosition: number | null; +} + +export interface SessionTurnLandmark { + readonly turnId: string; + readonly sequence: number; + readonly label: string; +} + +export interface SessionTurnLandmarkSnapshot { + readonly throughSequence: number | null; + readonly landmarks: readonly SessionTurnLandmark[]; +} + +export interface SessionStore { + create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise; + list(filter?: SessionListFilter): Promise; + /** Enumerate durable metadata without reading transcript bodies. */ + listHeaders(): Promise; + listForRecovery(): Promise; + /** Read only the durable header without triggering connection-lock self-healing. */ + readHeaderSnapshot(sessionId: string): Promise; + readMessagesSnapshot(sessionId: string): Promise; + readTranscriptHighWaterSnapshot(sessionId: string): Promise; + listTurnsSnapshot(sessionId: string): Promise; + readHeader(sessionId: string): Promise; + readMessages(sessionId: string): Promise; + readMessagesAfter( + sessionId: string, + request: SessionMessageScanRequest, + ): Promise; + listTurns(sessionId: string): Promise; + appendMessage(sessionId: string, message: StoredMessage): Promise; + appendMessages(sessionId: string, messages: StoredMessage[]): Promise; + /** Commit the Session-list facts a durable message carries. */ + commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; + setFlagged(sessionId: string, isFlagged: boolean): Promise; + rename(sessionId: string, name: string): Promise; + setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise; + remove(sessionId: string): Promise; + close?(): Promise; +} + +/** Rebuildable ordering only; the message body remains in its original store. */ +export interface CoordinationTranscriptReference { + readonly source: 'legacy' | 'runtime'; + readonly sourceSequence: number; +} +export interface CoordinationTranscriptIndexRecord extends CoordinationTranscriptReference { + readonly sequence: number; +} +export interface CoordinationTranscriptIndexState { + readonly highWater: number | null; + readonly legacy: number | null; + readonly runtime: number | null; +} + +export interface SessionAuthorityStore extends SessionStore, MessageAdmissionStore { + readCoordinationTranscriptIndexState(): Promise; + appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise; + readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise; + /** Read a bounded set of durable messages at an inclusive transcript watermark. */ + readTranscriptMessagesSnapshot( + sessionId: string, + request: SessionTranscriptMessageLookupRequest, + ): Promise; + /** Observe successful durable ledger appends. Listeners must not throw. */ + subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void; + /** Wait until the SQLite authority is ready for cross-domain transactions. */ + ready(): Promise; + /** Atomically create a Session from already-converted Maka raw messages. */ + createImportedSession( + input: CreateSessionInput, + messages: readonly StoredMessage[], + externalOrigin: SessionExternalOrigin, + ): Promise; + /** Look up live published imports for a bounded page of source Sessions. */ + lookupExternalSessionImports( + adapterId: string, + sourceSessionIds: readonly string[], + recentSessionIdLimit: number, + ): Promise; + createSubagent( + input: CreateSessionInput, + initialBoundary?: ExecutionBoundary, + ): Promise<{ header: SessionHeader; created: boolean }>; + createAgentGraphOperator( + input: CreateSessionInput, + request: AgentGraphOperatorProvisionRequest, + expectedRevision: number, + initialBoundary?: ExecutionBoundary, + ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult>; + readExecutionBoundary(sessionId: string): Promise; + createSandboxBoundaryRequest( + input: CreateSandboxBoundaryRequest, + ): Promise; + readSandboxBoundaryRequest( + sessionId: string, + requestId: string, + ): Promise; + listPendingSandboxBoundaryRequests(sessionId: string): Promise; + /** Requests already closed against the user because the host restarted. */ + listSandboxBoundaryRestartClosures(sessionId: string): Promise; + hasExplicitSandboxBoundaryDenial( + identities: readonly { sessionId: string; runId: string; turnId: string }[], + ): Promise; + settleSandboxBoundaryRequest( + input: SettleSandboxBoundaryRequest, + ): Promise; + setExecutionBoundaryKind( + sessionId: string, + kind: 'managed' | 'bypass', + projection?: { + permissionMode: SessionHeader['permissionMode']; + labels?: readonly string[]; + }, + ): Promise; + probeStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise; + createStableSession( + request: CreateStableSessionRequest, + initialBoundary?: ExecutionBoundary, + ): Promise; + /** Atomically persist a WorkHub linkage and the target Message admission. */ + assignWorkHubMessage( + request: WorkHubMessageAssignmentRequest, + ): Promise; + readWorkHubAssignment(actionId: string): Promise; + /** Newest active assignment first, across every requested target. */ + readActiveWorkHubAssignmentsByTarget( + targetSessionIds: readonly string[], + maxAssignmentsPerTarget?: number, + ): Promise; + readWorkHubReplacement( + delegationId: string, + ): Promise; + readWorkHubReplacementAbort( + delegationId: string, + ): Promise; + readWorkHubSupersession( + delegationId: string, + ): Promise; + readWorkHubStopRequest( + delegationId: string, + ): Promise; + readWorkHubStopResolution( + delegationId: string, + ): Promise; + /** + * Durably binds one action identity to one exact WorkHub operation before its + * effect. Survives removal of the target Session so a committed destructive + * claim can still converge afterwards. + */ + claimWorkHubAction(claim: WorkHubActionClaim): Promise; + readWorkHubActionClaim(actionId: string): Promise; + discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; + listCatalogPage( + filter: SessionListFilter | undefined, + cursor: SessionCatalogPageCursor | undefined, + limit: number, + expectedRevision?: `sha256:${string}`, + ): Promise; + readHeaderRecordSnapshot(sessionId: string): Promise; + readCatalogRecord( + sessionId: string, + roleScope?: 'ordinary' | 'recoverable', + ): Promise; + updateHeaderVersioned( + sessionId: string, + patch: SessionHeaderPatch, + expectedRevision: number, + ): Promise; + updateSessionConfiguration( + sessionId: string, + input: UpdateSessionConfigurationRequest, + ): Promise; + probeSessionRemoval(sessionId: string): Promise; + setSessionsArchivedVersioned( + sessions: readonly VersionedSessionIdentity[], + isArchived: boolean, + ): Promise; + removeSessionsVersioned( + sessions: readonly VersionedSessionIdentity[], + archiveSessions?: readonly VersionedSessionIdentity[], + ): Promise; + reconcileOrphanedAgentGraphRetirements(): Promise; + listPendingSessionRetirementCleanupIds(sessionId?: string): Promise; + completeSessionRetirementCleanup(sessionId: string): Promise; +} + +export function createSessionStore(workspaceRoot: string): SessionAuthorityStore { + return new SqliteSessionStore(workspaceRoot); +} + +class SqliteSessionStore implements SessionAuthorityStore { + private readonly metadata: SqliteSessionMetadataStore; + private readonly workspaceRoot: string; + private readonly transcriptChangeListeners = new Set<(sessionId: string) => void>(); + private closePromise: Promise | null = null; + + constructor(workspaceRoot: string) { + this.workspaceRoot = workspaceRoot; + const databaseLease = acquireOperationalStateDatabase(workspaceRoot); + this.metadata = createSqliteSessionMetadataStore( + join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), + { databaseLease }, + ); + } + + private ensureReady(): Promise { + return Promise.resolve(); + } + + ready(): Promise { + return this.ensureReady(); + } + + async create( + input: CreateSessionInput, + initialBoundary?: ExecutionBoundary, + ): Promise { + await this.ensureReady(); + assertNoConversationCopyMetadata(input); + if (input.subagentSpawn) { + throw new Error('Subagent spawn metadata requires createSubagent()'); + } + return ( + await this.metadata.create(buildSessionHeader(this.workspaceRoot, input), initialBoundary) + ).header; + } + + async createImportedSession( + input: CreateSessionInput, + messages: readonly StoredMessage[], + externalOrigin: SessionExternalOrigin, + ): Promise { + await this.ensureReady(); + assertNoConversationCopyMetadata(input); + if (input.subagentSpawn) { + throw new Error('Subagent spawn metadata requires createSubagent()'); + } + const canonicalMessages = messages.map((message) => + decodeCanonicalMessage(JSON.parse(JSON.stringify(message)) as unknown), + ); + const header: SessionHeader = { + ...buildSessionHeader(this.workspaceRoot, input), + externalOrigin, + transcriptLedgerVersion: 0, + }; + const outcome = await this.metadata.importSession( + header, + canonicalMessages, + projectSessionCatalogMessages(canonicalMessages), + ); + if (outcome !== 'imported') { + throw new Error(`Generated Session id already exists: ${header.id}`); + } + return (await this.metadata.read(header.id)).header; + } + + async lookupExternalSessionImports( + adapterId: string, + sourceSessionIds: readonly string[], + recentSessionIdLimit: number, + ): Promise { + await this.ensureReady(); + if (typeof adapterId !== 'string' || adapterId.trim().length === 0) { + throw new Error('External Session import lookup adapter id must not be empty'); + } + if ( + !Array.isArray(sourceSessionIds) || + sourceSessionIds.length > EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS + ) { + throw new Error( + `External Session import lookup accepts at most ${EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS} source ids`, + ); + } + const uniqueSourceSessionIds: string[] = []; + const seen = new Set(); + for (const sourceSessionId of sourceSessionIds) { + if (typeof sourceSessionId !== 'string' || sourceSessionId.length === 0) { + throw new Error('External Session import lookup source id must not be empty'); + } + if (!seen.has(sourceSessionId)) { + seen.add(sourceSessionId); + uniqueSourceSessionIds.push(sourceSessionId); + } + } + if ( + !Number.isSafeInteger(recentSessionIdLimit) || + recentSessionIdLimit < 1 || + recentSessionIdLimit > EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS + ) { + throw new Error( + `External Session import lookup recent id limit must be between 1 and ${EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS}`, + ); + } + if (uniqueSourceSessionIds.length === 0) return []; + return this.metadata.lookupExternalSessionImports( + adapterId, + uniqueSourceSessionIds, + recentSessionIdLimit, + ); + } + + async probeStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise { + await this.ensureReady(); + return projectStableSessionCreateProbe( + await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint), + ); + } + + async createStableSession( + request: CreateStableSessionRequest, + initialBoundary?: ExecutionBoundary, + ): Promise { + await this.ensureReady(); + // Asserted here as well as in the header builder so a malformed request is + // refused before claimStableSessionCreate() writes a durable claim for the + // identity it names. + assertCoordinationIdentityPairing(request.sessionId, request.input.role); + if ( + request.input.conversationCopy && + request.input.conversationCopy.requestFingerprint !== request.requestFingerprint + ) { + throw new Error('Conversation copy fingerprint does not match the stable create request'); + } + if (request.input.subagentSpawn) { + throw new Error('Subagent spawn metadata requires createSubagent()'); + } + const probe = await this.metadata.claimStableSessionCreate( + request.sessionId, + request.requestFingerprint, + ); + if (probe.kind === 'existing') { + return { kind: 'existing', record: projectHeaderSnapshot(probe.record) }; + } + if (probe.kind === 'conflict') return probe; + + const result = await this.metadata.createStableSession( + buildSessionHeader( + this.workspaceRoot, + request.input, + request.sessionId, + request.input.conversationCopy, + ), + request.requestFingerprint, + initialBoundary, + ); + return result.kind === 'created' || result.kind === 'existing' + ? { kind: result.kind, record: projectHeaderSnapshot(result.record) } + : result; + } + + async assignWorkHubMessage( + request: WorkHubMessageAssignmentRequest, + ): Promise { + await this.ensureReady(); + const create = request.create; + if (create) { + assertCoordinationIdentityPairing(create.sessionId, create.input.role); + if (create.sessionId !== request.assignment.targetSessionId) { + throw new Error('WorkHub assignment create identity does not match its target'); + } + } + const result = await this.metadata.assignWorkHubMessage({ + assignment: request.assignment, + admission: request.admission, + projection: projectSessionCatalogMessages([request.assignment]), + ...(request.supersession ? { supersession: request.supersession } : {}), + ...(create + ? { + create: { + header: buildSessionHeader( + this.workspaceRoot, + create.input, + create.sessionId, + create.input.conversationCopy, + ), + requestFingerprint: create.requestFingerprint, + }, + } + : {}), + }); + if (result.kind === 'assigned') { + for (const listener of this.transcriptChangeListeners) { + listener(WORKHUB_COORDINATION_SESSION_ID); + } + } + return result; + } + + async readWorkHubAssignment( + actionId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `wha_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_assigned' + ? message + : undefined; + } + + async readActiveWorkHubAssignmentsByTarget( + targetSessionIds: readonly string[], + maxAssignmentsPerTarget?: number, + ): Promise { + await this.ensureReady(); + return this.metadata.readActiveWorkHubAssignmentsByTarget( + targetSessionIds, + maxAssignmentsPerTarget, + ); + } + + async readWorkHubReplacement( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whp_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_replacement_requested' + ? message + : undefined; + } + + async readWorkHubReplacementAbort( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whb_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_replacement_aborted' + ? message + : undefined; + } + + async readWorkHubSupersession( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whx_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_superseded' + ? message + : undefined; + } + + async readWorkHubStopRequest( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whq_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested' + ? message + : undefined; + } + + async readWorkHubStopResolution( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whz_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved' + ? message + : undefined; + } + + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { + await this.ensureReady(); + return this.metadata.claimWorkHubAction(claim); + } + + async readWorkHubActionClaim(actionId: string): Promise { + await this.ensureReady(); + return this.metadata.readWorkHubActionClaim(actionId); + } + + private async readWorkHubCoordinationMessage( + messageId: string, + ): Promise { + await this.ensureReady(); + return this.metadata.readMessageById(WORKHUB_COORDINATION_SESSION_ID, messageId); + } + + async discardStableConversationCopy( + sessionId: string, + requestFingerprint: string, + ): Promise { + await this.ensureReady(); + if (!(await this.metadata.hasStableSessionCreateClaim(sessionId, requestFingerprint))) { + throw new Error('Session is not owned by the matching stable create request'); + } + const probe = await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint); + if (probe.kind === 'conflict') { + throw new Error('Stable Session identity belongs to a different request'); + } + if (probe.kind === 'existing') { + const copy = probe.record.header.conversationCopy; + if ( + copy?.requestFingerprint !== requestFingerprint || + !isDiscardableConversationCopy(probe.record.header) + ) { + throw new Error('Only a matching incomplete conversation copy can be discarded'); + } + } + return this.metadata.discardStableSessionCreate(sessionId, requestFingerprint); + } + + async createSubagent( + input: CreateSessionInput, + initialBoundary?: ExecutionBoundary, + ): Promise<{ header: SessionHeader; created: boolean }> { + await this.ensureReady(); + assertNoConversationCopyMetadata(input); + const result = await this.metadata.createSubagent( + buildSessionHeader(this.workspaceRoot, input), + initialBoundary, + ); + return { header: result.record.header, created: result.created }; + } + + async createAgentGraphOperator( + input: CreateSessionInput, + request: AgentGraphOperatorProvisionRequest, + expectedRevision: number, + initialBoundary?: ExecutionBoundary, + ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult> { + await this.ensureReady(); + assertNoConversationCopyMetadata(input); + const result = await this.metadata.createAgentGraphOperator( + buildSessionHeader(this.workspaceRoot, input), + request, + expectedRevision, + initialBoundary, + ); + return { + header: result.record.header, + provision: result.provision, + created: result.created, + }; + } + + async readExecutionBoundary(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.readExecutionBoundary(sessionId); + } + + async createSandboxBoundaryRequest( + input: CreateSandboxBoundaryRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.createSandboxBoundaryRequest(input); + } + + async readSandboxBoundaryRequest( + sessionId: string, + requestId: string, + ): Promise { + await this.ensureReady(); + return this.metadata.readSandboxBoundaryRequest(sessionId, requestId); + } + + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.listPendingSandboxBoundaryRequests(sessionId); + } + + async listSandboxBoundaryRestartClosures(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.listSandboxBoundaryRestartClosures(sessionId); + } + + async hasExplicitSandboxBoundaryDenial( + identities: readonly { sessionId: string; runId: string; turnId: string }[], + ): Promise { + await this.ensureReady(); + return this.metadata.hasExplicitSandboxBoundaryDenial(identities); + } + + async settleSandboxBoundaryRequest( + input: SettleSandboxBoundaryRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.settleSandboxBoundaryRequest(input); + } + + async setExecutionBoundaryKind( + sessionId: string, + kind: 'managed' | 'bypass', + projection?: { + permissionMode: SessionHeader['permissionMode']; + labels?: readonly string[]; + }, + ): Promise { + await this.ensureReady(); + return this.metadata.setExecutionBoundaryKind(sessionId, kind, projection); + } + + async list(filter?: SessionListFilter): Promise { + await this.ensureReady(); + return (await this.metadata.list(filter, 'ordinary')) + .filter((record) => record.header.conversationCopy?.state !== 'preparing') + .map((record) => toCatalogSummary(record.header, record.lastMessagePreview)); + } + + async listCatalogPage( + filter: SessionListFilter | undefined, + cursor: SessionCatalogPageCursor | undefined, + limit: number, + expectedRevision?: `sha256:${string}`, + ): Promise { + await this.ensureCatalogProjectionReadable(); + const page = await this.metadata.listCatalogPage(filter ?? {}, cursor, limit); + const revision = projectCatalogRevision(page.revision); + if (expectedRevision !== undefined && expectedRevision !== revision) { + return { + kind: 'revision_changed', + expectedRevision, + actualRevision: revision, + }; + } + + return { + kind: 'page', + revision, + records: page.records.map((record) => ({ + ...projectHeaderSnapshot(record), + activityAt: record.activityAt, + summary: toCatalogSummary(record.header, record.lastMessagePreview), + })), + hasMore: page.hasMore, + }; + } + + async listForRecovery(): Promise { + return this.listHeaders(); + } + + async listHeaders(): Promise { + await this.ensureReady(); + return (await this.metadata.list(undefined, 'recoverable')) + .map((record) => record.header) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + async readHeaderSnapshot(sessionId: string): Promise { + return (await this.readHeaderRecordSnapshot(sessionId)).header; + } + + async readHeaderRecordSnapshot(sessionId: string): Promise { + await this.ensureReady(); + // `maka --resume ` reads the header before any list; the + // import runs in ensureReady, so the first post-upgrade resume of a + // pre-cutover session sees its imported rows. + return projectHeaderSnapshot(await this.metadata.read(sessionId)); + } + + async readCatalogRecord( + sessionId: string, + roleScope: 'ordinary' | 'recoverable' = 'ordinary', + ): Promise { + await this.ensureCatalogProjectionReadable(); + const record = await this.metadata.readCatalogRecord(sessionId, roleScope); + return { + ...projectHeaderSnapshot(record), + activityAt: record.activityAt, + summary: toCatalogSummary(record.header, record.lastMessagePreview), + }; + } + + async readMessagesSnapshot(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.readMessages(sessionId); + } + + async readTranscriptMessagesSnapshot( + sessionId: string, + request: SessionTranscriptMessageLookupRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.readTranscriptMessages(sessionId, request); + } + + async readTranscriptHighWaterSnapshot(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.readTranscriptHighWater(sessionId); + } + + async readCoordinationTranscriptIndexState(): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndexState(); + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + await this.ensureReady(); + return this.metadata.appendCoordinationTranscriptIndex(records); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndex(request); + } + + async listTurnsSnapshot(sessionId: string): Promise { + return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); + } + + async readHeader(sessionId: string): Promise { + return this.readHeaderSnapshot(sessionId); + } + + async readMessages(sessionId: string): Promise { + return this.readMessagesSnapshot(sessionId); + } + + async readMessagesAfter( + sessionId: string, + request: SessionMessageScanRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.readMessagesAfter(sessionId, request); + } + + async listTurns(sessionId: string): Promise { + return deriveTurnRecords(await this.readMessages(sessionId)); + } + + async appendMessage(sessionId: string, message: StoredMessage): Promise { + await this.appendMessages(sessionId, [message]); + } + + async appendMessages(sessionId: string, messages: StoredMessage[]): Promise { + if (messages.length === 0) return; + await this.ensureReady(); + await this.metadata.appendMessages( + sessionId, + messages, + projectSessionCatalogMessages(messages), + ); + for (const listener of this.transcriptChangeListeners) listener(sessionId); + } + + /** @see SqliteSessionMetadataStore.commitMessageCatalogProjection */ + async commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise { + await this.ensureReady(); + await this.metadata.commitMessageCatalogProjection(sessionId, message); + } + + async commitMessageAdmission( + admission: PendingMessageAdmission, + ): Promise { + await this.ensureReady(); + return this.metadata.commitMessageAdmission(admission); + } + + async readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise { + await this.ensureReady(); + return this.metadata.readMessageAdmission(sessionId, messageId); + } + + async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise { + await this.ensureReady(); + return this.metadata.hasCancelledMessageAdmission(sessionId, messageId); + } + + async claimMessageAdmissionCancellation(sessionId: string, messageId: string, claimId: string) { + await this.ensureReady(); + return this.metadata.claimMessageAdmissionCancellation(sessionId, messageId, claimId); + } + + async listMessageAdmissions(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.listMessageAdmissions(sessionId); + } + + async markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise { + await this.ensureReady(); + await this.metadata.markMessagesHandedOff(input); + for (const listener of this.transcriptChangeListeners) listener(input.sessionId); + } + + async updateMessageAdmission(admission: PendingMessageAdmission): Promise { + await this.ensureReady(); + await this.metadata.updateMessageAdmission(admission); + } + + async reorderMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise { + await this.ensureReady(); + await this.metadata.reorderMessageAdmissions(sessionId, messageIds); + } + + async cancelMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise { + await this.ensureReady(); + await this.metadata.cancelMessageAdmissions(sessionId, messageIds); + } + + subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void { + this.transcriptChangeListeners.add(listener); + return () => this.transcriptChangeListeners.delete(listener); + } + + async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { + await this.ensureReady(); + return (await this.metadata.update(sessionId, patch)).header; + } + + async updateHeaderVersioned( + sessionId: string, + patch: SessionHeaderPatch, + expectedRevision: number, + ): Promise { + await this.ensureReady(); + return projectHeaderSnapshot( + await this.metadata.update(sessionId, patch, { + expectedVersion: expectedRevision, + skipNoop: true, + }), + ); + } + + async updateSessionConfiguration( + sessionId: string, + input: UpdateSessionConfigurationRequest, + ): Promise { + await this.ensureReady(); + return projectHeaderSnapshot(await this.metadata.updateSessionConfiguration(sessionId, input)); + } + + async probeSessionRemoval(sessionId: string): Promise { + await this.ensureReady(); + return projectRemovalProbe(await this.metadata.probeRemoval(sessionId)); + } + + async setSessionsArchivedVersioned( + sessions: readonly VersionedSessionIdentity[], + isArchived: boolean, + ): Promise { + await this.ensureReady(); + return (await this.metadata.setArchivedVersioned(sessions, isArchived)).map( + projectHeaderSnapshot, + ); + } + + async removeSessionsVersioned( + sessions: readonly VersionedSessionIdentity[], + archiveSessions: readonly VersionedSessionIdentity[] = [], + ): Promise { + await this.ensureReady(); + return this.metadata.removeVersioned(sessions, archiveSessions); + } + + async reconcileOrphanedAgentGraphRetirements(): Promise { + await this.ensureReady(); + return this.metadata.reconcileOrphanedAgentGraphRetirements(); + } + + async listPendingSessionRetirementCleanupIds(sessionId?: string): Promise { + await this.ensureReady(); + return this.metadata.listPendingSessionRetirementCleanupIds(sessionId); + } + + async completeSessionRetirementCleanup(sessionId: string): Promise { + await this.ensureReady(); + await this.metadata.completeSessionRetirementCleanup(sessionId); + } + + async setFlagged(sessionId: string, isFlagged: boolean): Promise { + await this.updateHeader(sessionId, { isFlagged }); + } + + async rename(sessionId: string, name: string): Promise { + const normalized = normalizeUserSessionName(name); + if (!normalized.ok) throw new Error(normalized.error); + await this.updateHeader(sessionId, { + name: normalized.value, + titleIsManual: true, + }); + } + + async setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise { + const normalized = normalizeUserSessionName(title); + if (!normalized.ok) return null; + // A generated title only ever fills an absence. Writing at the revision the + // check read makes a rename that lands between the two a winner rather than + // something this silently overwrites; a revision that moved for any other + // reason is re-read, so losing the race stays the only way to answer null. + for (let attempt = 0; attempt < 3; attempt += 1) { + const record = await this.readHeaderRecordSnapshot(sessionId); + const current = record.header; + if ( + current.titleIsManual || + current.name !== DEFAULT_SESSION_NAME || + normalized.value === current.name + ) { + return null; + } + try { + return ( + await this.updateHeaderVersioned(sessionId, { name: normalized.value }, record.revision) + ).header; + } catch (error) { + if (!(error instanceof SessionMetadataVersionConflictError)) throw error; + } + } + // Losing the race every attempt reads the same as losing it once: the + // Session keeps whichever name the writer that won gave it. + return null; + } + + async remove(sessionId: string): Promise { + await this.ensureReady(); + await this.metadata.remove(sessionId); + } + + close(): Promise { + this.closePromise ??= this.closeAfterReady(); + return this.closePromise; + } + + private async closeAfterReady(): Promise { + // Ensure the one-time import has settled before closing the database so + // a concurrent close cannot race an in-flight migration. + await this.ensureReady(); + this.metadata.close(); + } + + private async ensureCatalogProjectionReadable(): Promise { + await this.ensureReady(); + } +} + +function workHubIdentitySuffix(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 48); +} + +/** + * The reserved identity and the reserved role are one fact, and the invariant + * belongs to every creator that builds a header — subagents and Agent Graph + * operators included, whose inputs carry no role today. + */ +function assertCoordinationIdentityPairing(sessionId: string, role: SessionRole | undefined): void { + if (isWorkHubCoordinationSessionId(sessionId) !== (role === WORKHUB_COORDINATION_SESSION_ROLE)) { + throw new Error('WorkHub Coordination Session identity and role must be claimed together'); + } +} + +function buildSessionHeader( + workspaceRoot: string, + input: CreateSessionInput & { readonly role?: SessionRole }, + sessionId: string = randomUUID(), + conversationCopy?: SessionConversationCopy, +): SessionHeader { + if ( + input.projectId !== undefined && + input.projectId !== null && + (typeof input.projectId !== 'string' || input.projectId.length === 0) + ) { + throw new Error('Invalid project id'); + } + const now = Date.now(); + assertSafeSessionId(sessionId); + assertCoordinationIdentityPairing(sessionId, input.role); + const name = + input.name === undefined ? DEFAULT_SESSION_NAME : normalizeRequiredSessionName(input.name); + const header: SessionHeader = { + id: sessionId, + ...(input.role === undefined ? {} : { role: input.role }), + workspaceRoot, + cwd: input.cwd, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + createdAt: now, + name, + titleIsManual: false, + isFlagged: false, + labels: input.labels ?? [], + isArchived: false, + status: input.status ?? 'active', + ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), + statusUpdatedAt: now, + ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), + ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), + ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), + ...(input.subagentRuntime ? { subagentRuntime: input.subagentRuntime } : {}), + ...(input.subagentSpawn ? { subagentSpawn: input.subagentSpawn } : {}), + ...(input.subagentWorkspace ? { subagentWorkspace: input.subagentWorkspace } : {}), + ...(conversationCopy ? { conversationCopy } : {}), + ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), + ...(input.revisionParentSessionId + ? { revisionParentSessionId: input.revisionParentSessionId } + : {}), + ...(input.revisionOfTurnId ? { revisionOfTurnId: input.revisionOfTurnId } : {}), + ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), + ...(input.revisionState ? { revisionState: input.revisionState } : {}), + hasUnread: false, + backend: 'ai-sdk', + ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), + llmConnectionSlug: input.llmConnectionSlug, + // A subagent Session's route is chosen by the spawn that created it and is + // never re-targeted, so it is born frozen. Every other Session freezes on + // its first user Message. + connectionLocked: input.subagentParent !== undefined, + model: input.model ?? 'default', + ...(input.toolProfile !== undefined ? { toolProfile: input.toolProfile } : {}), + permissionMode: input.permissionMode, + collaborationMode: input.collaborationMode ?? 'agent', + orchestrationMode: input.orchestrationMode ?? 'default', + ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), + // Born on the ledger: a Session created here records its execution facts as + // RuntimeEvents from its first turn, so there is no transcript to convert. + // Only an imported transcript (staged at 0) and a Session written before + // this field existed have anything for the converter to do. + transcriptLedgerVersion: 1, + schemaVersion: 1, + }; + assertValidSessionLineage(header); + return header; +} + +function normalizeRequiredSessionName(name: string): string { + const normalized = normalizeUserSessionName(name); + if (!normalized.ok) throw new Error(normalized.error); + return normalized.value; +} + +/** Validate and normalize a current SessionHeader before canonical persistence. */ +export function normalizeSessionHeader( + header: SessionHeader, + sessionId: string = header.id, +): SessionHeader { + const valid = + header.id === sessionId && + (header.role === undefined || header.role === WORKHUB_COORDINATION_SESSION_ROLE) && + typeof header.workspaceRoot === 'string' && + typeof header.cwd === 'string' && + (header.projectId === undefined || + header.projectId === null || + (typeof header.projectId === 'string' && header.projectId.length > 0)) && + isFiniteNumber(header.createdAt) && + (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && + typeof header.name === 'string' && + typeof header.titleIsManual === 'boolean' && + typeof header.isFlagged === 'boolean' && + Array.isArray(header.labels) && + header.labels.every((label) => typeof label === 'string') && + typeof header.isArchived === 'boolean' && + !Object.prototype.hasOwnProperty.call(header, 'archivedAt') && + isSessionStatus(header.status) && + (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && + (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && + (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && + (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && + isValidConversationCopyLineage(header) && + isValidRevisionLineage(header) && + isValidSubagentSessionLineage(header) && + isValidSessionExternalOrigin(header.externalOrigin) && + (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && + typeof header.hasUnread === 'boolean' && + isPersistedBackendKind(header.backend) && + (header.llmConnectionId === undefined || + (typeof header.llmConnectionId === 'string' && header.llmConnectionId.length > 0)) && + typeof header.llmConnectionSlug === 'string' && + typeof header.connectionLocked === 'boolean' && + typeof header.model === 'string' && + (header.toolProfile === undefined || isSessionToolProfile(header.toolProfile)) && + isPermissionMode(header.permissionMode) && + isCollaborationMode(header.collaborationMode) && + isOrchestrationMode(header.orchestrationMode) && + (header.transcriptLedgerVersion === undefined || + header.transcriptLedgerVersion === 0 || + header.transcriptLedgerVersion === 1) && + header.schemaVersion === 1; + if (!valid) { + throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); + } + const normalizedName = normalizeSessionName(header.name); + if (header.blockedReason === undefined) { + const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; + return { ...withoutBlockedReason, name: normalizedName }; + } + return { ...header, name: normalizedName }; +} + +export function decodePersistedSessionHeader( + persisted: PersistedValue, + sessionId?: string, +): SessionHeader { + const header = persisted as unknown as SessionHeader; + const permissionMode = decodePersistedPermissionMode(header.permissionMode); + if (permissionMode === undefined) { + return normalizeSessionHeader(header, sessionId ?? header.id); + } + return normalizeSessionHeader( + permissionMode === header.permissionMode ? header : { ...header, permissionMode }, + sessionId ?? header.id, + ); +} + +function isValidSessionExternalOrigin(origin: SessionHeader['externalOrigin']): boolean { + if (origin === undefined) return true; + return ( + typeof origin === 'object' && + origin !== null && + typeof origin.adapterId === 'string' && + origin.adapterId.length > 0 && + typeof origin.sourceSessionId === 'string' && + origin.sourceSessionId.length > 0 + ); +} + +function isValidRevisionLineage(header: SessionHeader): boolean { + const values = [ + header.revisionRootSessionId, + header.revisionParentSessionId, + header.revisionOfTurnId, + header.revisionIndex, + header.revisionState, + ]; + if (values.every((value) => value === undefined)) return true; + return ( + typeof header.revisionRootSessionId === 'string' && + isSafeSessionId(header.revisionRootSessionId) && + typeof header.revisionParentSessionId === 'string' && + isSafeSessionId(header.revisionParentSessionId) && + typeof header.revisionOfTurnId === 'string' && + header.revisionOfTurnId.length > 0 && + header.revisionOfTurnId.length <= 128 && + Number.isSafeInteger(header.revisionIndex) && + header.revisionIndex! >= 2 && + (header.revisionState === 'preparing' || header.revisionState === 'committed') + ); +} + +function assertValidSessionLineage(header: SessionHeader): void { + if (!isValidConversationCopyLineage(header)) { + throw new Error('Invalid Session conversation-copy lineage'); + } + if (!isValidRevisionLineage(header)) { + throw new Error('Invalid session revision lineage'); + } + if (!isValidSubagentSessionLineage(header)) { + throw new Error('Invalid subagent session lineage'); + } +} + +function isValidConversationCopyLineage(header: SessionHeader): boolean { + const copy = header.conversationCopy; + if (copy === undefined) return true; + if ( + !isSessionConversationCopy(copy) || + !isSafeSessionId(copy.sourceSessionId) || + copy.sourceSessionId === header.id || + header.subagentParent !== undefined + ) { + return false; + } + if (copy.kind === 'branch') { + const revisionClear = + header.revisionRootSessionId === undefined && + header.revisionParentSessionId === undefined && + header.revisionOfTurnId === undefined && + header.revisionIndex === undefined && + header.revisionState === undefined; + if (!revisionClear || header.parentSessionId !== copy.sourceSessionId) { + return false; + } + // An empty copy (absent `sourceTurnId`) records provenance + // (`parentSessionId`) but must not fabricate a `branchOfTurnId`, and is only + // valid for a side conversation; a through-turn copy must anchor to it. + return copy.sourceTurnId === undefined + ? header.branchOfTurnId === undefined && copy.intent === 'side_conversation' + : header.branchOfTurnId === copy.sourceTurnId; + } + // Revision copies always carry a turn boundary (enforced at decode). + return ( + copy.sourceTurnId !== undefined && + header.revisionParentSessionId === copy.sourceSessionId && + header.revisionOfTurnId === copy.sourceTurnId + ); +} + +function isValidSubagentSessionLineage(header: SessionHeader): boolean { + if (header.subagentParent === undefined) { + return ( + header.subagentRuntime === undefined && + header.subagentSpawn === undefined && + header.subagentWorkspace === undefined + ); + } + if ( + !isSubagentSessionParent(header.subagentParent) || + !isSafeSessionId(header.subagentParent.parentSessionId) || + header.parentSessionId !== undefined || + header.branchOfTurnId !== undefined || + header.revisionRootSessionId !== undefined || + header.revisionParentSessionId !== undefined || + header.revisionOfTurnId !== undefined || + header.revisionIndex !== undefined || + header.revisionState !== undefined + ) { + return false; + } + return ( + (header.subagentRuntime === undefined && + header.subagentSpawn === undefined && + header.subagentWorkspace === undefined) || + (isSubagentSessionRuntime(header.subagentRuntime) && + isSubagentSessionSpawn(header.subagentSpawn) && + (header.subagentWorkspace === undefined || + isSubagentWorkspaceBinding(header.subagentWorkspace))) + ); +} + +/** + * Decode guard for a durable session header. `'fake'` stays accepted: + * narrowing it here would make every session written by a build that shipped + * FakeBackend fail `normalizeSessionHeader` and read back as malformed (#3211). + */ +function isPersistedBackendKind(value: unknown): value is SessionHeader['backend'] { + return value === 'ai-sdk' || value === 'fake'; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function assertNoConversationCopyMetadata(input: CreateSessionInput): void { + if (Object.prototype.hasOwnProperty.call(input, 'conversationCopy')) { + throw new Error('Conversation copy metadata requires createStableSession()'); + } +} + +function projectHeaderSnapshot(record: SessionMetadataRecord): SessionHeaderSnapshot { + return { + header: record.header, + revision: record.metadataVersion, + committedAt: record.committedAt, + }; +} + +function projectRemovalProbe(probe: SessionRemovalProbe): ProbeSessionRemovalResult { + return probe.kind === 'present' + ? { kind: 'present', record: projectHeaderSnapshot(probe.record) } + : probe; +} + +function projectCatalogRevision(state: SessionCatalogRevisionState): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update(`${state.epoch}:${state.generation}`) + .digest('hex')}`; +} + +function projectStableSessionCreateProbe( + probe: StableSessionCreateProbe, +): ProbeStableSessionCreateResult { + return probe.kind === 'existing' + ? { kind: 'existing', record: projectHeaderSnapshot(probe.record) } + : probe; +} + +function toSummary(header: SessionHeader): SessionSummary { + const lastMessageAt = header.lastMessageAt; + return { + id: header.id, + cwd: header.cwd, + ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), + name: normalizeSessionName(header.name), + isFlagged: header.isFlagged, + isArchived: header.isArchived, + labels: header.labels, + hasUnread: header.hasUnread, + lastMessageAt, + status: header.status, + ...(header.blockedReason ? { blockedReason: header.blockedReason } : {}), + ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), + ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), + ...(header.subagentRuntime + ? { + subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime), + } + : {}), + ...(header.subagentWorkspace ? { subagentWorkspace: header.subagentWorkspace } : {}), + ...(header.revisionRootSessionId + ? { revisionRootSessionId: header.revisionRootSessionId } + : {}), + ...(header.revisionParentSessionId + ? { revisionParentSessionId: header.revisionParentSessionId } + : {}), + ...(header.revisionOfTurnId ? { revisionOfTurnId: header.revisionOfTurnId } : {}), + ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), + ...(header.revisionState ? { revisionState: header.revisionState } : {}), + backend: header.backend, + ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), + llmConnectionSlug: header.llmConnectionSlug, + connectionLocked: header.connectionLocked, + model: header.model, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), + }; +} + +function toCatalogSummary( + header: SessionHeader, + lastMessagePreview: string | undefined, +): SessionSummary { + return { + ...toSummary(header), + ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + }; +} + +function normalizeSessionName(name: string): string { + return name === 'New Session' ? DEFAULT_SESSION_NAME : name; +} + +export function createUserMessage(input: { + turnId: string; + text: string; + displayText?: string; + attachments?: UserMessage['attachments']; + inlineReferences?: UserMessage['inlineReferences']; +}): UserMessage { + return { + type: 'user', + id: randomUUID(), + turnId: input.turnId, + ts: Date.now(), + text: input.text, + ...(input.displayText !== undefined ? { displayText: input.displayText } : {}), + attachments: input.attachments, + ...(input.inlineReferences !== undefined ? { inlineReferences: input.inlineReferences } : {}), + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e91b796163811741651a66d0129f09fdd09b6fda769dfeb82fd499a0a49454fd.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e91b796163811741651a66d0129f09fdd09b6fda769dfeb82fd499a0a49454fd.source new file mode 100644 index 0000000000..4b309675d0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/e91b796163811741651a66d0129f09fdd09b6fda769dfeb82fd499a0a49454fd.source @@ -0,0 +1,551 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseOptions, +} from './operational-state-store.js'; +import { + isProcessLifetimeOwnerReference, + type ProcessLifetimeOwner, + type ProcessLifetimeRecoveryClaim, +} from './process-lifetime-owner.js'; +import { isSafeStorageId } from './storage-id.js'; + +export interface SessionCopyCreationLease { + sessionId: string; + kind: 'branch' | 'revision'; + sourceSessionId: string; + /** Settled turn the copy branches through. Absent marks an empty copy. */ + sourceTurnId?: string; + intent?: 'side_conversation'; + ownerId: string; +} + +interface PersistedSessionCopyLease { + version: 1; + sessionId: string; + trackedAt: number; + ownerProcessId?: string; + ownerLifetimeRef?: string; + ownerId?: string; + phase: 'creating' | 'live' | 'cleanup'; + cancelRequested: boolean; + creation?: Omit; +} + +interface SessionCopyCleanupStore { + list(): Promise; + read(sessionId: string): Promise; + beginCreation( + creation: SessionCopyCreationLease, + ownerProcessId: string, + ownerLifetimeRef?: string, + ): Promise; + markLive(sessionId: string): Promise; + requestCleanup(sessionId: string): Promise; + markCleanup(sessionId: string): Promise; + forget(sessionId: string): Promise; +} + +export type SessionCopyCleanupDisposition = 'removed' | 'retained'; + +export interface SessionCopyCleanupRecovery { + removed: string[]; + failed: Array<{ sessionId: string; error: unknown }>; +} + +export interface SessionCopyCleanupAuthority { + ownCreation(creation: SessionCopyCreationLease, operation: () => Promise): Promise; + rejectCreation(sessionId: string): Promise; + cleanup(sessionId: string): Promise; + schedule(sessionId: string): Promise; + abandonOwner(ownerId: string): Promise; + recover(): Promise; +} + +export function createSessionCopyCleanupAuthority(input: { + workspaceRoot: string; + removeSession: (sessionId: string) => Promise; + resumeSessionCopy?: (creation: Omit) => Promise; + processId?: string; + isOwnerProcessActive?: (ownerProcessId: string) => boolean | Promise; + processLifetimeOwner?: ProcessLifetimeOwner; + databaseOptions?: OperationalStateDatabaseOptions; +}): SessionCopyCleanupAuthority { + return new SessionCopyCleanupAuthorityImpl( + new SqliteSessionCopyCleanupStore(input.workspaceRoot, input.databaseOptions), + input.removeSession, + input.resumeSessionCopy, + input.processId ?? randomUUID(), + input.isOwnerProcessActive, + input.processLifetimeOwner, + ); +} + +class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { + private readonly creations = new Map< + string, + { creation: SessionCopyCreationLease; operation: Promise } + >(); + private readonly cleanups = new Map>(); + + constructor( + private readonly store: SessionCopyCleanupStore, + private readonly removeSession: ( + sessionId: string, + ) => Promise, + private readonly resumeSessionCopy: + | ((creation: Omit) => Promise) + | undefined, + private readonly processId: string, + private readonly isOwnerProcessActive: + | ((ownerProcessId: string) => boolean | Promise) + | undefined, + private readonly processLifetimeOwner: ProcessLifetimeOwner | undefined, + ) {} + + ownCreation(creation: SessionCopyCreationLease, operation: () => Promise): Promise { + const normalized = normalizeCreationLease(creation); + const active = this.creations.get(normalized.sessionId); + if (active) { + if (!sameCreation(active.creation, normalized)) { + return Promise.reject(new Error('Session copy identity changed while creation was active')); + } + return active.operation as Promise; + } + const task = (async () => { + try { + await this.store.beginCreation( + normalized, + this.processId, + this.processLifetimeOwner?.reference, + ); + const result = await operation(); + const record = await this.store.markLive(normalized.sessionId); + if (record?.cancelRequested) void this.settleCleanup(normalized.sessionId); + return result; + } finally { + this.creations.delete(normalized.sessionId); + } + })(); + this.creations.set(normalized.sessionId, { creation: normalized, operation: task }); + return task; + } + + async rejectCreation(sessionId: string): Promise { + const normalized = normalizeSessionId(sessionId); + await this.creations.get(normalized)?.operation.catch(() => undefined); + const record = await this.store.read(normalized); + if (!record) return; + if (record.phase !== 'creating') { + throw new Error(`Session copy ${normalized} is no longer awaiting creation`); + } + await this.store.forget(normalized); + } + + async cleanup(sessionId: string): Promise { + const normalized = normalizeSessionId(sessionId); + const active = this.cleanups.get(normalized); + if (active) { + await active; + return; + } + await this.store.requestCleanup(normalized); + await this.settleCleanup(normalized); + } + + async schedule(sessionId: string): Promise { + const normalized = normalizeSessionId(sessionId); + if (this.cleanups.has(normalized)) return; + await this.store.requestCleanup(normalized); + void this.settleCleanup(normalized).catch(() => undefined); + } + + async abandonOwner(ownerId: string): Promise { + const normalizedOwnerId = normalizeOwnerId(ownerId); + const owned = (await this.store.list()).filter((record) => { + const currentIncarnation = this.processLifetimeOwner + ? record.ownerLifetimeRef === this.processLifetimeOwner.reference + : record.ownerProcessId === this.processId; + return currentIncarnation && record.ownerId === normalizedOwnerId; + }); + await Promise.all(owned.map((record) => this.schedule(record.sessionId))); + } + + async recover(): Promise { + const removed: string[] = []; + const failed: SessionCopyCleanupRecovery['failed'] = []; + const lifetimeGroups = new Map(); + for (const record of await this.store.list()) { + if ( + record.ownerLifetimeRef && + this.processLifetimeOwner && + isProcessLifetimeOwnerReference(record.ownerLifetimeRef) + ) { + if (record.ownerLifetimeRef === this.processLifetimeOwner.reference) continue; + const group = lifetimeGroups.get(record.ownerLifetimeRef) ?? []; + group.push(record); + lifetimeGroups.set(record.ownerLifetimeRef, group); + continue; + } + if ( + record.ownerLifetimeRef === undefined && + (record.phase === 'cleanup' || record.cancelRequested) + ) { + await this.recoverRecord(record, removed, failed); + continue; + } + try { + const staleOwner = + record.ownerProcessId !== undefined && + record.ownerProcessId !== this.processId && + !(await this.isOwnerProcessActive?.(record.ownerProcessId)); + if (staleOwner) await this.recoverRecord(record, removed, failed); + } catch (error) { + failed.push({ sessionId: record.sessionId, error }); + } + } + for (const [reference, records] of lifetimeGroups) { + let claim: ProcessLifetimeRecoveryClaim | undefined; + try { + claim = await this.processLifetimeOwner?.tryClaimReleased(reference); + } catch (error) { + for (const record of records) failed.push({ sessionId: record.sessionId, error }); + continue; + } + if (!claim) continue; + try { + for (const record of records) await this.recoverRecord(record, removed, failed); + const ownerStillReferenced = await this.store + .list() + .then((current) => current.some((record) => record.ownerLifetimeRef === reference)) + .catch(() => true); + if (!ownerStillReferenced) await claim.retire().catch(() => undefined); + } finally { + await claim.close().catch(() => undefined); + } + } + if (this.processLifetimeOwner) { + const referenced = await this.store + .list() + .then( + (records) => + new Set( + records.flatMap((record) => + record.ownerLifetimeRef ? [record.ownerLifetimeRef] : [], + ), + ), + ) + .catch(() => undefined); + if (referenced) { + await this.processLifetimeOwner + .retireUnreferencedReleasedOwners(referenced) + .catch(() => undefined); + } + } + return { removed, failed }; + } + + private async recoverRecord( + record: PersistedSessionCopyLease, + removed: string[], + failed: SessionCopyCleanupRecovery['failed'], + ): Promise { + try { + await this.store.requestCleanup(record.sessionId); + const disposition = await this.settleCleanup(record.sessionId); + if (disposition === 'removed') removed.push(record.sessionId); + } catch (error) { + failed.push({ sessionId: record.sessionId, error }); + } + } + + private settleCleanup(sessionId: string): Promise { + const active = this.cleanups.get(sessionId); + if (active) return active; + const operation = this.cleanupOnce(sessionId).finally(() => { + this.cleanups.delete(sessionId); + }); + this.cleanups.set(sessionId, operation); + return operation; + } + + private async cleanupOnce(sessionId: string): Promise { + await this.creations.get(sessionId)?.operation.catch(() => undefined); + let record = await this.store.read(sessionId); + if (!record) return 'removed'; + if (record.phase === 'creating') { + if (!record.creation || !this.resumeSessionCopy) { + throw new Error(`Session copy ${sessionId} cannot resolve its creating lease`); + } + await this.resumeSessionCopy({ sessionId, ...record.creation }); + record = await this.store.markCleanup(sessionId); + } else if (record.phase === 'live') { + record = await this.store.markCleanup(sessionId); + } + if (!record) return 'removed'; + const disposition = (await this.removeSession(sessionId)) ?? 'removed'; + await this.store.forget(sessionId); + return disposition; + } +} + +class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { + constructor( + private readonly workspaceRoot: string, + private readonly databaseOptions: OperationalStateDatabaseOptions = {}, + ) {} + + async list(): Promise { + return this.withDatabase('read', (database) => + ( + database + .prepare(` + SELECT session_id AS sessionId, tracked_at AS trackedAt, record_json AS recordJson + FROM workflow_quote_companion_cleanup + ORDER BY tracked_at, session_id + `) + .all() as Array<{ sessionId: string; trackedAt: number; recordJson: string }> + ).map(decodeLeaseRow), + ); + } + + async read(sessionId: string): Promise { + return this.withDatabase('read', (database) => { + const row = database + .prepare(` + SELECT session_id AS sessionId, tracked_at AS trackedAt, record_json AS recordJson + FROM workflow_quote_companion_cleanup + WHERE session_id = ? + `) + .get(sessionId) as { sessionId: string; trackedAt: number; recordJson: string } | undefined; + return row ? decodeLeaseRow(row) : undefined; + }); + } + + async beginCreation( + creation: SessionCopyCreationLease, + ownerProcessId: string, + ownerLifetimeRef?: string, + ): Promise { + return this.mutate(creation.sessionId, (current) => { + if (current?.creation && !samePersistedCreation(current.creation, creation)) { + throw new Error('Session copy target is already bound to another creation'); + } + if (current?.phase === 'cleanup' || current?.cancelRequested) { + throw new Error('Session copy target is already scheduled for cleanup'); + } + return { + version: 1, + sessionId: creation.sessionId, + trackedAt: current?.trackedAt ?? Date.now(), + ownerProcessId, + ...(ownerLifetimeRef ? { ownerLifetimeRef } : {}), + ownerId: creation.ownerId, + phase: current?.phase ?? 'creating', + cancelRequested: false, + creation: { + kind: creation.kind, + sourceSessionId: creation.sourceSessionId, + ...(creation.sourceTurnId === undefined ? {} : { sourceTurnId: creation.sourceTurnId }), + ...(creation.intent ? { intent: creation.intent } : {}), + }, + }; + }); + } + + async markLive(sessionId: string): Promise { + return this.mutateOptional(sessionId, (current) => + current.phase === 'creating' ? { ...current, phase: 'live' } : current, + ); + } + + async requestCleanup(sessionId: string): Promise { + return this.mutate(sessionId, (current) => { + if (!current) { + return { + version: 1, + sessionId, + trackedAt: Date.now(), + phase: 'cleanup', + cancelRequested: true, + }; + } + return current.phase === 'creating' + ? { ...current, cancelRequested: true } + : { ...current, phase: 'cleanup', cancelRequested: true }; + }); + } + + async markCleanup(sessionId: string): Promise { + return this.mutateOptional(sessionId, (current) => ({ + ...current, + phase: 'cleanup', + cancelRequested: true, + })); + } + + async forget(sessionId: string): Promise { + this.withDatabase('write', (database) => { + database + .prepare('DELETE FROM workflow_quote_companion_cleanup WHERE session_id = ?') + .run(sessionId); + }); + } + + private mutate( + sessionId: string, + update: (current: PersistedSessionCopyLease | undefined) => PersistedSessionCopyLease, + ): PersistedSessionCopyLease { + return this.withDatabase('write', (database) => { + const current = readLease(database, sessionId); + const next = update(current); + writeLease(database, next); + return next; + }); + } + + private mutateOptional( + sessionId: string, + update: (current: PersistedSessionCopyLease) => PersistedSessionCopyLease, + ): PersistedSessionCopyLease | undefined { + return this.withDatabase('write', (database) => { + const current = readLease(database, sessionId); + if (!current) return undefined; + const next = update(current); + writeLease(database, next); + return next; + }); + } + + private withDatabase( + mode: 'read' | 'write', + operation: (database: import('node:sqlite').DatabaseSync) => T, + ): T { + const lease = acquireOperationalStateDatabase(this.workspaceRoot, this.databaseOptions); + try { + return lease.transaction(mode, () => operation(lease.database)); + } finally { + lease.close(); + } + } +} + +function readLease( + database: import('node:sqlite').DatabaseSync, + sessionId: string, +): PersistedSessionCopyLease | undefined { + const row = database + .prepare(` + SELECT session_id AS sessionId, tracked_at AS trackedAt, record_json AS recordJson + FROM workflow_quote_companion_cleanup + WHERE session_id = ? + `) + .get(sessionId) as { sessionId: string; trackedAt: number; recordJson: string } | undefined; + return row ? decodeLeaseRow(row) : undefined; +} + +function writeLease( + database: import('node:sqlite').DatabaseSync, + record: PersistedSessionCopyLease, +): void { + database + .prepare(` + INSERT INTO workflow_quote_companion_cleanup(session_id, tracked_at, record_json) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + tracked_at = excluded.tracked_at, + record_json = excluded.record_json + `) + .run(record.sessionId, record.trackedAt, JSON.stringify(record)); +} + +function decodeLeaseRow(row: { + sessionId: string; + trackedAt: number; + recordJson: string; +}): PersistedSessionCopyLease { + const value = JSON.parse(row.recordJson) as Partial; + if ( + value.version !== 1 || + value.sessionId !== row.sessionId || + (value.phase !== 'creating' && value.phase !== 'live' && value.phase !== 'cleanup') || + typeof value.cancelRequested !== 'boolean' || + (value.ownerLifetimeRef !== undefined && typeof value.ownerLifetimeRef !== 'string') + ) { + throw new Error(`Invalid Session copy lease: ${row.sessionId}`); + } + return { ...value, trackedAt: row.trackedAt } as PersistedSessionCopyLease; +} + +function normalizeCreationLease(creation: SessionCopyCreationLease): SessionCopyCreationLease { + return { + sessionId: normalizeSessionId(creation.sessionId), + kind: creation.kind, + sourceSessionId: normalizeSessionId(creation.sourceSessionId), + ...(creation.sourceTurnId === undefined + ? {} + : { sourceTurnId: normalizeSessionId(creation.sourceTurnId) }), + ...(creation.intent === 'side_conversation' ? { intent: creation.intent } : {}), + ownerId: normalizeOwnerId(creation.ownerId), + }; +} + +function sameCreation(left: SessionCopyCreationLease, right: SessionCopyCreationLease): boolean { + return ( + left.sessionId === right.sessionId && + left.kind === right.kind && + left.sourceSessionId === right.sourceSessionId && + left.sourceTurnId === right.sourceTurnId && + left.intent === right.intent && + left.ownerId === right.ownerId + ); +} + +function samePersistedCreation( + left: NonNullable, + right: SessionCopyCreationLease, +): boolean { + return ( + left.kind === right.kind && + left.sourceSessionId === right.sourceSessionId && + left.sourceTurnId === right.sourceTurnId && + left.intent === right.intent + ); +} + +function normalizeSessionId(value: unknown): string { + if (typeof value !== 'string') throw new Error('Invalid Session copy id'); + const normalized = value.trim(); + if (!isSafeStorageId(normalized)) { + throw new Error('Invalid Session copy id'); + } + return normalized; +} + +function normalizeOwnerId(value: unknown): string { + if (typeof value !== 'string') throw new Error('Invalid Session copy owner id'); + const normalized = value.trim(); + if (!/^[A-Za-z0-9:_-]{1,128}$/.test(normalized)) { + throw new Error('Invalid Session copy owner id'); + } + return normalized; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ea3e70d7ec2a61e9202b51fcca0702e266eb9b6a55c4f29b55d5a3ab7b0796f2.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ea3e70d7ec2a61e9202b51fcca0702e266eb9b6a55c4f29b55d5a3ab7b0796f2.source new file mode 100644 index 0000000000..f3da7b016a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ea3e70d7ec2a61e9202b51fcca0702e266eb9b6a55c4f29b55d5a3ab7b0796f2.source @@ -0,0 +1,1295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { type BigIntStats, constants as fsConstants } from 'node:fs'; +import { + access, + copyFile, + lstat, + mkdir, + open, + readFile, + realpath, + rm, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join, relative, sep } from 'node:path'; +import { + ARTIFACT_ENTITY_ID_MAX_CHARS, + ARTIFACT_KINDS, + ARTIFACT_SOURCES, + ArtifactBinaryReadResult, + ArtifactKind, + ArtifactRecord, + ArtifactSource, + ArtifactTextReadResult, + canUserDeleteArtifact, + isArtifactTurnKey, + isCanonicalArtifactEntityId, +} from '@maka/core/artifacts'; +import { + isDeepResearchArtifactRole, + type DeepResearchArtifactRole, +} from '@maka/core/deep-research-run'; +import { sniffAttachmentMimeType } from '@maka/core/attachments'; +import { + isSafeRelativeArtifactPath, + validateRelativeArtifactPath, +} from './artifact-metadata-codec.js'; +import { + withArtifactWriterLock, + withLeaseBoundArtifactWriterLock, +} from './artifact-writer-lock.js'; +import type { ArtifactWriterLockAuthority } from './root-authority.js'; +import { syncDirectory, syncDirectoryChain, syncFile } from './stable-storage.js'; +import { + createSqliteArtifactMetadataRepository, + type ArtifactMetadataChanges, +} from './sqlite-artifact-metadata.js'; + +export { isSafeRelativeArtifactPath } from './artifact-metadata-codec.js'; + +export const ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024; +export const ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES = 50 * 1024 * 1024; + +const ARTIFACT_PURGE_RESOLVE_CONCURRENCY = 8; +interface ArtifactSessionSnapshot { + readonly records: readonly ArtifactRecord[]; + readonly revision: ArtifactListRevision; +} + +type ArtifactReadFailure = { + readonly ok: false; + readonly reason: 'not_found' | 'too_large' | 'read_failed' | 'not_allowed'; +}; + +interface PreparedArtifactRead { + readonly ok: true; + readonly path: string; + readonly record: ArtifactRecord; + readonly maxBytes: number; +} + +interface ArtifactRemovalEntry { + readonly unlinkPath: string; + readonly comparisonIdentity: string; +} + +type ArtifactRecordDraft = Omit; + +export interface CreateArtifactInput { + sessionId: string; + turnId: string; + name: string; + kind: ArtifactKind; + content: string | Uint8Array; + mimeType?: string; + source: ArtifactSource; + summary?: string; + deepResearchRole?: DeepResearchArtifactRole; + now?: number; + id?: string; +} + +export type ArtifactListRevision = `sha256:${string}`; + +export interface ArtifactListPage { + readonly revision: ArtifactListRevision; + readonly records: readonly ArtifactRecord[]; + readonly total: number; +} + +export interface ArtifactSessionEntry { + readonly revision: ArtifactListRevision; + readonly record: ArtifactRecord | null; +} + +export type ArtifactChunkReadResult = + | { + readonly ok: true; + readonly bytes: Uint8Array; + readonly offset: number; + readonly totalBytes: number; + readonly nextOffset: number | null; + } + | ArtifactReadFailure + | { readonly ok: false; readonly reason: 'out_of_range' }; + +export interface ConversationArtifactCopyInput { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly turnIds: readonly string[]; + readonly excludeArtifactIds?: readonly string[]; + /** + * Source-Session artifact ids to copy in addition to the turn-scoped + * selection, regardless of their `turnId`. Used to carry user-uploaded + * attachments (whose `turnId` is the upload id sentinel, not a conversation + * turn) that the copied transcript still references. Lenient: an id with no + * matching source record is a no-op. + */ + readonly includeArtifactIds?: readonly string[]; + readonly linkedArtifacts?: readonly { + readonly sessionId: string; + readonly artifactIds: readonly string[]; + }[]; +} + +export interface ConversationArtifactCopyResult { + readonly artifactIds: ReadonlyMap; + readonly relativePaths: ReadonlyMap; +} + +export type DurableArtifactBinaryReadResult = + | ArtifactBinaryReadResult + | { ok: false; reason: 'session_mismatch' }; + +export interface DurableArtifactAttachmentReader { + readDurableAttachmentBinary(input: { + artifactId: string; + sessionId: string; + maxBytes?: number; + }): Promise; +} + +export type ArtifactUserDeleteResult = + | { readonly kind: 'deleted' } + | { readonly kind: 'protected' } + | { readonly kind: 'not_found' }; + +export interface ArtifactUpgradeCleanupInput { + readonly after?: string; + readonly maxPaths: number; +} + +export interface ArtifactUpgradeCleanupResult { + readonly nextAfter: string | null; + readonly processedPaths: number; + readonly failedPaths: number; +} + +export interface ArtifactAuthorityStore extends DurableArtifactAttachmentReader { + create(input: CreateArtifactInput): Promise; + close(): void; + copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise; + purgeSessionArtifacts(sessionId: string): Promise; + reclaimUpgradeResidue(input: ArtifactUpgradeCleanupInput): Promise; + deleteOwnedArtifactInSession( + sessionId: string, + artifactId: string, + source: ArtifactSource, + ): Promise; + deleteUserArtifactInSession( + sessionId: string, + artifactId: string, + ): Promise; + listPage( + sessionId: string, + options: { offset: number; limit: number }, + ): Promise; + listTurnArtifacts(sessionId: string, turnId: string): Promise; + getInSession(sessionId: string, artifactId: string): Promise; + readTextInSession( + sessionId: string, + artifactId: string, + opts?: { maxBytes?: number }, + ): Promise; + readBinaryInSession( + sessionId: string, + artifactId: string, + opts?: { maxBytes?: number }, + ): Promise; + readChunkInSession( + sessionId: string, + artifactId: string, + options: { offset: number; maxBytes: number }, + ): Promise; +} + +export interface ArtifactStoreWriteAuthority { + readonly store: ArtifactAuthorityStore; + close(): void; +} + +export function createSqliteArtifactStoreWriteAuthority( + workspaceRoot: string, + options: { + assertAuthority?: () => Promise; + leaseBoundWriterLockAuthority?: ArtifactWriterLockAuthority; + } = {}, +): ArtifactStoreWriteAuthority { + const store = new SqliteArtifactStore( + workspaceRoot, + createSqliteArtifactMetadataRepository(workspaceRoot), + options.assertAuthority, + options.leaseBoundWriterLockAuthority, + ); + return Object.freeze({ + store, + close: () => store.close(), + }); +} + +class SqliteArtifactStore implements ArtifactAuthorityStore { + private artifactRoot: string; + private records: ArtifactRecord[] = []; + private queue: Promise = Promise.resolve(); + + constructor( + private workspaceRoot: string, + private readonly metadataRepository: ReturnType, + private readonly assertAuthority?: () => Promise, + private readonly leaseBoundWriterLockAuthority?: ArtifactWriterLockAuthority, + ) { + this.artifactRoot = join(workspaceRoot, 'artifacts'); + } + + close(): void { + this.metadataRepository.close(); + } + + async create(input: CreateArtifactInput): Promise { + const acceptedInput: CreateArtifactInput = Object.freeze({ + ...input, + content: typeof input.content === 'string' ? input.content : new Uint8Array(input.content), + }); + const id = acceptedInput.id ?? randomUUID(); + if (!ARTIFACT_KIND_SET.has(acceptedInput.kind)) throw new Error('Invalid Artifact kind'); + if (!ARTIFACT_SOURCE_SET.has(acceptedInput.source)) { + throw new Error('Invalid Artifact source'); + } + if ( + acceptedInput.deepResearchRole !== undefined && + !isDeepResearchArtifactRole(acceptedInput.deepResearchRole) + ) { + throw new Error('Invalid Artifact deep-research role'); + } + if ( + acceptedInput.now !== undefined && + (!Number.isSafeInteger(acceptedInput.now) || acceptedInput.now < 0) + ) { + throw new Error('Invalid Artifact creation time'); + } + assertCanonicalArtifactEntityId(id, 'id'); + assertCanonicalArtifactEntityId(acceptedInput.sessionId, 'sessionId'); + assertArtifactTurnKey(acceptedInput.turnId); + const name = sanitizeArtifactName(acceptedInput.name); + const relativePath = `${acceptedInput.sessionId}/${id}-${name}`; + validateRelativeArtifactPath(relativePath); + return this.enqueueMutation(async () => { + await this.prepareMutationUnlocked(); + const existing = this.records.find((record) => record.id === id); + if (existing) { + return this.replayExistingArtifactUnlocked(existing, acceptedInput, { + id, + name, + relativePath, + }); + } + return this.publishNewArtifactUnlocked( + { + id, + sessionId: acceptedInput.sessionId, + turnId: acceptedInput.turnId, + createdAt: acceptedInput.now ?? Date.now(), + name, + kind: acceptedInput.kind, + relativePath, + ...(acceptedInput.mimeType ? { mimeType: acceptedInput.mimeType } : {}), + source: acceptedInput.source, + ...(acceptedInput.summary ? { summary: acceptedInput.summary } : {}), + ...(acceptedInput.deepResearchRole + ? { deepResearchRole: acceptedInput.deepResearchRole } + : {}), + }, + (targetPath) => writeFile(targetPath, acceptedInput.content, { flag: 'wx' }), + ); + }); + } + + async copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise { + assertCanonicalArtifactEntityId(input.sourceSessionId, 'sessionId'); + assertCanonicalArtifactEntityId(input.targetSessionId, 'sessionId'); + if (input.sourceSessionId === input.targetSessionId) { + throw new Error('Artifact conversation copy requires distinct Sessions'); + } + const turnIds = new Set(input.turnIds); + const excludedArtifactIds = new Set(input.excludeArtifactIds ?? []); + const includedArtifactIds = new Set(input.includeArtifactIds ?? []); + for (const turnId of turnIds) assertArtifactTurnKey(turnId); + const linkedArtifacts = input.linkedArtifacts ?? []; + const requestedLinkedArtifactIds = new Map>(); + for (const linked of linkedArtifacts) { + assertCanonicalArtifactEntityId(linked.sessionId, 'sessionId'); + if (linked.sessionId === input.targetSessionId) { + throw new Error('Linked Artifact copy requires a distinct source Session'); + } + const artifactIds = requestedLinkedArtifactIds.get(linked.sessionId) ?? new Set(); + for (const artifactId of linked.artifactIds) { + assertCanonicalArtifactEntityId(artifactId, 'id'); + artifactIds.add(artifactId); + } + requestedLinkedArtifactIds.set(linked.sessionId, artifactIds); + } + const records = await this.enqueue(async () => { + await this.load(); + const selected = this.records + .filter( + (record) => + record.sessionId === input.sourceSessionId && + turnIds.has(record.turnId) && + !excludedArtifactIds.has(record.id), + ) + .map((record) => ({ ...record })); + for (const [sessionId, artifactIds] of requestedLinkedArtifactIds) { + for (const artifactId of artifactIds) { + const record = this.records.find( + (candidate) => candidate.sessionId === sessionId && candidate.id === artifactId, + ); + // A linked child result names every Artifact its turn held, and the + // ledger naming them cannot be rewritten. One that is no longer + // there is copied as nothing rather than failing the copy -- the + // caller is asking for what a past turn had, not asserting that all + // of it survived. + if (record) selected.push({ ...record }); + } + } + const selectedIds = new Set(selected.map((record) => record.id)); + for (const record of this.records) { + if ( + record.sessionId === input.sourceSessionId && + includedArtifactIds.has(record.id) && + !excludedArtifactIds.has(record.id) && + !selectedIds.has(record.id) + ) { + selected.push({ ...record }); + selectedIds.add(record.id); + } + } + return selected; + }); + + const artifactIds = new Map(); + const relativePaths = new Map(); + for (const record of records) { + const targetId = conversationCopyArtifactId( + record.sessionId, + input.targetSessionId, + record.id, + ); + const prepared = await this.enqueue(() => this.prepareRecordRead(record, record.sizeBytes)); + if (!prepared.ok) { + throw new Error(`Artifact ${record.id} could not be copied: ${prepared.reason}`); + } + const created = await this.copyConversationArtifact( + prepared, + input.targetSessionId, + targetId, + ); + artifactIds.set(record.id, created.id); + relativePaths.set(record.relativePath, created.relativePath); + } + return { artifactIds, relativePaths }; + } + + private copyConversationArtifact( + prepared: PreparedArtifactRead, + targetSessionId: string, + targetId: string, + ): Promise { + const source = prepared.record; + const name = sanitizeArtifactName(source.name); + const relativePath = `${targetSessionId}/${targetId}-${name}`; + assertCanonicalArtifactEntityId(targetId, 'id'); + validateRelativeArtifactPath(relativePath); + return this.enqueueMutation(async () => { + await this.prepareMutationUnlocked(); + if (this.records.some((record) => record.id === targetId)) { + throw new Error(`Artifact target already exists: ${targetId}`); + } + return this.publishNewArtifactUnlocked( + { + ...source, + id: targetId, + sessionId: targetSessionId, + name, + relativePath, + }, + (targetPath) => copyFile(prepared.path, targetPath, fsConstants.COPYFILE_EXCL), + source.sizeBytes, + ); + }); + } + + private async publishNewArtifactUnlocked( + draft: ArtifactRecordDraft, + writeTarget: (targetPath: string) => Promise, + expectedSize?: number, + ): Promise { + const target = join(this.artifactRoot, draft.relativePath); + const targetDirectory = dirname(target); + const createdDirectory = await mkdir(targetDirectory, { recursive: true }); + if (createdDirectory !== undefined) { + await syncDirectoryChain(targetDirectory, this.workspaceRoot); + } + await assertArtifactDirectory(this.artifactRoot, targetDirectory); + await rm(target, { force: true }); + try { + await writeTarget(target); + await syncFile(target); + await syncDirectory(targetDirectory); + const size = await stat(target); + if (expectedSize !== undefined && size.size !== expectedSize) { + throw new Error(`Artifact source changed while copying: ${draft.id}`); + } + const record: ArtifactRecord = { ...draft, sizeBytes: size.size }; + const nextRecords = [...this.records, record]; + await this.writeMetadataUnlocked({ upserts: [record] }); + this.records = nextRecords; + return { ...record }; + } catch (error) { + await removeFileDurably(target, targetDirectory).catch(() => undefined); + throw error; + } + } + + async purgeSessionArtifacts(sessionId: string): Promise { + assertCanonicalArtifactEntityId(sessionId, 'sessionId'); + await this.enqueueMutation(async () => { + await this.prepareMutationUnlocked(); + await this.purgeRecordsUnlocked( + this.records.filter((record) => record.sessionId === sessionId), + ); + }); + } + + /** + * Deletes the files the v1 upgrade recorded as no longer named by any record. + * + * A path some record has since claimed keeps its bytes. A note is discharged + * once its file is gone, and a file that will not go keeps only its own note + * rather than holding up the ones behind it. + */ + async reclaimUpgradeResidue( + input: ArtifactUpgradeCleanupInput, + ): Promise { + if (!Number.isSafeInteger(input.maxPaths) || input.maxPaths < 1 || input.maxPaths > 1024) { + throw new TypeError('Artifact cleanup path limit must be between 1 and 1024'); + } + const after = input.after ?? ''; + const maxPaths = input.maxPaths; + return this.enqueueMutation(async () => { + const recorded = this.metadataRepository.readUpgradeOrphanPaths(after, maxPaths + 1); + const selected = recorded.slice(0, maxPaths); + const directories = new Set(); + const discharged: string[] = []; + let failedPaths = 0; + let realArtifactRoot: string | undefined; + try { + for (const relativePath of selected) { + if ( + this.metadataRepository.hasRelativePath(relativePath) || + !isSafeRelativeArtifactPath(relativePath) + ) { + discharged.push(relativePath); + continue; + } + const entry = await resolveArtifactRemovalEntry(this.artifactRoot, relativePath); + if (!entry) { + discharged.push(relativePath); + continue; + } + realArtifactRoot ??= await ensureRealDirectory(this.artifactRoot); + if (!isInsideOrSamePath(realArtifactRoot, dirname(entry.unlinkPath))) { + failedPaths += 1; + continue; + } + const artifactIds = new Set([ + ...artifactIdsFromUpgradeOrphanPath(relativePath), + ...artifactIdsFromUpgradeOrphanPath(entry.unlinkPath), + ]); + if ( + artifactIds.size > 0 && + (await this.hasClaimedRemovalIdentityUnlocked( + [...artifactIds], + entry.comparisonIdentity, + )) + ) { + discharged.push(relativePath); + continue; + } + try { + await unlink(entry.unlinkPath); + directories.add(dirname(entry.unlinkPath)); + } catch (error) { + if (!isNotFound(error)) { + failedPaths += 1; + continue; + } + } + discharged.push(relativePath); + } + } finally { + for (const directory of directories) await syncDirectory(directory); + } + if (discharged.length > 0) this.metadataRepository.forgetUpgradeOrphanPaths(discharged); + return { + nextAfter: recorded.length > selected.length ? selected.at(-1)! : null, + processedPaths: selected.length, + failedPaths, + }; + }); + } + + private async hasClaimedRemovalIdentityUnlocked( + artifactIds: readonly string[], + comparisonIdentity: string, + ): Promise { + for (const relativePath of this.metadataRepository.readRelativePathsByCaseFoldedArtifactIds( + artifactIds, + )) { + const entry = await resolveArtifactRemovalEntry(this.artifactRoot, relativePath); + if (entry?.comparisonIdentity === comparisonIdentity) return true; + } + return false; + } + + private async replayExistingArtifactUnlocked( + existing: ArtifactRecord, + input: CreateArtifactInput, + canonical: { id: string; name: string; relativePath: string }, + ): Promise { + const expectedBytes = Buffer.from(input.content); + if ( + existing.id !== canonical.id || + existing.sessionId !== input.sessionId || + existing.turnId !== input.turnId || + existing.name !== canonical.name || + existing.kind !== input.kind || + existing.relativePath !== `${input.sessionId}/${canonical.id}-${existing.name}` || + existing.sizeBytes !== expectedBytes.byteLength || + existing.mimeType !== optionalCanonicalText(input.mimeType) || + existing.source !== input.source || + existing.summary !== optionalCanonicalText(input.summary) || + existing.deepResearchRole !== input.deepResearchRole || + (input.now !== undefined && existing.createdAt !== input.now) + ) { + throw artifactReplayConflict(canonical.id); + } + + const resolved = await resolveArtifactPath({ + artifactRoot: this.artifactRoot, + relativePath: existing.relativePath, + }); + if (!resolved.ok) throw artifactReplayConflict(canonical.id); + const payloadStat = await stat(resolved.path).catch(() => null); + if ( + !payloadStat?.isFile() || + payloadStat.size !== existing.sizeBytes || + payloadStat.size !== expectedBytes.byteLength + ) { + throw artifactReplayConflict(canonical.id); + } + const actualBytes = await readFile(resolved.path).catch(() => null); + if (!actualBytes || sha256(actualBytes) !== sha256(expectedBytes)) { + throw artifactReplayConflict(canonical.id); + } + + return { ...existing }; + } + + async listPage( + sessionId: string, + options: { offset: number; limit: number }, + ): Promise { + assertPageBound(options.offset, true, 'offset'); + assertPageBound(options.limit, false, 'limit'); + const { offset, limit } = options; + return this.enqueue(async () => { + await this.load(); + const snapshot = this.sessionSnapshot(sessionId); + return { + revision: snapshot.revision, + records: snapshot.records.slice(offset, offset + limit).map((record) => ({ ...record })), + total: snapshot.records.length, + }; + }); + } + + async listTurnArtifacts(sessionId: string, turnId: string): Promise { + assertCanonicalArtifactEntityId(sessionId, 'sessionId'); + assertArtifactTurnKey(turnId); + return this.enqueue(async () => { + await this.load(); + const snapshot = this.sessionSnapshot(sessionId); + return snapshot.records + .filter((record) => record.turnId === turnId) + .map((record) => ({ ...record })); + }); + } + + async getInSession(sessionId: string, artifactId: string): Promise { + return this.enqueue(async () => { + await this.load(); + const snapshot = this.sessionSnapshot(sessionId); + const record = snapshot.records.find((candidate) => candidate.id === artifactId); + return { + revision: snapshot.revision, + record: record ? { ...record } : null, + }; + }); + } + + readTextInSession( + sessionId: string, + artifactId: string, + opts: { maxBytes?: number } = {}, + ): Promise { + return this.enqueue(async () => { + const prepared = await this.prepareReadInSessionUnlocked( + sessionId, + artifactId, + opts.maxBytes ?? ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES, + ); + return this.readPreparedText(prepared); + }); + } + + readBinaryInSession( + sessionId: string, + artifactId: string, + opts: { maxBytes?: number } = {}, + ): Promise { + return this.enqueue(async () => { + const prepared = await this.prepareReadInSessionUnlocked( + sessionId, + artifactId, + opts.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES, + ); + return this.readPreparedBinary(prepared); + }); + } + + readChunkInSession( + sessionId: string, + artifactId: string, + options: { offset: number; maxBytes: number }, + ): Promise { + return this.enqueue(async () => { + if ( + !Number.isSafeInteger(options.offset) || + options.offset < 0 || + !Number.isSafeInteger(options.maxBytes) || + options.maxBytes < 1 + ) { + return { ok: false, reason: 'out_of_range' }; + } + const prepared = await this.prepareReadInSessionUnlocked( + sessionId, + artifactId, + Number.MAX_SAFE_INTEGER, + ); + return prepared.ok ? readPreparedChunk(prepared, options.offset, options.maxBytes) : prepared; + }); + } + + async readDurableAttachmentBinary(input: { + artifactId: string; + sessionId: string; + maxBytes?: number; + }): Promise { + return this.enqueue(async () => { + await this.load(); + const record = this.records.find((item) => item.id === input.artifactId); + if (!record) return { ok: false, reason: 'not_found' }; + if (record.sessionId !== input.sessionId) { + return { ok: false, reason: 'session_mismatch' }; + } + const prepared = await this.prepareRecordRead( + record, + input.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES, + ); + return this.readPreparedBinary(prepared); + }); + } + + private async readPreparedText( + prepared: PreparedArtifactRead | ArtifactReadFailure, + ): Promise { + if (!prepared.ok) return prepared; + const bytes = await readPreparedBytes(prepared); + return bytes.ok ? { ok: true, text: bytes.bytes.toString('utf8') } : bytes; + } + + private async readPreparedBinary( + prepared: PreparedArtifactRead | ArtifactReadFailure, + ): Promise { + if (!prepared.ok) return prepared; + const read = await readPreparedBytes(prepared); + if (!read.ok) return read; + const mimeType = sniffAllowedBinaryMime(read.bytes); + if (!mimeType) return { ok: false, reason: 'unsupported_mime' }; + return { ok: true, base64: read.bytes.toString('base64'), mimeType }; + } + + deleteOwnedArtifactInSession( + sessionId: string, + artifactId: string, + source: ArtifactSource, + ): Promise { + return this.enqueueMutation(async () => { + await this.prepareMutationUnlocked(); + const snapshot = this.sessionSnapshot(sessionId); + const existing = snapshot.records.find((record) => record.id === artifactId); + if (!existing || existing.source !== source) { + throw new Error('Artifact does not belong to the expected Session authority'); + } + await this.purgeRecordsUnlocked([existing]); + }); + } + + deleteUserArtifactInSession( + sessionId: string, + artifactId: string, + ): Promise { + return this.enqueueMutation(async () => { + await this.prepareMutationUnlocked(); + const snapshot = this.sessionSnapshot(sessionId); + const existing = snapshot.records.find((record) => record.id === artifactId); + if (!existing) return { kind: 'not_found' }; + if (!canUserDeleteArtifact(existing)) return { kind: 'protected' }; + await this.purgeRecordsUnlocked([existing]); + return { kind: 'deleted' }; + }); + } + + private async purgeRecordsUnlocked(records: readonly ArtifactRecord[]): Promise { + if (records.length === 0) return; + const ids = new Set(records.map((record) => record.id)); + const paths = await this.preparePurgePathsUnlocked(records); + await this.completePurgeUnlocked(ids, paths); + } + + private async preparePurgePathsUnlocked( + records: readonly ArtifactRecord[], + ): Promise { + const root = await ensureRealDirectory(this.artifactRoot); + const ids = new Set(records.map((record) => record.id)); + const entries = new Map< + string, + { readonly unlinkPath: string; readonly record: ArtifactRecord } + >(); + const relativePaths = new Map(records.map((record) => [record.relativePath, record] as const)); + for (const record of records) { + validateRelativeArtifactPath(record.relativePath); + } + const purgeEntries = await this.resolveRemovalEntriesUnlocked(records); + for (const [index, record] of records.entries()) { + const entry = purgeEntries[index]; + if (!entry) continue; + if (!isInsideOrSamePath(root, dirname(entry.unlinkPath))) { + throw new Error(`Artifact ${record.id} resolves outside the artifact root`); + } + entries.set(entry.comparisonIdentity, { unlinkPath: entry.unlinkPath, record }); + } + const guardRecords = this.records.filter((record) => !ids.has(record.id)); + for (const record of guardRecords) { + const exactTarget = relativePaths.get(record.relativePath); + if (exactTarget) { + throw new Error( + `Artifact ${exactTarget.id} path is still referenced by artifact ${record.id}`, + ); + } + } + const guardEntries = await this.resolveRemovalEntriesUnlocked(guardRecords); + for (const [index, record] of guardRecords.entries()) { + const entry = guardEntries[index]; + const target = entry ? entries.get(entry.comparisonIdentity)?.record : undefined; + if (target) { + throw new Error(`Artifact ${target.id} path is still referenced by artifact ${record.id}`); + } + } + return [...entries.values()].map((entry) => entry.unlinkPath); + } + + // Resolves removal entries with bounded concurrency: each resolution issues + // realpath/lstat syscalls, so a serial loop over the full record set turned + // session-cleanup purges into a syscall storm on large artifact stores. + // Workers capture per-record results and always drain the queue, so all + // filesystem work settles before this mutation releases the writer lock, + // and resolver failures surface in record order rather than completion + // order. + private async resolveRemovalEntriesUnlocked( + records: readonly ArtifactRecord[], + ): Promise { + type Resolution = + | { readonly ok: true; readonly entry: ArtifactRemovalEntry | undefined } + | { readonly ok: false; readonly error: unknown }; + const results: (Resolution | undefined)[] = new Array(records.length); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < records.length) { + const index = nextIndex++; + try { + const entry = await resolveArtifactRemovalEntry( + this.artifactRoot, + records[index]!.relativePath, + ); + results[index] = { ok: true, entry }; + } catch (error) { + results[index] = { ok: false, error }; + } + } + }; + await Promise.all( + Array.from({ length: Math.min(ARTIFACT_PURGE_RESOLVE_CONCURRENCY, records.length) }, worker), + ); + const resolved: (ArtifactRemovalEntry | undefined)[] = new Array(records.length); + for (const [index, result] of results.entries()) { + if (result === undefined) throw new Error('Artifact removal resolution did not settle'); + if (!result.ok) throw result.error; + resolved[index] = result.entry; + } + return resolved; + } + + private async completePurgeUnlocked( + ids: ReadonlySet, + paths: readonly string[], + ): Promise { + const nextRecords = this.records.filter((record) => !ids.has(record.id)); + const changedDirectories = new Set(); + try { + for (const path of paths) { + await rm(path, { force: true }); + changedDirectories.add(dirname(path)); + } + } finally { + for (const directory of changedDirectories) await syncDirectory(directory); + } + // Keep the paths discoverable until physical cleanup is durable. Session + // retirement already owns the pending cleanup intent and retries on reopen. + await this.writeMetadataUnlocked({ deleteIds: [...ids] }); + this.records = nextRecords; + } + + private async prepareReadInSessionUnlocked( + sessionId: string, + artifactId: string, + maxBytes: number, + ): Promise { + await this.load(); + const snapshot = this.sessionSnapshot(sessionId); + const record = snapshot.records.find((candidate) => candidate.id === artifactId); + if (!record) return { ok: false, reason: 'not_found' }; + return this.prepareRecordRead(record, maxBytes); + } + + private async prepareRecordRead( + record: ArtifactRecord, + maxBytes: number, + ): Promise { + const resolved = await resolveArtifactPath({ + artifactRoot: this.artifactRoot, + relativePath: record.relativePath, + }); + if (!resolved.ok) return { ok: false, reason: resolved.reason }; + return { ok: true, path: resolved.path, record, maxBytes }; + } + + private async load(): Promise { + this.records = this.metadataRepository.readAll(); + } + + private async writeMetadataUnlocked(changes: ArtifactMetadataChanges): Promise { + this.metadataRepository.applyChanges(changes); + } + + private async prepareMutationUnlocked(): Promise { + await this.reloadForMutationUnlocked(); + } + + private async reloadForMutationUnlocked(): Promise { + this.records = this.metadataRepository.readAll(); + } + + private bindMutationRoot(canonicalRoot: string): void { + if (this.workspaceRoot === canonicalRoot) return; + this.workspaceRoot = canonicalRoot; + this.artifactRoot = join(canonicalRoot, 'artifacts'); + this.records = []; + } + + /** + * Orders one session's records and stamps the revision readers compare on. + * + * Sealed on the way out rather than kept in a map. A revision hashes every + * record in its session, and every reader reloads the whole store from the + * database before it reads one, so a kept snapshot never survived to be read + * -- sealing all of them on load only charged each reader for the sessions it + * did not ask about. + */ + private sessionSnapshot(sessionId: string): ArtifactSessionSnapshot { + const records = this.records + .filter((record) => record.sessionId === sessionId) + .sort(compareArtifactRecords); + return { records, revision: artifactListRevision(records) }; + } + + private enqueueSerialized(operation: () => Promise): Promise { + const next = this.queue.then(operation, operation); + this.queue = next.then( + () => {}, + () => {}, + ); + return next; + } + + private enqueue(operation: () => Promise): Promise { + return this.enqueueSerialized(async () => { + await this.assertAuthority?.(); + return operation(); + }); + } + + private enqueueMutation(operation: () => Promise): Promise { + return this.enqueueSerialized(() => + this.runWithWriterLock(async () => { + await this.assertAuthority?.(); + return operation(); + }), + ); + } + + private runWithWriterLock(operation: () => Promise): Promise { + const leaseBoundWriterLockAuthority = this.leaseBoundWriterLockAuthority; + if (leaseBoundWriterLockAuthority) { + return withLeaseBoundArtifactWriterLock(leaseBoundWriterLockAuthority, operation); + } + return withArtifactWriterLock(this.workspaceRoot, async (canonicalRoot) => { + this.bindMutationRoot(canonicalRoot); + return operation(); + }); + } +} + +async function openRealTarget(path: string) { + const noFollowFlags = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW; + try { + return await open(path, noFollowFlags); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform !== 'win32' || (code !== 'EINVAL' && code !== 'ENOTSUP')) throw error; + return open(path, fsConstants.O_RDONLY); + } +} + +async function readPreparedBytes( + prepared: PreparedArtifactRead, +): Promise<{ readonly ok: true; readonly bytes: Buffer } | ArtifactReadFailure> { + let handle; + try { + handle = await openRealTarget(prepared.path); + const payloadStat = await handle.stat(); + if (!payloadStat.isFile()) return { ok: false, reason: 'not_found' }; + if (payloadStat.size > prepared.maxBytes) return { ok: false, reason: 'too_large' }; + + const bytes = Buffer.alloc(payloadStat.size + 1); + let total = 0; + while (total < bytes.byteLength) { + const read = await handle.read(bytes, total, bytes.byteLength - total, total); + if (read.bytesRead === 0) break; + total += read.bytesRead; + } + if (total !== payloadStat.size || total > prepared.maxBytes) { + return { ok: false, reason: total > prepared.maxBytes ? 'too_large' : 'read_failed' }; + } + return { ok: true, bytes: bytes.subarray(0, total) }; + } catch { + return { ok: false, reason: 'read_failed' }; + } finally { + await handle?.close(); + } +} + +async function readPreparedChunk( + prepared: PreparedArtifactRead, + offset: number, + maxBytes: number, +): Promise { + let handle; + try { + handle = await openRealTarget(prepared.path); + const payloadStat = await handle.stat(); + if (!payloadStat.isFile()) return { ok: false, reason: 'not_found' }; + if (offset > payloadStat.size) return { ok: false, reason: 'out_of_range' }; + const expected = Math.min(maxBytes, payloadStat.size - offset); + const bytes = Buffer.alloc(expected); + let total = 0; + while (total < expected) { + const read = await handle.read(bytes, total, expected - total, offset + total); + if (read.bytesRead === 0) break; + total += read.bytesRead; + } + if (total !== expected) return { ok: false, reason: 'read_failed' }; + const nextOffset = offset + total; + return { + ok: true, + bytes, + offset, + totalBytes: payloadStat.size, + nextOffset: nextOffset < payloadStat.size ? nextOffset : null, + }; + } catch { + return { ok: false, reason: 'read_failed' }; + } finally { + await handle?.close(); + } +} + +function artifactReplayConflict(artifactId: string): Error { + return new Error(`Artifact ${artifactId} already exists with different metadata or content`); +} + +function conversationCopyArtifactId( + sourceSessionId: string, + targetSessionId: string, + sourceArtifactId: string, +): string { + return `copy_${createHash('sha256') + .update(JSON.stringify([sourceSessionId, targetSessionId, sourceArtifactId])) + .digest('hex')}`; +} + +function optionalCanonicalText(value: string | undefined): string | undefined { + return value ? value : undefined; +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function artifactListRevision(records: readonly ArtifactRecord[]): ArtifactListRevision { + return `sha256:${createHash('sha256').update(JSON.stringify(records)).digest('hex')}`; +} + +function compareArtifactRecords(a: ArtifactRecord, b: ArtifactRecord): number { + const timestampDelta = b.createdAt - a.createdAt; + return timestampDelta !== 0 ? timestampDelta : a.id.localeCompare(b.id); +} + +function sameArtifactRecord(a: ArtifactRecord, b: ArtifactRecord): boolean { + return ( + a.id === b.id && + a.sessionId === b.sessionId && + a.turnId === b.turnId && + a.createdAt === b.createdAt && + a.name === b.name && + a.kind === b.kind && + a.relativePath === b.relativePath && + a.sizeBytes === b.sizeBytes && + a.mimeType === b.mimeType && + a.source === b.source && + a.summary === b.summary && + a.deepResearchRole === b.deepResearchRole + ); +} + +function assertPageBound(value: number, allowZero: boolean, label: string): void { + if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) { + throw new Error(`Artifact page ${label} is invalid`); + } +} + +export async function resolveArtifactPath(input: { + artifactRoot: string; + relativePath: string; +}): Promise< + { ok: true; path: string } | { ok: false; reason: 'not_found' | 'not_allowed' | 'read_failed' } +> { + if (!isSafeRelativeArtifactPath(input.relativePath)) return { ok: false, reason: 'not_allowed' }; + const target = join(input.artifactRoot, input.relativePath); + let root: string; + let resolvedTarget: string; + try { + root = await ensureRealDirectory(input.artifactRoot); + resolvedTarget = await realpath(target); + } catch { + return { ok: false, reason: 'not_found' }; + } + if (!isInsideOrSamePath(root, resolvedTarget)) return { ok: false, reason: 'not_allowed' }; + return { ok: true, path: resolvedTarget }; +} + +export function sanitizeArtifactName(name: string): string { + const cleaned = name + .trim() + .replace(/[\\/:*?"<>|\0]/g, '-') + .replace(/\s+/g, ' ') + .replace(/^[ .-]+/, '') + .replace(/[ .-]+$/, ''); + const truncated = truncateWithoutSplittingSurrogate(cleaned, 120).replace(/[ .-]+$/, ''); + return truncated || 'artifact'; +} + +function truncateWithoutSplittingSurrogate(value: string, maxCodeUnits: number): string { + const truncated = value.slice(0, maxCodeUnits); + const last = truncated.charCodeAt(truncated.length - 1); + return last >= 0xd800 && last <= 0xdbff ? truncated.slice(0, -1) : truncated; +} + +async function removeFileDurably(path: string, directory: string): Promise { + try { + await unlink(path); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + await syncDirectory(directory); + return true; +} + +function assertCanonicalArtifactEntityId( + value: unknown, + field: 'id' | 'sessionId', +): asserts value is string { + if (!isCanonicalArtifactEntityId(value)) { + throw new Error( + `Artifact ${field} must be a canonical entity ID of 1-${ARTIFACT_ENTITY_ID_MAX_CHARS} ASCII letters, digits, "_" or "-"`, + ); + } +} + +function assertArtifactTurnKey(value: unknown): asserts value is string { + if (!isArtifactTurnKey(value)) { + throw new Error('Artifact turnId must be a bounded opaque turn key without control characters'); + } +} + +const ARTIFACT_KIND_SET = new Set(ARTIFACT_KINDS); +const ARTIFACT_SOURCE_SET = new Set(ARTIFACT_SOURCES); + +async function assertArtifactDirectory(artifactRoot: string, directory: string): Promise { + const root = await ensureRealDirectory(artifactRoot); + const resolvedDirectory = await realpath(directory); + if (!isInsideOrSamePath(root, resolvedDirectory)) { + throw new Error('Artifact target directory resolves outside the artifact root'); + } +} + +async function ensureRealDirectory(path: string): Promise { + await access(path, fsConstants.R_OK); + return realpath(path); +} + +async function resolveArtifactRemovalEntry( + artifactRoot: string, + relativePath: string, +): Promise { + const target = join(artifactRoot, relativePath); + try { + const parent = await realpath(dirname(target)); + const entry = join(parent, basename(target)); + const entryStat = await lstat(entry, { bigint: true }).catch((error) => { + if (isNotFound(error)) return undefined; + throw error; + }); + // A previous attempt may have unlinked the file but failed to sync its + // parent. Retain that parent in the next purge's durability barrier. + if (!entryStat) return { unlinkPath: entry, comparisonIdentity: `path:${entry}` }; + if (entryStat.isSymbolicLink()) { + return { + unlinkPath: entry, + comparisonIdentity: symlinkEntryIdentity(entryStat), + }; + } + const resolvedEntry = await realpath(entry); + return { + unlinkPath: resolvedEntry, + comparisonIdentity: `path:${resolvedEntry}`, + }; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } +} + +function symlinkEntryIdentity(entryStat: BigIntStats): string { + if (entryStat.dev !== 0n || entryStat.ino !== 0n) { + return `symlink:${entryStat.dev}:${entryStat.ino}`; + } + return [ + 'symlink-stat', + entryStat.mode, + entryStat.size, + entryStat.birthtimeNs, + entryStat.ctimeNs, + entryStat.mtimeNs, + ].join(':'); +} + +function artifactIdsFromUpgradeOrphanPath(relativePath: string): readonly string[] { + const name = basename(relativePath); + const artifactIds: string[] = []; + for ( + let separator = name.indexOf('-'); + separator > 0; + separator = name.indexOf('-', separator + 1) + ) { + const artifactId = name.slice(0, separator); + if (isCanonicalArtifactEntityId(artifactId)) artifactIds.push(artifactId); + } + return artifactIds; +} + +function isInsideOrSamePath(root: string, target: string): boolean { + if (target === root) return true; + const rel = relative(root, target); + return ( + rel !== '' && + !rel.startsWith('..') && + rel !== '..' && + !rel.includes(`..${sep}`) && + !rel.startsWith(sep) + ); +} + +function isNotFound(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +} + +function sniffAllowedBinaryMime(bytes: Uint8Array): string | null { + // Core owns the binary signatures, shared with the attachment and image-read + // paths so the three cannot drift. SVG needs a wider text scan than a fixed + // prefix, so it stays local to this reader. + const sniffed = sniffAttachmentMimeType(bytes); + if (sniffed) return sniffed; + const leading = new TextDecoder('utf-8', { fatal: false }) + .decode(bytes.slice(0, Math.min(bytes.length, 512))) + .trimStart(); + if (/^]/i.test(leading) || /^<\?xml[\s\S]*]/i.test(leading)) + return 'image/svg+xml'; + return null; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee006f08adc2d3a798496046f907efaa355da9e1dd6c2e54109168faad090be6.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee006f08adc2d3a798496046f907efaa355da9e1dd6c2e54109168faad090be6.source new file mode 100644 index 0000000000..cc1cb637db --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee006f08adc2d3a798496046f907efaa355da9e1dd6c2e54109168faad090be6.source @@ -0,0 +1,798 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { writeFileSync, writeSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { type RuntimeEvent } from '@maka/core/runtime-event'; +import { + type WorkspaceBaselineAuthorityInput, + type WorkspaceSuccessorAuthorityInput, +} from '@maka/core/workspace-version-authority'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { + createSqliteRuntimeStore, + type SqliteRuntimeStoreFailpoint, +} from '../sqlite-runtime-store.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitManagedMutationTerminalInternal, + commitWorkspaceBaselineInternal, + commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, + registerManagedMutationNoEffectVerifierInternal, + registerWorkspaceSuccessorCandidateVerifierInternal, + type ManagedMutationNoEffectClaimV1, + type WorkspaceSuccessorCommitInput, +} from '../workspace-version-authority-internal.js'; + +const CRASH_READ_ARGS_HASH = canonicalToolArgsHash('Read', { + path: '/workspace/README.md', +}); +const CRASH_CANDIDATES = new WeakMap(); +const CRASH_NO_EFFECT_CLAIMS = new WeakMap(); + +function registerCrashCandidateVerifier(store: object): void { + registerWorkspaceSuccessorCandidateVerifierInternal(store, (capability) => { + const successor = CRASH_CANDIDATES.get(capability); + if (!successor) throw new Error('Unrecognized crash-test candidate capability'); + return structuredClone(successor); + }); + registerManagedMutationNoEffectVerifierInternal(store, (capability) => { + const claim = CRASH_NO_EFFECT_CLAIMS.get(capability); + if (!claim) throw new Error('Unrecognized crash-test no-effect capability'); + return structuredClone(claim); + }); +} + +function issueCrashNoEffect(claim: ManagedMutationNoEffectClaimV1): object { + const capability = Object.freeze({}); + CRASH_NO_EFFECT_CLAIMS.set(capability, structuredClone(claim)); + return capability; +} +const childMode = process.env.MAKA_SQLITE_CRASH_CHILD; + +if (childMode) { + await runCrashChild(childMode); +} else { + describe('SqliteRuntimeStore real-process crash boundaries', () => { + it('rolls back a process killed inside T1', { timeout: 30_000 }, async () => { + await withKilledChild('inside_t1', async (store) => { + assert.deepEqual(await store.readRuntimeEvents('session-1', 'run-1'), []); + assert.deepEqual(await store.listUnsettledToolOperations(), []); + }); + }); + + it('retains a prepared operation when killed after T1 and a possible side effect', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_effect', async (store, markerPath) => { + assert.equal(await readFile(markerPath, 'utf8'), 'effect-happened'); + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.deepEqual( + (await store.listUnsettledToolOperations()).map((operation) => operation.operationId), + ['operation-1'], + ); + }); + }); + + it('rolls back a process killed inside T2 without losing T1', { timeout: 30_000 }, async () => { + await withKilledChild('inside_t2', async (store) => { + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + + it('retains the committed outcome when killed after T2', { timeout: 30_000 }, async () => { + await withKilledChild('after_t2', async (store) => { + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1', 'response-event-1'], + ); + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'outcome_committed', + ); + assert.deepEqual(await store.listUnsettledToolOperations(), []); + }); + }); + + for (const mode of [ + 'inside_recovery_reconcile', + 'inside_recovery_outcome', + 'inside_recovery_decision', + ]) { + it(`rolls back the whole recovery bundle when killed at ${mode}`, { + timeout: 30_000, + }, async () => { + await withKilledChild(mode, async (store) => { + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + } + + it('retains the whole recovery bundle when killed after COMMIT', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_recovery_commit', async (store) => { + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + [ + 'call-event-1', + 'dispatch-event-1', + 'reconcile-event-1', + 'response-event-1', + 'decision-event-1', + ], + ); + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'recovery_completed', + ); + }); + }); + + it('rolls back a workspace baseline when killed inside its authority transaction', { + timeout: 30_000, + }, async () => { + await withKilledChild('inside_workspace_baseline', async (store) => { + assert.equal( + await store.readWorkspaceHead(`workspace_${'2'.repeat(32)}`, `epoch_${'3'.repeat(32)}`), + undefined, + ); + }); + }); + + it('retains the complete workspace baseline when killed after COMMIT', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_workspace_baseline_commit', async (store) => { + assert.equal( + (await store.readWorkspaceHead(`workspace_${'2'.repeat(32)}`, `epoch_${'3'.repeat(32)}`)) + ?.workspaceVersionId, + `version_${'5'.repeat(32)}`, + ); + }); + }); + + it('retains exclusive managed mutation ownership when killed after T1', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_workspace_mutation_t1', async (store) => { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + assert.equal( + (await readActiveManagedMutationInternal(store, `instance_${'4'.repeat(32)}`)) + ?.operationId, + 'workspace-successor-operation', + ); + await assert.rejects( + store.commitToolPrepared( + workspaceSuccessorPreparedCommit('workspace-conflicting-operation'), + ), + /managed mutation reservation conflict/i, + ); + }); + }); + + it('rolls back a workspace successor when killed inside its authority transaction', { + timeout: 30_000, + }, async () => { + await withKilledChild('inside_workspace_successor', async (store) => { + assert.equal( + (await store.readWorkspaceHead(`workspace_${'2'.repeat(32)}`, `epoch_${'3'.repeat(32)}`)) + ?.workspaceVersionId, + `version_${'5'.repeat(32)}`, + ); + assert.equal( + (await store.readToolOperation('workspace-successor-operation'))?.currentState, + 'prepared', + ); + assert.equal(await store.readWorkspaceVersion(`version_${'7'.repeat(32)}`), undefined); + }); + }); + + it('returns the accepted workspace successor after a process is killed post-commit', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_workspace_successor_commit', async (store) => { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + const retry = await commitWorkspaceSuccessorInternal(store, workspaceSuccessorCommit()); + assert.equal(retry.created, false); + assert.equal(retry.committedSuccessor.workspaceVersionId, `version_${'7'.repeat(32)}`); + assert.equal( + (await store.readToolOperation('workspace-successor-operation'))?.currentState, + 'outcome_committed', + ); + }); + }); + + it('rolls back a no-effect terminal when killed inside its transaction', { + timeout: 30_000, + }, async () => { + await withKilledChild('inside_workspace_terminal', async (store) => { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + assert.equal( + (await store.readToolOperation('workspace-successor-operation'))?.currentState, + 'prepared', + ); + assert.equal( + (await readActiveManagedMutationInternal(store, `instance_${'4'.repeat(32)}`)) + ?.operationId, + 'workspace-successor-operation', + ); + }); + }); + + it('retains a no-effect terminal and released reservation after process exit', { + timeout: 30_000, + }, async () => { + await withKilledChild('after_workspace_terminal_commit', async (store) => { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + assert.equal( + (await store.readToolOperation('workspace-successor-operation'))?.currentState, + 'outcome_committed', + ); + assert.equal( + await readActiveManagedMutationInternal(store, `instance_${'4'.repeat(32)}`), + undefined, + ); + }); + }); + }); +} + +async function withKilledChild( + mode: string, + inspect: ( + store: ReturnType, + markerPath: string, + ) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-crash-')); + const dbPath = join(root, 'runtime.sqlite'); + const markerPath = join(root, 'effect.marker'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], { + env: { + ...process.env, + MAKA_SQLITE_CRASH_CHILD: mode, + MAKA_SQLITE_CRASH_DB: dbPath, + MAKA_SQLITE_CRASH_MARKER: markerPath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await waitForReady(child); + child.kill('SIGKILL'); + await new Promise((resolve, reject) => { + child.once('exit', () => resolve()); + child.once('error', reject); + }); + const store = createSqliteRuntimeStore(dbPath); + registerCrashCandidateVerifier(store); + try { + await inspect(store, markerPath); + } finally { + store.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + } +} + +function waitForReady(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + if (stdout.includes('READY\n')) resolve(); + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.once('exit', (code, signal) => { + reject(new Error(`crash child exited before READY: code=${code} signal=${signal} ${stderr}`)); + }); + child.once('error', reject); + }); +} + +async function runCrashChild(mode: string): Promise { + const dbPath = requiredEnv('MAKA_SQLITE_CRASH_DB'); + const markerPath = requiredEnv('MAKA_SQLITE_CRASH_MARKER'); + let runtimeInsertCount = 0; + const failpoint = (point: SqliteRuntimeStoreFailpoint) => { + if (point === 'after_runtime_event_insert') { + runtimeInsertCount += 1; + if (mode === 'inside_t1' && runtimeInsertCount === 1) blockUntilKilled(); + if (mode === 'inside_t2' && runtimeInsertCount === 2) blockUntilKilled(); + if (mode === 'inside_workspace_terminal' && runtimeInsertCount === 2) blockUntilKilled(); + } + if (point === 'after_recovery_reconcile' && mode === 'inside_recovery_reconcile') { + blockUntilKilled(); + } + if (point === 'after_recovery_outcome' && mode === 'inside_recovery_outcome') { + blockUntilKilled(); + } + if (point === 'after_recovery_decision' && mode === 'inside_recovery_decision') { + blockUntilKilled(); + } + if (point === 'after_workspace_version_event_insert' && mode === 'inside_workspace_baseline') { + blockUntilKilled(); + } + if ( + point === 'after_workspace_successor_event_insert' && + mode === 'inside_workspace_successor' + ) { + blockUntilKilled(); + } + }; + const store = createSqliteRuntimeStore(dbPath, { failpoint }); + registerCrashCandidateVerifier(store); + if ( + mode === 'inside_workspace_baseline' || + mode === 'after_workspace_baseline_commit' || + mode === 'after_workspace_mutation_t1' || + mode === 'inside_workspace_successor' || + mode === 'after_workspace_successor_commit' || + mode === 'inside_workspace_terminal' || + mode === 'after_workspace_terminal_commit' + ) { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); + await commitWorkspaceBaselineInternal(store, workspaceBaselineInput()); + if (mode === 'after_workspace_baseline_commit') blockUntilKilled(); + if (mode === 'after_workspace_mutation_t1') { + await store.commitToolPrepared(workspaceSuccessorPreparedCommit()); + blockUntilKilled(); + } + if (mode === 'inside_workspace_successor' || mode === 'after_workspace_successor_commit') { + await store.commitToolPrepared(workspaceSuccessorPreparedCommit()); + await commitWorkspaceSuccessorInternal(store, workspaceSuccessorCommit()); + if (mode === 'after_workspace_successor_commit') blockUntilKilled(); + } + if (mode === 'inside_workspace_terminal' || mode === 'after_workspace_terminal_commit') { + await store.commitToolPrepared(workspaceSuccessorPreparedCommit()); + await commitManagedMutationTerminalInternal(store, workspaceTerminalCommit()); + if (mode === 'after_workspace_terminal_commit') blockUntilKilled(); + } + throw new Error(`Workspace baseline crash mode ${mode} missed its failpoint`); + } + await store.commitToolPrepared(preparedCommit()); + if (mode === 'after_effect') { + writeFileSync(markerPath, 'effect-happened'); + blockUntilKilled(); + } + if (mode.startsWith('inside_recovery_') || mode === 'after_recovery_commit') { + await store.commitToolRecoveryBundle(recoveryCommit()); + if (mode === 'after_recovery_commit') blockUntilKilled(); + throw new Error(`Recovery crash mode ${mode} missed its failpoint`); + } + await store.commitToolOutcome(outcomeCommit()); + if (mode === 'after_t2') blockUntilKilled(); + throw new Error(`Unknown crash child mode ${mode}`); +} + +function blockUntilKilled(): never { + writeSync(1, 'READY\n'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + throw new Error('unreachable'); +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function preparedCommit() { + return { + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: functionCallEvent(), + dispatchRuntimeEvent: toolDispatchEvent(), + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: CRASH_READ_ARGS_HASH, + recoveryMode: 'reconcile' as const, + committedAt: 1, + }; +} + +function workspaceBaselineInput(): WorkspaceBaselineAuthorityInput { + return { + epochOpenedEventId: 'workspace-epoch-event-1', + baselineAcceptedEventId: 'workspace-version-event-1', + committedAt: 1_700_000_000_000, + epoch: { + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + mode: 'managed_worktree', + objectFormat: 'sha1', + sourceCommitOid: '1'.repeat(40), + sourceTreeOid: '2'.repeat(40), + materializationProfileDigest: `sha256:${'3'.repeat(64)}`, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1', + policyHash: `sha256:${'4'.repeat(64)}`, + }, + baseline: { + workspaceVersionId: `version_${'5'.repeat(32)}`, + commitOid: '5'.repeat(40), + treeOid: '2'.repeat(40), + treeDeltaDigest: `sha256:${'6'.repeat(64)}`, + changedFileCount: 7, + deletedFileCount: 0, + }, + }; +} + +function workspaceSuccessorPreparedCommit(operationId = 'workspace-successor-operation') { + const args = { path: 'notes.txt', content: 'successor' }; + const canonicalArgsHash = canonicalToolArgsHash('Write', args); + const isCanonicalFixture = operationId === 'workspace-successor-operation'; + const toolCallId = isCanonicalFixture ? 'workspace-successor-call-id' : `${operationId}-call-id`; + const callEventId = isCanonicalFixture ? 'workspace-successor-call' : `${operationId}-call`; + const dispatchEventId = isCanonicalFixture + ? 'workspace-successor-dispatch' + : `${operationId}-dispatch`; + return { + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: { + id: callEventId, + invocationId: 'workspace-successor-invocation', + runId: 'workspace-successor-run', + sessionId: 'workspace-successor-session', + turnId: 'workspace-successor-turn', + ts: 1_700_000_000_001, + partial: false, + role: 'model' as const, + author: 'agent' as const, + content: { + kind: 'function_call' as const, + id: toolCallId, + name: 'Write', + args, + }, + refs: { + operationId, + toolCallId, + }, + }, + dispatchRuntimeEvent: { + id: dispatchEventId, + invocationId: 'workspace-successor-invocation', + runId: 'workspace-successor-run', + sessionId: 'workspace-successor-session', + turnId: 'workspace-successor-turn', + ts: 1_700_000_000_001, + partial: false, + role: 'system' as const, + author: 'system' as const, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1' as const, + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile' as const, + managedMutation: { + protocol: 'managed_mutation_v2' as const, + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: `version_${'5'.repeat(32)}`, + baseAcceptedEventId: 'workspace-version-event-1', + baseHeadRevision: 1, + baseCommitOid: '5'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'notes.txt', + pathPolicyVersion: 3 as const, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + }, + }, + refs: { + operationId, + toolCallId, + }, + }, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile' as const, + committedAt: 1_700_000_000_001, + }; +} + +function workspaceSuccessorCommit(): WorkspaceSuccessorCommitInput { + const successor: WorkspaceSuccessorAuthorityInput = { + acceptedEventId: 'workspace-successor-accepted', + committedAt: 1_700_000_000_002, + successor: { + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceVersionId: `version_${'7'.repeat(32)}`, + objectFormat: 'sha1', + parentWorkspaceVersionId: `version_${'5'.repeat(32)}`, + baseAcceptedEventId: 'workspace-version-event-1', + baseHeadRevision: 1, + commitOid: '7'.repeat(40), + treeOid: '8'.repeat(40), + policyHash: `sha256:${'4'.repeat(64)}`, + treeDeltaDigest: `sha256:${'9'.repeat(64)}`, + changedPaths: ['notes.txt'], + changedFileCount: 1, + deletedFileCount: 0, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + origin: { + operationId: 'workspace-successor-operation', + dispatchEventId: 'workspace-successor-dispatch', + outcomeEventId: 'workspace-successor-outcome', + }, + }; + const candidateOutcome = Object.freeze({}); + CRASH_CANDIDATES.set(candidateOutcome, successor); + return { + candidateOutcome, + toolOutcome: { + operationId: 'workspace-successor-operation', + journalEventId: 'workspace-successor-operation_outcome', + committedAt: 1_700_000_000_002, + runtimeEvent: { + id: 'workspace-successor-outcome', + invocationId: 'workspace-successor-invocation', + runId: 'workspace-successor-run', + sessionId: 'workspace-successor-session', + turnId: 'workspace-successor-turn', + ts: 1_700_000_000_002, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'workspace-successor-call-id', + name: 'Write', + result: 'Wrote notes.txt', + }, + refs: { + operationId: 'workspace-successor-operation', + toolCallId: 'workspace-successor-call-id', + }, + }, + }, + }; +} + +function workspaceTerminalCommit() { + return { + noEffectOutcome: issueCrashNoEffect({ + operationId: 'workspace-successor-operation', + dispatchEventId: 'workspace-successor-dispatch', + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + terminalKind: 'no_workspace_change', + }), + toolOutcome: { + operationId: 'workspace-successor-operation', + journalEventId: 'workspace-successor-operation_outcome', + committedAt: 1_700_000_000_002, + runtimeEvent: { + id: 'workspace-terminal-outcome', + invocationId: 'workspace-successor-invocation', + runId: 'workspace-successor-run', + sessionId: 'workspace-successor-session', + turnId: 'workspace-successor-turn', + ts: 1_700_000_000_002, + partial: false, + role: 'tool' as const, + author: 'tool' as const, + content: { + kind: 'function_response' as const, + id: 'workspace-successor-call-id', + name: 'Write', + result: 'No workspace change', + }, + actions: { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1' as const, + operationId: 'workspace-successor-operation', + dispatchEventId: 'workspace-successor-dispatch', + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + terminalKind: 'no_workspace_change' as const, + }, + }, + refs: { + operationId: 'workspace-successor-operation', + toolCallId: 'workspace-successor-call-id', + }, + }, + }, + }; +} + +function outcomeCommit() { + return { + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: functionResponseEvent(), + committedAt: 2, + }; +} + +function toolDispatchEvent(): RuntimeEvent { + return { + id: 'dispatch-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Read', + canonicalArgsHash: CRASH_READ_ARGS_HASH, + recoveryMode: 'reconcile', + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} + +function functionCallEvent(): RuntimeEvent { + return { + id: 'call-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Read', + args: { path: '/workspace/README.md' }, + }, + }; +} + +function functionResponseEvent(): RuntimeEvent { + return { + id: 'response-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: 'contents', + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} + +function recoveryCommit() { + return { + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: functionResponseEvent(), + decisionRuntimeEvent: decisionEvent(), + }; +} + +function reconcileEvent(): RuntimeEvent { + return { + id: 'reconcile-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'system', + author: 'system', + actions: { + toolRecovery: { + kind: 'maka.tool.reconcile_result', + version: 1, + payload: { + protocol: 'tool_reconcile_v1', + operationId: 'operation-1', + observation: 'matches_expected_state', + observationSchema: 'state_identity_v1', + observationDigest: + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} + +function decisionEvent(): RuntimeEvent { + return { + id: 'decision-event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 3, + partial: false, + role: 'system', + author: 'system', + actions: { + toolRecovery: { + kind: 'maka.tool.recovery_decision', + version: 1, + payload: { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'completed', + reasonCode: 'reconcile_matches_expected_state', + outcomeEventId: 'response-event-1', + evidenceEventIds: [ + 'call-event-1', + 'dispatch-event-1', + 'reconcile-event-1', + 'response-event-1', + ], + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee7793d345da1fd98b5e0bb7ca1f7f4deaea210bd94b3679ebe88a5248d8978c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee7793d345da1fd98b5e0bb7ca1f7f4deaea210bd94b3679ebe88a5248d8978c.source new file mode 100644 index 0000000000..1f97d01030 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ee7793d345da1fd98b5e0bb7ca1f7f4deaea210bd94b3679ebe88a5248d8978c.source @@ -0,0 +1,1211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, it } from 'node:test'; +import { TOOL_RECOVERY_BUNDLE_CAPABILITY_V1 } from '@maka/core/runtime-event-store'; +import { type RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; +import type { SqliteRuntimeStoreFailpoint } from '../sqlite-runtime-store.js'; + +const ARGS_HASH = canonicalToolArgsHash('Write', { + path: 'notes.txt', + content: 'after', +}); +// Mainline schema 4 persisted stableHash({ toolName, args }) for this exact +// provider call. Keep the literal independent from the current hash helper so +// an accidental identity-epoch change remains observable during migration. +const MAINLINE_SCHEMA_4_SPECIAL_ARGS_HASH = + 'sha256:0002fcd132216e3442d4ab7579d9659019019c6b7000456fbef198b7ae53ee43'; +const OBSERVATION_DIGEST = + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; + +describe('SQLite recovery persistence authority', () => { + it('rebuilds a real schema 4 T1 dispatch written with the mainline args identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-mainline-t1-upgrade-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + store.close(); + + const call = baseEvent({ + id: 'schema-4-call', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'schema-4-provider-call', + name: 'X', + args: { required: ['b', 'a'] }, + }, + }); + const dispatch = baseEvent({ + id: 'schema-4-dispatch', + ts: 2, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'schema-4-operation', + providerToolCallId: 'schema-4-provider-call', + toolName: 'X', + canonicalArgsHash: MAINLINE_SCHEMA_4_SPECIAL_ARGS_HASH, + recoveryMode: 'replay_safe', + }, + }, + refs: { + operationId: 'schema-4-operation', + toolCallId: 'schema-4-provider-call', + }, + }); + + const db = new DatabaseSync(dbPath); + const insertEvent = db.prepare(` + INSERT INTO runtime_events( + event_id, session_id, invocation_id, run_id, turn_id, + event_seq, event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertEvent.run( + call.id, + call.sessionId, + call.invocationId, + call.runId, + call.turnId, + 1, + 'function_call', + JSON.stringify(call), + call.ts, + ); + insertEvent.run( + dispatch.id, + dispatch.sessionId, + dispatch.invocationId, + dispatch.runId, + dispatch.turnId, + 2, + 'tool_dispatch', + JSON.stringify(dispatch), + dispatch.ts, + ); + db.exec( + 'DROP INDEX runtime_events_by_session_kind; DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + ); + db.close(); + + try { + const upgraded = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual(await upgraded.rebuildToolProjectionsFromRuntimeEvents(), { + operations: 1, + journalEvents: 1, + }); + assert.deepEqual(await upgraded.readToolOperation('schema-4-operation'), { + operationId: 'schema-4-operation', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + providerToolCallId: 'schema-4-provider-call', + toolName: 'X', + canonicalArgsHash: MAINLINE_SCHEMA_4_SPECIAL_ARGS_HASH, + recoveryMode: 'replay_safe', + currentState: 'prepared', + callEventId: 'schema-4-call', + dispatchEventId: 'schema-4-dispatch', + version: 1, + }); + } finally { + upgraded.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('upgrades and quarantines populated mainline schema 4 tool projections', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-mainline-upgrade-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + const preparedCall = callEvent(); + preparedCall.id = 'legacy-prepared-call'; + preparedCall.content = { + kind: 'function_call', + id: 'legacy-prepared-provider-call', + name: 'Read', + args: { path: 'prepared.txt' }, + }; + const completedCall = callEvent(); + completedCall.id = 'legacy-completed-call'; + completedCall.content = { + kind: 'function_call', + id: 'legacy-completed-provider-call', + name: 'Read', + args: { path: 'completed.txt' }, + }; + const completedResponse = outcomeEvent(); + completedResponse.id = 'legacy-completed-response'; + completedResponse.content = { + kind: 'function_response', + id: 'legacy-completed-provider-call', + name: 'Read', + result: 'contents', + }; + completedResponse.refs = { toolCallId: 'legacy-completed-provider-call' }; + await store.importRuntimeEventsBatch({ + sessionId: 'session-1', + runId: 'run-1', + events: [preparedCall, completedCall, completedResponse], + }); + store.close(); + + const db = new DatabaseSync(dbPath); + const insertOperation = db.prepare(` + INSERT INTO tool_operations( + operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, dispatch_event_id, result_event_id, version + ) VALUES (?, 'invocation-1', 'run-1', 'turn-1', ?, 'Read', ?, 'replay_safe', ?, ?, NULL, ?, ?) + `); + insertOperation.run( + 'legacy-prepared-operation', + 'legacy-prepared-provider-call', + canonicalToolArgsHash('Read', { path: 'prepared.txt' }), + 'prepared', + 'legacy-prepared-call', + null, + 1, + ); + insertOperation.run( + 'legacy-completed-operation', + 'legacy-completed-provider-call', + canonicalToolArgsHash('Read', { path: 'completed.txt' }), + 'outcome_committed', + 'legacy-completed-call', + 'legacy-completed-response', + 2, + ); + db.exec( + 'DROP INDEX runtime_events_by_session_kind; DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + ); + db.close(); + + try { + const upgraded = createSqliteRuntimeStore(dbPath); + try { + assert.equal( + (await upgraded.readToolOperation('legacy-prepared-operation'))?.currentState, + 'prepared', + ); + assert.equal( + (await upgraded.readToolOperation('legacy-completed-operation'))?.currentState, + 'outcome_committed', + ); + assert.deepEqual(await upgraded.listUnsettledToolOperations(), []); + assert.deepEqual(await upgraded.rebuildToolProjectionsFromRuntimeEvents(), { + operations: 0, + journalEvents: 0, + }); + assert.equal( + (await upgraded.readToolOperation('legacy-prepared-operation'))?.currentState, + 'prepared', + ); + assert.equal( + (await upgraded.readToolOperation('legacy-completed-operation'))?.currentState, + 'outcome_committed', + ); + assert.equal(upgraded.recoveryBundleCapability, TOOL_RECOVERY_BUNDLE_CAPABILITY_V1); + } finally { + upgraded.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects the old experimental capability marker instead of guessing compatibility', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-epoch-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + store.close(); + const db = new DatabaseSync(dbPath); + db.prepare('DELETE FROM runtime_capabilities').run(); + db.prepare('INSERT INTO runtime_capabilities(capability, version) VALUES (?, ?)').run( + 'tool_recovery_bundle', + 1, + ); + db.close(); + try { + assert.throws( + () => createSqliteRuntimeStore(dbPath), + /runtime_recovery_authority@1 is unavailable/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects reserved recovery facts through every generic append path', async () => { + await withStore(async (store) => { + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', reconcileEvent()), + /atomic recovery bundle writer/, + ); + await assert.rejects( + store.importRuntimeEventsBatch({ + sessionId: 'session-1', + runId: 'run-1', + events: [reconcileEvent()], + }), + /atomic recovery bundle writer/, + ); + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', dispatchEvent()), + /atomic tool boundary writer/, + ); + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', outcomeEvent()), + /atomic tool boundary writer/, + ); + }); + }); + + it('recomputes T1 identity from the persisted function-call arguments', async () => { + await withStore(async (store) => { + const dispatch = dispatchEvent(); + dispatch.actions!.toolDispatch!.canonicalArgsHash = 'sha256:wrong'; + await assert.rejects( + store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: callEvent(), + dispatchRuntimeEvent: dispatch, + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: 'sha256:wrong', + recoveryMode: 'reconcile', + committedAt: 10, + }), + /canonical function call/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('derives projection-local journal identities instead of trusting callers', async () => { + await withStore(async (store) => { + await assert.rejects( + store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'caller-selected-id', + runtimeEvent: callEvent(), + dispatchRuntimeEvent: dispatchEvent(), + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: ARGS_HASH, + recoveryMode: 'reconcile', + committedAt: 10, + }), + /journal identity/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('rejects duplicate call identities through the generic writer', async () => { + await withStore(async (store) => { + await store.appendRuntimeEvent('session-1', 'run-1', callEvent()); + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', { + ...callEvent(), + id: 'call-event-duplicate', + }), + /duplicate_call/, + ); + assert.deepEqual( + (await store.readImmutableRuntimeEvents('session-1', 'run-1')).map(({ id }) => id), + ['call-event-1'], + ); + }); + }); + + it('rejects one invocation identity crossing run, turn, or session boundaries', async () => { + await withStore(async (store) => { + await store.appendRuntimeEvent('session-1', 'run-1', userEvent()); + + for (const event of [ + baseEvent({ + id: 'other-run-event', + runId: 'run-2', + content: { kind: 'text', text: 'other run' }, + }), + baseEvent({ + id: 'other-turn-event', + turnId: 'turn-2', + content: { kind: 'text', text: 'other turn' }, + }), + baseEvent({ + id: 'other-session-event', + sessionId: 'session-2', + content: { kind: 'text', text: 'other session' }, + }), + ]) { + await assert.rejects( + store.appendRuntimeEvent(event.sessionId, event.runId, event), + /invocation identity conflict/, + ); + } + assert.deepEqual( + (await store.readImmutableRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['user-event-1'], + ); + }); + }); + + it('rejects an unbound generic response after T1', async () => { + await withStore(async (store) => { + await prepare(store); + const unboundResponse = outcomeEvent(); + unboundResponse.refs = { toolCallId: 'provider-call-1' }; + + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', unboundResponse), + /identity_conflict/, + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + + it('rejects a T1 dispatch after a pre-T1 synthetic response settled the call', async () => { + await withStore(async (store) => { + const unboundResponse = outcomeEvent(); + unboundResponse.refs = { toolCallId: 'provider-call-1' }; + await store.importRuntimeEventsBatch({ + sessionId: 'session-1', + runId: 'run-1', + events: [callEvent(), unboundResponse], + }); + + await assert.rejects( + store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: callEvent(), + dispatchRuntimeEvent: dispatchEvent(), + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: ARGS_HASH, + recoveryMode: 'reconcile', + committedAt: 10, + }), + /event_order_conflict/, + ); + assert.equal(await store.readToolOperation('operation-1'), undefined); + }); + }); + + it('rejects an out-of-order tool-bearing batch import atomically', async () => { + await withStore(async (store) => { + const unboundResponse = outcomeEvent(); + unboundResponse.refs = { toolCallId: 'provider-call-1' }; + await assert.rejects( + store.importRuntimeEventsBatch({ + sessionId: 'session-1', + runId: 'run-1', + events: [unboundResponse, callEvent()], + }), + /orphan_response/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('rejects a T2 row that also claims another authoritative semantic lane', async () => { + await withStore(async (store) => { + await prepare(store); + const outcome = outcomeEvent(); + outcome.actions = { endInvocation: true }; + await assert.rejects( + store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: outcome, + committedAt: 20, + }), + /semantic lane/, + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + assert.deepEqual( + (await store.readImmutableRuntimeEvents('session-1', 'run-1')).map(({ id }) => id), + ['call-event-1', 'dispatch-event-1'], + ); + }); + }); + + it('atomically settles completed recovery and rebuilds the same projection after reopen', async () => { + await withStore(async (store, dbPath) => { + assert.equal(store.recoveryBundleCapability, TOOL_RECOVERY_BUNDLE_CAPABILITY_V1); + await prepare(store); + await store.commitToolRecoveryBundle({ + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decisionEvent(), + }); + + const onlineOperation = await store.readToolOperation('operation-1'); + const onlineJournal = await store.readToolJournal('operation-1'); + assert.equal(onlineOperation?.currentState, 'recovery_completed'); + assert.equal(onlineOperation?.resultEventId, 'outcome-event-1'); + assert.deepEqual( + onlineJournal.map(({ state }) => state), + ['prepared', 'reconcile_observed', 'outcome_committed', 'recovery_completed'], + ); + + store.close(); + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual(await reopened.readToolOperation('operation-1'), onlineOperation); + assert.deepEqual(await reopened.readToolJournal('operation-1'), onlineJournal); + assert.deepEqual(await reopened.rebuildToolProjectionsFromRuntimeEvents(), { + operations: 1, + journalEvents: 4, + }); + assert.deepEqual(await reopened.readToolOperation('operation-1'), onlineOperation); + assert.deepEqual(await reopened.readToolJournal('operation-1'), onlineJournal); + } finally { + reopened.close(); + } + }); + }); + + it('rebuilds interleaved operations to the same projections and journal identities', async () => { + await withStore(async (store) => { + await prepare(store); + const secondHash = canonicalToolArgsHash('Write', { + path: 'other.txt', + content: 'after', + }); + const secondCall = callEvent(); + secondCall.id = 'call-event-2'; + secondCall.content = { + kind: 'function_call', + id: 'provider-call-2', + name: 'Write', + args: { path: 'other.txt', content: 'after' }, + }; + const secondDispatch = dispatchEvent(); + secondDispatch.id = 'dispatch-event-2'; + secondDispatch.actions!.toolDispatch = { + protocol: 't1_after_preflight_v1', + operationId: 'operation-2', + providerToolCallId: 'provider-call-2', + toolName: 'Write', + canonicalArgsHash: secondHash, + recoveryMode: 'reconcile', + }; + secondDispatch.refs = { operationId: 'operation-2', toolCallId: 'provider-call-2' }; + await store.commitToolPrepared({ + operationId: 'operation-2', + journalEventId: 'operation-2_prepared', + runtimeEvent: secondCall, + dispatchRuntimeEvent: secondDispatch, + providerToolCallId: 'provider-call-2', + toolName: 'Write', + canonicalArgsHash: secondHash, + recoveryMode: 'reconcile', + committedAt: 11, + }); + const secondOutcome = outcomeEvent(); + secondOutcome.id = 'outcome-event-2'; + secondOutcome.content = { + kind: 'function_response', + id: 'provider-call-2', + name: 'Write', + result: 'ok', + }; + secondOutcome.refs = { operationId: 'operation-2', toolCallId: 'provider-call-2' }; + await store.commitToolOutcome({ + operationId: 'operation-2', + journalEventId: 'operation-2_outcome', + runtimeEvent: secondOutcome, + committedAt: 20, + }); + await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: outcomeEvent(), + committedAt: 21, + }); + + const onlineOperations = await Promise.all([ + store.readToolOperation('operation-1'), + store.readToolOperation('operation-2'), + ]); + const onlineJournals = await Promise.all([ + store.readToolJournal('operation-1'), + store.readToolJournal('operation-2'), + ]); + await store.rebuildToolProjectionsFromRuntimeEvents(); + assert.deepEqual( + await Promise.all([ + store.readToolOperation('operation-1'), + store.readToolOperation('operation-2'), + ]), + onlineOperations, + ); + assert.deepEqual( + await Promise.all([ + store.readToolJournal('operation-1'), + store.readToolJournal('operation-2'), + ]), + onlineJournals, + ); + }); + }); + + it('rolls the whole recovery bundle back at every internal crash boundary', async () => { + for (const failpoint of [ + 'after_recovery_reconcile', + 'after_recovery_outcome', + 'after_recovery_decision', + ] as const satisfies readonly SqliteRuntimeStoreFailpoint[]) { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-failpoint-')); + const dbPath = join(root, 'runtime.sqlite'); + let active: SqliteRuntimeStoreFailpoint | undefined; + const store = createSqliteRuntimeStore(dbPath, { + failpoint: (point) => { + if (point === active) throw new Error(`recovery failpoint: ${point}`); + }, + }); + try { + await prepare(store); + active = failpoint; + await assert.rejects( + store.commitToolRecoveryBundle({ + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decisionEvent(), + }), + new RegExp(failpoint), + ); + active = undefined; + assert.deepEqual( + (await store.readImmutableRuntimeEvents('session-1', 'run-1')).map(({ id }) => id), + ['call-event-1', 'dispatch-event-1'], + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map(({ state }) => state), + ['prepared'], + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + } + }); + + it('makes one parked bundle terminal and only permits its exact retry', async () => { + await withStore(async (store) => { + await prepare(store); + const parked = { + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent('diverged'), + decisionRuntimeEvent: decisionEvent('parked'), + } as const; + await store.commitToolRecoveryBundle(parked); + await store.commitToolRecoveryBundle(parked); + + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'recovery_parked'); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map(({ state }) => state), + ['prepared', 'reconcile_observed', 'recovery_parked'], + ); + await assert.rejects( + store.commitToolRecoveryBundle({ + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decisionEvent(), + }), + /duplicate_event_id/, + ); + }); + }); + + it('fails immutable row/payload identity mismatches closed', async () => { + await withStore(async (store, dbPath) => { + await store.appendRuntimeEvent('session-1', 'run-1', userEvent()); + store.close(); + + const db = new DatabaseSync(dbPath); + const payload = { ...userEvent(), runId: 'run-other' }; + db.prepare('UPDATE runtime_events SET payload_json = ? WHERE event_id = ?').run( + JSON.stringify(payload), + 'user-event-1', + ); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.readImmutableRuntimeEvents('session-1', 'run-1'), + /row\/payload identity mismatch/, + ); + } finally { + reopened.close(); + } + }); + }); + + it('persists the decoder canonical RuntimeEvent and makes raw retries idempotent', async () => { + await withStore(async (store) => { + const raw = userEvent(); + raw.content = { + kind: 'text', + text: 'write it', + displayText: 'write it', + attachments: [], + quotes: [], + }; + + await store.appendRuntimeEvent('session-1', 'run-1', raw); + await store.appendRuntimeEvent('session-1', 'run-1', raw); + + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), [userEvent()]); + }); + }); + + it('rejects RuntimeEvents that lose required fields during JSON serialization', async () => { + await withStore(async (store) => { + const lossy = callEvent(); + if (lossy.content?.kind !== 'function_call') throw new Error('invalid fixture'); + lossy.content.args = undefined; + + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', lossy), + /RuntimeEvent schema|losslessly serializable/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('rejects nested JSON loss in a function response before persistence', async () => { + await withStore(async (store) => { + await store.appendRuntimeEvent('session-1', 'run-1', callEvent()); + const lossy = outcomeEvent(); + if (lossy.content?.kind !== 'function_response') throw new Error('invalid fixture'); + lossy.content.result = { value: undefined }; + lossy.refs = { toolCallId: 'provider-call-1' }; + + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', lossy), + /losslessly serializable/, + ); + assert.deepEqual( + (await store.readImmutableRuntimeEvents('session-1', 'run-1')).map((event) => event.id), + ['call-event-1'], + ); + }); + }); + + it('rejects provider metadata that rewrites itself through toJSON', async () => { + await withStore(async (store) => { + const lossy = callEvent(); + if (lossy.content?.kind !== 'function_call') throw new Error('invalid fixture'); + lossy.content.providerOptions = { + value: 1, + toJSON() { + return { value: 2 }; + }, + }; + + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', lossy), + /losslessly serializable/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), []); + }); + }); + + it('rejects recovery evidence whose JSON bytes can rewrite validated event ids', async () => { + await withStore(async (store) => { + await prepare(store); + const decision = decisionEvent(); + const fact = decision.actions?.toolRecovery; + if (fact?.kind !== 'maka.tool.recovery_decision') throw new Error('invalid fixture'); + Object.defineProperty(fact.payload.evidenceEventIds, 'toJSON', { + value: () => ['call-event-1', 'dispatch-event-1', 'reconcile-event-1', 'forged-outcome'], + }); + + await assert.rejects( + store.commitToolRecoveryBundle({ + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decision, + }), + /losslessly serializable/, + ); + assert.equal((await store.readToolOperation('operation-1'))?.currentState, 'prepared'); + }); + }); + + it('rejects row/payload identity corruption through the online bundle writer', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + store.close(); + + const db = new DatabaseSync(dbPath); + db.prepare(`UPDATE runtime_events SET run_id = 'run-other' WHERE event_id = ?`).run( + 'call-event-1', + ); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.commitToolRecoveryBundle({ + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decisionEvent(), + }), + /row\/payload identity mismatch/, + ); + assert.equal((await reopened.readToolOperation('operation-1'))?.currentState, 'prepared'); + } finally { + reopened.close(); + } + }); + }); + + it('fails an exact T1 retry closed when the immutable ledger is already corrupt', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + store.close(); + injectDuplicateCall(dbPath, 3); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects(prepare(reopened), /duplicate_call/); + } finally { + reopened.close(); + } + }); + }); + + it('fails an exact T2 retry closed when the immutable ledger is already corrupt', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: outcomeEvent(), + committedAt: 20, + }); + store.close(); + injectDuplicateCall(dbPath, 4); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: outcomeEvent(), + committedAt: 20, + }), + /duplicate_call/, + ); + } finally { + reopened.close(); + } + }); + }); + + it('fails an exact recovery retry closed when the immutable ledger is already corrupt', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + const bundle = { + operationId: 'operation-1', + reconcileRuntimeEvent: reconcileEvent(), + outcomeRuntimeEvent: outcomeEvent(), + decisionRuntimeEvent: decisionEvent(), + } as const; + await store.commitToolRecoveryBundle(bundle); + store.close(); + injectDuplicateCall(dbPath, 6); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects(reopened.commitToolRecoveryBundle(bundle), /duplicate_call/); + } finally { + reopened.close(); + } + }); + }); + + it('fail-stops a new session tool boundary when another session ledger is corrupt', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + injectDuplicateCall(dbPath, 3); + + const unrelatedCall = callEvent(); + unrelatedCall.id = 'unrelated-call-event'; + unrelatedCall.sessionId = 'session-2'; + unrelatedCall.invocationId = 'invocation-2'; + unrelatedCall.runId = 'run-2'; + unrelatedCall.turnId = 'turn-2'; + if (unrelatedCall.content?.kind !== 'function_call') { + throw new Error('invalid unrelated call fixture'); + } + unrelatedCall.content.id = 'provider-call-2'; + unrelatedCall.refs = { toolCallId: 'provider-call-2' }; + await assert.rejects( + store.appendRuntimeEvent('session-2', 'run-2', unrelatedCall), + /duplicate_call/, + ); + assert.deepEqual(await store.readImmutableRuntimeEvents('session-2', 'run-2'), []); + }); + }); + + it('rejects duplicate call identity while rebuilding canonical projections', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + store.close(); + + const db = new DatabaseSync(dbPath); + const row = db + .prepare(` + SELECT session_id, invocation_id, run_id, turn_id, event_kind, + payload_json, committed_at + FROM runtime_events + WHERE event_id = 'call-event-1' + `) + .get() as { + session_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + event_kind: string; + payload_json: string; + committed_at: number; + }; + const payload = JSON.parse(row.payload_json) as RuntimeEvent; + payload.id = 'call-event-duplicate'; + db.prepare(` + INSERT INTO runtime_events( + event_id, session_id, invocation_id, run_id, turn_id, + event_seq, event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + payload.id, + row.session_id, + row.invocation_id, + row.run_id, + row.turn_id, + 3, + row.event_kind, + JSON.stringify(payload), + row.committed_at, + ); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects(reopened.rebuildToolProjectionsFromRuntimeEvents(), /duplicate_call/); + } finally { + reopened.close(); + } + }); + }); + + it('rejects dispatch-before-call physical order while rebuilding projections', async () => { + await withStore(async (store, dbPath) => { + await prepare(store); + store.close(); + + const db = new DatabaseSync(dbPath); + db.prepare(`UPDATE runtime_events SET event_seq = 100 WHERE event_id = 'call-event-1'`).run(); + db.prepare( + `UPDATE runtime_events SET event_seq = 1 WHERE event_id = 'dispatch-event-1'`, + ).run(); + db.prepare(`UPDATE runtime_events SET event_seq = 2 WHERE event_id = 'call-event-1'`).run(); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + await assert.rejects( + reopened.rebuildToolProjectionsFromRuntimeEvents(), + /event_order_conflict/, + ); + } finally { + reopened.close(); + } + }); + }); + + it('skips a corrupt mutable partial snapshot without hiding immutable history', async () => { + await withStore(async (store, dbPath) => { + await store.appendRuntimeEvent('session-1', 'run-1', userEvent()); + await store.appendRuntimeEvent('session-1', 'run-1', partialTextEvent()); + store.close(); + + const db = new DatabaseSync(dbPath); + db.prepare('UPDATE runtime_partial_snapshots SET payload_json = ?').run('{broken json'); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual(await reopened.readRuntimeEvents('session-1', 'run-1'), [userEvent()]); + } finally { + reopened.close(); + } + }); + }); + + it('skips a mutable partial whose SQL identity disagrees with its payload', async () => { + await withStore(async (store, dbPath) => { + await store.appendRuntimeEvent('session-1', 'run-1', userEvent()); + await store.appendRuntimeEvent('session-1', 'run-1', partialTextEvent()); + store.close(); + + const db = new DatabaseSync(dbPath); + db.prepare(`UPDATE runtime_partial_snapshots SET invocation_id = 'invocation-other'`).run(); + db.close(); + + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual(await reopened.readRuntimeEvents('session-1', 'run-1'), [userEvent()]); + } finally { + reopened.close(); + } + }); + }); +}); + +type Store = ReturnType; + +function injectDuplicateCall(dbPath: string, eventSeq: number): void { + const db = new DatabaseSync(dbPath); + try { + const duplicate = callEvent(); + duplicate.id = `call-event-duplicate-${eventSeq}`; + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, + event_seq, event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + duplicate.id, + duplicate.sessionId, + duplicate.invocationId, + duplicate.runId, + duplicate.turnId, + eventSeq, + 'function_call', + JSON.stringify(duplicate), + duplicate.ts, + ); + } finally { + db.close(); + } +} + +async function withStore(run: (store: Store, dbPath: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-recovery-authority-')); + const dbPath = join(root, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + try { + await run(store, dbPath); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } +} + +async function prepare(store: Store): Promise { + await store.commitToolPrepared({ + operationId: 'operation-1', + journalEventId: 'operation-1_prepared', + runtimeEvent: callEvent(), + dispatchRuntimeEvent: dispatchEvent(), + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: ARGS_HASH, + recoveryMode: 'reconcile', + committedAt: 10, + }); +} + +function baseEvent(overrides: Partial): RuntimeEvent { + return { + id: 'event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + ...overrides, + }; +} + +function userEvent(): RuntimeEvent { + return baseEvent({ + id: 'user-event-1', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'write it' }, + }); +} + +function partialTextEvent(): RuntimeEvent { + return baseEvent({ + id: 'partial-event-1', + ts: 2, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'in progress' }, + refs: { providerEventId: 'provider-text-1' }, + }); +} + +function callEvent(): RuntimeEvent { + return baseEvent({ + id: 'call-event-1', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Write', + args: { path: 'notes.txt', content: 'after' }, + }, + }); +} + +function dispatchEvent(): RuntimeEvent { + return baseEvent({ + id: 'dispatch-event-1', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: ARGS_HASH, + recoveryMode: 'reconcile', + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }); +} + +function reconcileEvent( + observation: + | 'matches_expected_state' + | 'matches_prior_state' + | 'diverged' + | 'unreadable' = 'matches_expected_state', +): RuntimeEvent { + return baseEvent({ + id: 'reconcile-event-1', + ts: 2, + actions: { + toolRecovery: { + kind: 'maka.tool.reconcile_result', + version: 1, + payload: { + protocol: 'tool_reconcile_v1', + operationId: 'operation-1', + observation, + observationSchema: 'state_identity_v1', + observationDigest: OBSERVATION_DIGEST, + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }); +} + +function outcomeEvent(): RuntimeEvent { + return baseEvent({ + id: 'outcome-event-1', + ts: 3, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Write', + result: 'ok', + isError: false, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }); +} + +function decisionEvent(disposition: 'completed' | 'parked' = 'completed'): RuntimeEvent { + return baseEvent({ + id: 'decision-event-1', + ts: 4, + actions: { + toolRecovery: + disposition === 'completed' + ? { + kind: 'maka.tool.recovery_decision', + version: 1, + payload: { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'completed', + reasonCode: 'reconcile_matches_expected_state', + outcomeEventId: 'outcome-event-1', + evidenceEventIds: [ + 'call-event-1', + 'dispatch-event-1', + 'reconcile-event-1', + 'outcome-event-1', + ], + }, + } + : { + kind: 'maka.tool.recovery_decision', + version: 1, + payload: { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'parked', + reasonCode: 'reconcile_diverged', + evidenceEventIds: ['call-event-1', 'dispatch-event-1', 'reconcile-event-1'], + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f0578cba6901e2540bd1d848d9976a7734c907740018aaf3d6febf7c2eb432ac.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f0578cba6901e2540bd1d848d9976a7734c907740018aaf3d6febf7c2eb432ac.source new file mode 100644 index 0000000000..52411339af --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f0578cba6901e2540bd1d848d9976a7734c907740018aaf3d6febf7c2eb432ac.source @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { writeSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { MemoryItemWrite } from '@maka/core/long-term-memory'; +import { + SqliteMemoryItemStore, + type SqliteMemoryItemStoreFailpoint, +} from '../sqlite-long-term-memory-store.js'; + +const childMode = process.env.MAKA_LONG_TERM_MEMORY_CRASH_CHILD; + +if (childMode) { + await runCrashChild(childMode); +} else { + test('retains one committed Item and its receipt when killed between COMMIT and return', { + timeout: 30_000, + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-long-term-memory-crash-')); + const databasePath = join(root, 'memory.sqlite'); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], { + env: { + ...process.env, + MAKA_LONG_TERM_MEMORY_CRASH_CHILD: 'after_commit', + MAKA_LONG_TERM_MEMORY_CRASH_DB: databasePath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await waitForReady(child); + child.kill('SIGKILL'); + await new Promise((resolve, reject) => { + child.once('exit', () => resolve()); + child.once('error', reject); + }); + + const store = new SqliteMemoryItemStore(databasePath, { now: () => 1_000 }); + try { + const record = await store.readItem('crash-item'); + assert.equal(record?.item.content, memoryWrite().content); + assert.ok(await store.readOperation('crash-create')); + + const replayed = await store.applyMutations({ + operationId: 'crash-create', + mutations: [{ type: 'create', item: memoryWrite() }], + }); + assert.equal(replayed.replayed, true); + assert.equal(replayed.results[0]?.itemId, 'crash-item'); + assert.equal((await store.searchByKeys({ terms: ['crash'], match: 'exact' })).length, 1); + } finally { + store.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await rm(root, { recursive: true, force: true }); + } + }); +} + +async function runCrashChild(mode: string): Promise { + if (mode !== 'after_commit') throw new Error(`Unknown long-term memory crash mode ${mode}`); + const databasePath = requiredEnv('MAKA_LONG_TERM_MEMORY_CRASH_DB'); + const failpoint = (point: SqliteMemoryItemStoreFailpoint): void => { + if (point === 'after_commit') blockUntilKilled(); + }; + const store = new SqliteMemoryItemStore(databasePath, { + now: () => 1_000, + idFactory: () => 'crash-item', + failpoint, + }); + await store.applyMutations({ + operationId: 'crash-create', + mutations: [{ type: 'create', item: memoryWrite() }], + }); + throw new Error('Long-term memory crash mode missed its failpoint'); +} + +function waitForReady(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += String(chunk); + if (stdout.includes('READY\n')) resolve(); + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.once('exit', (code, signal) => { + reject( + new Error( + `long-term memory crash child exited before READY: code=${code} signal=${signal} ${stderr}`, + ), + ); + }); + child.once('error', reject); + }); +} + +function memoryWrite(): MemoryItemWrite { + return { + content: 'Committed before the process was killed.', + kind: 'knowledge', + statementType: 'fact', + temporalType: 'undated', + scopeType: 'global', + observedAt: 900, + origin: 'agent_extracted', + keys: [{ key: 'crash', keyType: 'exact', keyOrigin: 'deterministic' }], + sources: [ + { + sessionId: 'session-crash', + runId: 'run-crash', + turnId: 'turn-crash', + eventId: 'event-crash', + }, + ], + }; +} + +function blockUntilKilled(): never { + writeSync(1, 'READY\n'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)), 0, 0); + throw new Error('unreachable'); +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f22c36e0cb294e0bb2134045cb414d397971b9e656c0daddd446fdd3ce22efa8.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f22c36e0cb294e0bb2134045cb414d397971b9e656c0daddd446fdd3ce22efa8.source new file mode 100644 index 0000000000..0148ce3645 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f22c36e0cb294e0bb2134045cb414d397971b9e656c0daddd446fdd3ce22efa8.source @@ -0,0 +1,577 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ModelCallUsageBuckets, + ModelCallUsageLogs, + ModelCallUsageSummary, +} from '@maka/core/model-call-usage-projection'; +import type { + PricingConfig, + UsageBucket, + UsageGroupBy, + UsageLogRow, + UsageQuery, + UsageSummaryV2, +} from '@maka/core/usage-stats/types'; +import { throwDeduplicatedFailures } from './failure-utils.js'; +import { + createSqliteModelCallLedger, + type CatchUpModelCallProjectionInput, + type CatchUpModelCallProjectionResult, + ModelCallLedgerClosedError, + ModelCallLedgerPublicationError, + type ModelCallLedger, + type ModelCallLedgerReader, +} from './model-call-ledger.js'; +import { + PricingCommitUnknownError, + PricingRevisionConflictError, + PricingStoreClosedError, + PricingStoreNotLoadedError, + PricingStorePublicationError, + PricingValidationError, + type PricingMutationResult, + type PricingSnapshot, + type PricingStore, +} from './pricing-store.js'; +import { + runWithStorageRootLease, + StorageRootAuthorityError, + type StorageRootAuthorityErrorCode, + type StorageRootLease, +} from './root-authority.js'; +import { + TelemetryQueryValidationError, + TelemetryRepoClosedError, + TelemetryRepoNotLoadedError, + TelemetryRepoPublicationError, + type PersistedLlmCallRecord, + type PersistedToolInvocationRecord, + type TelemetryRepo, + type ToolUsageQuery, +} from './telemetry-repo.js'; +import { createSqlitePricingStore, createSqliteTelemetryRepo } from './sqlite-usage-store.js'; + +const readerBrand: unique symbol = Symbol('InteractiveUsageStoresReader'); +const writerBrand: unique symbol = Symbol('InteractiveUsageStoresWriter'); +const readers = new WeakSet(); +const writers = new WeakSet(); +const writerByLease = new WeakMap(); +const writerOpeningByLease = new WeakMap>(); + +export interface TelemetryIndexReader { + summary(query: UsageQuery): Promise; + toolSummary(query: UsageQuery): Promise<{ requests: number; durationMs: number }>; + buckets(query: UsageQuery, groupBy: UsageGroupBy): Promise; + logs( + query: UsageQuery, + offset?: number, + limit?: number, + ): Promise<{ rows: UsageLogRow[]; total: number }>; + toolLogs( + query: ToolUsageQuery, + offset?: number, + limit?: number, + ): Promise<{ rows: PersistedToolInvocationRecord[]; total: number }>; + latestLlmRuntimeProbe(connectionSlug: string, modelId?: string): Promise; +} + +export interface TelemetryIndexWriter extends TelemetryIndexReader { + recordLlmCall(record: PersistedLlmCallRecord): Promise; + recordToolInvocation(record: PersistedToolInvocationRecord): Promise; +} + +/** + * Read side of the canonical model-call ledger (#1679). Async here — unlike the + * synchronous store beneath it — because every authority read goes through the + * storage-root lease. + */ +/** One Usage answer from the canonical ledger, with the rows it could not read. */ +export interface ModelCallLedgerResult { + readonly projection: T; + readonly unreadableRecords: number; +} + +export interface ModelCallIndexReader { + modelCallSummary( + query: UsageQuery, + now: number, + ): Promise>; + modelCallBuckets( + query: UsageQuery, + groupBy: UsageGroupBy, + now: number, + ): Promise>; + modelCallLogs( + query: UsageQuery, + now: number, + offset: number, + limit: number, + ): Promise>; +} + +export interface ModelCallIndexWriter extends ModelCallIndexReader { + catchUpModelCallProjection( + input?: CatchUpModelCallProjectionInput, + ): Promise; +} + +export interface PricingAuthorityReader { + snapshot(): Promise; +} + +export interface PricingAuthorityWriter extends PricingAuthorityReader { + upsert(expectedRevision: number, pricing: PricingConfig): Promise; + delete(expectedRevision: number, modelKey: string): Promise; +} + +export interface InteractiveUsageStoresReader { + readonly kind: 'interactive'; + readonly access: 'read'; + readonly [readerBrand]: true; + readonly telemetry: Readonly; + readonly modelCalls: Readonly; + readonly pricing: Readonly; + close(): Promise; +} + +export interface InteractiveUsageStoresWriter { + readonly kind: 'interactive'; + readonly access: 'write'; + readonly [writerBrand]: true; + readonly telemetry: Readonly; + readonly modelCalls: Readonly; + readonly pricing: Readonly; + subscribeSessionUsageChanges(listener: (sessionId: string) => void): () => void; + beginDrain(): Promise; + flush(): Promise; + close(): Promise; +} + +export class InteractiveUsageStoresClosedError extends Error { + constructor() { + super('Interactive usage stores are draining or closed'); + this.name = 'InteractiveUsageStoresClosedError'; + } +} + +export type InteractiveUsageStoresFailureClassification = + | { readonly kind: 'lifecycle' } + | { + readonly kind: 'revision_conflict'; + readonly expectedRevision: number; + readonly actualRevision: number; + } + | { readonly kind: 'invalid_request' } + | { readonly kind: 'commit_outcome_unknown'; readonly needsDrain: true } + | { readonly kind: 'persistence_failed'; readonly needsDrain: boolean } + | { readonly kind: 'unknown'; readonly error: unknown }; + +export function classifyInteractiveUsageStoresFailure( + error: unknown, +): InteractiveUsageStoresFailureClassification { + if (error instanceof PricingRevisionConflictError) { + return { + kind: 'revision_conflict', + expectedRevision: error.expectedRevision, + actualRevision: error.actualRevision, + }; + } + if (error instanceof PricingValidationError || error instanceof TelemetryQueryValidationError) { + return { kind: 'invalid_request' }; + } + if ( + error instanceof InteractiveUsageStoresClosedError || + error instanceof PricingStoreClosedError || + error instanceof TelemetryRepoClosedError || + error instanceof ModelCallLedgerClosedError || + (error instanceof StorageRootAuthorityError && + (error.code === 'invalid_lease' || error.code === 'invalid_owner')) + ) { + return { kind: 'lifecycle' }; + } + if ( + error instanceof PricingCommitUnknownError || + (error instanceof TelemetryRepoPublicationError && error.commitUnknown) || + (error instanceof ModelCallLedgerPublicationError && error.commitUnknown) + ) { + return { kind: 'commit_outcome_unknown', needsDrain: true }; + } + if ( + error instanceof PricingStorePublicationError || + error instanceof TelemetryRepoPublicationError || + error instanceof ModelCallLedgerPublicationError + ) { + return { kind: 'persistence_failed', needsDrain: true }; + } + if (error instanceof PricingStoreNotLoadedError || error instanceof TelemetryRepoNotLoadedError) { + return { kind: 'persistence_failed', needsDrain: false }; + } + if (error instanceof StorageRootAuthorityError) { + return { + kind: 'persistence_failed', + needsDrain: rootAuthorityFailureNeedsDrain(error.code), + }; + } + return { kind: 'unknown', error }; +} + +function rootAuthorityFailureNeedsDrain(code: StorageRootAuthorityErrorCode): boolean { + switch (code) { + case 'root_unmarked': + case 'invalid_marker': + case 'root_identity_collision': + case 'root_identity_changed': + return true; + case 'invalid_root': + case 'invalid_root_kind': + case 'root_not_found': + case 'invalid_repair': + case 'invalid_capability': + case 'invalid_lease': + case 'invalid_owner': + case 'invalid_lock_artifact': + case 'insecure_control_directory': + case 'root_io_failed': + case 'control_io_failed': + case 'lock_failed': + return false; + } +} + +export function authenticateInteractiveUsageStoresReader( + stores: InteractiveUsageStoresReader, +): InteractiveUsageStoresReader { + if (!readers.has(stores)) throw new TypeError('Expected an authentic interactive usage reader'); + return stores; +} + +export function authenticateInteractiveUsageStoresWriter( + stores: InteractiveUsageStoresWriter, +): InteractiveUsageStoresWriter { + if (!writers.has(stores)) throw new TypeError('Expected an authentic interactive usage writer'); + return stores; +} + +export async function openInteractiveUsageStoresForRead( + lease: StorageRootLease<'interactive', 'read'>, +): Promise { + const repos = await runWithStorageRootLease(lease, 'interactive', 'read', (root) => + openRepos(root, false), + ); + let closed = false; + let closePromise: Promise | undefined; + const run = (operation: () => T | Promise): Promise => { + if (closed) return Promise.reject(new InteractiveUsageStoresClosedError()); + return runWithStorageRootLease(lease, 'interactive', 'read', async () => operation()); + }; + const stores: InteractiveUsageStoresReader = { + kind: 'interactive', + access: 'read', + [readerBrand]: true, + telemetry: telemetryReader(repos.telemetry, run), + modelCalls: modelCallReader(repos.modelCalls, run), + pricing: pricingReader(repos.pricing, run), + close: () => { + if (closePromise) return closePromise; + closed = true; + closePromise = closeRepos(repos.telemetry, repos.modelCalls, repos.pricing); + return closePromise; + }, + }; + freezeFacade(stores); + readers.add(stores); + return stores; +} + +export async function openInteractiveUsageStoresForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + const existing = writerByLease.get(lease); + if (existing) return existing; + const opening = writerOpeningByLease.get(lease); + if (opening) return opening; + const pending = runWithStorageRootLease(lease, 'interactive', 'write', async (root) => { + const repos = await openRepos(root, true); + const stores = createWriterFacade(lease, repos.telemetry, repos.modelCalls, repos.pricing); + writers.add(stores); + writerByLease.set(lease, stores); + return stores; + }); + writerOpeningByLease.set(lease, pending); + try { + return await pending; + } finally { + if (writerOpeningByLease.get(lease) === pending) writerOpeningByLease.delete(lease); + } +} + +async function openRepos( + root: string, + createIfMissing: boolean, +): Promise<{ telemetry: TelemetryRepo; modelCalls: ModelCallLedger; pricing: PricingStore }> { + const telemetry = createSqliteTelemetryRepo(root, { createIfMissing, managePricing: false }); + await telemetry.load(); + const modelCalls = createSqliteModelCallLedger(root); + const pricing = createSqlitePricingStore(root, { createIfMissing }); + try { + await pricing.load(); + return { telemetry, modelCalls, pricing }; + } catch (error) { + const closed = await Promise.allSettled([ + telemetry.close(), + modelCalls.close(), + pricing.close(), + ]); + const failures = [error, ...rejectedReasons(closed)]; + throwDeduplicatedFailures('Unable to open interactive usage stores', failures); + throw error; + } +} + +function createWriterFacade( + lease: StorageRootLease<'interactive', 'write'>, + telemetry: TelemetryRepo, + modelCalls: ModelCallLedger, + pricing: PricingStore, +): InteractiveUsageStoresWriter { + const run = (operation: () => T | Promise): Promise => + runWithStorageRootLease(lease, 'interactive', 'write', async () => operation()); + let state: 'open' | 'draining' | 'closed' = 'open'; + let barrier: Promise = Promise.resolve(); + const failures: unknown[] = []; + let drainPromise: Promise | undefined; + let closePromise: Promise | undefined; + const sessionUsageChangeListeners = new Set<(sessionId: string) => void>(); + + const publishSessionUsageChange = (sessionId: string): void => { + for (const listener of sessionUsageChangeListeners) { + try { + listener(sessionId); + } catch { + /* observers cannot perturb the Usage authority */ + } + } + }; + + const assertOpen = () => { + if (state !== 'open') throw new InteractiveUsageStoresClosedError(); + }; + const admit = ( + operation: () => Promise, + expectedFailure: (error: unknown) => boolean = () => false, + ): Promise => { + assertOpen(); + const admitted = Promise.resolve().then(operation); + const observed = admitted.then( + () => undefined, + (error: unknown) => { + if (!expectedFailure(error)) failures.push(error); + }, + ); + barrier = Promise.all([barrier, observed]).then(() => undefined); + return admitted; + }; + const admitSessionUsageMutation = ( + sessionId: string | undefined, + operation: () => T | Promise, + ): Promise => + admit(async () => { + const result = await run(operation); + if (sessionId) publishSessionUsageChange(sessionId); + return result; + }); + const admitSessionUsageChange = ( + sessionId: string, + operation: () => Promise, + ): Promise => + admit(async () => { + if (await run(operation)) publishSessionUsageChange(sessionId); + }); + const admitModelCallProjectionCatchUp = ( + input?: CatchUpModelCallProjectionInput, + ): Promise => + admit(async () => { + const result = await run(() => modelCalls.catchUpProjection(input)); + for (const sessionId of result.changedSessionIds) publishSessionUsageChange(sessionId); + return result; + }); + const read = (operation: () => T): Promise => { + assertOpen(); + return run(operation); + }; + + const beginDrain = (): Promise => { + if (drainPromise) return drainPromise; + state = 'draining'; + const accepted = barrier; + drainPromise = accepted.then(() => + throwDeduplicatedFailures('Interactive usage store drain failed', failures), + ); + return drainPromise; + }; + + const flush = async (): Promise => { + const accepted = barrier; + await accepted; + const flushed = await Promise.allSettled([ + run(() => telemetry.flush()), + run(() => modelCalls.flush()), + run(() => pricing.flush()), + ]); + throwDeduplicatedFailures('Interactive usage store flush failed', [ + ...failures, + ...rejectedReasons(flushed), + ]); + }; + + const close = (): Promise => { + if (closePromise) return closePromise; + state = 'draining'; + if (writerByLease.get(lease) === stores) writerByLease.delete(lease); + writers.delete(stores); + const accepted = barrier; + closePromise = accepted + .then(async () => { + const closed = await Promise.allSettled([ + telemetry.close(), + modelCalls.close(), + pricing.close(), + ]); + throwDeduplicatedFailures('Interactive usage stores close failed', [ + ...failures, + ...rejectedReasons(closed), + ]); + }) + .finally(() => { + state = 'closed'; + sessionUsageChangeListeners.clear(); + }); + return closePromise; + }; + + const stores: InteractiveUsageStoresWriter = { + kind: 'interactive', + access: 'write', + [writerBrand]: true, + telemetry: { + summary: (query) => read(() => telemetry.summary(query)), + toolSummary: (query) => read(() => telemetry.toolSummary(query)), + buckets: (query, groupBy) => read(() => telemetry.buckets(query, groupBy)), + logs: (query, offset, limit) => read(() => telemetry.logs(query, offset, limit)), + toolLogs: (query, offset, limit) => read(() => telemetry.toolLogs(query, offset, limit)), + latestLlmRuntimeProbe: (connectionSlug, modelId) => + read(() => telemetry.latestLlmRuntimeProbe(connectionSlug, modelId)), + recordLlmCall: (record) => + admitSessionUsageMutation(record.sessionId, () => telemetry.insertLlmCall(record)), + recordToolInvocation: (record) => + admitSessionUsageMutation(record.sessionId, () => telemetry.insertToolInvocation(record)), + }, + modelCalls: { + modelCallSummary: (query, now) => read(() => modelCalls.summary(query, now)), + modelCallBuckets: (query, groupBy, now) => + read(() => modelCalls.buckets(query, groupBy, now)), + modelCallLogs: (query, now, offset, limit) => + read(() => modelCalls.logs(query, now, offset, limit)), + catchUpModelCallProjection: admitModelCallProjectionCatchUp, + }, + pricing: { + snapshot: () => read(() => pricing.snapshot()), + upsert: (expectedRevision, value) => + admit(() => run(() => pricing.upsert(expectedRevision, value)), isExpectedPricingFailure), + delete: (expectedRevision, modelKey) => + admit( + () => run(() => pricing.delete(expectedRevision, modelKey)), + isExpectedPricingFailure, + ), + }, + subscribeSessionUsageChanges(listener) { + assertOpen(); + sessionUsageChangeListeners.add(listener); + return () => sessionUsageChangeListeners.delete(listener); + }, + beginDrain, + flush, + close, + }; + freezeFacade(stores); + return stores; +} + +function telemetryReader( + repo: TelemetryRepo, + run: (operation: () => T | Promise) => Promise, +): Readonly { + return Object.freeze({ + summary: (query: UsageQuery) => run(() => repo.summary(query)), + toolSummary: (query: UsageQuery) => run(() => repo.toolSummary(query)), + buckets: (query: UsageQuery, groupBy: UsageGroupBy) => run(() => repo.buckets(query, groupBy)), + logs: (query: UsageQuery, offset?: number, limit?: number) => + run(() => repo.logs(query, offset, limit)), + toolLogs: (query: ToolUsageQuery, offset?: number, limit?: number) => + run(() => repo.toolLogs(query, offset, limit)), + latestLlmRuntimeProbe: (connectionSlug: string, modelId?: string) => + run(() => repo.latestLlmRuntimeProbe(connectionSlug, modelId)), + }); +} + +function modelCallReader( + ledger: ModelCallLedgerReader, + run: (operation: () => T | Promise) => Promise, +): Readonly { + return Object.freeze({ + modelCallSummary: (query: UsageQuery, now: number) => run(() => ledger.summary(query, now)), + modelCallBuckets: (query: UsageQuery, groupBy: UsageGroupBy, now: number) => + run(() => ledger.buckets(query, groupBy, now)), + modelCallLogs: (query: UsageQuery, now: number, offset: number, limit: number) => + run(() => ledger.logs(query, now, offset, limit)), + }); +} + +function pricingReader( + store: PricingStore, + run: (operation: () => T | Promise) => Promise, +): Readonly { + return Object.freeze({ snapshot: () => run(() => store.snapshot()) }); +} + +function isExpectedPricingFailure(error: unknown): boolean { + return error instanceof PricingRevisionConflictError || error instanceof PricingValidationError; +} + +async function closeRepos( + telemetry: TelemetryRepo, + modelCalls: ModelCallLedger, + pricing: PricingStore, +): Promise { + const closed = await Promise.allSettled([telemetry.close(), modelCalls.close(), pricing.close()]); + throwDeduplicatedFailures('Unable to close interactive usage stores', rejectedReasons(closed)); +} + +function rejectedReasons(results: readonly PromiseSettledResult[]): unknown[] { + return results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); +} + +function freezeFacade(stores: InteractiveUsageStoresReader | InteractiveUsageStoresWriter): void { + Object.freeze(stores.telemetry); + Object.freeze(stores.modelCalls); + Object.freeze(stores.pricing); + Object.freeze(stores); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f3a1b7ebadf4fa0f9c7077119f454e00c2506594fe28b5873913d3b14bf3af4d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f3a1b7ebadf4fa0f9c7077119f454e00c2506594fe28b5873913d3b14bf3af4d.source new file mode 100644 index 0000000000..6dac4ec52a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f3a1b7ebadf4fa0f9c7077119f454e00c2506594fe28b5873913d3b14bf3af4d.source @@ -0,0 +1,753 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork, type ChildProcess } from 'node:child_process'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { after, test } from 'node:test'; +import type { ArtifactRecord } from '@maka/core/artifacts'; +import { + openInteractiveArtifactStoreForWrite as openInteractiveArtifactStoreForWriteRaw, + type InteractiveArtifactStoreWriter, +} from '../artifact-stores.js'; +import { + type ArtifactAuthorityStore, + type CreateArtifactInput, + createSqliteArtifactStoreWriteAuthority, +} from '../artifact-store.js'; +import { withArtifactWriterLock } from '../artifact-writer-lock.js'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type StorageRootLease, +} from '../root-authority.js'; +import { exportSessionBundleState } from '../session-bundle-policy.js'; +import { createSessionStore } from '../session-store.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +const TEST_TIMEOUT_MS = 15_000; +const OPERATION_TIMEOUT_MS = 5_000; +const BUNDLE_EXPORT_TIMEOUT_MS = 10_000; +const COMPETING_PAYLOAD_BYTES = 4 * 1024 * 1024; +// Windows does not permit replacing or deleting a directory while SQLite has files open in it. +// These tests exercise POSIX root-replacement semantics; Windows coverage verifies cleanup after +// every owner is closed instead. +const SKIP_OPEN_SQLITE_ROOT_REPLACEMENT = process.platform === 'win32'; +const artifactStoreClosersByRoot = new Map void>>(); + +function createArtifactStore(root: string): ArtifactAuthorityStore { + const authority = createSqliteArtifactStoreWriteAuthority(root); + const closers = artifactStoreClosersByRoot.get(root) ?? new Set<() => void>(); + closers.add(() => authority.close()); + artifactStoreClosersByRoot.set(root, closers); + return authority.store; +} + +async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) { + return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records; +} + +function readArtifactText(store: ArtifactAuthorityStore, artifactId: string) { + return store.readTextInSession('session-1', artifactId); +} + +async function openInteractiveArtifactStoreForWrite( + lease: StorageRootLease<'interactive', 'write'>, +): Promise { + const store = await openInteractiveArtifactStoreForWriteRaw(lease); + const root = lease.canonicalPath; + const closers = artifactStoreClosersByRoot.get(root) ?? new Set<() => void>(); + closers.add(() => store.close()); + artifactStoreClosersByRoot.set(root, closers); + return store; +} + +function closeArtifactStoresUnder(root: string): void { + for (const [storeRoot, closers] of artifactStoreClosersByRoot) { + if (storeRoot !== root && !storeRoot.startsWith(`${root}${sep}`)) continue; + artifactStoreClosersByRoot.delete(storeRoot); + for (const close of [...closers].reverse()) close(); + } +} + +test('unleased write authority waits for a child-held writer lock and preserves metadata', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const store = createArtifactStore(stateRoot); + await store.create(artifactInput('seed')); + const holder = await spawnLockHolder(stateRoot); + try { + const mutation = store.create(artifactInput('after-lock')); + await assertPending(mutation, 'Store mutation'); + await releaseHolder(holder); + await withTimeout(mutation, OPERATION_TIMEOUT_MS, 'Store mutation'); + + assert.deepEqual( + (await listArtifacts(createArtifactStore(stateRoot), 'session-1')) + .map((record) => record.id) + .sort(), + ['after-lock', 'seed'], + ); + } finally { + await stopHolder(holder); + } + }); +}); + +test('unleased write authorities in separate processes reload and publish under one OS lock', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const seedRecord = await createArtifactStore(stateRoot).create(artifactInput('seed')); + + const parentStore = createArtifactStore(stateRoot); + await listArtifacts(parentStore, 'session-1'); + const child = await spawnPublicWriter(stateRoot, 'session-1'); + const holder = await spawnLockHolder(stateRoot); + try { + const childInput = artifactInput('child-public', undefined, 3); + const childPayload = repeatedPayload('child-public:', COMPETING_PAYLOAD_BYTES); + const childMutation = startPublicWriterMutation(child, childInput, 'child-public:'); + const parentPayload = repeatedPayload('parent-public:', COMPETING_PAYLOAD_BYTES); + const parentMutation = parentStore.create(artifactInput('parent-public', parentPayload, 2)); + + await childMutation.queued; + await new Promise((resolve) => setImmediate(resolve)); + await releaseHolder(holder); + + const [parentRecord, childCreated] = await withTimeout( + Promise.all([parentMutation, childMutation.created]), + OPERATION_TIMEOUT_MS, + 'competing unleased write authority mutations', + ); + await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'public writer shutdown'); + + const freshStore = createArtifactStore(stateRoot); + const records = [...(await listArtifacts(freshStore, 'session-1'))].sort(compareRecordsById); + assert.deepEqual( + records, + [seedRecord, parentRecord, childCreated.record].sort(compareRecordsById), + ); + assert.deepEqual(await readArtifactText(freshStore, 'parent-public'), { + ok: true, + text: parentPayload, + }); + assert.deepEqual(await readArtifactText(freshStore, 'child-public'), { + ok: true, + text: childPayload, + }); + } finally { + await stopHolder(child); + await stopHolder(holder); + } + }); +}); + +test('unleased writes share the rootId writer lock used by lease-bound authority', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + await mkdir(stateRoot); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }), + ); + const holder = await spawnAuthorityLockHolder(stateRoot, capability.rootId); + try { + const mutation = createArtifactStore(stateRoot).create(artifactInput('public-marked-root')); + await assertPending(mutation, 'unleased write authority mutation on a marked root'); + + await releaseHolder(holder); + assert.equal( + ( + await withTimeout( + mutation, + OPERATION_TIMEOUT_MS, + 'unleased write authority mutation on a marked root', + ) + ).id, + 'public-marked-root', + ); + } finally { + await stopHolder(holder); + } + }); +}); + +test('mutations spanning initial root marking remain serialized by the bootstrap writer lock', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + await mkdir(stateRoot); + const holder = await spawnLockHolder(stateRoot); + try { + const firstMutation = createArtifactStore(stateRoot).create( + artifactInput('before-root-marking'), + ); + await assertPending(firstMutation, 'unleased mutation started before root marking'); + + trackControlDirectory(await resolveStorageRoot({ path: stateRoot, kind: 'interactive' })); + const secondMutation = createArtifactStore(stateRoot).create( + artifactInput('after-root-marking', undefined, 2), + ); + await assertPending(secondMutation, 'unleased mutation started after root marking'); + + await releaseHolder(holder); + await withTimeout( + Promise.all([firstMutation, secondMutation]), + OPERATION_TIMEOUT_MS, + 'mutations spanning initial root marking', + ); + + const freshStore = createArtifactStore(stateRoot); + assert.deepEqual( + (await listArtifacts(freshStore, 'session-1')).map((record) => record.id).sort(), + ['after-root-marking', 'before-root-marking'], + ); + assert.deepEqual(await readArtifactText(freshStore, 'before-root-marking'), { + ok: true, + text: 'before-root-marking', + }); + assert.deepEqual(await readArtifactText(freshStore, 'after-root-marking'), { + ok: true, + text: 'after-root-marking', + }); + } finally { + await stopHolder(holder); + } + }); +}); + +test('unleased mutation rejects an unmarked replacement installed while waiting for bootstrap', { + timeout: TEST_TIMEOUT_MS, + skip: SKIP_OPEN_SQLITE_ROOT_REPLACEMENT, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const displacedRoot = join(root, 'displaced-state'); + await mkdir(stateRoot); + const holder = await spawnLockHolder(stateRoot); + try { + const mutation = createArtifactStore(stateRoot).create( + artifactInput('stale-public-replacement'), + ); + await assertPending( + mutation, + 'unleased mutation waiting for its captured bootstrap authority', + ); + + await rename(stateRoot, displacedRoot); + await mkdir(stateRoot); + await writeFile(join(stateRoot, 'replacement-sentinel'), 'replacement'); + await releaseHolder(holder); + + await assert.rejects(mutation); + assert.deepEqual(await readdir(stateRoot), ['replacement-sentinel']); + await assert.rejects(() => lstat(join(stateRoot, 'artifacts')), { code: 'ENOENT' }); + await assert.rejects(() => lstat(join(displacedRoot, 'artifacts')), { code: 'ENOENT' }); + } finally { + await stopHolder(holder); + } + }); +}); + +test('unleased mutation through a retargeted alias stays bound to its verified canonical root', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const replacementRoot = join(root, 'replacement'); + const alias = join(root, 'state-alias'); + await Promise.all([mkdir(stateRoot), mkdir(replacementRoot)]); + await writeFile(join(replacementRoot, 'replacement-sentinel'), 'replacement'); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }), + ); + await createArtifactStore(stateRoot).create(artifactInput('seed')); + await symlink(stateRoot, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const store = createArtifactStore(alias); + assert.deepEqual( + (await listArtifacts(store, 'session-1')).map((record) => record.id), + ['seed'], + ); + + const holder = await spawnAuthorityLockHolder(stateRoot, capability.rootId); + try { + const mutation = store.create(artifactInput('alias-mutation', undefined, 2)); + await assertPending(mutation, 'unleased mutation through the original alias target'); + + await rm(alias, { force: true }); + await symlink(replacementRoot, alias, process.platform === 'win32' ? 'junction' : 'dir'); + await releaseHolder(holder); + assert.equal((await mutation).id, 'alias-mutation'); + + assert.deepEqual( + (await listArtifacts(createArtifactStore(stateRoot), 'session-1')) + .map((record) => record.id) + .sort(), + ['alias-mutation', 'seed'], + ); + assert.deepEqual(await readdir(replacementRoot), ['replacement-sentinel']); + await assert.rejects(() => lstat(join(replacementRoot, 'artifacts')), { code: 'ENOENT' }); + } finally { + await stopHolder(holder); + } + }); +}); + +test('admitted lease-bound mutations reject a replacement root without modifying it', { + timeout: TEST_TIMEOUT_MS, + skip: SKIP_OPEN_SQLITE_ROOT_REPLACEMENT, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + await mkdir(stateRoot); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const firstStore = await openInteractiveArtifactStoreForWrite(owner.lease); + const holder = await spawnLockHolder(stateRoot); + const displacedRoot = join(root, 'displaced-state'); + try { + const firstMutation = firstStore.create(artifactInput('stale-replacement-first')); + const secondMutation = firstStore.create( + artifactInput('stale-replacement-second', undefined, 2), + ); + await Promise.all([ + assertPending(firstMutation, 'first admitted lease-bound mutation'), + assertPending(secondMutation, 'second admitted lease-bound mutation'), + ]); + + await rename(stateRoot, displacedRoot); + await mkdir(stateRoot, { mode: 0o750 }); + await writeFile(join(stateRoot, 'replacement-sentinel'), 'replacement'); + const replacementMode = (await stat(stateRoot)).mode & 0o777; + await releaseHolder(holder); + + await Promise.all([assert.rejects(firstMutation), assert.rejects(secondMutation)]); + assert.deepEqual(await readdir(stateRoot), ['replacement-sentinel']); + assert.equal((await stat(stateRoot)).mode & 0o777, replacementMode); + await assert.rejects(() => lstat(join(stateRoot, '.maka-artifact-writer.lock')), { + code: 'ENOENT', + }); + await assert.rejects(() => stat(join(stateRoot, 'artifacts')), { code: 'ENOENT' }); + } finally { + await stopHolder(holder); + await owner.close(); + } + }); +}); + +test('lease-bound mutation does not rebuild a root deleted while waiting for the writer lock', { + timeout: TEST_TIMEOUT_MS, + skip: SKIP_OPEN_SQLITE_ROOT_REPLACEMENT, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + await mkdir(stateRoot); + const capability = trackControlDirectory( + await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const store = await openInteractiveArtifactStoreForWrite(owner.lease); + const holder = await spawnLockHolder(stateRoot); + try { + const mutation = store.create(artifactInput('stale-deleted')); + await assertPending(mutation, 'lease-bound mutation'); + + await rm(stateRoot, { recursive: true }); + await releaseHolder(holder); + + await assert.rejects(mutation); + await assert.rejects(() => lstat(stateRoot), { code: 'ENOENT' }); + } finally { + await stopHolder(holder); + await owner.close(); + } + }); +}); + +test('bundle export excludes a mutation queued behind the same child-held writer lock', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const configRoot = join(root, 'config'); + const destinationRoot = join(root, 'export'); + await Promise.all([mkdir(stateRoot), mkdir(configRoot)]); + const sessions = createSessionStore(stateRoot); + const session = await sessions.create(sessionInput()); + await sessions.close?.(); + const store = createArtifactStore(stateRoot); + await store.create({ ...artifactInput('seed'), sessionId: session.id }); + + const holder = await spawnLockHolder(stateRoot, session.id); + try { + const bundleExport = exportSessionBundleState({ + stateRoot, + configRoot, + destinationRoot, + sessionId: session.id, + }); + await assertPending(bundleExport, 'bundle export'); + const mutation = store.create({ + ...artifactInput('after-export'), + sessionId: session.id, + }); + await assertPending(mutation, 'Store mutation'); + + await releaseHolder(holder); + await withTimeout(bundleExport, BUNDLE_EXPORT_TIMEOUT_MS, 'bundle export'); + await withTimeout(mutation, OPERATION_TIMEOUT_MS, 'Store mutation'); + + const exportedStore = createArtifactStore(destinationRoot); + try { + assert.deepEqual( + (await listArtifacts(exportedStore, session.id)).map((record) => record.id), + ['seed'], + ); + } finally { + exportedStore.close?.(); + } + assert.deepEqual( + (await listArtifacts(createArtifactStore(stateRoot), session.id)) + .map((record) => record.id) + .sort(), + ['after-export', 'seed'], + ); + } finally { + await stopHolder(holder); + } + }); +}); + +test('bundle export holds Artifact authority through selected-session projection', { + timeout: TEST_TIMEOUT_MS, +}, async () => { + await withTemporaryDirectory(async (root) => { + const stateRoot = join(root, 'state'); + const configRoot = join(root, 'config'); + const destinationRoot = join(root, 'export'); + await Promise.all([mkdir(stateRoot), mkdir(configRoot)]); + const sessions = createSessionStore(stateRoot); + const session = await sessions.create(sessionInput()); + await sessions.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'portable transcript', + }); + await sessions.close?.(); + await createArtifactStore(stateRoot).create({ + ...artifactInput('seed'), + sessionId: session.id, + }); + + const holder = await spawnLockHolder(stateRoot, session.id); + try { + const bundleExport = exportSessionBundleState({ + stateRoot, + configRoot, + destinationRoot, + sessionId: session.id, + }); + await assertPending(bundleExport, 'bundle export'); + + const projectedMessages = withArtifactWriterLock(stateRoot, async () => { + const projectedSessions = createSessionStore(destinationRoot); + try { + return await projectedSessions.readMessagesSnapshot(session.id); + } finally { + await projectedSessions.close?.(); + } + }); + await assertPending(projectedMessages, 'writer queued after bundle export'); + + await releaseHolder(holder); + assert.equal( + ( + await withTimeout( + projectedMessages, + BUNDLE_EXPORT_TIMEOUT_MS, + 'selected-session projection', + ) + ).find((message) => message.type === 'user')?.text, + 'portable transcript', + ); + await withTimeout(bundleExport, BUNDLE_EXPORT_TIMEOUT_MS, 'bundle export'); + } finally { + await stopHolder(holder); + } + }); +}); + +function artifactInput(id: string, content = id, now = 1): CreateArtifactInput { + return { + id, + sessionId: 'session-1', + turnId: 'turn-1', + name: `${id}.txt`, + kind: 'file' as const, + content, + source: 'tool_result', + now, + }; +} + +function sessionInput() { + return { + cwd: '/repo', + backend: 'fake' as const, + llmConnectionSlug: 'fixture', + model: 'fixture-model', + permissionMode: 'ask' as const, + name: 'Selected', + }; +} + +async function spawnLockHolder( + workspaceRoot: string, + transientResidueSessionId?: string, +): Promise { + const child = fork( + new URL('./fixtures/artifact-writer-lock-holder.js', import.meta.url), + [workspaceRoot, ...(transientResidueSessionId ? [transientResidueSessionId] : [])], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }, + ); + try { + await waitForChildMessage(child, 'locked'); + return child; + } catch (error) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'failed lock holder shutdown'); + throw error; + } +} + +async function spawnAuthorityLockHolder( + workspaceRoot: string, + rootId: string, +): Promise { + const child = fork( + new URL('./fixtures/artifact-writer-lock-holder.js', import.meta.url), + [workspaceRoot, '--authority-holder', rootId], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }, + ); + try { + await waitForChildMessage(child, 'locked'); + return child; + } catch (error) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'failed lock holder shutdown'); + throw error; + } +} + +async function spawnPublicWriter(workspaceRoot: string, sessionId: string): Promise { + const child = fork( + new URL('./fixtures/artifact-writer-lock-holder.js', import.meta.url), + [workspaceRoot, '--public-writer', sessionId], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }, + ); + try { + await waitForChildMessage(child, 'ready'); + return child; + } catch (error) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'failed public writer shutdown'); + throw error; + } +} + +function startPublicWriterMutation( + child: ChildProcess, + input: CreateArtifactInput, + payloadUnit: string, +): { + queued: Promise>; + created: Promise>; +} { + const queued = waitForChildMessage(child, 'queued'); + const created = waitForChildMessage(child, 'created'); + const { content: _content, ...inputWithoutContent } = input; + child.send({ + type: 'create', + input: inputWithoutContent, + payloadUnit, + payloadBytes: COMPETING_PAYLOAD_BYTES, + }); + return { queued, created }; +} + +async function releaseHolder(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error('Artifact writer lock holder exited before release'); + } + const released = waitForChildMessage(child, 'released'); + child.send({ type: 'release' }); + await released; + await waitForExit(child); +} + +async function stopHolder(child: ChildProcess): Promise { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'lock holder shutdown'); +} + +async function waitForChildMessage( + child: ChildProcess, + expected: T, +): Promise> { + return withTimeout( + new Promise>((resolve, reject) => { + const cleanup = () => { + child.off('error', onError); + child.off('exit', onExit); + child.off('message', onMessage); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`Lock holder exited before ${expected}: ${code ?? signal}`)); + }; + const onMessage = (message: unknown) => { + if (!isFixtureMessage(message)) return; + if (message.type === 'error') { + cleanup(); + reject(new Error(`Lock holder failed: ${message.message}`)); + } else if (message.type === expected) { + cleanup(); + resolve(message as FixtureMessageOfType); + } + }; + child.on('error', onError); + child.on('exit', onExit); + child.on('message', onMessage); + }), + OPERATION_TIMEOUT_MS, + `lock holder ${expected}`, + ); +} + +type FixtureMessage = + | { type: 'locked' | 'released' | 'ready' | 'queued' } + | { type: 'created'; record: ArtifactRecord } + | { type: 'error'; message: string }; + +type FixtureMessageOfType = Extract; + +function isFixtureMessage(message: unknown): message is FixtureMessage { + if (!message || typeof message !== 'object' || !('type' in message)) return false; + const type = message.type; + return ( + type === 'locked' || + type === 'released' || + type === 'ready' || + type === 'queued' || + type === 'created' || + type === 'error' + ); +} + +async function assertPending(operation: Promise, label: string): Promise { + let settled = false; + void operation.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await delay(100); + assert.equal(settled, false, `${label} did not wait for the child-held writer lock`); +} + +async function waitForExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => child.once('exit', () => resolve())); +} + +async function withTimeout( + operation: Promise, + milliseconds: number, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function withTemporaryDirectory(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-writer-lock-')); + try { + await run(root); + } finally { + closeArtifactStoresUnder(root); + await rm(root, { recursive: true, force: true }); + } +} + +function repeatedPayload(unit: string, bytes: number): string { + return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes); +} + +function compareRecordsById(left: ArtifactRecord, right: ArtifactRecord): number { + return left.id.localeCompare(right.id); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f844d59f635a4247184a402724320c7d24fc5f003b4089e148a37c8667cbc024.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f844d59f635a4247184a402724320c7d24fc5f003b4089e148a37c8667cbc024.source new file mode 100644 index 0000000000..1743e9aa6f --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f844d59f635a4247184a402724320c7d24fc5f003b4089e148a37c8667cbc024.source @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + deriveMakaDataRoots, + resolveMakaClientDataRoot, + resolveMakaWorkspaceRoot, +} from '../workspace-root.js'; + +describe('Maka workspace root resolver', () => { + test('resolves Client-owned data outside every Host State Root', () => { + assert.equal( + resolveMakaClientDataRoot({ platform: 'linux', homeDir: '/home/ada', env: {} }), + '/home/ada/.config/Maka', + ); + }); + + test('resolves release and development profiles under each platform application-data root', () => { + const cases = [ + [ + { platform: 'darwin', homeDir: '/Users/ada', env: {} }, + '/Users/ada/Library/Application Support', + ], + [{ platform: 'linux', homeDir: '/home/ada', env: {} }, '/home/ada/.config'], + [ + { + platform: 'win32', + homeDir: 'C:\\Users\\Ada', + env: { APPDATA: 'C:\\Users\\Ada\\AppData\\Roaming' }, + }, + 'C:\\Users\\Ada\\AppData\\Roaming', + ], + ] as const; + + for (const [options, base] of cases) { + const separator = options.platform === 'win32' ? '\\' : '/'; + for (const profileName of ['Maka', 'Maka Dev']) { + const clientDataRoot = `${base}${separator}${profileName}`; + assert.equal( + resolveMakaClientDataRoot({ ...options, profileName }), + clientDataRoot, + `${options.platform} ${profileName} Client Data Root`, + ); + assert.equal( + resolveMakaWorkspaceRoot({ ...options, profileName }), + `${clientDataRoot}${separator}workspaces${separator}default`, + `${options.platform} ${profileName} Workspace Root`, + ); + } + } + }); + + test('derives all subordinate roots from an exact Client Data Root', () => { + assert.deepEqual(deriveMakaDataRoots('/custom/maka-profile', { platform: 'linux' }), { + clientDataRoot: '/custom/maka-profile', + workspaceRoot: '/custom/maka-profile/workspaces/default', + }); + }); + + test('honors Linux XDG_CONFIG_HOME and falls back from Windows APPDATA', () => { + assert.equal( + resolveMakaClientDataRoot({ + platform: 'linux', + homeDir: '/home/ada', + env: { XDG_CONFIG_HOME: '/var/config/ada' }, + profileName: 'Maka Dev', + }), + '/var/config/ada/Maka Dev', + ); + for (const XDG_CONFIG_HOME of ['', 'relative/config']) { + assert.equal( + resolveMakaClientDataRoot({ + platform: 'linux', + homeDir: '/home/ada', + env: { XDG_CONFIG_HOME }, + }), + '/home/ada/.config/Maka', + ); + } + assert.equal( + resolveMakaClientDataRoot({ + platform: 'win32', + homeDir: 'C:\\Users\\Ada', + env: {}, + profileName: 'Maka Dev', + }), + 'C:\\Users\\Ada\\AppData\\Roaming\\Maka Dev', + ); + }); + + test('rejects a profile name that can escape its application-data root', () => { + for (const profileName of ['', '.', '..', 'Maka/Dev', 'Maka\\Dev', 'Maka\0Dev']) { + assert.throws( + () => + resolveMakaClientDataRoot({ + platform: 'linux', + homeDir: '/home/ada', + env: {}, + profileName, + }), + /non-empty path segment/, + JSON.stringify(profileName), + ); + } + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8650367cac798c2a15f90edaff1a9465c48e0274fe50dc34cf6f684ba093f99.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8650367cac798c2a15f90edaff1a9465c48e0274fe50dc34cf6f684ba093f99.source new file mode 100644 index 0000000000..3d272ea7fa --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8650367cac798c2a15f90edaff1a9465c48e0274fe50dc34cf6f684ba093f99.source @@ -0,0 +1,438 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { describe, test } from 'node:test'; +import { + MODEL_CALL_ATTEMPT_EVENT_TYPE, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; +import { + createSqliteModelCallLedger, + ModelCallLedgerClosedError, + ModelCallLedgerPublicationError, + type ModelCallLedger, + type ModelCallLedgerReader, +} from '../model-call-ledger.js'; +import { acquireOperationalStateDatabase } from '../operational-state-store.js'; +import { MODEL_CALL_COLUMNS } from '../sqlite-usage-schema.js'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { openInvocation } from './fixtures/invocation-opening.js'; +import { + appendAuthorityEvent, + modelCallAttempt as attempt, + MODEL_CALL_NOW as NOW, + withLedger, +} from './fixtures/model-call-attempt.js'; + +/** The calls a window holds, newest first, as the Usage log surface sees them. */ +function ids(ledger: ModelCallLedgerReader, from = 0, sessionId?: string): string[] { + return ledger + .logs({ range: { from, to: NOW }, ...(sessionId ? { sessionId } : {}) }, NOW, 0, 100) + .projection.rows.map((row) => row.id); +} + +function unreadable(ledger: ModelCallLedgerReader, sessionId?: string): number { + return ledger.logs( + { range: { from: 0, to: NOW }, ...(sessionId ? { sessionId } : {}) }, + NOW, + 0, + 1, + ).unreadableRecords; +} + +/** Records one call whose pricing was lost before the ledger held columns. */ +function insertTombstone(root: string, attemptId: string, sessionId?: string): void { + const lease = acquireOperationalStateDatabase(root); + try { + lease.transaction('write', () => { + lease.database + .prepare( + 'INSERT INTO usage_model_call_attempts(attempt_id, completed_at, session_id) VALUES (?, ?, ?)', + ) + .run(attemptId, NOW - 400, sessionId ?? null); + }); + } finally { + lease.close(); + } +} + +describe('canonical model call ledger', () => { + test('reads back what it recorded, bounded to the queried window', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'inside', completedAt: NOW - 500 })); + appendAuthorityEvent( + root, + 1, + attempt({ attemptId: 'before', startedAt: NOW - 10_500, completedAt: NOW - 10_000 }), + ); + await ledger.catchUpProjection(); + + assert.deepEqual(ids(ledger, NOW - 1_000), ['inside']); + assert.equal(unreadable(ledger), 0); + }); + }); + + test('a failed call keeps its pricing basis and drops what pricing cannot use', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-call-ledger-reopen-')); + const first = createSqliteModelCallLedger(root); + try { + appendAuthorityEvent( + root, + 0, + attempt({ + callKind: 'history_compact', + historyCompactRoute: 'provider_native', + providerId: 'openai-codex', + status: 'failed', + usageBasis: 'missing', + inputTokens: undefined, + outputTokens: undefined, + costBasis: 'unpriced', + costUsd: undefined, + errorClass: 'RequestRejected', + httpStatus: 400, + providerCode: 'invalid_request_error', + providerRequestId: 'req-reopen-1', + retryable: false, + }), + ); + await first.catchUpProjection(); + await first.close(); + + const reopened = createSqliteModelCallLedger(root); + try { + const restored = reopened.logs({ range: { from: 0, to: NOW } }, NOW, 0, 10).projection + .rows[0]; + assert.equal(restored?.callKind, 'history_compact'); + assert.equal(restored?.status, 'error'); + assert.equal(restored?.costBasis, 'unpriced'); + assert.equal(Object.hasOwn(restored ?? {}, 'costUsd'), false); + // The Usage log row shows this one; the rest of the provider diagnostics + // have nowhere to land here and are answered from the AgentRun authority. + assert.equal(restored?.errorClass, 'RequestRejected'); + const lease = acquireOperationalStateDatabase(root); + try { + assert.deepEqual( + ( + lease.database + .prepare('PRAGMA table_info(usage_model_call_attempts)') + .all() as Array<{ + name: string; + }> + ).map((column) => column.name), + [...MODEL_CALL_COLUMNS], + ); + } finally { + lease.close(); + } + } finally { + await reopened.close(); + } + } finally { + await first.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } + }); + + test('migration deletes legacy repair intent without losing discoverable authority', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-call-ledger-migrate-')); + const first = createSqliteModelCallLedger(root); + appendAuthorityEvent(root, 0, attempt({ attemptId: 'pre-checkpoint' })); + await first.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + database.exec(` + DROP TABLE usage_model_call_projection_checkpoints; + CREATE TABLE usage_model_call_reprojection ( + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + marked_at INTEGER NOT NULL, + PRIMARY KEY (session_id, run_id) + ); + INSERT INTO usage_model_call_reprojection VALUES ('session-1', 'run-1', 1); + UPDATE operational_schema_migrations SET version = 4 WHERE scope = 'usage'; + `); + database.close(); + + const migrated = createSqliteModelCallLedger(root); + try { + await migrated.catchUpProjection(); + assert.deepEqual(ids(migrated), ['pre-checkpoint']); + const lease = acquireOperationalStateDatabase(root); + try { + assert.equal( + lease.database + .prepare( + "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name = 'usage_model_call_reprojection'", + ) + .get()?.count, + 0, + ); + } finally { + lease.close(); + } + } finally { + await migrated.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a row converted in place keeps spend the authority can no longer replay', async () => { + // Deleting a Session drops its runs and cascades their events, but leaves + // its ledger rows. Converging those rows by wiping and re-projecting would + // erase that spend from the all-time totals, so they are converted in place. + const root = await mkdtemp(join(tmpdir(), 'maka-model-call-ledger-convert-')); + const first = createSqliteModelCallLedger(root); + await first.close(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + database.exec(` + PRAGMA foreign_keys = ON; + DROP TABLE usage_model_call_attempts; + CREATE TABLE usage_model_call_attempts ( + attempt_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + session_id TEXT + ); + UPDATE operational_schema_migrations SET version = 6 WHERE scope = 'usage'; + `); + database + .prepare('INSERT INTO usage_model_call_attempts VALUES (?, ?, ?, ?)') + .run( + 'deleted-session-call', + NOW - 500, + JSON.stringify(attempt({ attemptId: 'deleted-session-call' })), + 'session-1', + ); + database.close(); + + const migrated = createSqliteModelCallLedger(root); + try { + const page = migrated.logs({ range: { from: 0, to: NOW } }, NOW, 0, 10); + assert.equal(page.unreadableRecords, 0); + assert.equal(page.projection.rows[0]?.id, 'deleted-session-call'); + assert.equal(page.projection.rows[0]?.costUsd, 0.004); + } finally { + await migrated.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a late settlement replaces the provisional record under the same attempt id', async () => { + // The abort path records provisionally without usage; a `finish` arriving + // afterwards settles the same attempt. Two rows would double-count it. + await withLedger(async (ledger, root) => { + appendAuthorityEvent( + root, + 0, + attempt({ + status: 'aborted', + usageBasis: 'missing', + inputTokens: undefined, + outputTokens: undefined, + }), + ); + appendAuthorityEvent(root, 1, attempt({ status: 'completed', usageBasis: 'reported' })); + await ledger.catchUpProjection(); + + const rows = ledger.logs({ range: { from: 0, to: NOW } }, NOW, 0, 10).projection.rows; + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'success'); + assert.equal(rows[0]?.inputTokens, 100); + }); + }); + + test('reports an authority record that does not satisfy the canonical schema', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ costBasis: 'unpriced', costUsd: 0.004 })); + const result = await ledger.catchUpProjection(); + assert.equal(result.unreadableEvents, 1); + assert.equal(ids(ledger).length, 0); + }); + }); + + test('one unreadable row is reported rather than failing the whole query', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'good' })); + await ledger.catchUpProjection(); + insertTombstone(root, 'lost'); + + assert.deepEqual(ids(ledger), ['good']); + assert.equal(unreadable(ledger), 1); + }); + }); + + test('a Session-scoped read excludes another Session records and corruption', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent( + root, + 0, + attempt({ attemptId: 'session-a-call', sessionId: 'session-a', runId: 'run-a' }), + 'session-a', + 'run-a', + ); + appendAuthorityEvent( + root, + 0, + attempt({ attemptId: 'session-b-call', sessionId: 'session-b', runId: 'run-b' }), + 'session-b', + 'run-b', + ); + await ledger.catchUpProjection(); + insertTombstone(root, 'session-b-lost', 'session-b'); + + assert.deepEqual(ids(ledger, 0, 'session-a'), ['session-a-call']); + assert.equal(unreadable(ledger, 'session-a'), 0); + // The other Session's lost row is still reported to whoever asks for it. + assert.equal(unreadable(ledger, 'session-b'), 1); + }); + }); + + test('reads and catch-up after close report the lifecycle rather than corrupting state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-call-ledger-')); + const ledger = createSqliteModelCallLedger(root); + appendAuthorityEvent(root, 0, attempt()); + await ledger.close(); + + await assert.rejects(() => ledger.catchUpProjection(), ModelCallLedgerClosedError); + assert.throws(() => ledger.summary({ range: 'all' }, NOW), ModelCallLedgerClosedError); + await rm(root, { recursive: true, force: true }); + }); +}); + +describe('catching the read model up from the AgentRun authority', () => { + test('consumes the high-water published by the real AgentRun append path', async () => { + await withLedger(async (ledger, root) => { + await openInvocation(root, { sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1' }); + const runStore = createSqliteAgentRunStore(root); + await runStore.appendEvent('session-1', 'run-1', { + id: 'attempt-real-append', + type: MODEL_CALL_ATTEMPT_EVENT_TYPE, + ts: NOW, + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + data: { ...attempt({ attemptId: 'real-append' }) }, + }); + runStore.close?.(); + + await ledger.catchUpProjection({ sessionId: 'session-1' }); + + assert.deepEqual(ids(ledger), ['real-append']); + }); + }); + + test('recovers an authority append even when no repair marker was written', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'missed' })); + + const result = await ledger.catchUpProjection({ sessionId: 'session-1' }); + + assert.deepEqual(result, { + changedSessionIds: ['session-1'], + pendingRuns: 0, + unreadableEvents: 0, + }); + assert.deepEqual(ids(ledger), ['missed']); + }); + }); + + test('a later authority append remains discoverable after an earlier catch-up', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'old' })); + await ledger.catchUpProjection({ sessionId: 'session-1', runId: 'run-1' }); + + appendAuthorityEvent(root, 1, attempt({ attemptId: 'new' })); + const result = await ledger.catchUpProjection({ sessionId: 'session-1' }); + + assert.equal(result.pendingRuns, 0); + assert.deepEqual(ids(ledger).sort(), ['new', 'old']); + }); + }); + + test('persists corrupt authority evidence without pinning later events', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, { schemaVersion: 1 }); + appendAuthorityEvent(root, 1, attempt({ attemptId: 'good' })); + + const first = await ledger.catchUpProjection({ sessionId: 'session-1' }); + const second = await ledger.catchUpProjection({ sessionId: 'session-1' }); + + assert.equal(first.unreadableEvents, 1); + assert.equal(first.pendingRuns, 0); + assert.equal(second.unreadableEvents, 1); + assert.deepEqual(second.changedSessionIds, []); + assert.deepEqual(ids(ledger), ['good']); + }); + }); + + test('reports a run as pending until a bounded catch-up reaches its high-water mark', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'first' })); + appendAuthorityEvent(root, 1, attempt({ attemptId: 'second' })); + + const first = await ledger.catchUpProjection({ + sessionId: 'session-1', + eventsPerRun: 1, + }); + const second = await ledger.catchUpProjection({ + sessionId: 'session-1', + eventsPerRun: 1, + }); + + assert.equal(first.pendingRuns, 1); + assert.equal(second.pendingRuns, 0); + assert.equal(ids(ledger).length, 2); + }); + }); + + test('does not advance the checkpoint when projection storage fails', async () => { + await withLedger(async (ledger, root) => { + appendAuthorityEvent(root, 0, attempt({ attemptId: 'retry-after-storage-failure' })); + const lease = acquireOperationalStateDatabase(root); + try { + lease.transaction('write', () => { + lease.database.exec(` + CREATE TRIGGER reject_model_call_projection + BEFORE INSERT ON usage_model_call_attempts + BEGIN + SELECT RAISE(ABORT, 'projection unavailable'); + END; + `); + }); + await assert.rejects(() => ledger.catchUpProjection(), ModelCallLedgerPublicationError); + lease.transaction('write', () => { + lease.database.exec('DROP TRIGGER reject_model_call_projection'); + }); + } finally { + lease.close(); + } + + const recovered = await ledger.catchUpProjection(); + assert.equal(recovered.pendingRuns, 0); + assert.deepEqual(ids(ledger), ['retry-after-storage-failure']); + }); + }); +}); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b77f2ce84c6a0cea6a26f29c3db0beb37ea2f0e51f71b6536403a3315b1e90.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b77f2ce84c6a0cea6a26f29c3db0beb37ea2f0e51f71b6536403a3315b1e90.source new file mode 100644 index 0000000000..0f5876a8f9 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b77f2ce84c6a0cea6a26f29c3db0beb37ea2f0e51f71b6536403a3315b1e90.source @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MODEL_CALL_ATTEMPT_EVENT_TYPE, + MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSqliteModelCallLedger, type ModelCallLedger } from '../../model-call-ledger.js'; +import { acquireOperationalStateDatabase } from '../../operational-state-store.js'; + +/** A realistic epoch-ms clock: a small value pushes "40 days ago" below zero. */ +export const MODEL_CALL_NOW = 1_750_000_000_000; + +export function modelCallAttempt(overrides: Partial = {}): ModelCallAttempt { + return { + schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + logicalCallId: 'call-1', + attemptId: 'attempt-1', + traceId: 'trace-1', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + step: 0, + attempt: 0, + callKind: 'main', + providerId: 'anthropic', + modelId: 'claude-opus-5', + startedAt: MODEL_CALL_NOW - 1_000, + completedAt: MODEL_CALL_NOW - 500, + latencyMs: 500, + status: 'completed', + usageBasis: 'reported', + inputTokens: 100, + outputTokens: 20, + costBasis: 'priced', + costUsd: 0.004, + ...overrides, + }; +} + +/** An attempt carrying the request evidence and diagnostics the ledger drops. */ +export function wideModelCallAttempt(overrides: Partial = {}): ModelCallAttempt { + return modelCallAttempt({ + promptComposition: { segments: [{ kind: 'messages', bytes: 4_096 }] }, + requestObservation: { + schemaVersion: 1, + digest: `sha256:${'a'.repeat(64)}`, + bytes: 27_817, + segments: [ + { + kind: 'tool_schema', + index: 0, + cacheable: true, + comparison: 'exact', + digest: `sha256:${'0'.repeat(64)}`, + bytes: 434, + label: 'tool-0', + }, + ], + }, + providerRequestId: 'req-1', + httpStatus: 200, + pricingRevision: 3, + ...overrides, + }); +} + +export async function withLedger( + run: (ledger: ModelCallLedger, root: string) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-model-call-ledger-')); + const ledger = createSqliteModelCallLedger(root); + try { + await run(ledger, root); + } finally { + await ledger.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +} + +/** + * Commits one attempt to the AgentRun authority the projection reads from. + * + * Tests seed through the authority rather than the ledger's table on purpose: + * nothing in production writes a row any other way. + */ +export function appendAuthorityEvent( + root: string, + sequence: number, + value: ModelCallAttempt | { readonly schemaVersion: number }, + sessionId = 'session-1', + runId = 'run-1', +): void { + const lease = acquireOperationalStateDatabase(root); + try { + lease.transaction('write', () => { + lease.database + .prepare(` + INSERT OR IGNORE INTO core_agent_runs(session_id, run_id, created_at) + VALUES (?, ?, ?) + `) + .run(sessionId, runId, MODEL_CALL_NOW - 1_000); + lease.database + .prepare(` + INSERT INTO core_agent_run_events( + session_id, run_id, sequence, event_id, event_type, event_ts, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + .run( + sessionId, + runId, + sequence, + `event-${sessionId}-${runId}-${sequence}`, + MODEL_CALL_ATTEMPT_EVENT_TYPE, + MODEL_CALL_NOW - 500 + sequence, + JSON.stringify({ + id: `event-${sessionId}-${runId}-${sequence}`, + type: MODEL_CALL_ATTEMPT_EVENT_TYPE, + ts: MODEL_CALL_NOW - 500 + sequence, + sessionId, + runId, + turnId: 'turn-1', + data: value, + }), + ); + lease.database + .prepare(` + UPDATE core_agent_runs + SET latest_model_call_sequence = ? + WHERE session_id = ? AND run_id = ? + `) + .run(sequence, sessionId, runId); + }); + } finally { + lease.close(); + } +} + +/** Projects a whole set of attempts and returns the ledger holding them. */ +export async function withProjectedAttempts( + attempts: readonly ModelCallAttempt[], + run: (ledger: ModelCallLedger, root: string) => Promise, +): Promise { + await withLedger(async (ledger, root) => { + attempts.forEach((value, index) => { + appendAuthorityEvent(root, index, value, value.sessionId, value.runId); + }); + await ledger.catchUpProjection(); + await run(ledger, root); + }); +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b8607bb3f6084a75091c2480c40dd22e4c4b92995467693c3757dce7af2f62.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b8607bb3f6084a75091c2480c40dd22e4c4b92995467693c3757dce7af2f62.source new file mode 100644 index 0000000000..cf9fa323a1 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8b8607bb3f6084a75091c2480c40dd22e4c4b92995467693c3757dce7af2f62.source @@ -0,0 +1,603 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { MCP_CONFIG_VERSION, resolveMcpProtocolPreference } from '@maka/core/mcp'; +import { + createMcpConfigStore, + normalizeMcpConfig, + normalizeMcpImport, +} from '../mcp-config-store.js'; + +const roots: string[] = []; +afterEach(async () => + Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))), +); + +test('creates and atomically updates a Claude-compatible mcp.json', async () => { + const root = await tempRoot(); + const store = createMcpConfigStore(root); + assert.deepEqual(await store.get(), { version: MCP_CONFIG_VERSION, mcpServers: {} }); + const next = await store.upsert('filesystem', { + command: 'npx', + args: ['-y', 'server'], + env: { TOKEN: 'secret' }, + enabled: true, + }); + assert.equal( + next.mcpServers.filesystem && 'command' in next.mcpServers.filesystem + ? next.mcpServers.filesystem.command + : undefined, + 'npx', + ); + assert.deepEqual(JSON.parse(await readFile(join(root, 'mcp.json'), 'utf8')), next); + if (process.platform !== 'win32') + assert.equal((await stat(join(root, 'mcp.json'))).mode & 0o777, 0o600); + await store.remove('filesystem'); + assert.deepEqual((await store.get()).mcpServers, {}); +}); + +test('leaves no temp file behind after writes', async () => { + const root = await tempRoot(); + const store = createMcpConfigStore(root); + await store.upsert('filesystem', { command: 'npx', args: ['-y', 'server'] }); + await store.remove('filesystem'); + const strays = (await readdir(root)).filter((entry) => entry.endsWith('.tmp')); + assert.deepEqual(strays, []); +}); + +test('reads version 1 without rewriting and persists version 3 on the next mutation', async () => { + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + const legacyText = `${JSON.stringify( + { + version: 1, + mcpServers: { + remote: { enabled: false, url: 'https://example.com/mcp' }, + }, + }, + null, + 2, + )}\n`; + await writeFile(path, legacyText, 'utf8'); + + const store = createMcpConfigStore(root); + const migrated = await store.get(); + assert.equal(migrated.version, MCP_CONFIG_VERSION); + assert.deepEqual(migrated.mcpServers.remote, { + enabled: false, + url: 'https://example.com/mcp', + transport: 'auto', + }); + assert.equal(await readFile(path, 'utf8'), legacyText); + + await store.upsert('local', { command: 'node' }); + const persisted = JSON.parse(await readFile(path, 'utf8')) as { + version: number; + mcpServers: Record>; + }; + assert.equal(persisted.version, MCP_CONFIG_VERSION); + assert.equal(Object.hasOwn(persisted.mcpServers.remote, 'protocol'), false); +}); + +test('reads a missing wrapper version as version 1 without rewriting it', async () => { + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + const legacyText = '{"mcpServers":{"remote":{"url":"https://example.com/mcp"}}}\n'; + await writeFile(path, legacyText, 'utf8'); + + const migrated = await createMcpConfigStore(root).get(); + const remote = migrated.mcpServers.remote; + assert.ok(remote && 'url' in remote); + assert.equal(migrated.version, MCP_CONFIG_VERSION); + assert.equal(Object.hasOwn(remote, 'protocol'), false); + assert.equal(resolveMcpProtocolPreference(remote), 'legacy'); + assert.equal(await readFile(path, 'utf8'), legacyText); +}); + +test('reads version 2 remote pins without rewriting and projects them as version 3', async () => { + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + const versionTwo = + '{"version":2,"mcpServers":{"local":{"command":"node"},"remote":{"url":"https://example.com/mcp","protocol":"2026-07-28"}}}\n'; + await writeFile(path, versionTwo, 'utf8'); + + const migrated = await createMcpConfigStore(root).get(); + assert.equal(migrated.version, MCP_CONFIG_VERSION); + assert.equal(resolveMcpProtocolPreference(migrated.mcpServers.local!), 'legacy'); + assert.equal(resolveMcpProtocolPreference(migrated.mcpServers.remote!), '2026-07-28'); + assert.equal(await readFile(path, 'utf8'), versionTwo); +}); + +test('round-trips every version 3 protocol preference for remote and stdio', () => { + for (const protocol of ['legacy', 'auto', '2026-07-28'] as const) { + const normalized = normalizeMcpConfig({ + version: MCP_CONFIG_VERSION, + mcpServers: { + local: { command: 'node', protocol }, + remote: { + url: 'https://example.com/mcp', + transport: 'streamable-http', + protocol, + }, + }, + }); + assert.deepEqual(normalized.mcpServers.remote, { + enabled: true, + url: 'https://example.com/mcp', + transport: 'streamable-http', + protocol, + }); + assert.deepEqual(normalized.mcpServers.local, { + enabled: true, + command: 'node', + protocol, + }); + } + assert.throws( + () => + normalizeMcpConfig({ + version: MCP_CONFIG_VERSION, + mcpServers: { + remote: { url: 'https://example.com/mcp', protocol: 'future' }, + }, + }), + /remote\.protocol is invalid/u, + ); +}); + +test('rejects protocol fields under missing or version 1 wrappers', () => { + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { + remote: { url: 'https://example.com/mcp', protocol: 'legacy' }, + }, + }), + /version 1 must not contain "protocol"/u, + ); + assert.throws( + () => + normalizeMcpConfig({ + mcpServers: { + remote: { url: 'https://example.com/mcp', protocol: undefined }, + }, + }), + /without a version must not contain "protocol"/u, + ); +}); + +test('rejects protocol on version 2 stdio servers', () => { + assert.throws( + () => + normalizeMcpConfig({ + version: 2, + mcpServers: { local: { command: 'node', protocol: 'legacy' } }, + }), + /protocol is not supported for stdio in version 2/u, + ); +}); + +test('allows SSE only with an omitted or explicit legacy protocol', () => { + for (const config of [ + { url: 'https://example.com/sse', transport: 'sse' }, + { url: 'https://example.com/sse', transport: 'sse', protocol: 'legacy' }, + ] as const) { + assert.doesNotThrow(() => normalizeMcpConfig({ version: 2, mcpServers: { remote: config } })); + } + for (const protocol of ['auto', '2026-07-28'] as const) { + assert.throws( + () => + normalizeMcpConfig({ + version: 2, + mcpServers: { + remote: { + url: 'https://example.com/sse', + transport: 'sse', + protocol, + }, + }, + }), + /transport "sse" requires protocol "legacy"/u, + ); + } +}); + +test('transform sees the latest committed config, not a caller snapshot', async () => { + // The restore-plus-mutation seam: a marker-bearing write that derived its + // restores from a stale snapshot could roll a rotated secret back. Inside + // transform, apply() must observe the concurrent writer's commit under the + // shared file transaction. + const root = await tempRoot(); + const store = createMcpConfigStore(root); + await store.upsert('local', { command: 'npx', env: { TOKEN: 'v1' } }); + const rotate = store.upsert('local', { command: 'npx', env: { TOKEN: 'v2-rotated' } }); + const observed: string[] = []; + const restoreLike = store.transform((current) => { + const server = current.mcpServers.local; + if (server && 'command' in server && server.env) observed.push(server.env.TOKEN ?? ''); + return current; + }); + await Promise.all([rotate, restoreLike]); + assert.deepEqual(observed, ['v2-rotated']); + const final = (await store.get()).mcpServers.local; + assert.ok(final && 'command' in final); + assert.equal(final.env?.TOKEN, 'v2-rotated'); +}); + +test('two independent stores preserve concurrent additions to one workspace', async () => { + const root = await tempRoot(); + await createMcpConfigStore(root).get(); + const desktop = createMcpConfigStore(root); + const tui = createMcpConfigStore(root); + + await Promise.all([ + desktop.upsert('desktop', { command: 'desktop-server' }), + tui.upsert('tui', { command: 'tui-server' }), + ]); + + const saved = await createMcpConfigStore(root).get(); + assert.equal( + saved.mcpServers.desktop && 'command' in saved.mcpServers.desktop + ? saved.mcpServers.desktop.command + : undefined, + 'desktop-server', + ); + assert.equal( + saved.mcpServers.tui && 'command' in saved.mcpServers.tui + ? saved.mcpServers.tui.command + : undefined, + 'tui-server', + ); +}); + +test('a new store commits after a killed MCP config writer releases its native lock', async (t) => { + const root = await tempRoot(); + const holder = fork(new URL('./fixtures/mcp-config-lock-holder.js', import.meta.url), [root], { + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + }); + t.after(() => { + if (holder.exitCode === null && holder.signalCode === null) holder.kill('SIGKILL'); + }); + await new Promise((resolve, reject) => { + holder.once('message', (message) => { + if (message === 'locked') resolve(); + else reject(new Error(`Unexpected child message: ${String(message)}`)); + }); + holder.once('error', reject); + holder.once('exit', (code, signal) => { + reject(new Error(`MCP config lock holder exited early (${String(code)}, ${signal})`)); + }); + }); + + holder.kill('SIGKILL'); + await new Promise((resolve) => holder.once('exit', () => resolve())); + + const saved = await createMcpConfigStore(root).upsert('recovered', { + command: 'recovered-server', + }); + const recovered = saved.mcpServers.recovered; + assert.ok(recovered && 'command' in recovered); + assert.equal(recovered.command, 'recovered-server'); + const reopened = (await createMcpConfigStore(root).get()).mcpServers.recovered; + assert.ok(reopened && 'command' in reopened); + assert.equal(reopened.command, 'recovered-server'); +}); + +test('serializes concurrent updates without corrupting the file', async () => { + const root = await tempRoot(); + const store = createMcpConfigStore(root); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + store.upsert(`s-${index}`, { command: `cmd-${index}` }), + ), + ); + const saved = await store.get(); + assert.deepEqual( + Object.fromEntries( + Object.entries(saved.mcpServers).map(([id, server]) => [ + id, + 'command' in server ? server.command : undefined, + ]), + ), + Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`s-${index}`, `cmd-${index}`])), + ); + const text = await readFile(join(root, 'mcp.json'), 'utf8'); + assert.deepEqual(JSON.parse(text), saved); +}); + +test('rejects corrupt files and unsafe or invalid configs', async () => { + const root = await tempRoot(); + await writeFile(join(root, 'mcp.json'), '{bad', 'utf8'); + await assert.rejects(createMcpConfigStore(root).get(), /JSON/u); + assert.throws( + () => + normalizeMcpConfig({ + version: 2, + mcpServers: { constructor: { command: 'x' } }, + }), + /Invalid server id/u, + ); + assert.throws( + () => + normalizeMcpConfig({ + version: 2, + mcpServers: { bad: { url: 'file:///tmp/x' } }, + }), + /http or https/u, + ); + await assert.rejects( + createMcpConfigStore(await tempRoot()).upsert('bad', { + url: 'https://user:secret@example.com/mcp', + }), + /embedded credentials/u, + ); + assert.throws(() => normalizeMcpConfig({ version: 4, mcpServers: {} }), /Unsupported/u); +}); + +test('leaves a higher-version file untouched when it is rejected', async () => { + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + const futureText = '{"version":4,"mcpServers":{}}\n'; + await writeFile(path, futureText, 'utf8'); + + const store = createMcpConfigStore(root); + await assert.rejects(store.get(), /Unsupported MCP config version: 4/u); + await assert.rejects( + store.upsert('local', { command: 'node' }), + /Unsupported MCP config version: 4/u, + ); + assert.equal(await readFile(path, 'utf8'), futureText); +}); + +test('normalizes wrapped imports and direct maps without losing source-version rules', () => { + assert.deepEqual( + normalizeMcpImport( + '{"version":2,"mcpServers":{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}}', + ), + { + version: MCP_CONFIG_VERSION, + mcpServers: { + remote: { + enabled: true, + url: 'https://example.com/mcp', + transport: 'auto', + protocol: 'auto', + }, + }, + }, + ); + assert.deepEqual(normalizeMcpImport('{"version":{"command":"node"}}'), { + version: MCP_CONFIG_VERSION, + mcpServers: { version: { enabled: true, command: 'node' } }, + }); + assert.deepEqual(normalizeMcpImport('{"mcpServers":{"command":"node"}}'), { + version: MCP_CONFIG_VERSION, + mcpServers: { mcpServers: { enabled: true, command: 'node' } }, + }); +}); + +test('import rejects malformed wrappers and protocol fields from older eras', () => { + assert.throws(() => normalizeMcpImport('{bad'), { reason: 'invalid-json' }); + assert.throws(() => normalizeMcpImport('[]'), { reason: 'not-object' }); + assert.throws(() => normalizeMcpImport('{"version":3}'), { reason: 'missing-servers' }); + assert.throws(() => normalizeMcpImport('{"version":4,"mcpServers":{}}'), { + reason: 'unsupported-version', + version: '4', + }); + for (const source of [ + '{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}', + '{"version":1,"mcpServers":{"remote":{"url":"https://example.com/mcp","protocol":"auto"}}}', + '{"version":2,"mcpServers":{"local":{"command":"node","protocol":"auto"}}}', + ]) { + assert.throws(() => normalizeMcpImport(source), { reason: 'protocol-version' }); + } +}); + +test('refuses cleartext http for non-loopback hosts at the write boundary', async () => { + const store = createMcpConfigStore(await tempRoot()); + await assert.rejects( + store.upsert('bad', { url: 'http://example.com/mcp' }), + /https for non-loopback/u, + ); + for (const url of [ + 'http://127.0.0.1:8080/mcp', + 'http://localhost:3000/mcp', + 'https://example.com/mcp', + ]) { + await assert.doesNotReject(store.upsert('ok', { url })); + } +}); + +test('grandfathers a pre-existing cleartext server on read and keeps the file repairable', async () => { + // A single entry every prior release accepted must not brick the whole + // file: the page would come up empty and even the remove that could fix + // it would take the same throwing path. + const root = await tempRoot(); + const path = join(root, 'mcp.json'); + await writeFile( + path, + `${JSON.stringify({ + version: 2, + mcpServers: { + internal: { url: 'http://mcp.internal.corp/mcp', transport: 'auto' }, + good: { command: 'npx' }, + }, + })}\n`, + 'utf8', + ); + const store = createMcpConfigStore(root); + + const loaded = await store.get(); + assert.ok(loaded.mcpServers.internal); + assert.ok(loaded.mcpServers.good); + + // Repair paths stay open: removing either server works, and toggling the + // grandfathered entry (same URL) works. + await assert.doesNotReject(store.remove('good')); + await assert.doesNotReject( + store.upsert('internal', { url: 'http://mcp.internal.corp/mcp', enabled: false }), + ); + // Introducing or repointing a cleartext endpoint still refuses. + await assert.rejects( + store.upsert('internal', { url: 'http://other.internal.corp/mcp' }), + /https for non-loopback/u, + ); + await assert.rejects( + store.upsert('fresh', { url: 'http://example.com/mcp' }), + /https for non-loopback/u, + ); + await assert.doesNotReject(store.remove('internal')); + assert.deepEqual((await store.get()).mcpServers, {}); +}); + +test('normalizes and bounds the remote oauth block', async () => { + const normalized = normalizeMcpConfig({ + version: 1, + mcpServers: { + notion: { + url: 'https://mcp.notion.com/mcp', + oauth: { clientId: 'abc', scopes: ['read', 'write'], callbackPort: 33389 }, + }, + }, + }); + const notion = normalized.mcpServers.notion; + assert.ok(notion && 'url' in notion); + assert.deepEqual(notion.oauth, { + clientId: 'abc', + scopes: ['read', 'write'], + callbackPort: 33389, + }); + + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { bad: { url: 'https://example.com/mcp', oauth: { callbackPort: 0 } } }, + }), + /callbackPort/u, + ); + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { bad: { url: 'https://example.com/mcp', oauth: { clientId: '' } } }, + }), + /clientId/u, + ); + // Scopes join space-delimited on the wire and must be RFC 6749 §3.3 + // scope-tokens: an empty, whitespace-containing, control-carrying, + // quoted/backslashed or non-ASCII entry would silently change the + // requested grant or come back as invalid_scope far from the mistake. + for (const scopes of [ + [''], + ['read write'], + ['read', 'a\tb'], + ['read"admin'], + ['read\\admin'], + ['read\u0001admin'], + ['caf\u00e9'], + ]) { + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { bad: { url: 'https://example.com/mcp', oauth: { clientId: 'x', scopes } } }, + }), + /scope token/u, + ); + } + // A clientSecret alone cannot form static client credentials. + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { bad: { url: 'https://example.com/mcp', oauth: { clientSecret: 's3cr3t' } } }, + }), + /clientId is required/u, + ); + // Scopes join space-delimited on the wire and must be RFC 6749 §3.3 + // scope-tokens: an empty, whitespace-containing, control-carrying, + // quoted/backslashed or non-ASCII entry would silently change the + // requested grant or come back as invalid_scope far from the mistake. + for (const scopes of [ + [''], + ['read write'], + ['read', 'a\tb'], + ['read"admin'], + ['read\\admin'], + ['readadmin'], + ['café'], + ]) { + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { bad: { url: 'https://example.com/mcp', oauth: { clientId: 'x', scopes } } }, + }), + /scope token/u, + ); + } + // stdio servers have no oauth block; unknown fields there stay rejected + // by the stdio branch simply dropping them. + const stdio = normalizeMcpConfig({ + version: 1, + mcpServers: { local: { command: 'npx', oauth: { clientId: 'x' } } }, + }).mcpServers.local; + assert.ok(stdio && !('oauth' in stdio)); +}); + +test('rejects a config that declares both an Authorization header and oauth', () => { + assert.throws( + () => + normalizeMcpConfig({ + version: 1, + mcpServers: { + bad: { + url: 'https://example.com/mcp', + headers: { authorization: 'Bearer x' }, + oauth: { clientId: 'abc' }, + }, + }, + }), + /must not include Authorization when oauth is configured/u, + ); + // Either alone is fine. + assert.doesNotThrow(() => + normalizeMcpConfig({ + version: 1, + mcpServers: { + headerOnly: { url: 'https://example.com/mcp', headers: { Authorization: 'Bearer x' } }, + oauthOnly: { url: 'https://example.com/mcp', oauth: { clientId: 'abc' } }, + }, + }), + ); +}); + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-mcp-store-')); + roots.push(root); + return root; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8d9630d425b7c9af813a75d89ce55ceed202516f51a794cd183ecedbfc47873.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8d9630d425b7c9af813a75d89ce55ceed202516f51a794cd183ecedbfc47873.source new file mode 100644 index 0000000000..2eefcec9f0 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8d9630d425b7c9af813a75d89ce55ceed202516f51a794cd183ecedbfc47873.source @@ -0,0 +1,625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + createEncryptedFileManagedSecretStore, + type EncryptedFileManagedSecretKey, + type EncryptedFileManagedSecretKeyProvider, + MANAGED_SECRET_DOCUMENT_FILE, + MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION, +} from '../encrypted-file-managed-secret-store.js'; +import { + InMemoryManagedSecretStore, + ManagedSecretError, + type ManagedSecretStore, +} from '../managed-secret-store.js'; + +const PRINCIPAL = 'user-1'; +const SOURCE_SESSION = 'cloud-session-source'; +const TARGET_SESSION = 'cloud-session-target'; +const ACTIVATION = 'activation-1'; +const SECRET_ONE = '00000000-0000-4000-8000-000000000001'; +const SECRET_TWO = '00000000-0000-4000-8000-000000000002'; + +interface StoreHarness { + readonly store: ManagedSecretStore; + close(): Promise; +} + +const harnesses: readonly [string, () => Promise][] = [ + [ + 'in-memory', + async () => ({ + store: new InMemoryManagedSecretStore({ newSecretId: sequenceIds() }), + async close() {}, + }), + ], + [ + 'encrypted-file', + async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-secrets-')); + return { + store: createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + newSecretId: sequenceIds(), + }), + close: () => rm(root, { recursive: true, force: true }), + }; + }, + ], +]; + +for (const [name, createHarness] of harnesses) { + describe(`${name} ManagedSecretStore`, () => { + test('requires an explicit target-Session authorization for copied references', async () => { + await withHarness(createHarness, async ({ store }) => { + const secret = await store.createSecret({ principalId: PRINCIPAL, value: 'source-value' }); + + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }), + 'unauthorized', + ); + + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SOURCE_SESSION, + }); + assert.equal( + ( + await store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }) + )[0]?.value, + 'source-value', + ); + + // A Fork may copy this reference, but the target has no implicit grant. + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(TARGET_SESSION), + references: [secret.reference], + }), + 'unauthorized', + ); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: TARGET_SESSION, + }); + assert.equal( + ( + await store.resolveForActivation({ + context: activationContext(TARGET_SESSION), + references: [secret.reference], + }) + )[0]?.value, + 'source-value', + ); + }); + }); + + test('rotates behind a stable reference and rejects a stale CAS basis', async () => { + await withHarness(createHarness, async ({ store }) => { + const created = await store.createSecret({ principalId: PRINCIPAL, value: 'version-one' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: created.reference, + cloudSessionId: SOURCE_SESSION, + }); + + const rotated = await store.rotateSecret({ + principalId: PRINCIPAL, + reference: created.reference, + expectedRevision: created.revision, + value: 'version-two', + }); + assert.equal(rotated.kind, 'committed'); + if (rotated.kind !== 'committed') return; + assert.deepEqual(rotated.secret.reference, created.reference); + assert.equal(rotated.secret.revision, 2); + + assert.deepEqual( + await store.rotateSecret({ + principalId: PRINCIPAL, + reference: created.reference, + expectedRevision: created.revision, + value: 'stale-write', + }), + { kind: 'revision_conflict', actualRevision: 2 }, + ); + const resolved = await store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [created.reference], + }); + assert.equal(resolved[0]?.revision, 2); + assert.equal(resolved[0]?.value, 'version-two'); + }); + }); + + test('revocation and grant removal affect later Activations without changing references', async () => { + await withHarness(createHarness, async ({ store }) => { + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'revocable' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: first.reference, + cloudSessionId: SOURCE_SESSION, + }); + await store.revokeSessionAuthorization({ + principalId: PRINCIPAL, + reference: first.reference, + cloudSessionId: SOURCE_SESSION, + }); + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [first.reference], + }), + 'unauthorized', + ); + + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: first.reference, + cloudSessionId: SOURCE_SESSION, + }); + const revoked = await store.revokeSecret({ + principalId: PRINCIPAL, + reference: first.reference, + expectedRevision: first.revision, + }); + assert.equal(revoked.kind, 'committed'); + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [first.reference], + }), + 'secret_revoked', + ); + }); + }); + + test('deletion removes material and target grants without retaining a tombstone', async () => { + await withHarness(createHarness, async ({ store }) => { + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'delete-me' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: first.reference, + cloudSessionId: SOURCE_SESSION, + }); + const deleted = await store.deleteSecret({ + principalId: PRINCIPAL, + reference: first.reference, + expectedRevision: first.revision, + }); + assert.equal(deleted.kind, 'committed'); + if (deleted.kind === 'committed') assert.equal(deleted.secret.status, 'deleted'); + assert.equal( + await store.getSecretMetadata({ principalId: PRINCIPAL, reference: first.reference }), + null, + ); + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [first.reference], + }), + 'secret_not_found', + ); + }); + }); + + test('does not expose secret material through metadata or unauthorized principals', async () => { + await withHarness(createHarness, async ({ store }) => { + const value = 'metadata-must-not-contain-this'; + const created = await store.createSecret({ principalId: PRINCIPAL, value }); + assert.equal(JSON.stringify(created).includes(value), false); + assert.equal( + JSON.stringify( + await store.getSecretMetadata({ principalId: PRINCIPAL, reference: created.reference }), + ).includes(value), + false, + ); + await assertRejectCode( + store.getSecretMetadata({ + principalId: 'another-user', + reference: created.reference, + }), + 'unauthorized', + ); + }); + }); + }); +} + +describe('encrypted file ManagedSecretStore security boundary', () => { + test('keeps mutation timestamps monotonic when the wall clock moves backwards', async () => { + await withTempRoot(async (root) => { + let now = 1_000; + const ids = [generatedSecretId(1), generatedSecretId(2), generatedSecretId(3)]; + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + now: () => now, + newSecretId: () => ids.shift()!, + }); + const rotating = await store.createSecret({ principalId: PRINCIPAL, value: 'rotate' }); + const revoking = await store.createSecret({ principalId: PRINCIPAL, value: 'revoke' }); + const deleting = await store.createSecret({ principalId: PRINCIPAL, value: 'delete' }); + + now = 900; + const rotated = await store.rotateSecret({ + principalId: PRINCIPAL, + reference: rotating.reference, + expectedRevision: rotating.revision, + value: 'rotated', + }); + const revoked = await store.revokeSecret({ + principalId: PRINCIPAL, + reference: revoking.reference, + expectedRevision: revoking.revision, + }); + const deleted = await store.deleteSecret({ + principalId: PRINCIPAL, + reference: deleting.reference, + expectedRevision: deleting.revision, + }); + for (const result of [rotated, revoked, deleted]) { + assert.equal(result.kind, 'committed'); + if (result.kind === 'committed') assert.equal(result.secret.updatedAt, 1_000); + } + + const reopened = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + }); + assert.equal( + ( + await reopened.getSecretMetadata({ + principalId: PRINCIPAL, + reference: rotating.reference, + }) + )?.updatedAt, + 1_000, + ); + assert.equal( + ( + await reopened.getSecretMetadata({ + principalId: PRINCIPAL, + reference: revoking.reference, + }) + )?.updatedAt, + 1_000, + ); + assert.equal( + await reopened.getSecretMetadata({ + principalId: PRINCIPAL, + reference: deleting.reference, + }), + null, + ); + }); + }); + + test('physically removes deleted records and grants so capacity is reusable', async () => { + await withTempRoot(async (root) => { + const ids = [SECRET_ONE, SECRET_TWO]; + const path = join(root, MANAGED_SECRET_DOCUMENT_FILE); + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + newSecretId: () => ids.shift()!, + }); + + const first = await store.createSecret({ principalId: PRINCIPAL, value: 'first' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: first.reference, + cloudSessionId: SOURCE_SESSION, + }); + await store.deleteSecret({ + principalId: PRINCIPAL, + reference: first.reference, + expectedRevision: first.revision, + }); + const second = await store.createSecret({ principalId: PRINCIPAL, value: 'second' }); + const document = JSON.parse(await readFile(path, 'utf8')) as { + secrets: Array<{ secretId: string }>; + grants: Array<{ secretId: string }>; + }; + assert.deepEqual( + document.secrets.map((record) => record.secretId), + [second.reference.secretId], + ); + assert.deepEqual(document.grants, []); + }); + }); + + test('persists only authenticated ciphertext in a private control-plane root', async () => { + await withTempRoot(async (root) => { + const value = 'never-write-this-in-plaintext'; + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + newSecretId: () => SECRET_ONE, + }); + await store.createSecret({ principalId: PRINCIPAL, value }); + + const path = join(root, MANAGED_SECRET_DOCUMENT_FILE); + const raw = await readFile(path, 'utf8'); + assert.equal(raw.includes(value), false); + assert.match(raw, /"algorithm": "A256GCM"/u); + if (process.platform !== 'win32') { + assert.equal((await stat(root)).mode & 0o777, 0o700); + assert.equal((await stat(path)).mode & 0o777, 0o600); + } + }); + }); + + test('reopens with the external key and fails closed when the key is unavailable', async () => { + await withTempRoot(async (root) => { + const provider = new TestKeyProvider(); + const writer = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + newSecretId: () => SECRET_ONE, + }); + const secret = await writer.createSecret({ principalId: PRINCIPAL, value: 'persisted' }); + await writer.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SOURCE_SESSION, + }); + + const reader = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + }); + assert.equal( + ( + await reader.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }) + )[0]?.value, + 'persisted', + ); + + provider.keys.clear(); + await assertRejectCode( + reader.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }), + 'key_unavailable', + ); + }); + }); + + test('uses the provider active key on rotation without changing the portable reference', async () => { + await withTempRoot(async (root) => { + const provider = new TestKeyProvider(); + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + newSecretId: () => SECRET_ONE, + }); + const secret = await store.createSecret({ principalId: PRINCIPAL, value: 'before' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SOURCE_SESSION, + }); + + provider.keys.set('test-key-2', Buffer.alloc(32, 9)); + provider.activeKeyId = 'test-key-2'; + const rotated = await store.rotateSecret({ + principalId: PRINCIPAL, + reference: secret.reference, + expectedRevision: secret.revision, + value: 'after', + }); + assert.equal(rotated.kind, 'committed'); + if (rotated.kind !== 'committed') return; + assert.deepEqual(rotated.secret.reference, secret.reference); + + provider.keys.delete('test-key-1'); + assert.equal( + ( + await store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }) + )[0]?.value, + 'after', + ); + const raw = await readFile(join(root, MANAGED_SECRET_DOCUMENT_FILE), 'utf8'); + assert.match(raw, /"keyId": "test-key-2"/u); + assert.doesNotMatch(raw, /"keyId": "test-key-1"/u); + }); + }); + + test('binds ciphertext to identity, owner, and revision with AEAD authentication', async () => { + await withTempRoot(async (root) => { + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + newSecretId: () => SECRET_ONE, + }); + const secret = await store.createSecret({ principalId: PRINCIPAL, value: 'authenticated' }); + await store.authorizeSession({ + principalId: PRINCIPAL, + reference: secret.reference, + cloudSessionId: SOURCE_SESSION, + }); + const path = join(root, MANAGED_SECRET_DOCUMENT_FILE); + const document = JSON.parse(await readFile(path, 'utf8')) as { + secrets: Array<{ envelope: { ciphertext: string } }>; + }; + const ciphertext = document.secrets[0]!.envelope.ciphertext; + document.secrets[0]!.envelope.ciphertext = + `${ciphertext.startsWith('A') ? 'B' : 'A'}${ciphertext.slice(1)}`; + await writeFile(path, `${JSON.stringify(document)}\n`, 'utf8'); + + await assertRejectCode( + store.resolveForActivation({ + context: activationContext(SOURCE_SESSION), + references: [secret.reference], + }), + 'integrity_failure', + ); + }); + }); + + test('serializes two store owners so concurrent creates do not lose records', async () => { + await withTempRoot(async (root) => { + const provider = new TestKeyProvider(); + const first = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + newSecretId: () => SECRET_ONE, + }); + const second = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + newSecretId: () => SECRET_TWO, + }); + const [one, two] = await Promise.all([ + first.createSecret({ principalId: PRINCIPAL, value: 'one' }), + second.createSecret({ principalId: PRINCIPAL, value: 'two' }), + ]); + const reader = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: provider, + }); + assert.equal( + (await reader.getSecretMetadata({ principalId: PRINCIPAL, reference: one.reference })) + ?.revision, + 1, + ); + assert.equal( + (await reader.getSecretMetadata({ principalId: PRINCIPAL, reference: two.reference })) + ?.revision, + 1, + ); + }); + }); + + test('fails closed on an unknown document schema', async () => { + await withTempRoot(async (root) => { + await writeFile( + join(root, MANAGED_SECRET_DOCUMENT_FILE), + JSON.stringify({ + schemaVersion: MANAGED_SECRET_DOCUMENT_SCHEMA_VERSION + 1, + revision: 0, + secrets: [], + grants: [], + }), + 'utf8', + ); + const store = createEncryptedFileManagedSecretStore({ + controlPlaneRoot: root, + keyProvider: new TestKeyProvider(), + }); + await assertRejectCode( + store.getSecretMetadata({ + principalId: PRINCIPAL, + reference: { + schemaVersion: 1, + secretId: SECRET_ONE, + }, + }), + 'integrity_failure', + ); + }); + }); +}); + +class TestKeyProvider implements EncryptedFileManagedSecretKeyProvider { + activeKeyId = 'test-key-1'; + readonly keys = new Map([['test-key-1', Buffer.alloc(32, 7)]]); + + async activeKey(): Promise { + const key = this.keys.get(this.activeKeyId); + if (!key) throw new Error('missing test key'); + return { keyId: this.activeKeyId, key }; + } + + async keyById(keyId: string): Promise { + const key = this.keys.get(keyId); + return key ? { keyId, key } : null; + } +} + +function sequenceIds(): () => string { + const ids = [SECRET_ONE, SECRET_TWO]; + return () => { + const value = ids.shift(); + if (!value) throw new Error('test secret ids exhausted'); + return value; + }; +} + +function generatedSecretId(index: number): string { + return `10000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; +} + +function activationContext(cloudSessionId: string) { + return { principalId: PRINCIPAL, cloudSessionId, activationId: ACTIVATION }; +} + +async function assertRejectCode( + promise: Promise, + code: ManagedSecretError['code'], +): Promise { + await assert.rejects( + promise, + (error: unknown) => error instanceof ManagedSecretError && error.code === code, + ); +} + +async function withHarness( + createHarness: () => Promise, + operation: (harness: StoreHarness) => Promise, +): Promise { + const harness = await createHarness(); + try { + await operation(harness); + } finally { + await harness.close(); + } +} + +async function withTempRoot(operation: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-secrets-')); + try { + await operation(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8ee4e06d9abc27d152a208e25298f8b9330d99a5b674993c44dd774841e6aba.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8ee4e06d9abc27d152a208e25298f8b9330d99a5b674993c44dd774841e6aba.source new file mode 100644 index 0000000000..5d77c285b8 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f8ee4e06d9abc27d152a208e25298f8b9330d99a5b674993c44dd774841e6aba.source @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { AgentGraphEpochConflictError } from '@maka/core/agent-graph-epoch'; +import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; + +describe('SQLite Agent Graph epochs', () => { + test('resolves legacy epoch 1 without persisting state for an unused graph', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-agent-graph-epoch-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path, { now: () => 100 }); + assert.deepEqual( + await store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_legacy', + }), + { + schemaVersion: 1, + rootSessionId: 'root-1', + epoch: 1, + graphId: 'agent_graph_legacy', + createdAt: 0, + }, + ); + assert.deepEqual(await store.listAgentGraphEpochs('root-1'), []); + store.close(); + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 200 }); + assert.deepEqual( + await reopened.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_legacy', + }), + { + schemaVersion: 1, + rootSessionId: 'root-1', + epoch: 1, + graphId: 'agent_graph_legacy', + createdAt: 0, + }, + ); + reopened.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('advances with compare-and-swap semantics and makes retries idempotent', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(100) }); + await store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_1', + }); + const request = { + rootSessionId: 'root-1', + expectedEpoch: 1, + expectedGraphId: 'agent_graph_1', + nextGraphId: 'agent_graph_2', + } as const; + + assert.equal((await store.advanceAgentGraphEpoch(request)).graphId, 'agent_graph_2'); + assert.equal((await store.advanceAgentGraphEpoch(request)).graphId, 'agent_graph_2'); + assert.deepEqual( + (await store.listAgentGraphEpochs('root-1')).map(({ epoch, graphId }) => ({ + epoch, + graphId, + })), + [ + { epoch: 1, graphId: 'agent_graph_1' }, + { epoch: 2, graphId: 'agent_graph_2' }, + ], + ); + assert.equal((await store.readAgentGraphEpochByGraphId('agent_graph_1'))?.epoch, 1); + assert.deepEqual(await store.listAgentGraphEpochPage({ rootSessionId: 'root-1', limit: 1 }), { + epochs: [ + { + schemaVersion: 1, + rootSessionId: 'root-1', + epoch: 2, + graphId: 'agent_graph_2', + createdAt: 100, + }, + ], + nextBeforeEpoch: 2, + currentEpoch: 2, + }); + assert.deepEqual( + await store.listAgentGraphEpochPage({ + rootSessionId: 'root-1', + beforeEpoch: 2, + limit: 1, + }), + { + epochs: [ + { + schemaVersion: 1, + rootSessionId: 'root-1', + epoch: 1, + graphId: 'agent_graph_1', + createdAt: 0, + }, + ], + nextBeforeEpoch: null, + currentEpoch: 2, + }, + ); + // A root Session without durable rows reports no current epoch, letting the + // caller fall back to the legacy virtual identity. + assert.deepEqual(await store.listAgentGraphEpochPage({ rootSessionId: 'root-2', limit: 1 }), { + epochs: [], + nextBeforeEpoch: null, + currentEpoch: null, + }); + store.close(); + }); + + test('rejects stale writers and graph identities already bound elsewhere', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + await store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_1', + }); + await store.advanceAgentGraphEpoch({ + rootSessionId: 'root-1', + expectedEpoch: 1, + expectedGraphId: 'agent_graph_1', + nextGraphId: 'agent_graph_2', + }); + + await assert.rejects( + () => + store.advanceAgentGraphEpoch({ + rootSessionId: 'root-1', + expectedEpoch: 1, + expectedGraphId: 'agent_graph_1', + nextGraphId: 'agent_graph_competing', + }), + AgentGraphEpochConflictError, + ); + await assert.rejects( + () => + store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-2', + legacyGraphId: 'agent_graph_2', + }), + AgentGraphEpochConflictError, + ); + store.close(); + }); + + test('rejects a drifted legacy identity after adoption', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + await store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_1', + }); + await store.advanceAgentGraphEpoch({ + rootSessionId: 'root-1', + expectedEpoch: 1, + expectedGraphId: 'agent_graph_1', + nextGraphId: 'agent_graph_2', + }); + await assert.rejects( + () => + store.resolveCurrentAgentGraphEpoch({ + rootSessionId: 'root-1', + legacyGraphId: 'agent_graph_other', + }), + AgentGraphEpochConflictError, + ); + store.close(); + }); + + test('retains the epoch directory until root-scoped retirement cleanup commits', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + await store.advanceAgentGraphEpoch({ + rootSessionId: 'root-1', + expectedEpoch: 1, + expectedGraphId: 'agent_graph_1', + nextGraphId: 'agent_graph_2', + }); + + await store.purgeAgentGraphControlState('agent_graph_1'); + assert.deepEqual( + (await store.listAgentGraphEpochs('root-1')).map(({ graphId }) => graphId), + ['agent_graph_1', 'agent_graph_2'], + ); + assert.equal(await store.purgeAgentGraphEpochs('root-1'), 2); + assert.deepEqual(await store.listAgentGraphEpochs('root-1'), []); + assert.equal(await store.purgeAgentGraphEpochs('root-1'), 0); + store.close(); + }); +}); + +function nextNow(start: number): () => number { + let value = start; + return () => value++; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f9218d4ded1d2ba6408997d6b7dccdc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f9218d4ded1d2ba6408997d6b7dccdc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source new file mode 100644 index 0000000000..5458f391ed --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/f9218d4ded1d2ba6408997d6b7dccdc0a5acb50fc0d7c0ffcb7d2b31cbc53d6d.source @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +declare module 'fs-native-extensions' { + export interface LockOptions { + shared?: boolean; + } + + export function tryLock(fd: number, options?: LockOptions): boolean; + export function waitForLock(fd: number, options?: LockOptions): Promise; + export function unlock(fd: number): void; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fa59f44a6f83dc7d6f67b36d9f49c54e07940b32dd19cce4bd4d91e93e910bdb.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fa59f44a6f83dc7d6f67b36d9f49c54e07940b32dd19cce4bd4d91e93e910bdb.source new file mode 100644 index 0000000000..292090846a --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fa59f44a6f83dc7d6f67b36d9f49c54e07940b32dd19cce4bd4d91e93e910bdb.source @@ -0,0 +1,4974 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; +import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; +import { isDeepStrictEqual } from 'node:util'; +import { assertHandoffClaimSource } from '@maka/core/runtime-handoff'; +import { + buildWorkspaceBaselineAuthorityEvents, + buildWorkspaceSuccessorAuthorityEvent, + scanWorkspaceBaselineAuthority, + WORKSPACE_AUTHORITY_SESSION_ID, + WORKSPACE_VERSION_AUTHORITY_CAPABILITY_V1, + type ScannedWorkspaceBaselineAuthority, + type ScannedWorkspaceSuccessorAuthority, + type WorkspaceAuthorityLedgerRow, + type WorkspaceBaselineAuthorityInput, + type WorkspaceBaselineCommitResult, + type WorkspaceEpochRecordV1, + type WorkspaceHeadRecordV1, + type WorkspaceProjectionRebuildResult, + type WorkspaceSuccessorAuthorityInput, + type WorkspaceVersionAcceptedV1, + type WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + decodeRuntimeEvent, + decodeRuntimeInvocationOpened, + isPartialRuntimeEvent, + isTerminalRuntimeEvent, + runtimeEventInvocationOpening, + TOOL_BOUNDARY_PROTOCOL_V1, + type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutationV2, + type ToolRecoveryMode, +} from '@maka/core/runtime-event'; +import { + RunSealedError, + RUNTIME_CONTINUATION_AUTHORITY_V1, + TOOL_RECOVERY_BUNDLE_CAPABILITY_V1, + type ContinuationClaimResult, + type ContinuationClaimStateV1, + type RuntimeContinuationAuthorityStore, + type RuntimeRecoveryBundleCommit, + type RuntimeRecoveryBundleStore, + type RuntimeWorkspaceVersionAuthorityStore, +} from '@maka/core/runtime-event-store'; +import type { + RuntimeInvocationPageCursor, + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; +import { type ToolRecoveryDecisionFact } from '@maka/core/tool-recovery-fact'; +import { canonicalToolArgsHash, stableJsonStringify } from '@maka/core/tool-args-identity'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { + scanToolLedger, + ToolLedgerCorruptionError, + ToolLedgerRejectionError, + validateGenericToolLedgerAppend, + validateToolLedgerEventLane, + validateToolLedgerTransition, +} from '@maka/core/tool-ledger-scanner'; +import { + buildImmutableRuntimePrefix, + continuationStartEventMatchesClaim, + decodeContinuationClaim, + type ContinuationClaimV1, + type ImmutableRuntimePrefixV1, + type RuntimeBoundaryDigest, +} from '@maka/core/runtime-boundary'; +import { + assertToolRecoveryEventBundle, + interpretScannedToolRecovery, +} from '@maka/core/tool-recovery-bundle'; +import { + configureSqliteRuntimeDatabase, + migrateSqliteRuntimeDatabase, + readUserVersion, + RUNTIME_RECOVERY_AUTHORITY_CAPABILITY, + RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION, + RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY, + RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY_VERSION, + RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY, + RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION, + runtimeEventKind, + SQLITE_RUNTIME_SCHEMA_VERSION, +} from './sqlite-runtime-schema.js'; +import { + registerWorkspaceBaselineAuthorityWriterInternal, + type ManagedMutationNoEffectClaimV1, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, + type WorkspaceSuccessorCommitInput, + type WorkspaceSuccessorCommitResult, +} from './workspace-version-authority-internal.js'; +import type { + ConversationCopyRuntimeEventBatch, + ImmutableSteeringMessageProof, + RuntimeEventScanBudget, + RuntimeEventScanResult, +} from './agent-run-store.js'; +import { + assertEvidenceReadBudget, + measureEvidenceRows, + type BoundedEvidenceReadResult, + type EvidenceReadBudget, +} from './bounded-evidence.js'; +import type { OperationalStateDatabaseLease } from './operational-state-store.js'; +import { immutableSteeringMessageId, isRuntimeStorageSafeId } from './runtime-event-invariants.js'; +import { assertNoReservedWorkspaceAuthorityAppend } from './runtime-event-authority.js'; +import { + RuntimeTranscriptQuery, + TERMINAL_RUNTIME_EVENT_SQL, + type RuntimeTranscriptInvocation, + type RuntimeTranscriptInvocationRequest, + type RuntimeTranscriptLandmark, +} from './runtime-transcript-query.js'; + +export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; + +export type { ToolRecoveryMode } from '@maka/core/runtime-event'; + +const RUNTIME_EVENT_SCAN_BATCH_SIZE = 128; +const RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES = 64 * 1024; + +function assertRuntimeEventScanBudget(budget: RuntimeEventScanBudget): void { + for (const [name, value] of Object.entries(budget)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid RuntimeEvent scan ${name}`); + } + } +} + +function requireRuntimeEventScanCount(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error('Invalid RuntimeEvent scan measurement'); + } + return value as number; +} + +const require = createRequire(import.meta.url); + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; +} + +function configureSqliteRuntimeReadOnlyDatabase(db: DatabaseSync): void { + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA query_only = ON'); +} + +export type ToolJournalState = + | 'prepared' + | 'reconcile_observed' + | 'outcome_committed' + | 'recovery_completed' + | 'recovery_parked'; + +export type SqliteRuntimeStoreFailpoint = + | 'after_runtime_event_insert' + | 'after_journal_event_insert' + | 'after_recovery_reconcile' + | 'after_recovery_outcome' + | 'after_recovery_decision' + | 'after_continuation_claim_insert' + | 'after_continuation_start_insert' + | 'after_workspace_epoch_event_insert' + | 'after_workspace_version_event_insert' + | 'after_workspace_epoch_projection_insert' + | 'after_workspace_version_projection_insert' + | 'after_workspace_head_projection_insert' + | 'after_workspace_successor_event_insert' + | 'after_workspace_successor_projection_insert' + | 'after_workspace_successor_head_update' + | 'after_workspace_canonical_scan'; + +export interface SqliteRuntimeStoreOptions { + failpoint?: (point: SqliteRuntimeStoreFailpoint) => void; + readOnly?: boolean; + /** @internal Repository connection supplied by the operational DB owner. */ + databaseLease?: OperationalStateDatabaseLease; +} + +export interface CommitToolPreparedInput { + operationId: string; + journalEventId: string; + runtimeEvent: RuntimeEvent; + dispatchRuntimeEvent: RuntimeEvent; + providerToolCallId: string; + toolName: string; + canonicalArgsHash: string; + recoveryMode: ToolRecoveryMode; + committedAt: number; +} + +export interface CommitToolOutcomeInput { + operationId: string; + journalEventId: string; + runtimeEvent: RuntimeEvent; + committedAt: number; +} + +export interface ToolCommitResult { + created: boolean; + runtimeEventSeq: number; +} + +export interface RuntimeEventBatchImportResult { + created: boolean[]; +} + +/** Storage-owned, immutable append position for an Event within one Session. */ +export interface SessionRuntimeEventEntry { + readonly ordinal: number; + readonly event: RuntimeEvent; +} + +export interface ToolProjectionRebuildResult { + operations: number; + journalEvents: number; +} + +export interface ToolOperationRecord { + operationId: string; + invocationId: string; + runId: string; + turnId: string; + providerToolCallId: string; + toolName: string; + canonicalArgsHash: string; + recoveryMode: ToolRecoveryMode; + currentState: 'prepared' | 'outcome_committed' | 'recovery_completed' | 'recovery_parked'; + callEventId: string; + dispatchEventId?: string; + resultEventId?: string; + version: number; +} + +export interface ToolJournalEventRecord { + journalEventId: string; + operationId: string; + invocationId: string; + runId: string; + turnId: string; + state: ToolJournalState; + runtimeEventId?: string; + canonicalArgsHash?: string; + recoveryMode?: ToolRecoveryMode; + externalHandle?: string; + metadata?: unknown; + committedAt: number; +} + +export function createSqliteRuntimeStore( + path: string, + options: SqliteRuntimeStoreOptions = {}, +): SqliteRuntimeStore { + return new SqliteRuntimeStore(path, options); +} + +export class SqliteRuntimeStore + implements + RuntimeRecoveryBundleStore, + RuntimeContinuationAuthorityStore, + RuntimeWorkspaceVersionAuthorityStore +{ + readonly durability = 'canonical' as const; + readonly toolBoundaryProtocol = 't1_after_preflight_v1' as const; + readonly recoveryBundleCapability = TOOL_RECOVERY_BUNDLE_CAPABILITY_V1; + readonly continuationAuthorityCapability = RUNTIME_CONTINUATION_AUTHORITY_V1; + readonly workspaceVersionAuthorityCapability = WORKSPACE_VERSION_AUTHORITY_CAPABILITY_V1; + private readonly db: DatabaseSync; + private readonly databaseLease?: OperationalStateDatabaseLease; + private toolLedgerHealth: ToolLedgerHealth | undefined; + private closed = false; + + constructor( + path: string, + private readonly options: SqliteRuntimeStoreOptions = {}, + ) { + if (options.readOnly && options.databaseLease) { + throw new Error('Operational state database leases cannot be opened read-only'); + } + if (path !== ':memory:' && !options.readOnly) mkdirSync(dirname(path), { recursive: true }); + if (options.databaseLease) { + this.databaseLease = options.databaseLease; + this.db = options.databaseLease.database; + assertRecoveryAuthorityCapability(this.db); + assertContinuationAuthorityCapability(this.db); + assertWorkspaceVersionAuthorityCapability(this.db); + if (!options.readOnly) { + this.registerWorkspaceBaselineAuthorityWriter(); + this.refreshToolLedgerHealth(); + } + return; + } + const DatabaseSync = loadDatabaseSync(); + this.db = options.readOnly + ? new DatabaseSync(path, { readOnly: true }) + : new DatabaseSync(path); + try { + if (options.readOnly) { + configureSqliteRuntimeReadOnlyDatabase(this.db); + const version = readUserVersion(this.db); + if (version !== SQLITE_RUNTIME_SCHEMA_VERSION) { + throw new Error( + `SQLite runtime schema ${version} cannot be read without upgrading to ${SQLITE_RUNTIME_SCHEMA_VERSION}`, + ); + } + } else { + configureSqliteRuntimeDatabase(this.db); + migrateSqliteRuntimeDatabase(this.db); + } + assertRecoveryAuthorityCapability(this.db); + assertContinuationAuthorityCapability(this.db); + assertWorkspaceVersionAuthorityCapability(this.db); + if (!options.readOnly) { + this.registerWorkspaceBaselineAuthorityWriter(); + this.refreshToolLedgerHealth(); + } + } catch (error) { + this.db.close(); + this.closed = true; + throw error; + } + } + + schemaVersion(): number { + return readUserVersion(this.db); + } + + journalMode(): string { + const row = this.db.prepare('PRAGMA journal_mode').get() as + | { journal_mode?: unknown } + | undefined; + return typeof row?.journal_mode === 'string' ? row.journal_mode.toLowerCase() : ''; + } + + foreignKeysEnabled(): boolean { + const row = this.db.prepare('PRAGMA foreign_keys').get() as + | { foreign_keys?: unknown } + | undefined; + return row?.foreign_keys === 1; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.databaseLease) this.databaseLease.close(); + else this.db.close(); + } + + async appendRuntimeEvent( + sessionId: string, + runId: string, + event: RuntimeEvent, + _options: { durable?: boolean } = {}, + ): Promise { + const canonicalEvent = canonicalizeRuntimeEventForStorage(event); + assertNoReservedToolLedgerFact(canonicalEvent); + await this.importRuntimeEvent(sessionId, runId, canonicalEvent); + } + + async appendRuntimePartialBatch( + sessionId: string, + runId: string, + events: readonly RuntimeEvent[], + ): Promise { + if (events.length === 0) return; + const canonicalEvents = events.map(canonicalizeRuntimeEventForStorage); + for (const event of canonicalEvents) { + assertNoReservedToolLedgerFact(event); + if (sessionId !== event.sessionId || runId !== event.runId) { + throw new Error(`RuntimeEvent store identity does not match event ${event.id}`); + } + } + this.transaction(() => this.importRuntimePartialBatchSync(canonicalEvents)); + } + + async ensureTerminalRuntimeEventDurable( + sessionId: string, + runId: string, + event: RuntimeEvent, + ): Promise { + const canonicalEvent = canonicalizeRuntimeEventForStorage(event); + assertNoReservedToolLedgerFact(canonicalEvent); + if (isPartialRuntimeEvent(canonicalEvent) || !isTerminalRuntimeEvent(canonicalEvent)) { + throw new Error( + 'Only a final terminal RuntimeEvent can cross the terminal durability barrier', + ); + } + const existing = await this.readImmutableRuntimeEvents(sessionId, runId); + const matching = existing.filter((candidate) => candidate.id === canonicalEvent.id); + if (matching.length > 1) { + throw new Error(`RuntimeEvent ${canonicalEvent.id} appears more than once in run ${runId}`); + } + if (matching.length === 1) { + if (!isDeepStrictEqual(matching[0], canonicalEvent)) { + throw new Error( + `RuntimeEvent ${canonicalEvent.id} does not match the durable ledger record`, + ); + } + const terminalEvents = existing.filter(isTerminalRuntimeEvent); + if ( + terminalEvents.length !== 1 || + terminalEvents[0]?.id !== canonicalEvent.id || + existing.at(-1)?.id !== canonicalEvent.id + ) { + throw new Error('Terminal RuntimeEvent must be the immutable ledger tail'); + } + return; + } + const existingTerminal = existing.find(isTerminalRuntimeEvent); + if (existingTerminal) { + throw new Error(`Run ${runId} already has terminal RuntimeEvent ${existingTerminal.id}`); + } + await this.importRuntimeEvent(sessionId, runId, canonicalEvent); + } + + async importRuntimeEvent( + sessionId: string, + runId: string, + event: RuntimeEvent, + ): Promise { + const canonicalEvent = canonicalizeRuntimeEventForStorage(event); + assertNoReservedToolLedgerFact(canonicalEvent); + if (sessionId !== canonicalEvent.sessionId || runId !== canonicalEvent.runId) { + throw new Error(`RuntimeEvent store identity does not match event ${canonicalEvent.id}`); + } + return this.transaction(() => this.importRuntimeEventSync(canonicalEvent)); + } + + async importRuntimeEventsBatch(input: { + sessionId: string; + runId: string; + events: readonly RuntimeEvent[]; + }): Promise { + const events = input.events.map(canonicalizeRuntimeEventForStorage); + for (const event of events) { + assertNoReservedToolLedgerFact(event); + if (event.sessionId !== input.sessionId || event.runId !== input.runId) { + throw new Error(`RuntimeEvent store identity does not match event ${event.id}`); + } + } + return this.transaction(() => { + if (events.some(isToolLedgerBearingEvent)) { + this.assertToolLedgerTransition(events, 'generic_append'); + } + const created = events.map((event) => this.importRuntimeEventSync(event)); + return { created }; + }); + } + + async importConversationCopyRuntimeEvents( + sessionId: string, + batches: readonly ConversationCopyRuntimeEventBatch[], + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + const canonicalBatches = batches.map(({ runId, events }) => { + assertRuntimeStorageSafeId(runId, 'Invalid run id'); + return { + runId, + events: events.map(canonicalizeRuntimeEventForStorage), + }; + }); + const canonicalEvents = canonicalBatches.flatMap(({ events }) => events); + if (new Set(canonicalEvents.map(({ id }) => id)).size !== canonicalEvents.length) { + throw new Error('Conversation copy contains duplicate RuntimeEvents'); + } + for (const { runId, events } of canonicalBatches) { + for (const event of events) { + assertNoReservedWorkspaceAuthorityAppend(event); + if (isPartialRuntimeEvent(event)) { + throw new Error('Conversation copy cannot import partial RuntimeEvents'); + } + if (event.sessionId !== sessionId || event.runId !== runId) { + throw new Error(`RuntimeEvent store identity does not match event ${event.id}`); + } + } + } + const scan = scanToolLedger(canonicalEvents); + if (scan.hasCorruption) { + throw new Error( + `Conversation copy RuntimeEvent ledger is corrupt: ${scan.issues[0]?.code ?? 'unknown'}`, + ); + } + this.transaction(() => { + const eventsByRun = new Map(); + for (const { runId, events } of canonicalBatches) { + eventsByRun.set(runId, [...(eventsByRun.get(runId) ?? []), ...events]); + } + const newRunIds = new Set(); + for (const [runId, events] of eventsByRun) { + const existing = ( + this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + ORDER BY event_seq ASC, event_id ASC + `) + .all(sessionId, runId) as unknown as RuntimeEventStorageRow[] + ).map(decodeRuntimeEventStorageRow); + if (existing.length > 0 && !isDeepStrictEqual(existing, events)) { + throw new Error(`Conversation copy RuntimeEvent identity conflict for run ${runId}`); + } + if (existing.length === 0) newRunIds.add(runId); + } + for (const { runId, events } of canonicalBatches) { + if (!newRunIds.has(runId)) continue; + for (const event of events) this.insertRuntimeEvent(event, event.ts, true); + } + if (canonicalEvents.some(isToolLedgerBearingEvent)) { + this.rebuildToolProjectionsFromRuntimeEventsSync(sessionId); + } + }); + } + + async readRuntimeEvents(sessionId: string, runId: string): Promise { + return this.readRuntimeEventsSync(sessionId, runId); + } + + private transcriptQuery(): RuntimeTranscriptQuery { + return new RuntimeTranscriptQuery(this.db, (sessionId, invocationId) => { + // By invocation rather than by run: both shelves key their opening on it, + // so a page's records cost the page instead of the Session's Turns. + const opening = this.readInvocationOpeningsSync(sessionId, { + direction: 'asc', + invocationId, + }).at(0); + if (!opening) throw new Error(`Transcript invocation ${invocationId} is missing`); + return this.completeInvocationRecordSync(opening); + }); + } + + async readTranscriptHighWater(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => this.transcriptQuery().highWater(sessionId)); + } + + async readTranscriptInvocations( + sessionId: string, + request: RuntimeTranscriptInvocationRequest, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(request.limit); + return this.readTransaction(() => this.transcriptQuery().invocations(sessionId, request)); + } + + async readTranscriptLandmarks( + sessionId: string, + throughOrdinal: number, + limit: number, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(limit); + return this.readTransaction(() => + this.transcriptQuery().landmarks(sessionId, throughOrdinal, limit), + ); + } + + /** + * Enumerate a Session's invocations: the opening fact names each one, and its + * highest-sequence event says whether it ended. + * + * Invocations that predate the opening fact could not be given one without + * rewriting an immutable sequence, so the migration parked their openings in + * `runtime_legacy_invocation_openings`. Both shelves are merged here and the + * result says nothing about which one a record came from: an opening is an + * opening, and a consumer that branched on its storage would be encoding the + * migration window into its own logic. + */ + async listSessionInvocations(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => + this.readInvocationOpeningsSync(sessionId, { direction: 'asc' }).map((row) => + this.completeInvocationRecordSync(row), + ), + ); + } + + async readRunInvocation( + sessionId: string, + runId: string, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertRuntimeStorageSafeId(runId, 'Invalid run id'); + return this.readTransaction(() => { + const row = this.readInvocationOpeningsSync(sessionId, { direction: 'asc', runId }).at(0); + return row ? this.completeInvocationRecordSync(row) : undefined; + }); + } + + /** + * The first page of a Session's invocations, plus whether more exist. + * + * The extra row this reads past the limit is the whole truncation signal, so a + * caller never has to count a Session it declined to load. + */ + async listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(limit); + return this.readTransaction(() => { + const rows = this.readInvocationOpeningsSync(sessionId, { + direction: 'asc', + limit: limit + 1, + }); + return { + invocations: rows.slice(0, limit).map((row) => this.completeInvocationRecordSync(row)), + truncated: rows.length > limit, + }; + }); + } + + /** One newest-first page of a Session's invocations. */ + async listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(input.limit); + if (input.before) { + assertRuntimeStorageSafeId(input.before.invocationId, 'Invalid invocation page cursor'); + if (!Number.isFinite(input.before.openedAt)) { + throw new Error('Invalid invocation page cursor'); + } + } + return this.readTransaction(() => { + const rows = this.readInvocationOpeningsSync(sessionId, { + direction: 'desc', + limit: input.limit + 1, + ...(input.before ? { before: input.before } : {}), + }); + const page = rows.slice(0, input.limit); + const last = page.at(-1); + return { + invocations: page.map((row) => this.completeInvocationRecordSync(row)), + nextCursor: + rows.length > input.limit && last + ? { openedAt: last.openedAt, invocationId: last.invocationId } + : null, + }; + }); + } + + /** + * One invocation named by its own identity. + * + * Absence throws rather than returning `undefined`: every caller here holds an + * invocation id that some durable fact already handed it, so a missing opening + * is corruption and not a branch a reader should be asked to handle. + */ + async readInvocation(sessionId: string, invocationId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertRuntimeStorageSafeId(invocationId, 'Invalid invocation id'); + return this.readTransaction(() => { + const row = this.readInvocationOpeningsSync(sessionId, { + direction: 'asc', + invocationId, + }).at(0); + if (!row) throw new Error(`Runtime invocation not found: ${invocationId}`); + return this.completeInvocationRecordSync(row); + }); + } + + /** + * Read invocation openings off both shelves as one ordered sequence. + * + * Every writer of an opening event stamps `committed_at` with the event's own + * timestamp, so that column orders the event shelf by the same value the + * record reports as `openedAt` and the legacy shelf keeps under `opened_at`. + * Ordering and paging therefore happen in SQL, and a bounded caller decodes + * only the openings it asked for. + */ + private readInvocationOpeningsSync( + sessionId: string, + options: { + direction: 'asc' | 'desc'; + limit?: number; + before?: RuntimeInvocationPageCursor; + invocationId?: string; + runId?: string; + }, + ): Omit[] { + const order = options.direction === 'desc' ? 'DESC' : 'ASC'; + const rows = this.db + .prepare(` + SELECT * FROM ( + SELECT + event_id AS event_id, + invocation_id AS invocation_id, + run_id AS run_id, + turn_id AS turn_id, + committed_at AS opened_at, + payload_json AS opening_json, + 1 AS from_events + FROM runtime_events + WHERE session_id = :sessionId AND event_kind = 'invocation_opened' + ${options.runId === undefined ? '' : 'AND run_id = :runId'} + ${options.invocationId === undefined ? '' : 'AND invocation_id = :invocationId'} + UNION ALL + SELECT + NULL, + legacy.invocation_id, + legacy.run_id, + legacy.turn_id, + legacy.opened_at, + legacy.opening_json, + 0 + FROM runtime_legacy_invocation_openings AS legacy + WHERE legacy.session_id = :sessionId + ${options.runId === undefined ? '' : 'AND legacy.run_id = :runId'} + ${options.invocationId === undefined ? '' : 'AND legacy.invocation_id = :invocationId'} + AND NOT EXISTS ( + SELECT 1 FROM runtime_events + WHERE runtime_events.invocation_id = legacy.invocation_id + AND runtime_events.event_kind = 'invocation_opened' + ) + ) + WHERE ( + :beforeOpenedAt IS NULL + OR opened_at < :beforeOpenedAt + OR (opened_at = :beforeOpenedAt AND invocation_id < :beforeInvocationId) + ) + ORDER BY opened_at ${order}, invocation_id ${order} + LIMIT :limit + `) + .all({ + sessionId, + ...(options.invocationId === undefined ? {} : { invocationId: options.invocationId }), + ...(options.runId === undefined ? {} : { runId: options.runId }), + beforeOpenedAt: options.before?.openedAt ?? null, + beforeInvocationId: options.before?.invocationId ?? null, + limit: options.limit ?? -1, + }) as unknown as Array<{ + event_id: string | null; + invocation_id: string; + run_id: string; + turn_id: string; + opened_at: number; + opening_json: string; + from_events: number; + }>; + return rows.map((row) => { + if (row.from_events !== 1) { + return { + sessionId, + invocationId: row.invocation_id, + runId: row.run_id, + turnId: row.turn_id, + openedAt: row.opened_at, + opening: decodeRuntimeInvocationOpened(JSON.parse(row.opening_json)), + }; + } + const event = decodeRuntimeEventStorageRow({ + event_id: row.event_id ?? '', + session_id: sessionId, + invocation_id: row.invocation_id, + run_id: row.run_id, + turn_id: row.turn_id, + payload_json: row.opening_json, + }); + const opening = runtimeEventInvocationOpening(event); + if (!opening) { + throw new Error(`RuntimeEvent ${event.id} is indexed as an opening fact but is not one`); + } + return { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + openedAt: event.ts, + opening, + }; + }); + } + + /** + * An invocation's ending is its first terminal event, wherever it sits. + * + * The store seals a run on that event, so for anything it wrote itself the + * first terminal is also the only one and the last event. Ledgers written + * before the seal existed can carry a straggler after the terminal, and + * reading those as unfinished would contradict every other reader of the same + * rule: recovery, the read model and continuation resume all take the first + * terminal. A ledger that somehow holds two is corrupt, and saying so is the + * job of those readers — this inventory feeds Session lists, so it reports the + * ending it can see rather than poisoning the whole Session over one run. + */ + private completeInvocationRecordSync( + record: Omit, + ): RuntimeInvocationRecord { + const terminalRow = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE invocation_id = ? + AND ${TERMINAL_RUNTIME_EVENT_SQL} + ORDER BY event_seq ASC + LIMIT 1 + `) + .get(record.invocationId) as unknown as RuntimeEventStorageRow | undefined; + const terminal = terminalRow ? decodeRuntimeEventStorageRow(terminalRow) : undefined; + return { + ...record, + ...(terminal && isTerminalRuntimeEvent(terminal) ? { terminalEvent: terminal } : {}), + }; + } + + async scanRuntimeEvents( + sessionId: string, + runId: string, + budget: RuntimeEventScanBudget, + visit: (events: readonly RuntimeEvent[]) => void, + ): Promise { + assertRuntimeEventScanBudget(budget); + return this.readTransaction(() => { + if (!this.runtimePartialSnapshotFitsScanBudget(sessionId, runId, budget)) { + return { status: 'limit_exceeded' }; + } + const snapshots = this.readRuntimePartialSnapshotsSync(sessionId, runId); + const { leading, afterEvent } = groupRuntimePartialSnapshots(snapshots); + if (leading.length > 0) { + visit(leading.sort(compareRuntimePartialSnapshots).map(({ event }) => event)); + } + + let afterSequence = 0; + let immutableRecords = 0; + let immutableBytes = 0; + for (;;) { + const measured = this.db + .prepare( + ` + SELECT event_seq, length(CAST(payload_json AS BLOB)) AS stored_bytes + FROM runtime_events + WHERE session_id = ? AND run_id = ? AND event_seq > ? + ORDER BY event_seq ASC, event_id ASC + LIMIT ? + `, + ) + .all(sessionId, runId, afterSequence, RUNTIME_EVENT_SCAN_BATCH_SIZE) as Array<{ + event_seq?: unknown; + stored_bytes?: unknown; + }>; + if (measured.length === 0) break; + const sequences: number[] = []; + let batchBytes = 0; + for (const row of measured) { + const sequence = requireRuntimeEventScanCount(row.event_seq); + const storedBytes = requireRuntimeEventScanCount(row.stored_bytes); + if (storedBytes < 1 || storedBytes > budget.maxRecordBytes) { + return { status: 'limit_exceeded' }; + } + if (sequences.length > 0 && batchBytes + storedBytes > budget.maxBatchBytes) break; + if ( + immutableRecords + 1 > budget.maxImmutableRecords || + immutableBytes + storedBytes > budget.maxImmutableBytes + ) { + return { status: 'limit_exceeded' }; + } + sequences.push(sequence); + batchBytes += storedBytes; + immutableRecords += 1; + immutableBytes += storedBytes; + if (batchBytes >= budget.maxBatchBytes) break; + } + const placeholders = sequences.map(() => '?').join(', '); + const rows = this.db + .prepare( + ` + SELECT event_id, session_id, invocation_id, run_id, turn_id, + event_seq, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? AND event_seq IN (${placeholders}) + ORDER BY event_seq ASC, event_id ASC + `, + ) + .all(sessionId, runId, ...sequences) as unknown as Array< + RuntimeEventStorageRow & { event_seq: number } + >; + if (rows.length !== sequences.length) { + throw new Error('RuntimeEvent scan changed inside its read transaction'); + } + const batch: RuntimeEvent[] = []; + for (const row of rows) { + const event = decodeRuntimeEventStorageRow(row); + batch.push(event); + const anchored = afterEvent.get(event.id); + if (anchored) { + batch.push( + ...anchored.sort(compareRuntimePartialSnapshots).map((snapshot) => snapshot.event), + ); + afterEvent.delete(event.id); + } + } + visit(batch); + afterSequence = rows.at(-1)!.event_seq; + } + for (const orphaned of afterEvent.values()) { + visit(orphaned.sort(compareRuntimePartialSnapshots).map((snapshot) => snapshot.event)); + } + return { status: 'complete' }; + }); + } + + private runtimePartialSnapshotFitsScanBudget( + sessionId: string, + runId: string, + budget: RuntimeEventScanBudget, + ): boolean { + const rows = this.db + .prepare( + ` + SELECT + length(CAST(snapshot.payload_json AS BLOB)) + + length(CAST(snapshot.text_content AS BLOB)) + + coalesce(sum(length(CAST(segment.text_content AS BLOB))), 0) + + coalesce(length(CAST(snapshot.after_event_id AS BLOB)), 0) AS stored_bytes + FROM runtime_partial_snapshots AS snapshot + LEFT JOIN runtime_partial_segments AS segment + ON segment.stream_key = snapshot.stream_key + WHERE snapshot.session_id = ? AND snapshot.run_id = ? + GROUP BY snapshot.stream_key + LIMIT ? + `, + ) + .all(sessionId, runId, budget.maxPartialRecords + 1) as Array<{ stored_bytes?: unknown }>; + if (rows.length > budget.maxPartialRecords) return false; + let bytes = 0; + for (const row of rows) { + const storedBytes = requireRuntimeEventScanCount(row.stored_bytes); + if (storedBytes < 1 || storedBytes > budget.maxRecordBytes) return false; + bytes += storedBytes; + if (bytes > budget.maxPartialBytes) return false; + } + return true; + } + + async readRuntimeEventsBounded( + sessionId: string, + runId: string, + budget: EvidenceReadBudget, + ): Promise> { + assertEvidenceReadBudget(budget); + const rows = this.db + .prepare(` + SELECT stored_bytes + FROM ( + SELECT length(CAST(payload_json AS BLOB)) AS stored_bytes + FROM runtime_events + WHERE session_id = ? AND run_id = ? + UNION ALL + SELECT + length(CAST(payload_json AS BLOB)) + + length(CAST(text_content AS BLOB)) + + coalesce(( + SELECT sum(length(CAST(segment.text_content AS BLOB))) + FROM runtime_partial_segments AS segment + WHERE segment.stream_key = runtime_partial_snapshots.stream_key + ), 0) + + coalesce(length(CAST(after_event_id AS BLOB)), 0) AS stored_bytes + FROM runtime_partial_snapshots + WHERE session_id = ? AND run_id = ? + ) + LIMIT ? + `) + .all(sessionId, runId, sessionId, runId, budget.maxRecords + 1) as Array<{ + stored_bytes?: unknown; + }>; + const measurement = measureEvidenceRows( + rows, + budget, + 'Invalid SQLite RuntimeEvent evidence measurement row', + ); + if (!measurement) return { status: 'limit_exceeded' }; + return { + status: 'complete', + records: this.readRuntimeEventsSync(sessionId, runId), + ...measurement, + }; + } + + private readRuntimeEventsSync(sessionId: string, runId: string): RuntimeEvent[] { + const immutable = this.readImmutableRuntimeEventsSync(sessionId, runId); + return mergeRuntimePartialSnapshots( + immutable, + this.readRuntimePartialSnapshotsSync(sessionId, runId), + ); + } + + private readRuntimePartialSnapshotsSync( + sessionId: string, + runId: string, + ): RuntimePartialSnapshot[] { + const partials = this.db + .prepare(` + SELECT stream_key, session_id, invocation_id, run_id, turn_id, + payload_json, text_content, after_event_id + FROM runtime_partial_snapshots + WHERE session_id = ? AND run_id = ? + ORDER BY updated_at ASC, stream_key ASC + `) + .all(sessionId, runId) as unknown as RuntimePartialStorageRow[]; + const segmentText = new Map(); + const segments = this.db + .prepare(` + SELECT segment.stream_key, segment.text_content + FROM runtime_partial_segments AS segment + INNER JOIN runtime_partial_snapshots AS snapshot + ON snapshot.stream_key = segment.stream_key + WHERE snapshot.session_id = ? AND snapshot.run_id = ? + ORDER BY segment.stream_key ASC, segment.segment_seq ASC + `) + .iterate(sessionId, runId) as Iterable<{ stream_key: string; text_content: string }>; + let streamKey: string | undefined; + let chunks: string[] = []; + let tail: string[] = []; + let tailBytes = 0; + const flushTail = () => { + if (tail.length === 0) return; + chunks.push(tail.join('')); + tail = []; + tailBytes = 0; + }; + const flushStream = () => { + if (streamKey === undefined) return; + flushTail(); + segmentText.set(streamKey, chunks); + chunks = []; + }; + for (const segment of segments) { + if (typeof segment.stream_key !== 'string' || typeof segment.text_content !== 'string') { + throw new Error('Invalid RuntimeEvent partial segment'); + } + if (segment.stream_key !== streamKey) { + flushStream(); + streamKey = segment.stream_key; + } + const bytes = Buffer.byteLength(segment.text_content, 'utf8'); + if (bytes === 0) continue; + if (bytes > RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES) { + flushTail(); + chunks.push(segment.text_content); + continue; + } + if (tailBytes + bytes > RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES) flushTail(); + tail.push(segment.text_content); + tailBytes += bytes; + } + flushStream(); + return partials.flatMap((row) => { + try { + const event = decodeRuntimePartialStorageRow(row); + if (event.content?.kind === 'text' || event.content?.kind === 'thinking') { + event.content = { + ...event.content, + text: row.text_content + (segmentText.get(row.stream_key)?.join('') ?? ''), + }; + } + return [ + { + event, + ...(row.after_event_id ? { afterEventId: row.after_event_id } : {}), + }, + ]; + } catch { + // Mutable partial snapshots are presentation state, never ledger + // authority. A corrupt snapshot is skipped without hiding immutable + // RuntimeEvents from the same run. + return []; + } + }); + } + + async readImmutableRuntimeEvents(sessionId: string, runId: string): Promise { + return this.readImmutableRuntimeEventsSync(sessionId, runId); + } + + private readImmutableRuntimeEventsSync(sessionId: string, runId: string): RuntimeEvent[] { + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + ORDER BY event_seq ASC, event_id ASC + `) + .all(sessionId, runId) as unknown as RuntimeEventStorageRow[]; + return rows.map(decodeRuntimeEventStorageRow); + } + + async readImmutableRuntimePrefix(input: { + sessionId: string; + runId: string; + upToEventSeq?: number; + }): Promise { + return this.readImmutableRuntimePrefixSync(input); + } + + private readImmutableRuntimePrefixSync(input: { + sessionId: string; + runId: string; + upToEventSeq?: number; + }): ImmutableRuntimePrefixV1 { + if ( + input.upToEventSeq !== undefined && + (!Number.isSafeInteger(input.upToEventSeq) || input.upToEventSeq <= 0) + ) { + throw new Error('Invalid immutable RuntimeEvent prefix high-water'); + } + const highWater = input.upToEventSeq ?? null; + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, event_seq, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + AND (? IS NULL OR event_seq <= ?) + ORDER BY event_seq ASC + `) + .all( + input.sessionId, + input.runId, + highWater, + highWater, + ) as unknown as RuntimeEventPrefixStorageRow[]; + if (rows.length === 0) { + throw new Error('immutable RuntimeEvent prefix is empty'); + } + const lastEventSeq = rows.at(-1)?.event_seq; + if (input.upToEventSeq !== undefined && lastEventSeq !== input.upToEventSeq) { + throw new Error( + `immutable RuntimeEvent prefix high-water ${input.upToEventSeq} is unavailable`, + ); + } + const decoded = rows.map((row) => ({ + eventSeq: row.event_seq, + event: decodeRuntimeEventStorageRow(row), + })); + const first = decoded[0]!.event; + return buildImmutableRuntimePrefix( + { + sessionId: first.sessionId, + invocationId: first.invocationId, + runId: first.runId, + turnId: first.turnId, + }, + decoded, + ); + } + + async claimContinuation(input: { claim: ContinuationClaimV1 }): Promise { + const claim = decodeContinuationClaim(input.claim); + if ( + claim.target.sessionId === WORKSPACE_AUTHORITY_SESSION_ID || + claim.boundary.segments.some( + (segment) => segment.identity.sessionId === WORKSPACE_AUTHORITY_SESSION_ID, + ) + ) { + throw new Error('Continuation cannot target the reserved workspace authority stream'); + } + const boundaryJson = stableJsonStringify(claim.boundary); + return this.transaction(() => { + this.assertContinuationAuthorityIntegrity(); + this.assertContinuationBoundaryMatchesLedger(claim); + const byBoundary = this.readContinuationClaimRow('boundary_digest = ?', claim.boundaryDigest); + if (byBoundary) { + const existing = decodeContinuationClaimRow(byBoundary); + if (byBoundary.boundary_json !== boundaryJson) { + throw new Error('Continuation claim boundary digest has conflicting canonical JSON'); + } + return { kind: 'existing', claim: existing }; + } + + const source = claim.boundary.segments.at(-1)!; + const conflict = this.readContinuationClaimRow( + `claim_id = ? + OR target_invocation_id = ? + OR target_run_id = ? + OR (? = 0 AND target_session_id = ? AND target_turn_id = ?) + OR ( + source_session_id = ? + AND source_run_id = ? + AND source_event_high_water = ? + )`, + claim.claimId, + claim.target.invocationId, + claim.target.runId, + claim.targetOpening.source.kind === 'handoff' ? 1 : 0, + claim.target.sessionId, + claim.target.turnId, + source.identity.sessionId, + source.identity.runId, + source.position.lastEventSeq, + ); + if (conflict) { + return { kind: 'conflict', claim: decodeContinuationClaimRow(conflict) }; + } + if (this.continuationTargetHasRuntimeState(claim)) { + throw new Error('Continuation claim target RuntimeEvent ledger is not empty'); + } + + try { + this.db + .prepare(` + INSERT INTO runtime_continuation_claims ( + claim_id, + source_session_id, + source_invocation_id, + source_run_id, + source_turn_id, + source_event_high_water, + source_prefix_digest, + boundary_digest, + boundary_json, + provider_projection_version, + provider_replay_digest, + target_session_id, + target_invocation_id, + target_run_id, + target_turn_id, + target_opening_json, + claimed_at, + protocol_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `) + .run( + claim.claimId, + source.identity.sessionId, + source.identity.invocationId, + source.identity.runId, + source.identity.turnId, + source.position.lastEventSeq, + source.prefixDigest, + claim.boundaryDigest, + boundaryJson, + claim.providerProjectionVersion, + claim.providerReplayDigest, + claim.target.sessionId, + claim.target.invocationId, + claim.target.runId, + claim.target.turnId, + stableJsonStringify(claim.targetOpening), + claim.claimedAt, + ); + } catch (error) { + const raced = + this.readContinuationClaimRow('boundary_digest = ?', claim.boundaryDigest) ?? + this.readContinuationClaimRow( + `claim_id = ? + OR target_invocation_id = ? + OR target_run_id = ? + OR (? = 0 AND target_session_id = ? AND target_turn_id = ?) + OR ( + source_session_id = ? + AND source_run_id = ? + AND source_event_high_water = ? + )`, + claim.claimId, + claim.target.invocationId, + claim.target.runId, + claim.targetOpening.source.kind === 'handoff' ? 1 : 0, + claim.target.sessionId, + claim.target.turnId, + source.identity.sessionId, + source.identity.runId, + source.position.lastEventSeq, + ); + if (!raced) throw error; + const racedClaim = decodeContinuationClaimRow(raced); + return racedClaim.boundaryDigest === claim.boundaryDigest + ? { kind: 'existing', claim: racedClaim } + : { kind: 'conflict', claim: racedClaim }; + } + this.options.failpoint?.('after_continuation_claim_insert'); + return { kind: 'acquired', claim }; + }); + } + + async readContinuationClaimByBoundary( + boundaryDigest: RuntimeBoundaryDigest, + ): Promise { + return (await this.readContinuationClaimStateByBoundary(boundaryDigest))?.claim; + } + + async readContinuationClaimStateByBoundary( + boundaryDigest: RuntimeBoundaryDigest, + ): Promise { + if (!/^sha256:[0-9a-f]{64}$/.test(boundaryDigest)) { + throw new Error('Invalid continuation boundary digest'); + } + const row = this.readContinuationClaimRow('boundary_digest = ?', boundaryDigest); + return row ? this.decodeContinuationClaimStateRow(row) : undefined; + } + + async listContinuationClaimsForRecovery(sessionId: string): Promise { + const rows = this.db + .prepare(` + SELECT + claim_id, + source_session_id, + source_invocation_id, + source_run_id, + source_turn_id, + source_event_high_water, + source_prefix_digest, + boundary_digest, + boundary_json, + provider_projection_version, + provider_replay_digest, + target_session_id, + target_invocation_id, + target_run_id, + target_turn_id, + target_opening_json, + claimed_at, + start_event_id, + start_kind, + protocol_version + FROM runtime_continuation_claims + WHERE target_session_id = ? + ORDER BY claimed_at ASC, claim_id ASC + `) + .all(sessionId) as unknown as ContinuationClaimStorageRow[]; + return rows.map((row) => this.decodeContinuationClaimStateRow(row)); + } + + async commitContinuationStart(input: { + claim: ContinuationClaimV1; + event: RuntimeEvent; + }): Promise { + return this.commitContinuationStartOfKind(input, 'runtime_admission'); + } + + async commitContinuationRepairStart(input: { + claim: ContinuationClaimV1; + event: RuntimeEvent; + }): Promise { + return this.commitContinuationStartOfKind(input, 'claim_repair'); + } + + private commitContinuationStartOfKind( + input: { + claim: ContinuationClaimV1; + event: RuntimeEvent; + }, + startKind: 'runtime_admission' | 'claim_repair', + ): ToolCommitResult { + const claim = decodeContinuationClaim(input.claim); + const event = canonicalizeRuntimeEventForStorage(input.event); + assertNoReservedWorkspaceAuthorityAppend(event); + assertContinuationStartEvent(claim, event, startKind); + return this.transaction(() => { + const row = this.readContinuationClaimRow('boundary_digest = ?', claim.boundaryDigest); + if (!row) { + throw new Error('Continuation start requires an acquired durable claim'); + } + const storedClaim = decodeContinuationClaimRow(row); + if (!isDeepStrictEqual(storedClaim, claim)) { + throw new Error('Continuation start claim identity conflict'); + } + if (row.start_event_id) { + if (row.start_event_id !== event.id || row.start_kind !== startKind) { + throw new Error('Continuation claim already has a different start event'); + } + assertStoredRuntimeEventEquals(event, this.readRuntimeEventJson(event.id)); + return { created: false, runtimeEventSeq: this.runtimeEventSeq(event.id) }; + } + this.assertInvocationIdentity([event]); + const runtimeEventSeq = this.insertRuntimeEvent(event, event.ts, false, claim.claimId); + if (runtimeEventSeq !== 1) { + throw new Error('Continuation start must be the first target RuntimeEvent'); + } + this.options.failpoint?.('after_continuation_start_insert'); + this.db + .prepare(` + UPDATE runtime_continuation_claims + SET start_event_id = ?, start_kind = ? + WHERE claim_id = ? AND start_event_id IS NULL + `) + .run(event.id, startKind, claim.claimId); + return { created: true, runtimeEventSeq }; + }); + } + + async readImmutableSteeringMessageProof( + sessionId: string, + messageId: string, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertRuntimeStorageSafeId(messageId, 'Invalid message id'); + const matches = this.readImmutableSessionRuntimeEvents(sessionId).filter( + (event) => immutableSteeringMessageId(event) === messageId, + ); + if (matches.length > 1) { + throw new Error(`Immutable steering message identity conflict: ${messageId}`); + } + return matches[0] ? Object.freeze({ event: matches[0] }) : undefined; + } + + async repairImmutableSteeringMessageProofsForRecovery(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + const messages = new Map(); + for (const event of this.readImmutableSessionRuntimeEvents(sessionId)) { + const messageId = immutableSteeringMessageId(event); + if (!messageId) continue; + const existing = messages.get(messageId); + if (existing && !isDeepStrictEqual(existing, event)) { + throw new Error(`Immutable steering message identity conflict: ${messageId}`); + } + messages.set(messageId, event); + } + } + + async readSessionRuntimeEvents(sessionId: string): Promise { + const rows = this.db + .prepare(` + SELECT run_id FROM runtime_events WHERE session_id = ? + UNION + SELECT run_id FROM runtime_partial_snapshots WHERE session_id = ? + ORDER BY run_id ASC + `) + .all(sessionId, sessionId) as Array<{ run_id: string }>; + const ordered: Array<{ event: RuntimeEvent; runId: string; eventIndex: number }> = []; + for (const row of rows) { + const events = await this.readRuntimeEvents(sessionId, row.run_id); + for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) { + ordered.push({ event: events[eventIndex]!, runId: row.run_id, eventIndex }); + } + } + ordered.sort( + (a, b) => + a.event.ts - b.event.ts || + a.runId.localeCompare(b.runId) || + a.eventIndex - b.eventIndex || + a.event.id.localeCompare(b.event.id), + ); + return ordered.map((item) => item.event); + } + + async readSessionRuntimeEventEntries(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + const rows = this.db + .prepare(` + SELECT o.ordinal, e.event_id, e.session_id, e.invocation_id, e.run_id, e.turn_id, + e.payload_json + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + WHERE o.session_id = ? + ORDER BY o.ordinal ASC + `) + .all(sessionId) as unknown as Array; + return rows.map((row) => { + if ( + typeof row.ordinal !== 'number' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 1 + ) { + throw new Error(`Invalid RuntimeEvent Session ordinal for ${sessionId}`); + } + const event = decodeRuntimeEventStorageRow(row); + if (event.sessionId !== sessionId) { + throw new Error(`RuntimeEvent Session ordinal identity mismatch for ${event.id}`); + } + return { ordinal: row.ordinal, event }; + }); + } + + async resequenceSessionEventOrdinals(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + this.transaction(() => { + // Lifted above the range first: the second statement renumbers into the + // space these rows occupy, and (session_id, ordinal) is a primary key. + // Shifting up rather than below zero keeps every intermediate value + // inside the table's own `ordinal > 0`, and lands them past the 1..N the + // renumber assigns, since the count cannot exceed the maximum. + const { shift } = this.db + .prepare(` + SELECT COALESCE(MAX(ordinal), 0) AS shift + FROM runtime_session_event_ordinals + WHERE session_id = ? + `) + .get(sessionId) as { shift: number }; + this.db + .prepare(` + UPDATE runtime_session_event_ordinals + SET ordinal = ordinal + :shift + WHERE session_id = :sessionId + `) + .run({ sessionId, shift }); + this.db + .prepare(` + WITH opening AS ( + SELECT invocation_id, CAST(json_extract(payload_json, '$.ts') AS INTEGER) AS opened_at + FROM runtime_events + WHERE session_id = :sessionId AND event_kind = 'invocation_opened' + ), + ordered AS MATERIALIZED ( + SELECT + o.event_id AS event_id, + ROW_NUMBER() OVER ( + ORDER BY COALESCE(opening.opened_at, e.committed_at), e.invocation_id, o.ordinal + ) AS ordinal + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + LEFT JOIN opening ON opening.invocation_id = e.invocation_id + WHERE o.session_id = :sessionId + ) + UPDATE runtime_session_event_ordinals + SET ordinal = ( + SELECT ordered.ordinal + FROM ordered + WHERE ordered.event_id = runtime_session_event_ordinals.event_id + ) + WHERE session_id = :sessionId + `) + .run({ sessionId }); + }); + } + + async #commitWorkspaceBaseline( + input: WorkspaceBaselineAuthorityInput, + rootId: string, + ): Promise { + const events = buildWorkspaceBaselineAuthorityEvents(input); + return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); + const existingAuthority = this.readCanonicalWorkspaceAuthoritySync(); + const existingBaselines = existingAuthority.baselines; + const existing = existingBaselines.find( + (candidate) => + candidate.epoch.workspaceId === input.epoch.workspaceId && + candidate.epoch.workspaceEpochId === input.epoch.workspaceEpochId, + ); + if (existing) { + this.assertWorkspaceProjectionsMatchSync(existingAuthority); + if ( + !isDeepStrictEqual( + [ + this.readRequiredRuntimeEvent(existing.epochOpenedEventId), + this.readRequiredRuntimeEvent(existing.baselineAcceptedEventId), + ], + [events.epochOpenedEvent, events.baselineAcceptedEvent], + ) + ) { + throw new Error('Workspace baseline authority conflict'); + } + const head = existingAuthority.heads.find( + (candidate) => candidate.workspaceEpochId === input.epoch.workspaceEpochId, + ); + if (!head) throw new Error('Workspace baseline authority head is unavailable'); + return { created: false, head }; + } + + if (this.workspaceProjectionCountSync() !== 0 || existingBaselines.length !== 0) { + this.assertWorkspaceProjectionsMatchSync(existingAuthority); + } + this.assertWorkspaceAuthorityStreamIsEmpty(events.epochOpenedEvent); + this.assertInvocationIdentity([events.epochOpenedEvent, events.baselineAcceptedEvent]); + const epochEventSeq = this.insertRuntimeEvent( + events.epochOpenedEvent, + input.committedAt, + false, + ); + if (epochEventSeq !== 1) { + throw new Error('Workspace epoch-opened fact must be authority sequence one'); + } + this.options.failpoint?.('after_workspace_epoch_event_insert'); + const baselineEventSeq = this.insertRuntimeEvent( + events.baselineAcceptedEvent, + input.committedAt, + false, + ); + if (baselineEventSeq !== 2) { + throw new Error('Workspace baseline version fact must be authority sequence two'); + } + this.options.failpoint?.('after_workspace_version_event_insert'); + + const scanned = this.readCanonicalWorkspaceAuthoritySync(); + const accepted = scanned.baselines.find( + (candidate) => candidate.epoch.workspaceEpochId === input.epoch.workspaceEpochId, + ); + if (!accepted) throw new Error('Workspace baseline authority scan lost the committed epoch'); + this.insertWorkspaceEpochProjection(accepted, input.committedAt); + this.options.failpoint?.('after_workspace_epoch_projection_insert'); + this.insertWorkspaceBaselineVersionProjection(accepted, input.committedAt); + this.options.failpoint?.('after_workspace_version_projection_insert'); + const acceptedHead = scanned.heads.find( + (candidate) => candidate.workspaceEpochId === input.epoch.workspaceEpochId, + ); + if (!acceptedHead) throw new Error('Workspace baseline authority scan lost its head'); + this.insertWorkspaceHeadProjection(acceptedHead); + this.options.failpoint?.('after_workspace_head_projection_insert'); + this.assertWorkspaceProjectionsMatchSync(scanned); + const head = scanned.heads.find( + (candidate) => candidate.workspaceEpochId === input.epoch.workspaceEpochId, + ); + if (!head) throw new Error('Workspace baseline authority scan lost the committed head'); + return { created: true, head }; + }); + } + + async #commitWorkspaceSuccessor( + input: { + successor: WorkspaceSuccessorAuthorityInput; + toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; + }, + rootId: string, + ): Promise { + const toolOutcome: CommitToolOutcomeInput = { + ...input.toolOutcome, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.toolOutcome.runtimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(toolOutcome.runtimeEvent); + assertOutcomeInput(toolOutcome); + const successorEvent = buildWorkspaceSuccessorAuthorityEvent(input.successor); + if ( + input.successor.origin.operationId !== toolOutcome.operationId || + input.successor.origin.outcomeEventId !== toolOutcome.runtimeEvent.id + ) { + throw new Error('Workspace successor does not match its tool outcome identity'); + } + if ( + toolOutcome.runtimeEvent.content?.kind !== 'function_response' || + toolOutcome.runtimeEvent.content.isError === true + ) { + throw new Error('Workspace successor requires a successful tool outcome'); + } + + return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); + const before = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(before); + const currentHead = before.heads.find( + (candidate) => + candidate.workspaceId === input.successor.successor.workspaceId && + candidate.workspaceEpochId === input.successor.successor.workspaceEpochId, + ); + if (!currentHead) throw new Error('Workspace successor base head is unavailable'); + + const existing = before.successors.find( + (candidate) => + candidate.acceptedEventId === input.successor.acceptedEventId || + candidate.successor.workspaceVersionId === input.successor.successor.workspaceVersionId, + ); + if (existing) { + assertStoredRuntimeEventEquals( + successorEvent, + this.readRuntimeEventJson(successorEvent.id), + ); + const operation = this.readToolOperationSync(toolOutcome.operationId); + if (!operation?.resultEventId) { + throw new Error('Workspace successor exists without its tool outcome'); + } + assertStoredRuntimeEventEquals( + toolOutcome.runtimeEvent, + this.readRuntimeEventJson(operation.resultEventId), + ); + return { + created: false, + committedSuccessor: { + repositoryId: existing.successor.repositoryId, + workspaceId: existing.successor.workspaceId, + workspaceEpochId: existing.successor.workspaceEpochId, + workspaceVersionId: existing.successor.workspaceVersionId, + acceptedEventId: existing.acceptedEventId, + commitOid: existing.successor.commitOid, + treeOid: existing.successor.treeOid, + revision: existing.successor.baseHeadRevision + 1, + }, + outcomeRuntimeEventSeq: this.runtimeEventSeq(operation.resultEventId), + }; + } + + const successor = input.successor.successor; + if ( + successor.repositoryId !== currentHead.repositoryId || + successor.parentWorkspaceVersionId !== currentHead.workspaceVersionId || + successor.baseAcceptedEventId !== currentHead.acceptedEventId || + successor.baseHeadRevision !== currentHead.revision + ) { + throw new Error('Workspace successor compare-and-set base head conflict'); + } + const operation = this.readToolOperationSync(toolOutcome.operationId); + if ( + !operation || + operation.currentState !== 'prepared' || + operation.resultEventId !== undefined || + operation.dispatchEventId !== input.successor.origin.dispatchEventId || + operation.recoveryMode !== 'reconcile' || + (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + ) { + throw new Error('Workspace successor requires one prepared Write/Edit reconcile operation'); + } + if (!operation.dispatchEventId) { + throw new Error('Workspace successor operation is missing its dispatch event'); + } + const dispatchJson = this.readRuntimeEventJson(operation.dispatchEventId); + const dispatchEvent = dispatchJson + ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) + : undefined; + const mutation = dispatchEvent?.actions?.toolDispatch?.managedMutation; + const reservation = this.db + .prepare(` + SELECT + workspace_instance_id, repository_id, workspace_id, workspace_epoch_id, + operation_id, dispatch_event_id, base_workspace_version_id, + base_accepted_event_id, base_head_revision, base_commit_oid, base_tree_oid, + expected_paths_json, execution_profile_digest, protocol_version, reserved_at + FROM runtime_managed_mutation_reservations + WHERE operation_id = ? + `) + .get(operation.operationId) as ManagedMutationReservationProjectionRow | undefined; + if ( + !mutation || + !reservation || + reservation.workspace_instance_id !== mutation.workspaceInstanceId || + reservation.repository_id !== mutation.repositoryId || + reservation.workspace_id !== mutation.workspaceId || + reservation.workspace_epoch_id !== mutation.workspaceEpochId || + reservation.operation_id !== operation.operationId || + reservation.dispatch_event_id !== operation.dispatchEventId || + reservation.base_workspace_version_id !== mutation.baseWorkspaceVersionId || + reservation.base_accepted_event_id !== mutation.baseAcceptedEventId || + reservation.base_head_revision !== mutation.baseHeadRevision || + reservation.base_commit_oid !== mutation.baseCommitOid || + reservation.base_tree_oid !== mutation.baseTreeOid || + reservation.execution_profile_digest !== mutation.executionProfileDigest || + mutation.repositoryId !== successor.repositoryId || + mutation.workspaceId !== successor.workspaceId || + mutation.workspaceEpochId !== successor.workspaceEpochId || + mutation.objectFormat !== successor.objectFormat || + mutation.baseWorkspaceVersionId !== successor.parentWorkspaceVersionId || + mutation.baseAcceptedEventId !== successor.baseAcceptedEventId || + mutation.baseHeadRevision !== successor.baseHeadRevision || + mutation.baseCommitOid !== currentHead.commitOid || + mutation.baseTreeOid !== currentHead.treeOid || + mutation.executionProfileDigest !== successor.executionProfileDigest + ) { + throw new Error('Workspace successor requires its exact durable mutation reservation'); + } + const reservedPaths = JSON.parse(reservation.expected_paths_json) as unknown; + if ( + !isDeepStrictEqual(reservedPaths, [mutation.expectedPath]) || + !isDeepStrictEqual(successor.changedPaths, [mutation.expectedPath]) + ) { + throw new Error('Managed mutation path authorization conflict'); + } + + const outcomeResult = this.commitToolOutcomeSync(toolOutcome, 'workspace_successor'); + const successorSeq = this.insertRuntimeEvent( + successorEvent, + input.successor.committedAt, + false, + ); + if (successorSeq !== currentHead.revision + 2) { + throw new Error('Workspace successor fact is not the next authority event'); + } + this.options.failpoint?.('after_workspace_successor_event_insert'); + + const after = this.readCanonicalWorkspaceAuthoritySync(); + const accepted = after.successors.find( + (candidate) => candidate.acceptedEventId === input.successor.acceptedEventId, + ); + const nextHead = after.heads.find( + (candidate) => + candidate.workspaceId === successor.workspaceId && + candidate.workspaceEpochId === successor.workspaceEpochId, + ); + if (!accepted || !nextHead) { + throw new Error('Workspace successor authority scan lost the committed version'); + } + this.insertWorkspaceSuccessorVersionProjection(accepted, input.successor.committedAt); + this.options.failpoint?.('after_workspace_successor_projection_insert'); + const updated = this.db + .prepare(` + UPDATE runtime_workspace_heads + SET workspace_version_id = ?, accepted_event_id = ?, commit_oid = ?, tree_oid = ?, + revision = ? + WHERE workspace_id = ? AND workspace_epoch_id = ? + AND workspace_version_id = ? AND accepted_event_id = ? AND revision = ? + `) + .run( + nextHead.workspaceVersionId, + nextHead.acceptedEventId, + nextHead.commitOid, + nextHead.treeOid, + nextHead.revision, + currentHead.workspaceId, + currentHead.workspaceEpochId, + currentHead.workspaceVersionId, + currentHead.acceptedEventId, + currentHead.revision, + ); + if (updated.changes !== 1) { + throw new Error('Workspace successor head compare-and-set failed'); + } + this.options.failpoint?.('after_workspace_successor_head_update'); + const released = this.db + .prepare(` + DELETE FROM runtime_managed_mutation_reservations + WHERE workspace_instance_id = ? AND operation_id = ? AND dispatch_event_id = ? + `) + .run(mutation.workspaceInstanceId, operation.operationId, operation.dispatchEventId); + if (released.changes !== 1) { + throw new Error('Managed mutation reservation release compare-and-set failed'); + } + this.assertWorkspaceProjectionsMatchSync(after); + return { + created: true, + committedSuccessor: nextHead, + outcomeRuntimeEventSeq: outcomeResult.runtimeEventSeq, + }; + }); + } + + async #commitManagedMutationTerminal( + input: { + noEffect: ManagedMutationNoEffectClaimV1; + toolOutcome: ManagedMutationTerminalCommitInput['toolOutcome']; + }, + rootId: string, + ): Promise { + const toolOutcome: CommitToolOutcomeInput = { + ...input.toolOutcome, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.toolOutcome.runtimeEvent), + }; + assertOutcomeInput(toolOutcome); + const terminal = toolOutcome.runtimeEvent.actions?.managedMutationTerminal; + if (!terminal) throw new Error('Managed mutation terminal fact is missing'); + if ( + input.noEffect.operationId !== terminal.operationId || + input.noEffect.dispatchEventId !== terminal.dispatchEventId || + input.noEffect.workspaceInstanceId !== terminal.workspaceInstanceId || + input.noEffect.terminalKind !== terminal.terminalKind + ) { + throw new Error('Managed mutation terminal does not match its owner-issued no-effect proof'); + } + + return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); + const operation = this.readToolOperationSync(toolOutcome.operationId); + if ( + !operation || + !operation.dispatchEventId || + operation.dispatchEventId !== terminal.dispatchEventId || + terminal.operationId !== operation.operationId || + operation.recoveryMode !== 'reconcile' || + (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + ) { + throw new Error('Managed mutation terminal requires its exact prepared operation'); + } + const dispatchJson = this.readRuntimeEventJson(operation.dispatchEventId); + const dispatchEvent = dispatchJson + ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) + : undefined; + const mutation = dispatchEvent?.actions?.toolDispatch?.managedMutation; + if (!mutation || mutation.workspaceInstanceId !== terminal.workspaceInstanceId) { + throw new Error('Managed mutation terminal requires its exact durable reservation'); + } + const response = toolOutcome.runtimeEvent.content; + if ( + response?.kind !== 'function_response' || + (terminal.terminalKind === 'no_workspace_change' + ? response.isError === true + : response.isError !== true) + ) { + throw new Error('Managed mutation terminal outcome has the wrong success state'); + } + + const result = this.commitToolOutcomeSync(toolOutcome, 'workspace_terminal'); + const released = this.db + .prepare(` + DELETE FROM runtime_managed_mutation_reservations + WHERE workspace_instance_id = ? AND operation_id = ? AND dispatch_event_id = ? + `) + .run(terminal.workspaceInstanceId, operation.operationId, operation.dispatchEventId); + if (result.created && released.changes !== 1) { + throw new Error('Managed mutation terminal reservation release compare-and-set failed'); + } + if (!result.created && released.changes !== 0) { + throw new Error('Managed mutation terminal exact retry found an active reservation'); + } + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + return { created: result.created, outcomeRuntimeEventSeq: result.runtimeEventSeq }; + }); + } + + private registerWorkspaceBaselineAuthorityWriter(): void { + registerWorkspaceBaselineAuthorityWriterInternal( + this, + (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), + (input, rootId) => this.#commitWorkspaceSuccessor(input, rootId), + (input, rootId) => this.#commitManagedMutationTerminal(input, rootId), + (rootId) => this.#bindWorkspaceStorageRoot(rootId), + (workspaceInstanceId) => this.#readActiveManagedMutation(workspaceInstanceId), + ); + } + + async #readActiveManagedMutation( + workspaceInstanceId: string, + ): Promise< + | import('./workspace-version-authority-internal.js').ManagedMutationReservationRecordV1 + | undefined + > { + return this.readTransaction(() => { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const reservation = authority.activeManagedMutations.find( + (candidate) => candidate.workspace_instance_id === workspaceInstanceId, + ); + if (!reservation) return undefined; + const expectedPaths = JSON.parse(reservation.expected_paths_json) as unknown; + if ( + !Array.isArray(expectedPaths) || + expectedPaths.length !== 1 || + typeof expectedPaths[0] !== 'string' + ) { + throw new Error('Managed mutation reservation has invalid expected paths'); + } + return { + workspaceInstanceId: reservation.workspace_instance_id, + repositoryId: reservation.repository_id, + workspaceId: reservation.workspace_id, + workspaceEpochId: reservation.workspace_epoch_id, + operationId: reservation.operation_id, + dispatchEventId: reservation.dispatch_event_id, + baseWorkspaceVersionId: reservation.base_workspace_version_id, + baseAcceptedEventId: reservation.base_accepted_event_id, + baseHeadRevision: reservation.base_head_revision, + baseCommitOid: reservation.base_commit_oid, + baseTreeOid: reservation.base_tree_oid, + expectedPath: expectedPaths[0], + executionProfileDigest: reservation.execution_profile_digest, + reservedAt: reservation.reserved_at, + }; + }); + } + + #bindWorkspaceStorageRoot(rootId: string): void { + this.transaction(() => { + const existing = this.#readWorkspaceStorageRootBinding(); + if (existing) { + if (existing.root_id !== rootId || existing.protocol_version !== 1) { + throw new Error( + 'Workspace authority database belongs to a different durable storage root', + ); + } + return; + } + if (this.#databaseHasLogicalStateBeforeRootBinding()) { + throw new Error('Unbound operational data require explicit storage-root adoption'); + } + this.db + .prepare(` + INSERT INTO runtime_storage_root_binding(singleton, root_id, protocol_version) + VALUES (1, ?, 1) + `) + .run(rootId); + }); + } + + #assertWorkspaceStorageRootBinding(rootId: string): void { + const existing = this.#readWorkspaceStorageRootBinding(); + if (!existing || existing.root_id !== rootId || existing.protocol_version !== 1) { + throw new Error('Workspace authority database durable storage-root binding changed'); + } + } + + #readWorkspaceStorageRootBinding(): { root_id: string; protocol_version: number } | undefined { + return this.db + .prepare(` + SELECT root_id, protocol_version + FROM runtime_storage_root_binding + WHERE singleton = 1 + `) + .get() as { root_id: string; protocol_version: number } | undefined; + } + + #databaseHasLogicalStateBeforeRootBinding(): boolean { + const metadataTables = new Set([ + 'operational_schema_migrations', + 'runtime_capabilities', + 'runtime_storage_root_binding', + ]); + const tables = this.db + .prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `) + .all() as Array<{ name: string }>; + for (const { name } of tables) { + if (metadataTables.has(name)) continue; + const quotedName = `"${name.replaceAll('"', '""')}"`; + if (this.db.prepare(`SELECT 1 FROM ${quotedName} LIMIT 1`).get()) return true; + } + return false; + } + + async readWorkspaceEpoch( + workspaceId: string, + workspaceEpochId: string, + ): Promise { + return this.readTransaction(() => { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const baseline = authority.baselines.find( + (candidate) => + candidate.epoch.workspaceId === workspaceId && + candidate.epoch.workspaceEpochId === workspaceEpochId, + ); + return baseline ? workspaceEpochRecord(baseline) : undefined; + }); + } + + async readWorkspaceVersion( + workspaceVersionId: string, + ): Promise { + return this.readTransaction(() => { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const baseline = authority.baselines.find( + (candidate) => candidate.baseline.workspaceVersionId === workspaceVersionId, + ); + if (baseline) return workspaceBaselineVersionRecord(baseline); + const successor = authority.successors.find( + (candidate) => candidate.successor.workspaceVersionId === workspaceVersionId, + ); + return successor ? workspaceSuccessorVersionRecord(successor) : undefined; + }); + } + + async readWorkspaceHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise { + return this.readTransaction(() => { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + return authority.heads.find( + (candidate) => + candidate.workspaceId === workspaceId && candidate.workspaceEpochId === workspaceEpochId, + ); + }); + } + + async rebuildWorkspaceVersionProjections(): Promise { + return this.transaction(() => { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.db.prepare('DELETE FROM runtime_managed_mutation_reservations').run(); + this.db.prepare('DELETE FROM runtime_workspace_heads').run(); + this.db.prepare('DELETE FROM runtime_workspace_versions').run(); + this.db.prepare('DELETE FROM runtime_workspace_epochs').run(); + for (const baseline of authority.baselines) { + const committedAt = Math.max( + this.runtimeEventCommittedAt(baseline.epochOpenedEventId), + this.runtimeEventCommittedAt(baseline.baselineAcceptedEventId), + ); + this.insertWorkspaceEpochProjection(baseline, committedAt); + this.insertWorkspaceBaselineVersionProjection(baseline, committedAt); + } + for (const successor of authority.successors) { + this.insertWorkspaceSuccessorVersionProjection( + successor, + this.runtimeEventCommittedAt(successor.acceptedEventId), + ); + } + for (const head of authority.heads) this.insertWorkspaceHeadProjection(head); + for (const reservation of authority.activeManagedMutations) { + this.insertManagedMutationReservationProjectionSync(reservation); + } + this.assertWorkspaceProjectionsMatchSync(authority); + return { + epochs: authority.baselines.length, + versions: authority.baselines.length + authority.successors.length, + heads: authority.heads.length, + }; + }); + } + + private readCanonicalWorkspaceAuthoritySync(): CanonicalWorkspaceAuthority { + const partial = this.db + .prepare(` + SELECT stream_key FROM runtime_partial_snapshots + WHERE session_id = ? + LIMIT 1 + `) + .get(WORKSPACE_AUTHORITY_SESSION_ID) as { stream_key: string } | undefined; + if (partial) { + throw new Error( + `Corrupt workspace RuntimeEvent authority: authority_stream_contamination at ${partial.stream_key}`, + ); + } + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, event_seq, payload_json + FROM runtime_events + ORDER BY invocation_id ASC, event_seq ASC, event_id ASC + `) + .all() as unknown as RuntimeEventPrefixStorageRow[]; + const events = rows.map(decodeRuntimeEventStorageRow); + const authorityRows: WorkspaceAuthorityLedgerRow[] = rows.map((row, index) => ({ + event: events[index]!, + eventSeq: row.event_seq, + })); + const scan = scanWorkspaceBaselineAuthority(authorityRows); + if (scan.hasCorruption) { + const issue = scan.issues[0]!; + throw new Error( + `Corrupt workspace RuntimeEvent authority: ${issue.code} at ${issue.eventId}`, + ); + } + const toolScan = scanToolLedger(events); + for (const accepted of scan.successors) { + const origin = accepted.successor.origin; + const operation = toolScan.operations.find( + (candidate) => candidate.operationId === origin.operationId, + ); + const dispatch = operation?.dispatchEvent?.actions?.toolDispatch; + const response = operation?.responseEvent; + const epoch = scan.baselines.find( + (candidate) => + candidate.epoch.workspaceId === accepted.successor.workspaceId && + candidate.epoch.workspaceEpochId === accepted.successor.workspaceEpochId, + )?.epoch; + const baseHead = workspaceHeadBeforeSuccessor(scan, accepted.successor); + if ( + !operation || + operation.issues.length > 0 || + operation.dispatchEvent?.id !== origin.dispatchEventId || + !dispatch || + dispatch.operationId !== origin.operationId || + dispatch.recoveryMode !== 'reconcile' || + (dispatch.toolName !== 'Write' && dispatch.toolName !== 'Edit') || + !epoch || + !baseHead || + !managedMutationMatchesAcceptedSuccessor( + dispatch.managedMutation, + accepted.successor, + baseHead, + epoch.workspaceInstanceId, + ) || + !response || + response.id !== origin.outcomeEventId || + response.content?.kind !== 'function_response' || + response.content.isError === true + ) { + throw new Error( + `Corrupt workspace successor tool evidence: identity_conflict at ${accepted.acceptedEventId}`, + ); + } + } + const activeManagedMutations = this.scanCanonicalManagedMutationReservationsSync( + toolScan, + scan, + ); + this.options.failpoint?.('after_workspace_canonical_scan'); + return { ...scan, activeManagedMutations }; + } + + private scanCanonicalManagedMutationReservationsSync( + toolScan: ReturnType, + authority: ReturnType, + ): ManagedMutationReservationProjectionRow[] { + const acceptedOperations = new Set( + authority.successors.map((candidate) => candidate.successor.origin.operationId), + ); + const reservations: ManagedMutationReservationProjectionRow[] = []; + const occupied = new Set(); + for (const operation of toolScan.operations) { + const dispatchEvent = operation.dispatchEvent; + const dispatch = dispatchEvent?.actions?.toolDispatch; + const mutation = dispatch?.managedMutation; + if (!mutation) continue; + if ( + operation.issues.length > 0 || + !dispatchEvent || + dispatch.operationId !== operation.operationId || + dispatch.recoveryMode !== 'reconcile' || + (dispatch.toolName !== 'Write' && dispatch.toolName !== 'Edit') + ) { + throw new Error( + `Corrupt managed mutation reservation: identity_conflict at ${dispatchEvent?.id ?? operation.operationId}`, + ); + } + if (acceptedOperations.has(operation.operationId)) continue; + if (operation.responseEvent) { + const terminal = operation.responseEvent.actions?.managedMutationTerminal; + if ( + !terminal || + terminal.operationId !== operation.operationId || + terminal.dispatchEventId !== dispatchEvent.id || + terminal.workspaceInstanceId !== mutation.workspaceInstanceId || + operation.responseEvent.content?.kind !== 'function_response' || + (terminal.terminalKind === 'no_workspace_change' + ? operation.responseEvent.content.isError === true + : operation.responseEvent.content.isError !== true) + ) { + throw new Error( + `Corrupt managed mutation reservation: generic_outcome at ${operation.responseEvent.id}`, + ); + } + continue; + } + const epoch = authority.baselines.find( + (candidate) => + candidate.epoch.workspaceId === mutation.workspaceId && + candidate.epoch.workspaceEpochId === mutation.workspaceEpochId, + )?.epoch; + const head = authority.heads.find( + (candidate) => + candidate.workspaceId === mutation.workspaceId && + candidate.workspaceEpochId === mutation.workspaceEpochId, + ); + if ( + !epoch || + !head || + epoch.repositoryId !== mutation.repositoryId || + epoch.workspaceInstanceId !== mutation.workspaceInstanceId || + epoch.objectFormat !== mutation.objectFormat || + head.workspaceVersionId !== mutation.baseWorkspaceVersionId || + head.acceptedEventId !== mutation.baseAcceptedEventId || + head.revision !== mutation.baseHeadRevision || + head.commitOid !== mutation.baseCommitOid || + head.treeOid !== mutation.baseTreeOid || + occupied.has(mutation.workspaceInstanceId) + ) { + throw new Error( + `Corrupt managed mutation reservation: workspace_conflict at ${dispatchEvent.id}`, + ); + } + occupied.add(mutation.workspaceInstanceId); + reservations.push({ + workspace_instance_id: mutation.workspaceInstanceId, + repository_id: mutation.repositoryId, + workspace_id: mutation.workspaceId, + workspace_epoch_id: mutation.workspaceEpochId, + operation_id: operation.operationId, + dispatch_event_id: dispatchEvent.id, + base_workspace_version_id: mutation.baseWorkspaceVersionId, + base_accepted_event_id: mutation.baseAcceptedEventId, + base_head_revision: mutation.baseHeadRevision, + base_commit_oid: mutation.baseCommitOid, + base_tree_oid: mutation.baseTreeOid, + expected_paths_json: JSON.stringify([mutation.expectedPath]), + execution_profile_digest: mutation.executionProfileDigest, + protocol_version: 1, + reserved_at: this.runtimeEventCommittedAt(dispatchEvent.id), + }); + } + return reservations.sort((left, right) => + left.workspace_instance_id.localeCompare(right.workspace_instance_id), + ); + } + + private assertWorkspaceAuthorityStreamIsEmpty(event: RuntimeEvent): void { + const row = this.db + .prepare(` + SELECT event_id FROM runtime_events + WHERE invocation_id = ? + OR (session_id = ? AND run_id = ?) + OR (session_id = ? AND turn_id = ?) + LIMIT 1 + `) + .get(event.invocationId, event.sessionId, event.runId, event.sessionId, event.turnId) as + | { event_id: string } + | undefined; + if (row) throw new Error('Workspace baseline authority conflict'); + } + + private insertWorkspaceEpochProjection( + baseline: ReturnType['baselines'][number], + committedAt: number, + ): void { + const { epoch, authority } = baseline; + this.db + .prepare(` + INSERT INTO runtime_workspace_epochs ( + workspace_id, + workspace_epoch_id, + repository_id, + workspace_instance_id, + mode, + object_format, + source_commit_oid, + source_tree_oid, + initial_workspace_version_id, + materialization_profile_digest, + materialization_semantics, + policy_hash, + authority_session_id, + authority_invocation_id, + authority_run_id, + authority_turn_id, + epoch_opened_event_id, + protocol_version, + committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `) + .run( + epoch.workspaceId, + epoch.workspaceEpochId, + epoch.repositoryId, + epoch.workspaceInstanceId, + epoch.mode, + epoch.objectFormat, + epoch.sourceCommitOid, + epoch.sourceTreeOid, + epoch.initialWorkspaceVersionId, + epoch.materializationProfileDigest, + epoch.materializationSemantics, + epoch.policyHash, + authority.sessionId, + authority.invocationId, + authority.runId, + authority.turnId, + baseline.epochOpenedEventId, + committedAt, + ); + } + + private insertWorkspaceBaselineVersionProjection( + accepted: ReturnType['baselines'][number], + committedAt: number, + ): void { + const { baseline } = accepted; + this.db + .prepare(` + INSERT INTO runtime_workspace_versions ( + workspace_version_id, + repository_id, + workspace_id, + workspace_epoch_id, + object_format, + origin_kind, + origin_event_id, + parents_json, + operation_id, + dispatch_event_id, + outcome_event_id, + base_head_revision, + execution_profile_digest, + commit_oid, + tree_oid, + policy_hash, + tree_delta_digest, + changed_paths_json, + changed_file_count, + deleted_file_count, + accepted_event_id, + protocol_version, + committed_at + ) VALUES (?, ?, ?, ?, ?, 'baseline', ?, '[]', NULL, NULL, NULL, NULL, NULL, + ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `) + .run( + baseline.workspaceVersionId, + baseline.repositoryId, + baseline.workspaceId, + baseline.workspaceEpochId, + baseline.objectFormat, + baseline.origin.epochOpenedEventId, + baseline.commitOid, + baseline.treeOid, + baseline.policyHash, + baseline.treeDeltaDigest, + '[]', + baseline.changedFileCount, + baseline.deletedFileCount, + accepted.baselineAcceptedEventId, + committedAt, + ); + } + + private insertWorkspaceSuccessorVersionProjection( + accepted: ScannedWorkspaceSuccessorAuthority, + committedAt: number, + ): void { + const { successor } = accepted; + this.db + .prepare(` + INSERT INTO runtime_workspace_versions ( + workspace_version_id, + repository_id, + workspace_id, + workspace_epoch_id, + object_format, + origin_kind, + origin_event_id, + parents_json, + operation_id, + dispatch_event_id, + outcome_event_id, + base_head_revision, + execution_profile_digest, + commit_oid, + tree_oid, + policy_hash, + tree_delta_digest, + changed_paths_json, + changed_file_count, + deleted_file_count, + accepted_event_id, + protocol_version, + committed_at + ) VALUES (?, ?, ?, ?, ?, 'tool_mutation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `) + .run( + successor.workspaceVersionId, + successor.repositoryId, + successor.workspaceId, + successor.workspaceEpochId, + successor.objectFormat, + successor.origin.outcomeEventId, + JSON.stringify(successor.parents), + successor.origin.operationId, + successor.origin.dispatchEventId, + successor.origin.outcomeEventId, + successor.baseHeadRevision, + successor.executionProfileDigest, + successor.commitOid, + successor.treeOid, + successor.policyHash, + successor.treeDeltaDigest, + JSON.stringify(successor.changedPaths), + successor.changedFileCount, + successor.deletedFileCount, + accepted.acceptedEventId, + committedAt, + ); + } + + private insertWorkspaceHeadProjection(head: WorkspaceHeadRecordV1): void { + this.db + .prepare(` + INSERT INTO runtime_workspace_heads ( + workspace_id, + workspace_epoch_id, + repository_id, + workspace_version_id, + accepted_event_id, + commit_oid, + tree_oid, + revision + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + head.workspaceId, + head.workspaceEpochId, + head.repositoryId, + head.workspaceVersionId, + head.acceptedEventId, + head.commitOid, + head.treeOid, + head.revision, + ); + } + + private assertWorkspaceProjectionsMatchSync(authority: CanonicalWorkspaceAuthority): void { + const expectedEpochs = authority.baselines + .map(workspaceEpochProjectionRow) + .sort(compareWorkspaceEpochRow); + const expectedVersions = [ + ...authority.baselines.map(workspaceBaselineVersionProjectionRow), + ...authority.successors.map(workspaceSuccessorVersionProjectionRow), + ].sort(compareWorkspaceVersionRow); + const expectedHeads = authority.heads + .map(workspaceHeadProjectionRow) + .sort(compareWorkspaceHeadRow); + const epochs = ( + this.db + .prepare(` + SELECT + workspace_id, + workspace_epoch_id, + repository_id, + workspace_instance_id, + mode, + object_format, + source_commit_oid, + source_tree_oid, + initial_workspace_version_id, + materialization_profile_digest, + materialization_semantics, + policy_hash, + authority_session_id, + authority_invocation_id, + authority_run_id, + authority_turn_id, + epoch_opened_event_id, + protocol_version, + committed_at + FROM runtime_workspace_epochs + ORDER BY workspace_id ASC, workspace_epoch_id ASC + `) + .all() as unknown as WorkspaceEpochProjectionRow[] + ) + .map((row) => ({ ...row })) + .sort(compareWorkspaceEpochRow); + const versions = ( + this.db + .prepare(` + SELECT + workspace_version_id, + repository_id, + workspace_id, + workspace_epoch_id, + object_format, + origin_kind, + origin_event_id, + parents_json, + operation_id, + dispatch_event_id, + outcome_event_id, + base_head_revision, + execution_profile_digest, + commit_oid, + tree_oid, + policy_hash, + tree_delta_digest, + changed_paths_json, + changed_file_count, + deleted_file_count, + accepted_event_id, + protocol_version, + committed_at + FROM runtime_workspace_versions + ORDER BY workspace_version_id ASC + `) + .all() as unknown as WorkspaceVersionProjectionRow[] + ) + .map((row) => ({ ...row })) + .sort(compareWorkspaceVersionRow); + const heads = ( + this.db + .prepare(` + SELECT + workspace_id, + workspace_epoch_id, + repository_id, + workspace_version_id, + accepted_event_id, + commit_oid, + tree_oid, + revision + FROM runtime_workspace_heads + ORDER BY workspace_id ASC, workspace_epoch_id ASC + `) + .all() as unknown as WorkspaceHeadProjectionRow[] + ) + .map((row) => ({ ...row })) + .sort(compareWorkspaceHeadRow); + const activeManagedMutations = ( + this.db + .prepare(` + SELECT + workspace_instance_id, repository_id, workspace_id, workspace_epoch_id, + operation_id, dispatch_event_id, base_workspace_version_id, + base_accepted_event_id, base_head_revision, base_commit_oid, base_tree_oid, + expected_paths_json, execution_profile_digest, protocol_version, reserved_at + FROM runtime_managed_mutation_reservations + ORDER BY workspace_instance_id ASC + `) + .all() as unknown as ManagedMutationReservationProjectionRow[] + ).map((row) => ({ ...row })); + if ( + !isDeepStrictEqual(epochs, expectedEpochs) || + !isDeepStrictEqual(versions, expectedVersions) || + !isDeepStrictEqual(heads, expectedHeads) + ) { + throw new Error('Workspace version projection is incomplete or inconsistent'); + } + if (!isDeepStrictEqual(activeManagedMutations, authority.activeManagedMutations)) { + throw new Error('Managed mutation reservation projection is incomplete or inconsistent'); + } + } + + private workspaceProjectionCountSync(): number { + const row = this.db + .prepare(` + SELECT + (SELECT COUNT(*) FROM runtime_workspace_epochs) + + (SELECT COUNT(*) FROM runtime_workspace_versions) + + (SELECT COUNT(*) FROM runtime_workspace_heads) + + (SELECT COUNT(*) FROM runtime_managed_mutation_reservations) AS count + `) + .get() as { count: number }; + return row.count; + } + + private runtimeEventCommittedAt(eventId: string): number { + const row = this.db + .prepare('SELECT committed_at FROM runtime_events WHERE event_id = ?') + .get(eventId) as { committed_at: number } | undefined; + if (!row) throw new Error(`Missing RuntimeEvent committed time for ${eventId}`); + return row.committed_at; + } + + async commitToolPrepared(input: CommitToolPreparedInput): Promise { + const canonicalInput: CommitToolPreparedInput = { + ...input, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.runtimeEvent), + dispatchRuntimeEvent: canonicalizeRuntimeEventForStorage(input.dispatchRuntimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.runtimeEvent); + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.dispatchRuntimeEvent); + assertPreparedInput(canonicalInput); + return this.transaction(() => { + this.assertToolLedgerTransition( + [canonicalInput.runtimeEvent, canonicalInput.dispatchRuntimeEvent], + 't1_prepare', + ); + const existing = this.readToolOperationSync(canonicalInput.operationId); + if (existing) { + assertPreparedIdentity(existing, canonicalInput); + assertStoredRuntimeEventEquals( + canonicalInput.runtimeEvent, + this.readRuntimeEventJson(canonicalInput.runtimeEvent.id), + ); + assertStoredRuntimeEventEquals( + canonicalInput.dispatchRuntimeEvent, + this.readRuntimeEventJson(canonicalInput.dispatchRuntimeEvent.id), + ); + if (canonicalInput.dispatchRuntimeEvent.actions?.toolDispatch?.managedMutation) { + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + } + return { + created: false, + runtimeEventSeq: this.runtimeEventSeq(canonicalInput.dispatchRuntimeEvent.id), + }; + } + this.assertManagedMutationReservationAvailableSync(canonicalInput); + this.insertRuntimeEvent(canonicalInput.runtimeEvent, canonicalInput.committedAt, true); + const runtimeEventSeq = this.insertRuntimeEvent( + canonicalInput.dispatchRuntimeEvent, + canonicalInput.committedAt, + false, + ); + this.options.failpoint?.('after_runtime_event_insert'); + this.db + .prepare(` + INSERT INTO tool_journal_events ( + journal_event_id, operation_id, invocation_id, run_id, turn_id, state, + runtime_event_id, canonical_args_hash, recovery_mode, committed_at + ) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?) + `) + .run( + canonicalInput.journalEventId, + canonicalInput.operationId, + canonicalInput.runtimeEvent.invocationId, + canonicalInput.runtimeEvent.runId, + canonicalInput.runtimeEvent.turnId, + canonicalInput.dispatchRuntimeEvent.id, + canonicalInput.canonicalArgsHash, + canonicalInput.recoveryMode, + canonicalInput.committedAt, + ); + this.options.failpoint?.('after_journal_event_insert'); + this.db + .prepare(` + INSERT INTO tool_operations ( + operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, dispatch_event_id, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?, 1) + `) + .run( + canonicalInput.operationId, + canonicalInput.runtimeEvent.invocationId, + canonicalInput.runtimeEvent.runId, + canonicalInput.runtimeEvent.turnId, + canonicalInput.providerToolCallId, + canonicalInput.toolName, + canonicalInput.canonicalArgsHash, + canonicalInput.recoveryMode, + canonicalInput.runtimeEvent.id, + canonicalInput.dispatchRuntimeEvent.id, + ); + this.insertManagedMutationReservationSync(canonicalInput); + return { created: true, runtimeEventSeq }; + }); + } + + private assertManagedMutationReservationAvailableSync(input: CommitToolPreparedInput): void { + const mutation = input.dispatchRuntimeEvent.actions?.toolDispatch?.managedMutation; + if (!mutation) return; + const call = input.runtimeEvent.content; + const callArgs = call?.kind === 'function_call' ? call.args : undefined; + const callPath = + callArgs && typeof callArgs === 'object' && !Array.isArray(callArgs) + ? (callArgs as { path?: unknown }).path + : undefined; + if ( + (input.toolName !== 'Write' && input.toolName !== 'Edit') || + input.recoveryMode !== 'reconcile' || + input.dispatchRuntimeEvent.actions?.toolDispatch?.toolName !== input.toolName + ) { + throw new Error('Managed mutation reservation requires a reconcile Write operation'); + } + if (typeof callPath !== 'string' || mutation.expectedPath !== callPath) { + throw new Error('Managed mutation path does not match its durable tool call'); + } + if (!this.#readWorkspaceStorageRootBinding()) { + throw new Error('Managed mutation reservation requires a durable storage-root binding'); + } + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const epoch = authority.baselines.find( + (candidate) => + candidate.epoch.workspaceId === mutation.workspaceId && + candidate.epoch.workspaceEpochId === mutation.workspaceEpochId, + )?.epoch; + const head = authority.heads.find( + (candidate) => + candidate.workspaceId === mutation.workspaceId && + candidate.workspaceEpochId === mutation.workspaceEpochId, + ); + if ( + !epoch || + !head || + epoch.repositoryId !== mutation.repositoryId || + epoch.workspaceInstanceId !== mutation.workspaceInstanceId || + epoch.objectFormat !== mutation.objectFormat || + head.workspaceVersionId !== mutation.baseWorkspaceVersionId || + head.acceptedEventId !== mutation.baseAcceptedEventId || + head.revision !== mutation.baseHeadRevision || + head.commitOid !== mutation.baseCommitOid || + head.treeOid !== mutation.baseTreeOid + ) { + throw new Error('Managed mutation reservation does not match the canonical workspace head'); + } + const active = this.db + .prepare(` + SELECT operation_id FROM runtime_managed_mutation_reservations + WHERE workspace_instance_id = ? + `) + .get(mutation.workspaceInstanceId) as { operation_id: string } | undefined; + if (active) { + throw new Error( + `Managed mutation reservation conflict with operation ${active.operation_id}`, + ); + } + } + + private insertManagedMutationReservationSync(input: CommitToolPreparedInput): void { + const dispatch = input.dispatchRuntimeEvent.actions?.toolDispatch; + const mutation = dispatch?.managedMutation; + if (!dispatch || !mutation) return; + this.insertManagedMutationReservationProjectionSync({ + workspace_instance_id: mutation.workspaceInstanceId, + repository_id: mutation.repositoryId, + workspace_id: mutation.workspaceId, + workspace_epoch_id: mutation.workspaceEpochId, + operation_id: input.operationId, + dispatch_event_id: input.dispatchRuntimeEvent.id, + base_workspace_version_id: mutation.baseWorkspaceVersionId, + base_accepted_event_id: mutation.baseAcceptedEventId, + base_head_revision: mutation.baseHeadRevision, + base_commit_oid: mutation.baseCommitOid, + base_tree_oid: mutation.baseTreeOid, + expected_paths_json: JSON.stringify([mutation.expectedPath]), + execution_profile_digest: mutation.executionProfileDigest, + protocol_version: 1, + reserved_at: input.committedAt, + }); + } + + private insertManagedMutationReservationProjectionSync( + reservation: ManagedMutationReservationProjectionRow, + ): void { + this.db + .prepare(` + INSERT INTO runtime_managed_mutation_reservations ( + workspace_instance_id, repository_id, workspace_id, workspace_epoch_id, + operation_id, dispatch_event_id, base_workspace_version_id, + base_accepted_event_id, base_head_revision, base_commit_oid, base_tree_oid, + expected_paths_json, execution_profile_digest, protocol_version, reserved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `) + .run( + reservation.workspace_instance_id, + reservation.repository_id, + reservation.workspace_id, + reservation.workspace_epoch_id, + reservation.operation_id, + reservation.dispatch_event_id, + reservation.base_workspace_version_id, + reservation.base_accepted_event_id, + reservation.base_head_revision, + reservation.base_commit_oid, + reservation.base_tree_oid, + reservation.expected_paths_json, + reservation.execution_profile_digest, + reservation.reserved_at, + ); + } + + async commitToolOutcome(input: CommitToolOutcomeInput): Promise { + const canonicalInput: CommitToolOutcomeInput = { + ...input, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.runtimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.runtimeEvent); + assertOutcomeInput(canonicalInput); + return this.transaction(() => this.commitToolOutcomeSync(canonicalInput)); + } + + async commitToolRecoveryBundle(input: RuntimeRecoveryBundleCommit): Promise { + const canonicalInput: RuntimeRecoveryBundleCommit = { + ...input, + reconcileRuntimeEvent: canonicalizeRuntimeEventForStorage(input.reconcileRuntimeEvent), + ...(input.outcomeRuntimeEvent + ? { outcomeRuntimeEvent: canonicalizeRuntimeEventForStorage(input.outcomeRuntimeEvent) } + : {}), + decisionRuntimeEvent: canonicalizeRuntimeEventForStorage(input.decisionRuntimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.reconcileRuntimeEvent); + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.decisionRuntimeEvent); + if (canonicalInput.outcomeRuntimeEvent) { + assertNoReservedWorkspaceAuthorityAppend(canonicalInput.outcomeRuntimeEvent); + assertNoReservedRecoveryFact(canonicalInput.outcomeRuntimeEvent); + } + this.transaction(() => { + const operation = this.readToolOperationSync(canonicalInput.operationId); + if (!operation) throw new Error(`Unknown tool operation ${canonicalInput.operationId}`); + if (!operation.dispatchEventId) { + throw new Error('Recovery bundle requires a durable dispatch RuntimeEvent'); + } + assertToolRecoveryEventBundle({ + operation: recoveryOperationIdentity(operation), + callEvent: this.readRequiredRuntimeEvent(operation.callEventId), + dispatchEvent: this.readRequiredRuntimeEvent(operation.dispatchEventId), + reconcileEvent: canonicalInput.reconcileRuntimeEvent, + outcomeEvent: canonicalInput.outcomeRuntimeEvent, + decisionEvent: canonicalInput.decisionRuntimeEvent, + }); + assertStrictRuntimeEventOrder([ + this.runtimeEventSeq(operation.callEventId), + this.runtimeEventSeq(operation.dispatchEventId), + ]); + this.assertToolLedgerTransition( + [ + canonicalInput.reconcileRuntimeEvent, + ...(canonicalInput.outcomeRuntimeEvent ? [canonicalInput.outcomeRuntimeEvent] : []), + canonicalInput.decisionRuntimeEvent, + ], + 'recovery_bundle', + ); + if (operation.currentState !== 'prepared' || operation.resultEventId !== undefined) { + this.assertExactRecoveryBundleAlreadyCommitted(canonicalInput, operation); + return; + } + + this.commitRecoveryFactSync( + operation, + canonicalInput.reconcileRuntimeEvent, + 'reconcile_observed', + ); + this.options.failpoint?.('after_recovery_reconcile'); + if (canonicalInput.outcomeRuntimeEvent) { + this.commitToolOutcomeSync({ + operationId: canonicalInput.operationId, + journalEventId: `${canonicalInput.operationId}_outcome`, + runtimeEvent: canonicalInput.outcomeRuntimeEvent, + committedAt: canonicalInput.outcomeRuntimeEvent.ts, + }); + this.options.failpoint?.('after_recovery_outcome'); + } + + const decision = canonicalInput.decisionRuntimeEvent.actions?.toolRecovery; + if (!decision || decision.kind !== 'maka.tool.recovery_decision') { + throw new Error('Recovery bundle requires a recovery decision'); + } + const current = this.readToolOperationSync(canonicalInput.operationId); + if (!current) throw new Error(`Unknown tool operation ${canonicalInput.operationId}`); + this.commitRecoveryFactSync( + current, + canonicalInput.decisionRuntimeEvent, + decision.payload.disposition === 'completed' ? 'recovery_completed' : 'recovery_parked', + decision.payload, + ); + this.options.failpoint?.('after_recovery_decision'); + }); + } + + async readToolOperation(operationId: string): Promise { + return this.readToolOperationSync(operationId); + } + + async listUnsettledToolOperations(sessionId?: string): Promise { + const query = ` + SELECT operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, dispatch_event_id, result_event_id, version + FROM tool_operations + WHERE current_state = 'prepared' + AND result_event_id IS NULL + AND dispatch_event_id IS NOT NULL + ${ + sessionId === undefined + ? '' + : 'AND call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?)' + } + ORDER BY invocation_id ASC, operation_id ASC + `; + const statement = this.db.prepare(query); + const rows = (sessionId === undefined + ? statement.all() + : statement.all(sessionId)) as unknown as ToolOperationRow[]; + return rows.map(toolOperationFromRow); + } + + async readToolJournal(operationId: string): Promise { + const rows = this.db + .prepare(` + SELECT journal_event_id, operation_id, invocation_id, run_id, turn_id, + state, runtime_event_id, canonical_args_hash, recovery_mode, + external_handle, metadata_json, committed_at + FROM tool_journal_events + WHERE operation_id = ? + ORDER BY journal_seq ASC + `) + .all(operationId) as unknown as ToolJournalRow[]; + return rows.map(toolJournalRecordFromRow); + } + + async rebuildToolProjectionsFromRuntimeEvents(): Promise { + return this.transaction(() => this.rebuildToolProjectionsFromRuntimeEventsSync()); + } + + private rebuildToolProjectionsFromRuntimeEventsSync( + sessionId?: string, + ): ToolProjectionRebuildResult { + const statement = this.db.prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, + event_seq, payload_json, committed_at + FROM runtime_events + ${sessionId === undefined ? '' : 'WHERE session_id = ?'} + ORDER BY invocation_id ASC, event_seq ASC, event_id ASC + `); + const rows = (sessionId === undefined + ? statement.all() + : statement.all(sessionId)) as unknown as Array< + RuntimeEventStorageRow & { event_seq: number; committed_at: number } + >; + const events = rows.map(decodeRuntimeEventStorageRow); + const eventOrder = new Map(events.map((event, index) => [event.id, index] as const)); + const committedAt = new Map( + rows.map((row, index) => [events[index]!.id, row.committed_at] as const), + ); + const scan = scanToolLedger(events); + if (scan.hasCorruption) { + const first = scan.issues[0]; + throw new Error( + `Corrupt tool RuntimeEvent ledger: ${first?.code ?? 'unknown'} at ${first?.eventId ?? 'unknown'}`, + ); + } + const projected = scan.operations.filter((operation) => operation.dispatchEvent); + + // Mainline schema 4 can contain pre-authority projections without a + // dispatch RuntimeEvent. They remain readable but quarantined from + // recovery; only projections backed by canonical T1 facts are rebuilt. + if (sessionId === undefined) { + this.db.exec(` + DELETE FROM tool_journal_events + WHERE operation_id IN ( + SELECT operation_id FROM tool_operations WHERE dispatch_event_id IS NOT NULL + ); + DELETE FROM tool_operations WHERE dispatch_event_id IS NOT NULL; + `); + } else { + this.db + .prepare(` + DELETE FROM tool_journal_events + WHERE operation_id IN ( + SELECT operation_id + FROM tool_operations + WHERE dispatch_event_id IS NOT NULL + AND call_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + ) + `) + .run(sessionId); + this.db + .prepare(` + DELETE FROM tool_operations + WHERE dispatch_event_id IS NOT NULL + AND call_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + `) + .run(sessionId); + } + let journalEvents = 0; + for (const operation of projected) { + const call = operation.callEvent; + const event = operation.dispatchEvent; + const dispatch = event?.actions?.toolDispatch; + if (!call || !event || !dispatch) { + throw new Error('Tool projection scan produced an incomplete dispatched operation'); + } + const recovery = interpretScannedToolRecovery(operation, eventOrder); + if (recovery.kind === 'corruption') { + throw new Error( + `Corrupt tool recovery bundle for ${dispatch.operationId}: ${recovery.code}`, + ); + } + const reconcileEvent = recovery.kind === 'valid' ? recovery.reconcileEvent : undefined; + const decisionEvent = recovery.kind === 'valid' ? recovery.decisionEvent : undefined; + + this.db + .prepare(` + INSERT INTO tool_journal_events ( + journal_event_id, operation_id, invocation_id, run_id, turn_id, state, + runtime_event_id, canonical_args_hash, recovery_mode, committed_at + ) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?) + `) + .run( + `${dispatch.operationId}_prepared`, + dispatch.operationId, + event.invocationId, + event.runId, + event.turnId, + event.id, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + committedAt.get(event.id) ?? event.ts, + ); + journalEvents += 1; + const response = operation.responseEvent; + const decision = recovery.kind === 'valid' ? recovery.decision : undefined; + const currentState = decision + ? decision.disposition === 'completed' + ? 'recovery_completed' + : 'recovery_parked' + : response + ? 'outcome_committed' + : 'prepared'; + const tail = [ + ...(reconcileEvent + ? [{ event: reconcileEvent, state: 'reconcile_observed' as const }] + : []), + ...(response ? [{ event: response, state: 'outcome_committed' as const }] : []), + ...(decisionEvent + ? [ + { + event: decisionEvent, + state: + decision?.disposition === 'parked' + ? ('recovery_parked' as const) + : ('recovery_completed' as const), + }, + ] + : []), + ].sort( + (a, b) => + requireRuntimeEventOrder(eventOrder, a.event.id) - + requireRuntimeEventOrder(eventOrder, b.event.id), + ); + this.db + .prepare(` + INSERT INTO tool_operations ( + operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, dispatch_event_id, result_event_id, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + dispatch.operationId, + event.invocationId, + event.runId, + event.turnId, + dispatch.providerToolCallId, + dispatch.toolName, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + currentState, + call.id, + event.id, + response?.id ?? null, + 1 + tail.length, + ); + for (const item of tail) { + this.db + .prepare(` + INSERT INTO tool_journal_events ( + journal_event_id, operation_id, invocation_id, run_id, turn_id, state, + runtime_event_id, canonical_args_hash, recovery_mode, metadata_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + journalEventIdFor(dispatch.operationId, item.event, item.state), + dispatch.operationId, + item.event.invocationId, + item.event.runId, + item.event.turnId, + item.state, + item.event.id, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + item.event.actions?.toolRecovery + ? JSON.stringify(item.event.actions.toolRecovery) + : null, + committedAt.get(item.event.id) ?? item.event.ts, + ); + journalEvents += 1; + } + } + return { operations: projected.length, journalEvents }; + } + + private commitToolOutcomeSync( + input: CommitToolOutcomeInput, + settlementOwner: 'generic' | 'workspace_successor' | 'workspace_terminal' = 'generic', + ): ToolCommitResult { + const operation = this.readToolOperationSync(input.operationId); + if (!operation) throw new Error(`Unknown tool operation ${input.operationId}`); + assertOutcomeIdentity(operation, input.runtimeEvent); + this.assertToolLedgerTransition([input.runtimeEvent], 't2_outcome'); + if (operation.resultEventId) { + if (operation.resultEventId !== input.runtimeEvent.id) { + throw new Error(`Tool operation outcome conflict for ${input.operationId}`); + } + assertStoredRuntimeEventEquals( + input.runtimeEvent, + this.readRuntimeEventJson(input.runtimeEvent.id), + ); + return { created: false, runtimeEventSeq: this.runtimeEventSeq(input.runtimeEvent.id) }; + } + if (!operation.dispatchEventId) { + throw new Error(`Tool operation ${input.operationId} is missing its dispatch event`); + } + const dispatchJson = this.readRuntimeEventJson(operation.dispatchEventId); + const dispatchEvent = dispatchJson + ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) + : undefined; + if ( + dispatchEvent?.actions?.toolDispatch?.resultProjectionVersion === 1 && + input.runtimeEvent.content?.kind === 'function_response' && + input.runtimeEvent.content.modelProjection === undefined + ) { + throw new Error('Projected Tool Result T2 requires its durable model projection'); + } + if (dispatchEvent?.actions?.toolDispatch?.managedMutation) { + const reservation = this.db + .prepare(` + SELECT operation_id FROM runtime_managed_mutation_reservations + WHERE operation_id = ? + `) + .get(input.operationId) as { operation_id: string } | undefined; + if (!reservation) { + throw new Error('Managed mutation T1 is missing its durable reservation'); + } + if (settlementOwner === 'generic') { + throw new Error('Managed mutation outcome requires a managed mutation authority writer'); + } + } + const runtimeEventSeq = this.insertRuntimeEvent(input.runtimeEvent, input.committedAt, false); + this.options.failpoint?.('after_runtime_event_insert'); + this.insertToolJournalEvent( + operation, + input.runtimeEvent, + 'outcome_committed', + input.journalEventId, + input.committedAt, + ); + const updated = this.db + .prepare(` + UPDATE tool_operations + SET current_state = 'outcome_committed', result_event_id = ?, version = version + 1 + WHERE operation_id = ? AND current_state = 'prepared' AND result_event_id IS NULL + `) + .run(input.runtimeEvent.id, input.operationId); + if (updated.changes !== 1) { + throw new Error(`Tool operation compare-and-set failed for ${input.operationId}`); + } + return { created: true, runtimeEventSeq }; + } + + private commitRecoveryFactSync( + operation: ToolOperationRecord, + event: RuntimeEvent, + state: 'reconcile_observed' | 'recovery_completed' | 'recovery_parked', + decision?: ToolRecoveryDecisionFact, + ): void { + this.insertRuntimeEvent(event, event.ts, false); + this.options.failpoint?.('after_runtime_event_insert'); + this.insertToolJournalEvent(operation, event, state); + if (state === 'reconcile_observed') { + const updated = this.db + .prepare('UPDATE tool_operations SET version = version + 1 WHERE operation_id = ?') + .run(operation.operationId); + if (updated.changes !== 1) { + throw new Error(`Tool operation compare-and-set failed for ${operation.operationId}`); + } + return; + } + + if ( + state === 'recovery_completed' && + (decision?.disposition !== 'completed' || + operation.currentState !== 'outcome_committed' || + operation.resultEventId !== decision.outcomeEventId) + ) { + throw new Error('Completed recovery decision does not match the persisted outcome'); + } + if ( + state === 'recovery_parked' && + (decision?.disposition !== 'parked' || + operation.currentState !== 'prepared' || + operation.resultEventId !== undefined) + ) { + throw new Error('Parked recovery decision does not match the prepared operation'); + } + const updated = this.db + .prepare(` + UPDATE tool_operations + SET current_state = ?, version = version + 1 + WHERE operation_id = ? AND current_state = ? + `) + .run( + state, + operation.operationId, + state === 'recovery_completed' ? 'outcome_committed' : 'prepared', + ); + if (updated.changes !== 1) { + throw new Error(`Tool operation compare-and-set failed for ${operation.operationId}`); + } + } + + private insertToolJournalEvent( + operation: ToolOperationRecord, + event: RuntimeEvent, + state: ToolJournalState, + journalEventId = `${event.id}_journal`, + committedAt = event.ts, + ): void { + this.db + .prepare(` + INSERT INTO tool_journal_events ( + journal_event_id, operation_id, invocation_id, run_id, turn_id, state, + runtime_event_id, canonical_args_hash, recovery_mode, metadata_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + journalEventId, + operation.operationId, + operation.invocationId, + operation.runId, + operation.turnId, + state, + event.id, + operation.canonicalArgsHash, + operation.recoveryMode, + event.actions?.toolRecovery ? JSON.stringify(event.actions.toolRecovery) : null, + committedAt, + ); + this.options.failpoint?.('after_journal_event_insert'); + } + + private assertExactRecoveryBundleAlreadyCommitted( + input: RuntimeRecoveryBundleCommit, + operation: ToolOperationRecord, + ): void { + const decision = input.decisionRuntimeEvent.actions?.toolRecovery; + const completed = + decision?.kind === 'maka.tool.recovery_decision' && + decision.payload.disposition === 'completed'; + if ( + (completed && + (!input.outcomeRuntimeEvent || + operation.currentState !== 'recovery_completed' || + operation.resultEventId !== input.outcomeRuntimeEvent.id)) || + (!completed && + (input.outcomeRuntimeEvent !== undefined || + operation.currentState !== 'recovery_parked' || + operation.resultEventId !== undefined)) + ) { + throw new Error(`Tool operation ${operation.operationId} is already settled`); + } + for (const event of [ + input.reconcileRuntimeEvent, + ...(input.outcomeRuntimeEvent ? [input.outcomeRuntimeEvent] : []), + input.decisionRuntimeEvent, + ]) { + const stored = this.readRuntimeEventJson(event.id); + if (stored === undefined) { + throw new Error(`Tool recovery bundle is incomplete for ${operation.operationId}`); + } + assertStoredRuntimeEventEquals(event, stored); + } + } + + private transaction(operation: () => T): T { + if (this.databaseLease) return this.databaseLease.transaction('write', operation); + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = operation(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Preserve the protocol failure that caused rollback. + } + throw error; + } + } + + private readTransaction(operation: () => T): T { + if (this.databaseLease) return this.databaseLease.transaction('read', operation); + this.db.exec('BEGIN'); + try { + const result = operation(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Preserve the consistency failure that caused rollback. + } + throw error; + } + } + + private readContinuationClaimRow( + predicate: string, + ...values: readonly SQLInputValue[] + ): ContinuationClaimStorageRow | undefined { + return this.db + .prepare(` + SELECT + claim_id, + source_session_id, + source_invocation_id, + source_run_id, + source_turn_id, + source_event_high_water, + source_prefix_digest, + boundary_digest, + boundary_json, + provider_projection_version, + provider_replay_digest, + target_session_id, + target_invocation_id, + target_run_id, + target_turn_id, + target_opening_json, + claimed_at, + start_event_id, + start_kind, + protocol_version + FROM runtime_continuation_claims + WHERE ${predicate} + LIMIT 1 + `) + .get(...values) as ContinuationClaimStorageRow | undefined; + } + + private readContinuationClaimRows(): ContinuationClaimStorageRow[] { + return this.db + .prepare(` + SELECT + claim_id, + source_session_id, + source_invocation_id, + source_run_id, + source_turn_id, + source_event_high_water, + source_prefix_digest, + boundary_digest, + boundary_json, + provider_projection_version, + provider_replay_digest, + target_session_id, + target_invocation_id, + target_run_id, + target_turn_id, + target_opening_json, + claimed_at, + start_event_id, + start_kind, + protocol_version + FROM runtime_continuation_claims + ORDER BY claimed_at ASC, claim_id ASC + `) + .all() as unknown as ContinuationClaimStorageRow[]; + } + + private assertContinuationAuthorityIntegrity(): void { + for (const row of this.readContinuationClaimRows()) { + this.decodeContinuationClaimStateRow(row); + } + } + + private continuationTargetHasRuntimeState(claim: ContinuationClaimV1): boolean { + const { target } = claim; + const values = [ + target.invocationId, + target.sessionId, + target.runId, + claim.targetOpening.source.kind === 'handoff' ? 1 : 0, + target.sessionId, + target.turnId, + ] as const; + const runtimeEvent = this.db + .prepare(` + SELECT 1 AS found + FROM runtime_events + WHERE invocation_id = ? + OR (session_id = ? AND run_id = ?) + OR (? = 0 AND session_id = ? AND turn_id = ?) + LIMIT 1 + `) + .get(...values) as { found: number } | undefined; + if (runtimeEvent) return true; + return ( + (this.db + .prepare(` + SELECT 1 AS found + FROM runtime_partial_snapshots + WHERE invocation_id = ? + OR (session_id = ? AND run_id = ?) + OR (? = 0 AND session_id = ? AND turn_id = ?) + LIMIT 1 + `) + .get(...values) as { found: number } | undefined) !== undefined + ); + } + + private decodeContinuationClaimStateRow( + row: ContinuationClaimStorageRow, + ): ContinuationClaimStateV1 { + const claim = decodeContinuationClaimRow(row); + if (!row.start_event_id) { + if (row.start_kind !== null) { + throw new Error(`Continuation claim start kind exists without event for ${claim.claimId}`); + } + return { claim }; + } + if (row.start_kind !== 'runtime_admission' && row.start_kind !== 'claim_repair') { + throw new Error(`Continuation claim start kind is missing for ${claim.claimId}`); + } + const start = this.readRequiredRuntimeEvent(row.start_event_id); + assertContinuationStartEvent(claim, start, row.start_kind); + if (start.id !== row.start_event_id || this.runtimeEventSeq(start.id) !== 1) { + throw new Error(`Continuation claim start identity mismatch for ${claim.claimId}`); + } + return { claim, startEventId: row.start_event_id, startKind: row.start_kind }; + } + + private assertContinuationBoundaryMatchesLedger(claim: ContinuationClaimV1): void { + const lastIndex = claim.boundary.segments.length - 1; + let previousPrefix: ImmutableRuntimePrefixV1 | undefined; + for (const [index, segment] of claim.boundary.segments.entries()) { + let prefix: ImmutableRuntimePrefixV1; + try { + prefix = this.readImmutableRuntimePrefixSync({ + sessionId: segment.identity.sessionId, + runId: segment.identity.runId, + ...(index === lastIndex ? {} : { upToEventSeq: segment.position.lastEventSeq }), + }); + } catch (error) { + if ( + error instanceof Error && + (error.message === 'immutable RuntimeEvent prefix is empty' || + error.message.includes('high-water') || + error.message.includes('event_seq gap')) + ) { + throw new Error( + index === lastIndex + ? 'Continuation source boundary is missing' + : `Continuation ancestor boundary is missing for ${segment.identity.runId}`, + ); + } + throw error; + } + if ( + !isDeepStrictEqual(prefix.identity, segment.identity) || + !isDeepStrictEqual(prefix.position, segment.position) || + prefix.prefixDigest !== segment.prefixDigest + ) { + throw new Error( + index === lastIndex + ? 'Continuation source boundary changed' + : `Continuation ancestor boundary changed for ${segment.identity.runId}`, + ); + } + const opening = prefix.events[0]?.content; + const repeatsTurn = previousPrefix?.identity.turnId === prefix.identity.turnId; + if ( + repeatsTurn || + (opening?.kind === 'invocation_opened' && opening.source.kind === 'handoff') + ) { + if ( + !previousPrefix || + opening?.kind !== 'invocation_opened' || + opening.source.kind !== 'handoff' + ) { + throw new Error('Same-turn boundary requires an authenticated handoff edge'); + } + const row = this.readContinuationClaimRow('claim_id = ?', opening.source.claimId); + const state = row && this.decodeContinuationClaimStateRow(row); + if ( + !state || + state.startEventId !== prefix.events[0]?.id || + !isDeepStrictEqual( + state.claim.boundary.segments, + claim.boundary.segments.slice(0, index), + ) || + !continuationStartEventMatchesClaim(prefix.events[0], state.claim, state.startKind) + ) { + throw new Error('Same-turn boundary handoff claim does not authenticate its lineage'); + } + assertHandoffClaimSource(state.claim, previousPrefix); + } + if (index === lastIndex) { + assertHandoffClaimSource(claim, prefix); + const terminalEvents = prefix.events.filter(isTerminalRuntimeEvent); + const terminal = terminalEvents[0]; + if (terminalEvents.length !== 1 || !terminal || prefix.events.at(-1)?.id !== terminal.id) { + throw new Error( + 'Continuation source boundary must end with exactly one terminal RuntimeEvent', + ); + } + } + previousPrefix = prefix; + } + } + + private assertToolLedgerTransition( + candidateEvents: readonly RuntimeEvent[], + expectedTransition: Parameters[0]['expectedTransition'], + ): void { + this.assertWorkspaceToolLedgerHealthy(); + // Tool-call identity is scoped by invocation. Reading unrelated invocations here turns + // concurrent subagents into repeated whole-workspace scans without strengthening the + // transition check; event and operation uniqueness remain enforced by SQLite keys. + const rows: RuntimeEventStorageRow[] = []; + const invocationIds = [...new Set(candidateEvents.map((event) => event.invocationId))].sort(); + const readInvocation = this.db.prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE invocation_id = ? + ORDER BY event_seq ASC, event_id ASC + `); + for (const invocationId of invocationIds) { + rows.push(...(readInvocation.all(invocationId) as unknown as RuntimeEventStorageRow[])); + } + const validation = validateToolLedgerTransition({ + existingEvents: rows.map(decodeRuntimeEventStorageRow), + candidateEvents: candidateEvents.map(canonicalizeRuntimeEventForStorage), + expectedTransition, + }); + if (!validation.ok) { + throw new ToolLedgerRejectionError(validation.code, validation.eventId); + } + } + + private assertWorkspaceToolLedgerHealthy(): void { + const dataVersion = this.runtimeDataVersion(); + if (!this.toolLedgerHealth || this.toolLedgerHealth.dataVersion !== dataVersion) { + this.refreshToolLedgerHealth(); + } + const health = this.toolLedgerHealth!; + if (health.decodeFailure) throw health.decodeFailure.error; + if (health.issue) { + // Pre-existing damage, not a bad candidate. Note the reach of "refused": + // this gate is only ever consulted for tool-bearing events, so a damaged + // ledger refuses tool facts and takes everything else. Callers that treat + // this as "the store is gone" are overreading it — see the note on the + // latch in `AgentRun.enqueueRuntimeEventStore`. + throw new ToolLedgerCorruptionError(health.issue.code, health.issue.eventId); + } + } + + private refreshToolLedgerHealth(): void { + const dataVersion = this.runtimeDataVersion(); + try { + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + ORDER BY invocation_id ASC, event_seq ASC, event_id ASC + `) + .all() as unknown as RuntimeEventStorageRow[]; + const scan = scanToolLedger(rows.map(decodeRuntimeEventStorageRow)); + this.toolLedgerHealth = { dataVersion, issue: scan.issues[0] }; + } catch (error) { + this.toolLedgerHealth = { dataVersion, decodeFailure: { error } }; + } + } + + private runtimeDataVersion(): number { + const row = this.db.prepare('PRAGMA data_version').get() as { data_version: number }; + return row.data_version; + } + + private assertInvocationIdentity(events: readonly RuntimeEvent[]): void { + const candidates = new Map(); + const runs = new Map< + string, + { sessionId: string; invocationId: string; runId: string; turnId: string } + >(); + for (const event of events) { + const identity = { + sessionId: event.sessionId, + runId: event.runId, + turnId: event.turnId, + }; + const prior = candidates.get(event.invocationId); + if ( + prior && + (prior.sessionId !== identity.sessionId || + prior.runId !== identity.runId || + prior.turnId !== identity.turnId) + ) { + throw new Error(`RuntimeEvent invocation identity conflict for ${event.invocationId}`); + } + candidates.set(event.invocationId, identity); + const runKey = `${event.sessionId}\0${event.runId}`; + const priorRun = runs.get(runKey); + if ( + priorRun && + (priorRun.invocationId !== event.invocationId || priorRun.turnId !== event.turnId) + ) { + throw new Error(`RuntimeEvent run identity conflict for ${event.runId}`); + } + runs.set(runKey, { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + }); + } + for (const [invocationId, identity] of candidates) { + const rows = this.db + .prepare(` + SELECT DISTINCT session_id, run_id, turn_id + FROM runtime_events + WHERE invocation_id = ? + UNION + SELECT DISTINCT session_id, run_id, turn_id + FROM runtime_partial_snapshots + WHERE invocation_id = ? + `) + .all(invocationId, invocationId) as Array<{ + session_id: string; + run_id: string; + turn_id: string; + }>; + if ( + rows.some( + (row) => + row.session_id !== identity.sessionId || + row.run_id !== identity.runId || + row.turn_id !== identity.turnId, + ) + ) { + throw new Error(`RuntimeEvent invocation identity conflict for ${invocationId}`); + } + } + for (const identity of runs.values()) { + const rows = this.db + .prepare(` + SELECT DISTINCT invocation_id, turn_id + FROM runtime_events + WHERE session_id = ? AND run_id = ? + UNION + SELECT DISTINCT invocation_id, turn_id + FROM runtime_partial_snapshots + WHERE session_id = ? AND run_id = ? + `) + .all(identity.sessionId, identity.runId, identity.sessionId, identity.runId) as Array<{ + invocation_id: string; + turn_id: string; + }>; + if ( + rows.some( + (row) => row.invocation_id !== identity.invocationId || row.turn_id !== identity.turnId, + ) + ) { + throw new Error(`RuntimeEvent run identity conflict for ${identity.runId}`); + } + } + } + + private assertContinuationAuthorityAllowsEvent( + event: RuntimeEvent, + authorizedPendingClaimId?: string, + exactRetry = false, + ): void { + const rows = this.readContinuationClaimRows(); + const ownClaim = rows.find( + (row) => + row.target_session_id === event.sessionId && + row.target_invocation_id === event.invocationId && + row.target_run_id === event.runId && + row.target_turn_id === event.turnId, + ); + const ownHandoff = ownClaim && decodeContinuationClaimRow(ownClaim); + for (const row of rows) { + const claim = decodeContinuationClaimRow(row); + const source = claim.boundary.segments.find( + (segment) => + segment.identity.sessionId === event.sessionId && segment.identity.runId === event.runId, + ); + if (source && !exactRetry) { + throw new Error( + `RuntimeEvent source boundary is sealed by continuation claim ${claim.claimId}`, + ); + } + if (source && exactRetry) continue; + + const target = claim.target; + const collidesWithTarget = + event.invocationId === target.invocationId || + (event.sessionId === target.sessionId && event.runId === target.runId) || + (event.sessionId === target.sessionId && + event.turnId === target.turnId && + !( + ownHandoff?.targetOpening.source.kind === 'handoff' && + ownHandoff.boundary.segments.some((segment) => segment.identity.runId === target.runId) + )); + if (!collidesWithTarget) continue; + if ( + event.sessionId !== target.sessionId || + event.invocationId !== target.invocationId || + event.runId !== target.runId || + event.turnId !== target.turnId + ) { + throw new Error(`RuntimeEvent continuation target identity conflict for ${claim.claimId}`); + } + if (!row.start_event_id && authorizedPendingClaimId !== claim.claimId) { + throw new Error( + `RuntimeEvent target sequence one is reserved for continuation-start by claim ${claim.claimId}`, + ); + } + } + } + + private assertRunNotSealed(event: RuntimeEvent): void { + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + AND ${TERMINAL_RUNTIME_EVENT_SQL} + ORDER BY event_seq ASC + `) + .all(event.sessionId, event.runId) as unknown as RuntimeEventStorageRow[]; + const terminal = rows.map(decodeRuntimeEventStorageRow).find(isTerminalRuntimeEvent); + if (terminal) { + throw new RunSealedError(event.runId); + } + } + + private importRuntimeEventSync(event: RuntimeEvent): boolean { + const canonicalEvent = canonicalizeRuntimeEventForStorage(event); + this.assertInvocationIdentity([canonicalEvent]); + const partial = partialRuntimeStream(canonicalEvent); + if (partial) { + this.assertContinuationAuthorityAllowsEvent(canonicalEvent); + this.assertRunNotSealed(canonicalEvent); + return this.upsertRuntimePartial(canonicalEvent, partial); + } + const existing = this.readRuntimeEventJson(canonicalEvent.id) !== undefined; + // Seal before tool-ledger semantics, so every post-terminal append + // refuses the same way (#2311): a late tool-bearing straggler must read + // as the sealed-run boundary it is, not as a producer bug or ledger + // corruption. Continuation authority stays ahead of the seal, its + // refusals are more specific, and an exact-id retry keeps its dedup + // semantics: the event is already inside the seal, so only new events + // consult either. + if (!existing) { + this.assertContinuationAuthorityAllowsEvent(canonicalEvent); + this.assertRunNotSealed(canonicalEvent); + } + if (isToolLedgerBearingEvent(canonicalEvent)) { + this.assertToolLedgerTransition([canonicalEvent], 'generic_append'); + } + this.insertRuntimeEvent(canonicalEvent, canonicalEvent.ts, true); + return !existing; + } + + private importRuntimePartialBatchSync(events: readonly RuntimeEvent[]): void { + const first = events[0]; + if (!first) return; + const partials = events.map((event) => partialRuntimeStream(event)); + const firstPartial = partials[0]; + if (!firstPartial) { + throw new Error('Runtime partial batch contains a non-partial event'); + } + for (let index = 0; index < events.length; index += 1) { + const event = events[index]!; + const partial = partials[index]; + if (!partial) throw new Error('Runtime partial batch contains a non-partial event'); + if ( + partial.key !== firstPartial.key || + event.sessionId !== first.sessionId || + event.invocationId !== first.invocationId || + event.runId !== first.runId || + event.turnId !== first.turnId + ) { + throw new Error('Runtime partial batch must contain exactly one presentation stream'); + } + } + this.assertInvocationIdentity(events); + this.assertContinuationAuthorityAllowsEvent(first); + this.assertRunNotSealed(first); + const last = events.at(-1)!; + this.upsertRuntimePartial(first, { + ...firstPartial, + text: partials.map((partial) => partial!.text).join(''), + updatedAt: last.ts, + }); + } + + private assertImmutableSteeringMessageIdentity(event: RuntimeEvent): void { + const messageId = immutableSteeringMessageId(event); + if (!messageId) return; + const matches = this.readImmutableSessionRuntimeEvents(event.sessionId).filter( + (candidate) => immutableSteeringMessageId(candidate) === messageId, + ); + if (matches.some((candidate) => !isDeepStrictEqual(candidate, event))) { + throw new Error(`Immutable steering message identity conflict: ${messageId}`); + } + } + + private readImmutableSessionRuntimeEvents(sessionId: string): RuntimeEvent[] { + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? + ORDER BY committed_at ASC, event_id ASC + `) + .all(sessionId) as unknown as RuntimeEventStorageRow[]; + return rows.map(decodeRuntimeEventStorageRow); + } + + private insertRuntimeEvent( + event: RuntimeEvent, + committedAt: number, + allowExactDuplicate: boolean, + authorizedPendingContinuationClaimId?: string, + ): number { + const encoding = encodeCanonicalRuntimeEvent(event); + const canonicalEvent = encoding.event; + this.assertInvocationIdentity([canonicalEvent]); + assertRuntimeEventIdentity(canonicalEvent); + this.assertImmutableSteeringMessageIdentity(canonicalEvent); + const existingJson = this.readRuntimeEventJson(canonicalEvent.id); + if (existingJson !== undefined) { + assertStoredRuntimeEventEquals(canonicalEvent, existingJson); + this.assertContinuationAuthorityAllowsEvent( + canonicalEvent, + authorizedPendingContinuationClaimId, + true, + ); + this.deleteCompletedPartialSnapshot(canonicalEvent); + if (!allowExactDuplicate) { + throw new Error( + `RuntimeEvent ${canonicalEvent.id} already exists outside this tool transaction`, + ); + } + return this.runtimeEventSeq(canonicalEvent.id); + } + this.assertContinuationAuthorityAllowsEvent( + canonicalEvent, + authorizedPendingContinuationClaimId, + ); + this.assertRunNotSealed(canonicalEvent); + const next = this.nextRuntimeEventSeq(canonicalEvent.invocationId); + this.db + .prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + canonicalEvent.id, + canonicalEvent.sessionId, + canonicalEvent.invocationId, + canonicalEvent.runId, + canonicalEvent.turnId, + next, + runtimeEventKind(canonicalEvent), + encoding.json, + committedAt, + ); + const ordinalRow = this.db + .prepare(` + SELECT COALESCE(MAX(ordinal), 0) + 1 AS next_ordinal + FROM runtime_session_event_ordinals + WHERE session_id = ? + `) + .get(canonicalEvent.sessionId) as { next_ordinal?: unknown }; + const ordinal = ordinalRow.next_ordinal; + if (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1) { + throw new Error(`Invalid next RuntimeEvent Session ordinal for ${canonicalEvent.sessionId}`); + } + this.db + .prepare(` + INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) + VALUES (?, ?, ?) + `) + .run(canonicalEvent.sessionId, ordinal, canonicalEvent.id); + this.deleteCompletedPartialSnapshot(canonicalEvent); + return next; + } + + private deleteCompletedPartialSnapshot(event: RuntimeEvent): void { + const completedPartialKey = completedPartialRuntimeStreamKey(event); + if (!completedPartialKey) return; + this.db + .prepare('DELETE FROM runtime_partial_snapshots WHERE stream_key = ?') + .run(completedPartialKey); + } + + private upsertRuntimePartial( + event: RuntimeEvent, + partial: { key: string; snapshot: RuntimeEvent; text: string; updatedAt?: number }, + ): boolean { + const existing = this.db + .prepare(` + SELECT 1 AS found FROM runtime_partial_snapshots WHERE stream_key = ? + `) + .get(partial.key) as { found: number } | undefined; + if (!existing && this.hasCompletedPartialStream(event.sessionId, event.runId, partial.key)) { + return false; + } + const anchor = existing + ? undefined + : (this.db + .prepare(` + SELECT event_id FROM runtime_events + WHERE session_id = ? AND run_id = ? + ORDER BY event_seq DESC LIMIT 1 + `) + .get(event.sessionId, event.runId) as { event_id: string } | undefined); + if (!existing) { + this.db + .prepare(` + INSERT INTO runtime_partial_snapshots ( + stream_key, session_id, invocation_id, run_id, turn_id, + after_event_id, payload_json, text_content, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + partial.key, + event.sessionId, + event.invocationId, + event.runId, + event.turnId, + anchor?.event_id ?? null, + JSON.stringify(partial.snapshot), + '', + partial.updatedAt ?? event.ts, + ); + } else { + this.db + .prepare('UPDATE runtime_partial_snapshots SET updated_at = ? WHERE stream_key = ?') + .run(partial.updatedAt ?? event.ts, partial.key); + } + if (partial.text.length > 0) { + this.appendRuntimePartialSegment(partial.key, partial.text, partial.updatedAt ?? event.ts); + } + return !existing; + } + + private appendRuntimePartialSegment(streamKey: string, text: string, updatedAt: number): void { + const tail = this.db + .prepare(` + SELECT segment_seq, length(CAST(text_content AS BLOB)) AS stored_bytes + FROM runtime_partial_segments + WHERE stream_key = ? + ORDER BY segment_seq DESC + LIMIT 1 + `) + .get(streamKey) as { segment_seq?: unknown; stored_bytes?: unknown } | undefined; + if (tail) { + const segmentSequence = requireRuntimeEventScanCount(tail.segment_seq); + const storedBytes = requireRuntimeEventScanCount(tail.stored_bytes); + if (storedBytes + Buffer.byteLength(text, 'utf8') <= RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES) { + this.db + .prepare(` + UPDATE runtime_partial_segments + SET text_content = text_content || ?, updated_at = ? + WHERE stream_key = ? AND segment_seq = ? + `) + .run(text, updatedAt, streamKey, segmentSequence); + return; + } + } + this.db + .prepare(` + INSERT INTO runtime_partial_segments(stream_key, segment_seq, text_content, updated_at) + VALUES (?, ?, ?, ?) + `) + .run( + streamKey, + tail ? requireRuntimeEventScanCount(tail.segment_seq) + 1 : 1, + text, + updatedAt, + ); + } + + private hasCompletedPartialStream(sessionId: string, runId: string, streamKey: string): boolean { + const rows = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + `) + .all(sessionId, runId) as unknown as RuntimeEventStorageRow[]; + return rows.some( + (row) => completedPartialRuntimeStreamKey(decodeRuntimeEventStorageRow(row)) === streamKey, + ); + } + + private nextRuntimeEventSeq(invocationId: string): number { + const row = this.db + .prepare(` + SELECT COALESCE(MAX(event_seq), 0) + 1 AS next_seq + FROM runtime_events + WHERE invocation_id = ? + `) + .get(invocationId) as { next_seq: number }; + return row.next_seq; + } + + private runtimeEventSeq(eventId: string): number { + const row = this.db + .prepare(` + SELECT event_seq FROM runtime_events WHERE event_id = ? + `) + .get(eventId) as { event_seq: number } | undefined; + if (!row) throw new Error(`Missing RuntimeEvent ${eventId}`); + return row.event_seq; + } + + private readRuntimeEventJson(eventId: string): string | undefined { + const row = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE event_id = ? + `) + .get(eventId) as RuntimeEventStorageRow | undefined; + if (row) decodeRuntimeEventStorageRow(row); + return row?.payload_json; + } + + private readRequiredRuntimeEvent(eventId: string): RuntimeEvent { + const stored = this.readRuntimeEventJson(eventId); + if (stored === undefined) throw new Error(`Missing RuntimeEvent ${eventId}`); + return decodeStoredRuntimeEvent(stored); + } + + private readToolOperationSync(operationId: string): ToolOperationRecord | undefined { + const row = this.db + .prepare(` + SELECT operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, dispatch_event_id, result_event_id, version + FROM tool_operations + WHERE operation_id = ? + `) + .get(operationId) as ToolOperationRow | undefined; + return row ? toolOperationFromRow(row) : undefined; + } +} + +interface ToolLedgerHealth { + dataVersion: number; + issue?: ReturnType['issues'][number]; + decodeFailure?: { error: unknown }; +} + +interface ToolOperationRow { + operation_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + provider_tool_call_id: string; + tool_name: string; + canonical_args_hash: string; + recovery_mode: ToolRecoveryMode; + current_state: 'prepared' | 'outcome_committed' | 'recovery_completed' | 'recovery_parked'; + call_event_id: string; + dispatch_event_id: string | null; + result_event_id: string | null; + version: number; +} + +interface ToolJournalRow { + journal_event_id: string; + operation_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + state: ToolJournalState; + runtime_event_id: string | null; + canonical_args_hash: string | null; + recovery_mode: ToolRecoveryMode | null; + external_handle: string | null; + metadata_json: string | null; + committed_at: number; +} + +function toolOperationFromRow(row: ToolOperationRow): ToolOperationRecord { + return { + operationId: row.operation_id, + invocationId: row.invocation_id, + runId: row.run_id, + turnId: row.turn_id, + providerToolCallId: row.provider_tool_call_id, + toolName: row.tool_name, + canonicalArgsHash: row.canonical_args_hash, + recoveryMode: row.recovery_mode, + currentState: row.current_state, + callEventId: row.call_event_id, + ...(row.dispatch_event_id ? { dispatchEventId: row.dispatch_event_id } : {}), + ...(row.result_event_id ? { resultEventId: row.result_event_id } : {}), + version: row.version, + }; +} + +function toolJournalRecordFromRow(row: ToolJournalRow): ToolJournalEventRecord { + return { + journalEventId: row.journal_event_id, + operationId: row.operation_id, + invocationId: row.invocation_id, + runId: row.run_id, + turnId: row.turn_id, + state: row.state, + ...(row.runtime_event_id ? { runtimeEventId: row.runtime_event_id } : {}), + ...(row.canonical_args_hash ? { canonicalArgsHash: row.canonical_args_hash } : {}), + ...(row.recovery_mode ? { recoveryMode: row.recovery_mode } : {}), + ...(row.external_handle ? { externalHandle: row.external_handle } : {}), + ...(row.metadata_json ? { metadata: JSON.parse(row.metadata_json) } : {}), + committedAt: row.committed_at, + }; +} + +function assertPreparedInput(input: CommitToolPreparedInput): void { + if (input.journalEventId !== `${input.operationId}_prepared`) { + throw new Error('T1 journal identity must be derived from the tool operation'); + } + assertNoReservedRecoveryFact(input.runtimeEvent); + assertNoReservedRecoveryFact(input.dispatchRuntimeEvent); + const content = input.runtimeEvent.content; + if (content?.kind !== 'function_call') + throw new Error('T1 requires a function_call RuntimeEvent'); + if (content.id !== input.providerToolCallId || content.name !== input.toolName) { + throw new Error('T1 RuntimeEvent identity does not match the tool operation'); + } + let derivedArgsHash: string; + try { + derivedArgsHash = canonicalToolArgsHash(content.name, content.args); + } catch { + throw new Error('T1 argument hash does not match its canonical function call'); + } + if ( + derivedArgsHash !== input.canonicalArgsHash || + validateToolLedgerEventLane(input.runtimeEvent).ok !== true + ) { + throw new Error('T1 argument hash does not match its canonical function call'); + } + const dispatch = input.dispatchRuntimeEvent.actions?.toolDispatch; + if ( + !dispatch || + input.dispatchRuntimeEvent.content !== undefined || + input.dispatchRuntimeEvent.partial || + dispatch.operationId !== input.operationId || + dispatch.providerToolCallId !== input.providerToolCallId || + dispatch.toolName !== input.toolName || + dispatch.canonicalArgsHash !== input.canonicalArgsHash || + dispatch.recoveryMode !== input.recoveryMode || + validateToolLedgerEventLane(input.dispatchRuntimeEvent).ok !== true + ) { + throw new Error('T1 requires a matching tool-dispatch RuntimeEvent'); + } + assertSameRuntimeIdentity(input.runtimeEvent, input.dispatchRuntimeEvent, 'T1'); +} + +function assertOutcomeInput(input: CommitToolOutcomeInput): void { + if (input.journalEventId !== `${input.operationId}_outcome`) { + throw new Error('T2 journal identity must be derived from the tool operation'); + } + assertNoReservedRecoveryFact(input.runtimeEvent); + const content = input.runtimeEvent.content; + if (content?.kind !== 'function_response') { + throw new Error('T2 requires a function_response RuntimeEvent'); + } + if ( + input.runtimeEvent.refs?.operationId !== input.operationId || + input.runtimeEvent.refs?.toolCallId !== content.id + ) { + throw new Error( + 'T2 requires operation and tool-call refs on the function_response RuntimeEvent', + ); + } + if (validateToolLedgerEventLane(input.runtimeEvent).ok !== true) { + throw new Error('T2 requires one canonical function-response semantic lane'); + } +} + +function assertPreparedIdentity( + operation: ToolOperationRecord, + input: CommitToolPreparedInput, +): void { + const event = input.runtimeEvent; + const matches = + operation.invocationId === event.invocationId && + operation.runId === event.runId && + operation.turnId === event.turnId && + operation.providerToolCallId === input.providerToolCallId && + operation.toolName === input.toolName && + operation.canonicalArgsHash === input.canonicalArgsHash && + operation.recoveryMode === input.recoveryMode && + operation.callEventId === event.id && + operation.dispatchEventId === input.dispatchRuntimeEvent.id; + if (!matches) throw new Error(`Tool operation identity conflict for ${input.operationId}`); +} + +function assertSameRuntimeIdentity( + first: RuntimeEvent, + second: RuntimeEvent, + boundary: string, +): void { + if ( + first.sessionId !== second.sessionId || + first.invocationId !== second.invocationId || + first.runId !== second.runId || + first.turnId !== second.turnId + ) { + throw new Error(`${boundary} RuntimeEvents do not share one execution identity`); + } +} + +function assertOutcomeIdentity(operation: ToolOperationRecord, event: RuntimeEvent): void { + const content = event.content; + const matches = + content?.kind === 'function_response' && + operation.invocationId === event.invocationId && + operation.runId === event.runId && + operation.turnId === event.turnId && + operation.providerToolCallId === content.id && + operation.toolName === content.name; + if (!matches) + throw new Error(`Tool operation outcome identity conflict for ${operation.operationId}`); +} + +function assertRuntimeEventIdentity(event: RuntimeEvent): void { + decodeRuntimeEvent(event); + for (const [field, value] of Object.entries({ + id: event.id, + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + })) { + if (typeof value !== 'string' || value.length === 0) + throw new Error(`Invalid RuntimeEvent ${field}`); + } +} + +function assertStoredRuntimeEventEquals(event: RuntimeEvent, storedJson: string | undefined): void { + if (storedJson === undefined) return; + const stored = decodeStoredRuntimeEvent(storedJson); + if (!isDeepStrictEqual(stored, canonicalizeRuntimeEventForStorage(event))) { + throw new Error(`RuntimeEvent identity conflict for ${event.id}`); + } +} + +function canonicalizeRuntimeEventForStorage(event: RuntimeEvent): RuntimeEvent { + return encodeCanonicalRuntimeEvent(event).event; +} + +function assertNoReservedRecoveryFact(event: RuntimeEvent): void { + if (event.actions?.toolRecovery !== undefined) { + throw new Error('Tool recovery facts require the atomic recovery bundle writer'); + } +} + +function assertNoReservedToolLedgerFact(event: RuntimeEvent): void { + assertNoReservedWorkspaceAuthorityAppend(event); + if (event.actions?.continuationStart !== undefined) { + throw new Error('Continuation start facts require the continuation authority writer'); + } + const validation = validateGenericToolLedgerAppend(event); + if (validation.ok) return; + if (validation.code === 'reserved_recovery_fact') { + throw new Error('Tool recovery facts require the atomic recovery bundle writer'); + } + if (validation.code === 'reserved_tool_boundary_fact') { + throw new Error('Durable tool facts require the atomic tool boundary writer'); + } + throw new Error(`RuntimeEvent ${event.id} violates its semantic lane`); +} + +function isToolLedgerBearingEvent(event: RuntimeEvent): boolean { + return ( + event.content?.kind === 'function_call' || + event.content?.kind === 'function_response' || + event.actions?.toolDispatch !== undefined || + event.actions?.toolRecovery !== undefined + ); +} + +function recoveryOperationIdentity(operation: ToolOperationRecord) { + if (!operation.dispatchEventId) { + throw new Error('Recovery bundle requires a durable dispatch RuntimeEvent'); + } + return { + operationId: operation.operationId, + invocationId: operation.invocationId, + runId: operation.runId, + turnId: operation.turnId, + providerToolCallId: operation.providerToolCallId, + toolName: operation.toolName, + canonicalArgsHash: operation.canonicalArgsHash, + recoveryMode: operation.recoveryMode, + callEventId: operation.callEventId, + dispatchEventId: operation.dispatchEventId, + }; +} + +function assertStrictRuntimeEventOrder(eventSequences: readonly number[]): void { + if ( + eventSequences.some( + (eventSequence, index) => index > 0 && eventSequence <= (eventSequences[index - 1] ?? -1), + ) + ) { + throw new Error('Recovery facts violate canonical RuntimeEvent causal order'); + } +} + +function requireRuntimeEventOrder( + eventOrder: ReadonlyMap, + eventId: string, +): number { + const order = eventOrder.get(eventId); + if (order === undefined) throw new Error(`Missing RuntimeEvent order for ${eventId}`); + return order; +} + +function journalEventIdFor( + operationId: string, + event: RuntimeEvent, + state: Exclude, +): string { + return state === 'outcome_committed' ? `${operationId}_outcome` : `${event.id}_journal`; +} + +function assertRecoveryAuthorityCapability(db: DatabaseSync): void { + const row = db + .prepare('SELECT version FROM runtime_capabilities WHERE capability = ?') + .get(RUNTIME_RECOVERY_AUTHORITY_CAPABILITY) as { version?: unknown } | undefined; + if (row?.version !== RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION) { + throw new Error( + `SQLite runtime recovery capability ${RUNTIME_RECOVERY_AUTHORITY_CAPABILITY}@${RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION} is unavailable`, + ); + } +} + +function assertContinuationStartEvent( + claim: ContinuationClaimV1, + event: RuntimeEvent, + startKind: 'runtime_admission' | 'claim_repair', +): void { + if (!continuationStartEventMatchesClaim(event, claim, startKind)) { + throw new Error('Invalid continuation-start authority event'); + } +} + +function assertContinuationAuthorityCapability(db: DatabaseSync): void { + const row = db + .prepare('SELECT version FROM runtime_capabilities WHERE capability = ?') + .get(RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY) as { version?: unknown } | undefined; + if (row?.version !== RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY_VERSION) { + throw new Error( + `SQLite runtime continuation capability ${RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY}@${RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY_VERSION} is unavailable`, + ); + } +} + +function assertRuntimeStorageSafeId(value: string, message: string): void { + if (!isRuntimeStorageSafeId(value)) throw new Error(message); +} + +function assertInvocationSearchLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) { + throw new RangeError('Runtime invocation search limit must be an integer between 1 and 256'); + } +} + +interface RuntimeEventStorageRow { + event_id: string; + session_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + payload_json: string; +} + +interface ManagedMutationReservationProjectionRow { + workspace_instance_id: string; + repository_id: string; + workspace_id: string; + workspace_epoch_id: string; + operation_id: string; + dispatch_event_id: string; + base_workspace_version_id: string; + base_accepted_event_id: string; + base_head_revision: number; + base_commit_oid: string; + base_tree_oid: string; + expected_paths_json: string; + execution_profile_digest: string; + protocol_version: number; + reserved_at: number; +} + +type CanonicalWorkspaceAuthority = ReturnType & { + activeManagedMutations: ManagedMutationReservationProjectionRow[]; +}; + +function assertWorkspaceVersionAuthorityCapability(db: DatabaseSync): void { + const row = db + .prepare('SELECT version FROM runtime_capabilities WHERE capability = ?') + .get(RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY) as { version?: unknown } | undefined; + if (row?.version !== RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION) { + throw new Error( + `SQLite runtime workspace capability ${RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY}@${RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION} is unavailable`, + ); + } +} + +interface WorkspaceEpochProjectionRow { + workspace_id: string; + workspace_epoch_id: string; + repository_id: string; + workspace_instance_id: string; + mode: string; + object_format: string; + source_commit_oid: string; + source_tree_oid: string; + initial_workspace_version_id: string; + materialization_profile_digest: string; + materialization_semantics: string; + policy_hash: string; + authority_session_id: string; + authority_invocation_id: string; + authority_run_id: string; + authority_turn_id: string; + epoch_opened_event_id: string; + protocol_version: number; + committed_at: number; +} + +interface WorkspaceVersionProjectionRow { + workspace_version_id: string; + repository_id: string; + workspace_id: string; + workspace_epoch_id: string; + object_format: string; + origin_kind: string; + origin_event_id: string; + parents_json: string; + operation_id: string | null; + dispatch_event_id: string | null; + outcome_event_id: string | null; + base_head_revision: number | null; + execution_profile_digest: string | null; + commit_oid: string; + tree_oid: string; + policy_hash: string; + tree_delta_digest: string; + changed_paths_json: string; + changed_file_count: number; + deleted_file_count: number; + accepted_event_id: string; + protocol_version: number; + committed_at: number; +} + +interface WorkspaceHeadProjectionRow { + workspace_id: string; + workspace_epoch_id: string; + repository_id: string; + workspace_version_id: string; + accepted_event_id: string; + commit_oid: string; + tree_oid: string; + revision: number; +} + +function workspaceEpochRecord( + authority: ScannedWorkspaceBaselineAuthority, +): WorkspaceEpochRecordV1 { + return { + ...authority.epoch, + epochOpenedEventId: authority.epochOpenedEventId, + authority: authority.authority, + committedAt: authority.epochOpenedAt, + }; +} + +function workspaceBaselineVersionRecord(authority: ScannedWorkspaceBaselineAuthority) { + return { + ...authority.baseline, + acceptedEventId: authority.baselineAcceptedEventId, + committedAt: authority.baselineAcceptedAt, + }; +} + +function workspaceSuccessorVersionRecord(authority: ScannedWorkspaceSuccessorAuthority) { + return { + ...authority.successor, + acceptedEventId: authority.acceptedEventId, + committedAt: authority.acceptedAt, + }; +} + +function workspaceHeadBeforeSuccessor( + authority: ReturnType, + successor: WorkspaceVersionAcceptedV1, +): WorkspaceHeadRecordV1 | undefined { + const parentId = successor.parents[0]; + const baseline = authority.baselines.find( + (candidate) => candidate.baseline.workspaceVersionId === parentId, + ); + if (baseline) { + return { + repositoryId: baseline.baseline.repositoryId, + workspaceId: baseline.baseline.workspaceId, + workspaceEpochId: baseline.baseline.workspaceEpochId, + workspaceVersionId: baseline.baseline.workspaceVersionId, + acceptedEventId: baseline.baselineAcceptedEventId, + commitOid: baseline.baseline.commitOid, + treeOid: baseline.baseline.treeOid, + revision: successor.baseHeadRevision, + }; + } + const prior = authority.successors.find( + (candidate) => candidate.successor.workspaceVersionId === parentId, + ); + if (!prior) return undefined; + return { + repositoryId: prior.successor.repositoryId, + workspaceId: prior.successor.workspaceId, + workspaceEpochId: prior.successor.workspaceEpochId, + workspaceVersionId: prior.successor.workspaceVersionId, + acceptedEventId: prior.acceptedEventId, + commitOid: prior.successor.commitOid, + treeOid: prior.successor.treeOid, + revision: successor.baseHeadRevision, + }; +} + +function managedMutationMatchesAcceptedSuccessor( + mutation: RuntimeEventManagedWorkspaceMutationV2 | undefined, + successor: WorkspaceVersionAcceptedV1, + baseHead: WorkspaceHeadRecordV1, + workspaceInstanceId: string, +): boolean { + return ( + mutation?.protocol === 'managed_mutation_v2' && + mutation.repositoryId === successor.repositoryId && + mutation.workspaceId === successor.workspaceId && + mutation.workspaceEpochId === successor.workspaceEpochId && + mutation.workspaceInstanceId === workspaceInstanceId && + mutation.objectFormat === successor.objectFormat && + mutation.baseWorkspaceVersionId === successor.parents[0] && + mutation.baseAcceptedEventId === successor.baseAcceptedEventId && + mutation.baseHeadRevision === successor.baseHeadRevision && + mutation.baseCommitOid === baseHead.commitOid && + mutation.baseTreeOid === baseHead.treeOid && + mutation.executionProfileDigest === successor.executionProfileDigest && + isDeepStrictEqual([mutation.expectedPath], successor.changedPaths) + ); +} + +function workspaceEpochProjectionRow( + authority: ScannedWorkspaceBaselineAuthority, +): WorkspaceEpochProjectionRow { + const record = workspaceEpochRecord(authority); + return { + workspace_id: record.workspaceId, + workspace_epoch_id: record.workspaceEpochId, + repository_id: record.repositoryId, + workspace_instance_id: record.workspaceInstanceId, + mode: record.mode, + object_format: record.objectFormat, + source_commit_oid: record.sourceCommitOid, + source_tree_oid: record.sourceTreeOid, + initial_workspace_version_id: record.initialWorkspaceVersionId, + materialization_profile_digest: record.materializationProfileDigest, + materialization_semantics: record.materializationSemantics, + policy_hash: record.policyHash, + authority_session_id: record.authority.sessionId, + authority_invocation_id: record.authority.invocationId, + authority_run_id: record.authority.runId, + authority_turn_id: record.authority.turnId, + epoch_opened_event_id: record.epochOpenedEventId, + protocol_version: 1, + committed_at: record.committedAt, + }; +} + +function workspaceBaselineVersionProjectionRow( + authority: ScannedWorkspaceBaselineAuthority, +): WorkspaceVersionProjectionRow { + const record = workspaceBaselineVersionRecord(authority); + return { + workspace_version_id: record.workspaceVersionId, + repository_id: record.repositoryId, + workspace_id: record.workspaceId, + workspace_epoch_id: record.workspaceEpochId, + object_format: record.objectFormat, + origin_kind: record.origin.kind, + origin_event_id: record.origin.epochOpenedEventId, + parents_json: '[]', + operation_id: null, + dispatch_event_id: null, + outcome_event_id: null, + base_head_revision: null, + execution_profile_digest: null, + commit_oid: record.commitOid, + tree_oid: record.treeOid, + policy_hash: record.policyHash, + tree_delta_digest: record.treeDeltaDigest, + changed_paths_json: '[]', + changed_file_count: record.changedFileCount, + deleted_file_count: record.deletedFileCount, + accepted_event_id: record.acceptedEventId, + protocol_version: 1, + committed_at: record.committedAt, + }; +} + +function workspaceSuccessorVersionProjectionRow( + authority: ScannedWorkspaceSuccessorAuthority, +): WorkspaceVersionProjectionRow { + const record = workspaceSuccessorVersionRecord(authority); + return { + workspace_version_id: record.workspaceVersionId, + repository_id: record.repositoryId, + workspace_id: record.workspaceId, + workspace_epoch_id: record.workspaceEpochId, + object_format: record.objectFormat, + origin_kind: record.origin.kind, + origin_event_id: record.origin.outcomeEventId, + parents_json: JSON.stringify(record.parents), + operation_id: record.origin.operationId, + dispatch_event_id: record.origin.dispatchEventId, + outcome_event_id: record.origin.outcomeEventId, + base_head_revision: record.baseHeadRevision, + execution_profile_digest: record.executionProfileDigest, + commit_oid: record.commitOid, + tree_oid: record.treeOid, + policy_hash: record.policyHash, + tree_delta_digest: record.treeDeltaDigest, + changed_paths_json: JSON.stringify(record.changedPaths), + changed_file_count: record.changedFileCount, + deleted_file_count: record.deletedFileCount, + accepted_event_id: record.acceptedEventId, + protocol_version: 1, + committed_at: record.committedAt, + }; +} + +function workspaceHeadProjectionRow(record: WorkspaceHeadRecordV1): WorkspaceHeadProjectionRow { + return { + workspace_id: record.workspaceId, + workspace_epoch_id: record.workspaceEpochId, + repository_id: record.repositoryId, + workspace_version_id: record.workspaceVersionId, + accepted_event_id: record.acceptedEventId, + commit_oid: record.commitOid, + tree_oid: record.treeOid, + revision: record.revision, + }; +} + +function compareWorkspaceEpochRow( + left: WorkspaceEpochProjectionRow, + right: WorkspaceEpochProjectionRow, +): number { + return ( + left.workspace_id.localeCompare(right.workspace_id) || + left.workspace_epoch_id.localeCompare(right.workspace_epoch_id) + ); +} + +function compareWorkspaceVersionRow( + left: WorkspaceVersionProjectionRow, + right: WorkspaceVersionProjectionRow, +): number { + return left.workspace_version_id.localeCompare(right.workspace_version_id); +} + +function compareWorkspaceHeadRow( + left: WorkspaceHeadProjectionRow, + right: WorkspaceHeadProjectionRow, +): number { + return ( + left.workspace_id.localeCompare(right.workspace_id) || + left.workspace_epoch_id.localeCompare(right.workspace_epoch_id) + ); +} + +interface RuntimeEventPrefixStorageRow extends RuntimeEventStorageRow { + event_seq: number; +} + +interface ContinuationClaimStorageRow { + claim_id: string; + source_session_id: string; + source_invocation_id: string; + source_run_id: string; + source_turn_id: string; + source_event_high_water: number; + source_prefix_digest: string; + boundary_digest: string; + boundary_json: string; + provider_projection_version: number; + provider_replay_digest: string; + target_session_id: string; + target_invocation_id: string; + target_run_id: string; + target_turn_id: string; + target_opening_json: string; + claimed_at: number; + start_event_id: string | null; + start_kind: 'runtime_admission' | 'claim_repair' | null; + protocol_version: number; +} + +interface RuntimePartialStorageRow { + stream_key: string; + session_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + payload_json: string; + text_content: string; + after_event_id: string | null; +} + +function decodeRuntimeEventStorageRow(row: RuntimeEventStorageRow): RuntimeEvent { + const event = decodeStoredRuntimeEvent(row.payload_json); + if ( + event.id !== row.event_id || + event.sessionId !== row.session_id || + event.invocationId !== row.invocation_id || + event.runId !== row.run_id || + event.turnId !== row.turn_id + ) { + throw new Error(`RuntimeEvent row/payload identity mismatch for ${row.event_id}`); + } + return event; +} + +function decodeRuntimePartialStorageRow(row: RuntimePartialStorageRow): RuntimeEvent { + const event = decodeStoredRuntimeEvent(row.payload_json); + if ( + event.sessionId !== row.session_id || + event.invocationId !== row.invocation_id || + event.runId !== row.run_id || + event.turnId !== row.turn_id || + partialRuntimeStream(event)?.key !== row.stream_key + ) { + throw new Error(`Runtime partial row/payload identity mismatch for ${row.stream_key}`); + } + return event; +} + +function decodeStoredRuntimeEvent(storedJson: string): RuntimeEvent { + return decodeRuntimeEvent(JSON.parse(storedJson)); +} + +interface RuntimePartialSnapshot { + event: RuntimeEvent; + afterEventId?: string; +} + +function mergeRuntimePartialSnapshots( + immutableEvents: readonly RuntimeEvent[], + snapshots: readonly RuntimePartialSnapshot[], +): RuntimeEvent[] { + const { leading, afterEvent } = groupRuntimePartialSnapshots(snapshots); + const merged = leading.sort(compareRuntimePartialSnapshots).map(({ event }) => event); + for (const event of immutableEvents) { + merged.push(event); + const anchored = afterEvent.get(event.id); + if (!anchored) continue; + merged.push(...anchored.sort(compareRuntimePartialSnapshots).map((snapshot) => snapshot.event)); + afterEvent.delete(event.id); + } + for (const orphaned of afterEvent.values()) { + merged.push(...orphaned.sort(compareRuntimePartialSnapshots).map((snapshot) => snapshot.event)); + } + return merged; +} + +function groupRuntimePartialSnapshots(snapshots: readonly RuntimePartialSnapshot[]): { + leading: RuntimePartialSnapshot[]; + afterEvent: Map; +} { + const leading: RuntimePartialSnapshot[] = []; + const afterEvent = new Map(); + for (const snapshot of snapshots) { + if (!snapshot.afterEventId) { + leading.push(snapshot); + continue; + } + const grouped = afterEvent.get(snapshot.afterEventId) ?? []; + grouped.push(snapshot); + afterEvent.set(snapshot.afterEventId, grouped); + } + return { leading, afterEvent }; +} + +function compareRuntimePartialSnapshots( + left: RuntimePartialSnapshot, + right: RuntimePartialSnapshot, +): number { + return left.event.ts - right.event.ts || left.event.id.localeCompare(right.event.id); +} + +function partialRuntimeStream(event: RuntimeEvent): + | { + key: string; + snapshot: RuntimeEvent; + text: string; + } + | undefined { + if (!event.partial || event.status !== undefined || event.actions) return undefined; + const content = event.content; + let identity: string | undefined; + let text = ''; + if ( + content?.kind === 'text' && + content.attachments === undefined && + event.refs?.providerEventId && + hasOnlyKeys(event.refs, ['providerEventId']) + ) { + identity = `${content.kind}:provider:${event.refs.providerEventId}`; + text = content.text; + } else if ( + content?.kind === 'thinking' && + content.signature === undefined && + event.refs?.providerEventId && + hasOnlyKeys(event.refs, ['providerEventId']) + ) { + identity = `${content.kind}:provider:${event.refs.providerEventId}`; + text = content.text; + } else if (!content && event.refs?.toolCallId && hasOnlyKeys(event.refs, ['toolCallId'])) { + identity = `tool:call:${event.refs.toolCallId}`; + } + if (!identity) return undefined; + const key = runtimePartialStreamKey(identity, event); + const snapshot = + content?.kind === 'text' || content?.kind === 'thinking' + ? { ...event, content: { ...content, text: '' } } + : event; + return { key, snapshot, text }; +} + +function completedPartialRuntimeStreamKey(event: RuntimeEvent): string | undefined { + if (event.partial) return undefined; + const content = event.content; + let identity: string | undefined; + if ((content?.kind === 'text' || content?.kind === 'thinking') && event.refs?.providerEventId) { + identity = `${content.kind}:provider:${event.refs.providerEventId}`; + } else if (content?.kind === 'function_response' && event.refs?.toolCallId) { + identity = `tool:call:${event.refs.toolCallId}`; + } + return identity ? runtimePartialStreamKey(identity, event) : undefined; +} + +function runtimePartialStreamKey(identity: string, event: RuntimeEvent): string { + return createHash('sha256') + .update( + JSON.stringify([ + identity, + event.sessionId, + event.invocationId, + event.runId, + event.turnId, + event.branch ?? null, + event.role, + event.author, + ]), + ) + .digest('hex'); +} + +function hasOnlyKeys(value: object, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(value).every((key) => allowedSet.has(key)); +} + +function decodeContinuationClaimRow(row: ContinuationClaimStorageRow): ContinuationClaimV1 { + if (row.protocol_version !== 1) { + throw new Error(`Unsupported continuation claim protocol ${row.protocol_version}`); + } + const boundary = JSON.parse(row.boundary_json) as unknown; + const targetOpening = JSON.parse(row.target_opening_json) as unknown; + const claim = decodeContinuationClaim({ + protocol: 'continuation_claim_v1', + claimId: row.claim_id, + boundaryDigest: row.boundary_digest, + boundary, + providerProjectionVersion: row.provider_projection_version, + providerReplayDigest: row.provider_replay_digest, + target: { + sessionId: row.target_session_id, + invocationId: row.target_invocation_id, + runId: row.target_run_id, + turnId: row.target_turn_id, + }, + targetOpening, + claimedAt: row.claimed_at, + }); + const source = claim.boundary.segments.at(-1)!; + if ( + row.source_session_id !== source.identity.sessionId || + row.source_invocation_id !== source.identity.invocationId || + row.source_run_id !== source.identity.runId || + row.source_turn_id !== source.identity.turnId || + row.source_event_high_water !== source.position.lastEventSeq || + row.source_prefix_digest !== source.prefixDigest + ) { + throw new Error(`Continuation claim row/payload identity mismatch for ${row.claim_id}`); + } + return claim; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fc814ec5d77a4b5ccf903dd5aab9ad9c20dcd6597c7d33925bd9fa018f5c5ccc.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fc814ec5d77a4b5ccf903dd5aab9ad9c20dcd6597c7d33925bd9fa018f5c5ccc.source new file mode 100644 index 0000000000..3ec04a3622 --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fc814ec5d77a4b5ccf903dd5aab9ad9c20dcd6597c7d33925bd9fa018f5c5ccc.source @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import type { DailyReviewArchive } from '@maka/core/daily-review'; +import { + authenticateInteractiveDailyReviewAuthorityWriter, + openInteractiveDailyReviewAuthorityForWrite, +} from '../daily-review-authority.js'; +import { + resolveStorageRoot, + StorageRootAuthorityError, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +// The control directory of each resolved root lives outside that root, so a +// temporary root's removal leaves it behind; reclaim the recorded rootIds here. +after(removeTrackedControlDirectories); + +test('Daily Review authority serializes config revisions and preserves archives', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const [first, second] = await Promise.all([ + openInteractiveDailyReviewAuthorityForWrite(owner.lease), + openInteractiveDailyReviewAuthorityForWrite(owner.lease), + ]); + assert.equal(first, second); + assert.equal(authenticateInteractiveDailyReviewAuthorityWriter(first), first); + assert.deepEqual(await first.readConfig(), { + revision: 0, + config: { enabled: false, executeTime: '08:00', modelKey: '' }, + }); + + assert.deepEqual( + await first.updateConfig(0, { + enabled: true, + executeTime: '09:30', + modelKey: 'openrouter::openrouter/free', + }), + { + kind: 'committed', + snapshot: { + revision: 1, + config: { + enabled: true, + executeTime: '09:30', + modelKey: 'openrouter::openrouter/free', + }, + }, + }, + ); + assert.deepEqual( + await second.updateConfig(0, { + enabled: false, + executeTime: '10:00', + modelKey: '', + }), + { kind: 'revision_conflict', expectedRevision: 0, actualRevision: 1 }, + ); + + const stored = await first.publishArchive(archive(), 180); + assert.deepEqual(await second.getArchive(stored.id), stored); + assert.deepEqual( + (await second.listArchivePage(null, 180)).archives.map((item) => item.id), + [stored.id], + ); + + first.close(); + assert.throws(() => authenticateInteractiveDailyReviewAuthorityWriter(first), isInvalidLease); + const reopened = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); + assert.equal((await reopened.readConfig()).revision, 1); + assert.deepEqual(await reopened.getArchive(stored.id), stored); + reopened.close(); + } finally { + if (!owner.closed) await owner.close(); + } + }); +}); + +test('Daily Review authority rejects operations after its root lease closes', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); + await owner.close(); + await assert.rejects(() => writer.readConfig(), isInvalidLease); + await assert.rejects(() => writer.publishArchive(archive(), 180), isInvalidLease); + writer.close(); + }); +}); + +test('Daily Review authority publishes and prunes archives in one operation', async () => { + await withInteractiveRoot(async ({ capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const writer = await openInteractiveDailyReviewAuthorityForWrite(owner.lease); + const older = archive(); + const newer = { + ...archive(new Date(2026, 7, 4).getTime()), + id: '2026-08-04-1d', + generatedAt: older.generatedAt + 1, + }; + await writer.publishArchive(older, 1); + await writer.publishArchive(newer, 1); + assert.deepEqual( + (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), + [newer.id], + ); + assert.equal(await writer.getArchive(older.id), null); + const oldest = archive(new Date(2026, 7, 2).getTime()); + await writer.publishArchive(oldest, 1); + assert.deepEqual( + (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), + [oldest.id], + ); + assert.deepEqual(await writer.getArchive(oldest.id), oldest); + await assert.rejects(() => writer.publishArchive({ ...newer, id: '2026-08-05-1d' }, 1)); + assert.deepEqual( + (await writer.listArchivePage(null, 180)).archives.map((item) => item.id), + [oldest.id], + ); + writer.close(); + } finally { + if (!owner.closed) await owner.close(); + } + }); +}); + +function archive(fromMs = new Date(2026, 7, 3).getTime()): DailyReviewArchive { + const start = new Date(fromMs); + const toMs = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1).getTime(); + return { + id: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String( + start.getDate(), + ).padStart(2, '0')}-1d`, + day: { fromMs, toMs }, + range: 1, + status: 'ok', + generatedAt: toMs + 1, + trigger: 'manual', + modelKey: 'openrouter::openrouter/free', + sections: { summary: 'One durable review.' }, + totals: { + sessionCount: 1, + requestCount: 2, + totalTokens: 3, + costUsd: 0, + errorCount: 0, + }, + }; +} + +async function withInteractiveRoot( + run: (input: { + capability: Awaited>>; + }) => Promise, +): Promise { + const base = await mkdtemp(join(tmpdir(), 'maka-daily-review-authority-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: join(base, 'interactive'), kind: 'interactive' }), + ); + await run({ capability }); + } finally { + await rm(base, { recursive: true, force: true }); + } +} + +function isInvalidLease(error: unknown): boolean { + return error instanceof StorageRootAuthorityError && error.code === 'invalid_lease'; +} diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fdf3aa99c8eb82c563fa0f23f229997e426c6a39c2af0df494af69e7e362911c.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fdf3aa99c8eb82c563fa0f23f229997e426c6a39c2af0df494af69e7e362911c.source new file mode 100644 index 0000000000..e93874c05d --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/fdf3aa99c8eb82c563fa0f23f229997e426c6a39c2af0df494af69e7e362911c.source @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { join } from 'node:path'; +import type { CommitSessionRevisionInput } from '../../session-repository.js'; + +const [root, inputPath, crashPoint] = process.argv.slice(2); +if (!root || !inputPath || !['before-rename', 'after-rename'].includes(crashPoint)) { + throw new Error('Expected repository root, commit input and crash point'); +} +const statePath = join(root, 'session-repository-v1.json'); +const originalRename = fs.promises.rename.bind(fs.promises); +fs.promises.rename = async (...args) => { + if (args[1].toString() !== statePath) return originalRename(...args); + if (crashPoint === 'after-rename') await originalRename(...args); + // Pause a real public commit while its repository lock is held, without + // adding a production fault-injection API or running the holder's finally. + process.send?.(crashPoint); + await new Promise(() => setInterval(() => undefined, 1_000)); +}; +syncBuiltinESMExports(); + +const { openFileSessionRepository } = await import('../../file-session-repository.js'); +const input = JSON.parse( + await fs.promises.readFile(inputPath, 'utf8'), +) as CommitSessionRevisionInput; +await (await openFileSessionRepository({ storageRoot: root })).commit(input); +throw new Error('Repository commit unexpectedly passed its crash point'); diff --git a/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ff721394b3de3f5ca2fd0d0278c85bb37fbbab581f93a22d38475578b45cc402.source b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ff721394b3de3f5ca2fd0d0278c85bb37fbbab581f93a22d38475578b45cc402.source new file mode 100644 index 0000000000..888fecbbdb --- /dev/null +++ b/packages/storage/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtup3h7i-31752-8c73aad84b.baseline/ff721394b3de3f5ca2fd0d0278c85bb37fbbab581f93a22d38475578b45cc402.source @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { createConversationOperationalStateStore } from '../conversation-operational-state.js'; +import { + closeSqliteInteractionStoreFacade, + openSqliteInteractiveInteractionStoreForWrite, +} from '../interaction-store.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { + removeTrackedControlDirectories, + trackControlDirectory, +} from './fixtures/control-directory-hygiene.js'; + +after(removeTrackedControlDirectories); + +test('persists Client Capability grants for one Session and purges them with it', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-client-capability-grant-')); + try { + const capability = trackControlDirectory( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const store = await openSqliteInteractiveInteractionStoreForWrite(owner.lease); + const key = { + sessionId: 'session-1', + providerId: 'provider-1', + contractId: 'contract-1', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + capability: 'browser' as const, + scope: { kind: 'browser_origin' as const, origin: 'https://example.com' }, + }; + try { + const grant = { + version: 1, + ...key, + grantedAt: 10, + } as const; + const established = await store.establishRequest({ + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + requestId: 'request-1', + createdAt: 5, + request: { + kind: 'client_capability', + toolUseId: 'tool-call-1', + target: key, + }, + }); + assert.equal(established.status, 'stable'); + const committed = await store.commitClientCapabilityOutcome( + 'request-1', + { kind: 'client_capability_decision', decision: 'allow', committedAt: 10 }, + grant, + ); + assert.equal(committed.status, 'stable'); + assert.deepEqual(await store.readClientCapabilitySessionGrant(key), grant); + assert.deepEqual( + await store.readClientCapabilitySessionGrant({ + ...key, + toolName: 'browser_click', + }), + grant, + ); + + const operationalState = createConversationOperationalStateStore(root); + try { + await operationalState.purge('session-1'); + } finally { + operationalState.close(); + } + assert.equal(await store.readClientCapabilitySessionGrant(key), undefined); + } finally { + closeSqliteInteractionStoreFacade(store); + await owner.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json b/packages/storage/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json new file mode 100644 index 0000000000..bf14dd7328 --- /dev/null +++ b/packages/storage/.mimosa/hook-status/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d-af04ddfd4a.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "mimosa-hook-status/v1", + "recordedAt": "2026-09-11T12:26:52.680Z", + "sessionId": "sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d", + "event": "PostToolUse", + "toolName": "Edit", + "file": "src/__tests__/foreign-session-store.test.ts", + "outcome": "clear", + "coverage": "complete", + "findingCount": 0, + "durationMs": 7, + "hostState": "hook_complete", + "reportHint": ".mimosa/reports/" +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json new file mode 100644 index 0000000000..117948e613 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.continue.json @@ -0,0 +1 @@ +{"schemaVersion":"mimosa-stop-continuation/v1","generation":"mtvjekfz-17172-55196b44f0","used":false,"reportPersisted":false,"claim":null,"updatedAt":"2026-09-10T13:02:21.779Z"} \ No newline at end of file diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json new file mode 100644 index 0000000000..a1634c0965 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.json @@ -0,0 +1 @@ +{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":{"storageId":"mtvjekfz-17172-55196b44f0","createdAt":"2026-09-10T13:02:21.695Z","files":{"package.json":{"existed":true,"snapshot":"7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519.source"},"stories/accessibility-dialogs.stories.tsx":{"existed":true,"snapshot":"8f825cf0cfe5557ae13cff398fc5205d6af2ca9cda1dc0171a88161e381fe1f3.source"},"stories/attachment.stories.tsx":{"existed":true,"snapshot":"5db3c137120b4a1416254d8d8d015ae15ea7b56992e6058c82c30e129537089c.source"},"stories/capability-audit-strip.stories.tsx":{"existed":true,"snapshot":"3d95ed7c2da1553ba490707929a3d7754b3c9e4f4f6f7ab3b3a34ec8e6c74e06.source"},"stories/composite-row-states.stories.tsx":{"existed":true,"snapshot":"84108f3d2663c67f6a73d06999fa8de19e8106be76dea2b532c8426d6617a9ad.source"},"stories/functional-motion.stories.tsx":{"existed":true,"snapshot":"56440de6917f116c5cba82a4f9473b72300773623cd994d36ecd3901dfb275c0.source"},"stories/icons.stories.tsx":{"existed":true,"snapshot":"2095a48aefbb0e500af735c89e977e2f141be36bd5e7127000cb72318315c13b.source"},"stories/markdown.stories.tsx":{"existed":true,"snapshot":"d4d723ee5eb082eb079d5ac8ca8018904fa84826e81cfe87e444e2642484881d.source"},"stories/model-picker.stories.tsx":{"existed":true,"snapshot":"076f407ce17b1a472d4af8e2ebba13ea61d2538fe13814d47819284be2e4ea8a.source"},"stories/palette-matrix.stories.tsx":{"existed":true,"snapshot":"adf4c7040f0aeedef56ec75cf5233bc5948df1eca007ca1abd566ca624529680.source"},"stories/sandbox-boundary-prompt.stories.tsx":{"existed":true,"snapshot":"4ab0c504c3d304675179bb67eec31b73ab29ae70bf44f536a802c18a501878dc.source"},"stories/session-list-panel.stories.tsx":{"existed":true,"snapshot":"d08abe10beabfbc7ffdec1f93548b3dd840dea6379ec152c9cf4d52c7bbac89d.source"},"stories/session-rail-harness.tsx":{"existed":true,"snapshot":"7e4430f205d8caa9eef27e059a860a1ce0bd2d57c96b0f24b5e12a7e4cd10932.source"},"stories/stat-tile.stories.tsx":{"existed":true,"snapshot":"c3787ace59a3b3dca6b02e03ed833cafe42c22a2bc05aad35550a61baaa31dcc.source"},"stories/toast.stories.tsx":{"existed":true,"snapshot":"357aa4084e3af998dfb5733d335e2869c5d4efa057cc49a454fa3fc8102afba2.source"},"stories/tool-activity.fixtures.ts":{"existed":true,"snapshot":"7959e1d235fee8699c5de435624b7897d595a652bfea699fdc0c7bfe7d1c6c12.source"},"stories/tool-activity.stories.tsx":{"existed":true,"snapshot":"b50566747c11356438372319017a2afe9612dcaf07813c05179d6edaba79e164.source"},"stories/transcript-scroll-rounding.stories.tsx":{"existed":true,"snapshot":"46c8d0663a9684081c9b4e6db8102c5b1320cdfd77a10e5157c0de8f87e44034.source"},"src/artifact-preview-registry.ts":{"existed":true,"snapshot":"1e48870458eea460e58dd6cad2a55194dc3e4381d0c31875cf5334e8b6711fcb.source"},"src/assistant-stream.ts":{"existed":true,"snapshot":"5f917773c840854734ed2dae7713f3153fb6bcd0664972dae6508a92be755ddc.source"},"src/astryx-chat-reasoning.tsx":{"existed":true,"snapshot":"2f9a99cf9d8d3ef0b5df137ce8e8da009ac5ef61ccf3a4634f3147b2ed5ea7c3.source"},"src/astryx-copy.ts":{"existed":true,"snapshot":"0145f9e818ab7d8a1c7e0af2cacfe38a920672726d8990cc9c0219a44e72d9c1.source"},"src/astryx-i18n.tsx":{"existed":true,"snapshot":"2de7ad4c6cd4c988d31c720489eb39122f6d4b3b650d6edd3b94ace8fcd06573.source"},"src/attachment-image.tsx":{"existed":true,"snapshot":"bef2e89ceb3cd3b2e6c16029007655b1e4427093196809aea45720179fe7bd53.source"},"src/attachment-kinds.tsx":{"existed":true,"snapshot":"431523aebfa975dabf85eaa5ff46f7a87591af584883ff3a3ab9c00a13769adb.source"},"src/bot-brand-logo.tsx":{"existed":true,"snapshot":"6a22720d387f79b871edb31e684ec75e9bfb8b18d41bcb676bfa6421ed5d7d4e.source"},"src/bot-brand.ts":{"existed":true,"snapshot":"59764e66009353dccba1b0a674f8ec64ef36e9592ad2884dd8dfdcef68654dcc.source"},"src/capability-audit-strip.tsx":{"existed":true,"snapshot":"28c41e7337f4f6b312d746b60549012f367ff4b6dc4f95f522ccbe2b0f287551.source"},"src/chat-conversation-items.ts":{"existed":true,"snapshot":"44290be90699b43a3786b991208e4b0b6d76e67c5e11de28370d25af0c1f6dc7.source"},"src/chat-display-helpers.ts":{"existed":true,"snapshot":"9c59809157e2c4ec0c0181a086ac832f9190a471c76a0de39d680e7703a52395.source"},"src/chat-empty-hero.tsx":{"existed":true,"snapshot":"a3a971f68b4a87d5fa707b9d15e3d43305a55b2fa5dd61e35454d2b3bb8d7c4c.source"},"src/chat-input-behavior.ts":{"existed":true,"snapshot":"ae1ba9256604e39272783a1d0c3873183aafd33c704537ec67e08bb3a68479c5.source"},"src/chat-model-helpers.ts":{"existed":true,"snapshot":"bf05ebabb004e92e6705215004010ffac91a3ebcdd0996120377af3f0d0370eb.source"},"src/chat-model-switcher.tsx":{"existed":true,"snapshot":"533f89873bef9b8063cab2bf63a41054ea1b8a189ca80daba9e1944154c49bf0.source"},"src/chat-surface-layout.tsx":{"existed":true,"snapshot":"c3b74e56b0d1d2b833b728e08a094b3a1365b901f85c5bc9dd7ba16b68a4460f.source"},"src/chat-turn.tsx":{"existed":true,"snapshot":"b9063ca356f737a9b6050fb081537e3792d30820e5aa694768b520abef31692a.source"},"src/chat-view.tsx":{"existed":true,"snapshot":"5a0583601dc229297b01c61ba811ccf3e530b23aa8362a3d692c744b4630551f.source"},"src/client-capability-prompt.tsx":{"existed":true,"snapshot":"4e5eeacbb2d8d06700dcfb6bc4a58024878c4153ee0b9b2e862325fb32dae11f.source"},"src/clipboard-feedback.ts":{"existed":true,"snapshot":"be9f1f26d7093ff1864d3104d5621b37c26dcb9e785f4aa5ebd32560fef33cd2.source"},"src/components.tsx":{"existed":true,"snapshot":"1a55f0d83d02e7afdcd6510ebd84f36fdb36e8682ad6037dec2980d9f0362d40.source"},"src/composer-attachments.ts":{"existed":true,"snapshot":"36c4a8f0f641bdf78d01362f41b7693d603a8ad93cfb0a92d6b74038249a0cb3.source"},"src/composer-helpers.ts":{"existed":true,"snapshot":"e1583889b109008b59c32c6fd3f2c5c1fc878f97bd3ef4329544fe2f18dbf145.source"},"src/composer-message-queue.tsx":{"existed":true,"snapshot":"25e8e6777a4f5d16f278c0897658aea085bbc4a62ea59f7b005a86585aa35f64.source"},"src/composer.tsx":{"existed":true,"snapshot":"3105028fa6b54262aae3beef4d69754276fe8b5bef433ca05fc9cfa880615f8b.source"},"src/conversation-copy.ts":{"existed":true,"snapshot":"cab9a437516e8648f1a71bf97cc42af89ef506575a48f3e5a56fddd01ed53758.source"},"src/daily-review-copy.ts":{"existed":true,"snapshot":"231fc80237755e05a7cf759c063c6664b56b20ebbd9c2a32cdf05a469d4ba9cd.source"},"src/daily-review-helpers.ts":{"existed":true,"snapshot":"7e3b806790981f9cbed1a33340ba668b1505812834457ac1ae3136e5577bbbab.source"},"src/daily-review-panel.tsx":{"existed":true,"snapshot":"c9b4ce63a17285de4c6085fbf54b07032cc63c1760dc359427942e83d6aed4ab.source"},"src/daily-review-view-state.ts":{"existed":true,"snapshot":"dd624358dc9da41c2d02661dd9c54236dff64f9e7cbfed044b7d098622451631.source"},"src/directory-reference-chip.tsx":{"existed":true,"snapshot":"2a46d13b979cb083654c4e80ca4ce8d905fc49cb148463dbbfc5ee49188651e8.source"},"src/form-interaction-prompt-state.test.ts":{"existed":true,"snapshot":"f8a60550cdf3bba617e077e9d1c027bd2a28c2e535e903f41abf4b0eb224e527.source"},"src/form-interaction-prompt-state.ts":{"existed":true,"snapshot":"2ad5ab6567176e491769da4ccfc07769a8d32d2ad572031ebb9876a561e4a524.source"},"src/form-interaction-prompt.tsx":{"existed":true,"snapshot":"d670aaff43d68d3336227b4426a45c859fdab8d446795bee46cebcdd2da82666.source"},"src/goal-projection-context.ts":{"existed":true,"snapshot":"464a234d3e3f9533aca2df575ee56e19b47e20c814ac8445fe9e21fccbaf8493.source"},"src/icons.tsx":{"existed":true,"snapshot":"c6d3c2946804f416d7cd9ac74c286569b5a1b08706e85f9b9b64dcea19a378c5.source"},"src/index.ts":{"existed":true,"snapshot":"a2a171449d862fe29692ce031981047d7ab755ae7f84c707aef80701b3ea0c80.source"},"src/inline-reference.tsx":{"existed":true,"snapshot":"a748583a3dd4fc48cf08656ca33e7e79157fce0a138a1355aba3ff438f365e2d.source"},"src/inline-rename-input.tsx":{"existed":true,"snapshot":"7c90d528149130dd9061cd4335cc54c0bcac00067c8dc35db26b03da17154b5b.source"},"src/input-history.ts":{"existed":true,"snapshot":"007c21b28483e994b03d4ca42651ac030cd231dc0b21a8a256f7cefced27706a.source"},"src/interaction-queue.ts":{"existed":true,"snapshot":"a7f1b08ea58ebb6d8c94326418212429edeb674106571f16bb21aec463e94004.source"},"src/listed-selection.ts":{"existed":true,"snapshot":"3acb76b01223d5715ef99718cb403644467383150a89b75c9b83c5a0c5d2c1c2.source"},"src/live-turn-projection.ts":{"existed":true,"snapshot":"5a1be6ef89c623bd396753dc64dbdeaf7b17f40feb93ad49a9307d4d86d341b7.source"},"src/locale-context.tsx":{"existed":true,"snapshot":"2d98ef2486c54c77f8a58d27babbf5d1cb1ea7fdfc469835ed1e950e07420cdd.source"},"src/locale-helpers.ts":{"existed":true,"snapshot":"9e9d828bf2383767270af404ce6d8b4472455622d6aa6ceda46e60a7b2b837fc.source"},"src/maka-uri.ts":{"existed":true,"snapshot":"0644ff5f46a9b1259034ec0f7bb1cd1faaf4d68d608fb1004cc1fc556140c586.source"},"src/maka-wordmark.tsx":{"existed":true,"snapshot":"c33129eb661919927fd8cfc5f0c1d6e1fcad7088d8662578b08c8a953a73c7cf.source"},"src/markdown-body.tsx":{"existed":true,"snapshot":"d0ba7d9936b37bc0c5e188cb5c8222167ce34b50702758d1572509e3a8862074.source"},"src/markdown-math.tsx":{"existed":true,"snapshot":"6ba7e62b7e76441f3a48c60a175e0a8aad2814abeda23c83ac45b1d603c801dc.source"},"src/markdown.tsx":{"existed":true,"snapshot":"26268a09bfd55c524a462853e5dfc0284de801caed488e15549bd9ed97729489.source"},"src/materialize.ts":{"existed":true,"snapshot":"3dfe2fef8aada3e9674c29ce3a9575449d9f6e13d7e647f0aea570e493fc34a7.source"},"src/mermaid-diagram.tsx":{"existed":true,"snapshot":"80047ceceb0c736ecbac87433b6b35fd4ec7888cb7c3b38bed5636f196708942.source"},"src/model-picker-internals.ts":{"existed":true,"snapshot":"970528b268c6e900d9dcde3709c92a48f1397da005dc424fb13f4c7fe325f844.source"},"src/model-picker.tsx":{"existed":true,"snapshot":"bb61cbc9a4e75427e87c7880cf7e26e3b42eaf22b8c1bc31e46e11ac810e7d02.source"},"src/module-hub-selector.tsx":{"existed":true,"snapshot":"d117c2cf4fd40246dd8e52cef889be4a1e58fb6ff1fee6037bb4d792f1906945.source"},"src/module-pages.tsx":{"existed":true,"snapshot":"4d1f14d1be3e169c49bfb462c57541d9b12e4805d0986792c6c85f717abaf4ca.source"},"src/module-panel-types.ts":{"existed":true,"snapshot":"3301ba29eed142cbbdd4becc79e59a92a78125de891cb9cdca1e471de05b9178.source"},"src/nav-selection.ts":{"existed":true,"snapshot":"6d43d48005ac7cc823af3a5c500b720adb715ef88779894e992e587c6e4969c0.source"},"src/pending-items.ts":{"existed":true,"snapshot":"82d7b91ed12a4d206a00988ce59168d6691e2da3166d7d3c647a7ac36e5677b4.source"},"src/permission-mode-menu.tsx":{"existed":true,"snapshot":"ddff1886cc3d33e549aa300942f11113db6fccf6a4e6c4e57cc7cfa222bb7414.source"},"src/platform-shortcut-text.tsx":{"existed":true,"snapshot":"82b7f4cfd2f084bd0ee593691cd40f281bd61d79564048628309750aa20c2ae4.source"},"src/prompt-anchor-rail.tsx":{"existed":true,"snapshot":"9023168f7dcdc22aa7b27492d8aa7c2a5f14cc909ad5af5ca1be24b526c11e14.source"},"src/quote-ref-chip.tsx":{"existed":true,"snapshot":"e4f2cff83264e9bf94e3579e13ceb6dfb2604fb70dabdaef272da492a8b39dc7.source"},"src/redact.ts":{"existed":true,"snapshot":"699aea40118202b23a22b18057100494b0ed4cf4b6619e88a17b040a7d19c27e.source"},"src/relative-time.tsx":{"existed":true,"snapshot":"d86562e2b815af62ef78d8c62efed04a0576e4b0fde4c2d979dc4901f4920602.source"},"src/runtime-resume-copy.ts":{"existed":true,"snapshot":"0d0371a169779eff8fd03c48c53f454a18a1b5dbbb0d451989fbef4d670fd5f9.source"},"src/sandbox-boundary-prompt.tsx":{"existed":true,"snapshot":"2abb26767a8e043226b8dd40348e3d9af676edb8f3630f833de039e3b5e971f1.source"},"src/scheduled-task-copy.ts":{"existed":true,"snapshot":"cd4098b0e242fa37016d81715b91239638ac51530940e664cff7dcda0a7a5bd5.source"},"src/scheduled-task-form-dialog.tsx":{"existed":true,"snapshot":"3ef0ceebd1669a489f7c153abcd844bffa1086660dda57f6116920f9de6beb12.source"},"src/scheduled-task-helpers.ts":{"existed":true,"snapshot":"49395f0ec06742b99993459f1533964b8a7fc79065cdbdcaab01af93187cd380.source"},"src/scheduled-task-inspector.tsx":{"existed":true,"snapshot":"a080765289338ff46cc09f933250cc165bf67b22affe6410181f5494578c3707.source"},"src/scheduled-task-panel.tsx":{"existed":true,"snapshot":"3a10b83080d835a773bd42485b377323d5d6211d1245aee44e879962d06426bc.source"},"src/scheduled-task-status.ts":{"existed":true,"snapshot":"bdae1710ce7248aa91df38fcc3af0e3eb616fa26381c3a07ea2535c22803f557.source"},"src/search-modal.tsx":{"existed":true,"snapshot":"cc0d740ebea524374635fa93bf584f5523e9d0198007690646102ba6094a7d3a.source"},"src/selection-quote-target.ts":{"existed":true,"snapshot":"dc03536b9a17870fb5549d10b987b451c1197c1076aa14deb2af9e55b1a0b7a4.source"},"src/session-context-layer.tsx":{"existed":true,"snapshot":"89dc9ddf70c58e56991649d0c5eeb229bbec7138d2c691cb86677923d683020c.source"},"src/session-history-list.tsx":{"existed":true,"snapshot":"434d3ab459cd01e9edeca89d0b3e35a2551d7a0c6c28c0dbab8fd5b8c51a3de5.source"},"src/session-hover-card-copy.ts":{"existed":true,"snapshot":"49f8f2f0bd15b22829b950660e84c02e91779083ac7993d83bb4ba2eb2ec847a.source"},"src/session-list-panel.tsx":{"existed":true,"snapshot":"3e79518a49bbab8a62a7aced604f5c3c8c4d591591a684870fcab820de2568d5.source"},"src/session-rail-context.tsx":{"existed":true,"snapshot":"da0f6abce04b8a8fd2ed1e189a3750bd9bf19ba3eecceda90ff238f2b6b3fceb.source"},"src/session-rename-dialog.tsx":{"existed":true,"snapshot":"8f80f83f8f7af6bb70af8d11bc59f2ba86c4a270efc7fb8f82351d510f03fd6e.source"},"src/session-setting-intent.test.ts":{"existed":true,"snapshot":"f9cbb7b49ac7b8b5b270440636df9f1a1f81647b6ce2e6d01783c728d687b51d.source"},"src/session-setting-intent.ts":{"existed":true,"snapshot":"eff429d7e81f80b19ff4e545edc7601bad157e9977f1c5246c352997558dfa79.source"},"src/session-sidebar-nav.tsx":{"existed":true,"snapshot":"9b14c5facae795eab20d894d2f4a7056553098aa66934b6c56923e0f3903f3d4.source"},"src/session-status-presentation.ts":{"existed":true,"snapshot":"0128b2d6d8d06bff61a294effe7e0001382003e69719916b428af458f1c30bf4.source"},"src/shared-ui-copy.ts":{"existed":true,"snapshot":"4e9befaa89762616da0342329c1671c4fff0aa461b0aa03dd78be2c4268bfaec.source"},"src/shell-controls-copy.ts":{"existed":true,"snapshot":"211bb033357d7d014a7292d4e4cdf1049abb349719f6c241ac9ee68ace5f2bad.source"},"src/sidebar-update-projection-context.ts":{"existed":true,"snapshot":"c4b2a97e750787b89f9b11a990161f4df4bdb523641dc44b474bfb0a6fb498b8.source"},"src/skill-inspector.tsx":{"existed":true,"snapshot":"79267b61848f7ba5a94f8bb40704557df68666ec0431adb470a6f058c3f4d285.source"},"src/skill-status.ts":{"existed":true,"snapshot":"3a39101412d6f99f0967be17ad9fa1ef00c7b6bbeac11f5cdc9be6356e4c818b.source"},"src/skills-copy.ts":{"existed":true,"snapshot":"29ea9fbb5cd1c5af9e6688e629e108aa02326779fbb4b512c27ff5ff6c13b622.source"},"src/skills-panel.tsx":{"existed":true,"snapshot":"df7999c845ea561af7ed67a8ae7e4b41afe5c4ea16faa8c4e33510437e464fef.source"},"src/status-vocabulary.ts":{"existed":true,"snapshot":"9b30652b48e33d5c002e3000f9b2f790592bea7f7d10b6ee4aefcf799ce16a23.source"},"src/stream-delta.ts":{"existed":true,"snapshot":"7232ae8d927d102b8f07a69d19a329c820bd8bf3e2ce90940a9981631f661e4c.source"},"src/streaming-display-redaction.ts":{"existed":true,"snapshot":"c3d6f84d30fa3c528a80c23e31e8c189972625a61c569dbb7867005fb60630d0.source"},"src/streaming-presentation.ts":{"existed":true,"snapshot":"8632f96813655723443f8fe6772e06cf533a338408ad4589f298bed888cfa590.source"},"src/testing.ts":{"existed":true,"snapshot":"65d07d21860152fadbf7db78a1237f485ceb21d615eab9e4c5862e20e1404437.source"},"src/thinking-stream.ts":{"existed":true,"snapshot":"4e253c36cc21052cde2f7615a39013f4351b6c2aa575606b71b71072bd2f0be5.source"},"src/timeline-fold.ts":{"existed":true,"snapshot":"3c5ff78a43a06cf90619fc7d7c5d989bab9b38ef2ae2649cd8f1504a720a0451.source"},"src/titlebar-session-identity.tsx":{"existed":true,"snapshot":"2672d57c088c7e2b50e8ea796b0a468053da90de72112615122298db001545b0.source"},"src/toast.tsx":{"existed":true,"snapshot":"d974e2b79c8706a9c39c03d86f34c17e19488c820fa53aa54214edd9bc43e5b4.source"},"src/tool-activity.tsx":{"existed":true,"snapshot":"7c6b229569bc812e3d9dacea06179678341853698faf10b0a59f4d4caad4a906.source"},"src/tool-format.ts":{"existed":true,"snapshot":"087c9ec376ffc7e04a7fa2669d6a810a1a06989cc04ec1eb32e8213d4789d0a6.source"},"src/tool-output-stream.ts":{"existed":true,"snapshot":"d0c62b90784a4efa560874127c229a6770c70ef1a3469a69c60d8976510af51f.source"},"src/transcript-projection.ts":{"existed":true,"snapshot":"70a4af0c331feae2236fcf58b52e3936864b3f0c64d51b91ccca7f3fecac4f6a.source"},"src/transcript-row-projection.ts":{"existed":true,"snapshot":"2ee00c94498c9000ac0dc2a314c192f9ac58e39283448676e34a76f8bdfbcbb4.source"},"src/transcript-scroll-authority.tsx":{"existed":true,"snapshot":"a541e882e0be575d9fedee5e9b94565322caa5b8ce90b946601a1253fa4f5a66.source"},"src/transcript-viewport-navigation.ts":{"existed":true,"snapshot":"57fe83306af58aa844fd8ca628249a6134868d7181049a9a0db5caf452a8ab90.source"},"src/ui.tsx":{"existed":true,"snapshot":"7b0214bba99065421d5dc7d735982f891989c1ea05767b8c5a1dd5a39d7700a2.source"},"src/use-chat-scroll.ts":{"existed":true,"snapshot":"061c423107b01244af2001da955c56d4a12b55010a066c5a210ed97e19417667.source"},"src/use-composer-attachments.ts":{"existed":true,"snapshot":"7f0c6c74c4cf2fe1ed7af207880bc74b2f9d2ea506dfdec6515013fc0c5bc51b.source"},"src/use-composer-draft.ts":{"existed":true,"snapshot":"4351da938474bb5a36db7ec0500d635e69724cbbf6f1266c96201ca82f82e5a6.source"},"src/use-composer-history.ts":{"existed":true,"snapshot":"fc96c3fed8e1ff9615e57fc0e110df0242bfc411ce3a1e8a6dab4447dd113ce8.source"},"src/use-message-selection-quote.ts":{"existed":true,"snapshot":"be4d672e1c65b65609068dbead52cb6ec98209826aeafd3eaa99aa6b92d0a8ba.source"},"src/use-mounted-ref.ts":{"existed":true,"snapshot":"8af7ffcd5e687c2e89a67b8662eab0241ccda44df07a813d591faa94c2b79429.source"},"src/use-pending-selection.ts":{"existed":true,"snapshot":"b2df50e592cbf150a6e56b2249b6caad45bc0c966427974f19ded212a1dc4202.source"},"src/use-roving-row-focus.ts":{"existed":true,"snapshot":"962946b9cbaa6c0ee15eb7fceae4ff013eaf87495d3cf2fad811d92b0bcd2603.source"},"src/use-transcript-projection.ts":{"existed":true,"snapshot":"e51b5d4a774624df3d83027ebbead7d0f320a331ec780682e174e21e39793b84.source"},"src/user-question-prompt-state.ts":{"existed":true,"snapshot":"a1ad5f2bdcca0273db4be0363e75c6032edc5f062dfe527c5d69e311da4164f3.source"},"src/user-question-prompt.tsx":{"existed":true,"snapshot":"01f029a27a5ecff535e702e5fe631911b0da4c6438d06017115ca0ecd743fc9a.source"},"src/utils.ts":{"existed":true,"snapshot":"39b2554fd18da165b59a6351b1aafff3714e2a80c1435f2de9706355b4d32351.source"},"src/workspace-picker.tsx":{"existed":true,"snapshot":"a453fdcbe4859022afd9e58e1fd877e86d5bb6bff0424e76a9c1093d0877d534.source"},"src/__tests__/astryx-i18n.test.tsx":{"existed":true,"snapshot":"83f10b088bfa8e361d172fc7d37a480e348fe5d9e4bf6a8aae5ea622667029aa.source"},"src/__tests__/attachment-image.test.tsx":{"existed":true,"snapshot":"2c6a406d6db9d8b16a06607b75647989f96699971978bb0bf5a2d1de30b63ae0.source"},"src/__tests__/chat-conversation-items.test.ts":{"existed":true,"snapshot":"9d08de23603be7836d5d00649f5b25bd3dcf53c2241d0244e537e15ec0aad086.source"},"src/__tests__/chat-input-behavior.test.ts":{"existed":true,"snapshot":"08665a01dcfce23afceada9ccb744769ae6fb077f9b6bd1146747a034cc40ac9.source"},"src/__tests__/chat-turn-answer-identity.test.tsx":{"existed":true,"snapshot":"dad44505b0ad8d7f0b8bcae1171b65403afb0d16cc66b7896de19ca749540116.source"},"src/__tests__/chat-turn-steering-order.test.ts":{"existed":true,"snapshot":"609ab104af15889da21c65f68c7e3db9d5c119ff844a7b32e97affb08f321f7b.source"},"src/__tests__/chat-view-empty-compaction.test.tsx":{"existed":true,"snapshot":"d1fdd46ab730db285659ef38946c0809a6eb2ee08a1c9bb0e7c7af9a38e659f5.source"},"src/__tests__/composer-context-usage.test.tsx":{"existed":true,"snapshot":"f2a42c4a13889cefa238749c51bebd51bd08987c563799755a10553cda2239e9.source"},"src/__tests__/composer-draft-caret-focus.test.tsx":{"existed":true,"snapshot":"0ba74e65bc041ec5d40ee134af6f4bfdf689b0bf4f7bc84b7495e5501426540b.source"},"src/__tests__/composer-inline-completion-seam.test.tsx":{"existed":true,"snapshot":"44a1a092ae0a0311ac7f801347360a87ccfe1a844185f2f9344ed74d358f8aed.source"},"src/__tests__/composer-model-picker-recovery.test.tsx":{"existed":true,"snapshot":"55b45d36631ba8877ad43e6861fa0ee8bcb52d8e8ff544fb0c382b6263d03dab.source"},"src/__tests__/composer-plus-menu.test.tsx":{"existed":true,"snapshot":"30e094816254aa5acad651adc1663e96a9202b0ac946119824d6fec93d535464.source"},"src/__tests__/composer-running-attachments.test.tsx":{"existed":true,"snapshot":"c827d9b7de7da74cdcf6dd1d889c052635a0279f1d7e2235e07ff982170429e6.source"},"src/__tests__/composer-send-toggle.test.tsx":{"existed":true,"snapshot":"d0719f6b478dc86d325c9fa305f503dbaf34a9b1905e51745e135681025d196b.source"},"src/__tests__/conversation-copy.test.ts":{"existed":true,"snapshot":"2766473a6a84f835580b8b1da07ad78b00dc10dc5510d051500c0a1c6909a596.source"},"src/__tests__/daily-review-copy.test.ts":{"existed":true,"snapshot":"ec14d3c6b3f0f5fd3a34f9080e8f51affe69332a8b1050f3968d2841600445a9.source"},"src/__tests__/form-interaction-prompt.test.tsx":{"existed":true,"snapshot":"04e90e010c6bf0704d0ad0afcb0c2c6d179c63dadc7ec470426f3e8a757bcc25.source"},"src/__tests__/interaction-queue.test.ts":{"existed":true,"snapshot":"37beba220b29585a931a0fadd5ba3f552d80bc0ee1a6c561f53874fb065d7418.source"},"src/__tests__/listed-selection.test.ts":{"existed":true,"snapshot":"db2165696c1129c6f50de4cd8837cca16554ed415db3936b8062f52539320db0.source"},"src/__tests__/live-turn-projection.test.ts":{"existed":true,"snapshot":"7fa68c5f9ab584c1955d732e254438e5874425812a032a4e6e068b379d324b30.source"},"src/__tests__/live-turn-zh.ts":{"existed":true,"snapshot":"6b9e12ab5f12072b7c2468b2a7f6e6888e9d2b1facf50d82b5c502b31d7bd019.source"},"src/__tests__/markdown-body.test.ts":{"existed":true,"snapshot":"176c9c46302904c5d7bf0b61ad4b67f116f28c6bb55e190c8cb224a883a4aaad.source"},"src/__tests__/markdown-han-script.test.tsx":{"existed":true,"snapshot":"efb7bc25dc1305315922c76e4fa5ceac721bddabe213df72867ee999818f6556.source"},"src/__tests__/markdown-rhythm-contract.test.tsx":{"existed":true,"snapshot":"eac44c43a0e9dee036bbe7ccea1b89131f68363c0f1c4f9a92b7bec8f8d853a0.source"},"src/__tests__/materialize.test.ts":{"existed":true,"snapshot":"2d796f382af17681fb172aedba663ab5fcaf203a392d2fb88d3f53a1bb46579b.source"},"src/__tests__/mermaid-render-cache.test.tsx":{"existed":true,"snapshot":"dfdbb9e4c91b43563d9aaa67da42a16ded38d45d8028d1e9d65f31ef7896e2be.source"},"src/__tests__/message-selection-quote-boundary.test.ts":{"existed":true,"snapshot":"f36a4af36e158b74bbbbfb8f4563802df692f28bee4a0145305beb15b5d91670.source"},"src/__tests__/prompt-anchor-rail.test.ts":{"existed":true,"snapshot":"5f2f789653bbdec73ce2f209462ed48de335e40184a0bfd7ee267fd1bbbe1630.source"},"src/__tests__/prompt-rail-observer-identity.test.tsx":{"existed":true,"snapshot":"59eb1da0182a9a2e2e6fcabf79de565bfb83f06d940bf347df1dc0cbc86f0d3b.source"},"src/__tests__/provider-retry-countdown.test.tsx":{"existed":true,"snapshot":"14a4a5dc3195f39bb397f93dd56e988cbfc67095d24ad2dad261f3e33adbb89d.source"},"src/__tests__/rail-alignment-claim.test.ts":{"existed":true,"snapshot":"0fe53240f934e53c9c89a8765dc9b92aa09b05cc3c5023eba7058d744cd8938a.source"},"src/__tests__/return-to-latest-pin.test.tsx":{"existed":true,"snapshot":"3e34c9cf0cc114439ad7be1efc315e3e14f197b1c0584b8e49dec58033505d93.source"},"src/__tests__/runtime-resume-copy.test.ts":{"existed":true,"snapshot":"33bb39698ab0a01dbfff0c0db60980cdf7f3eb7dec8d0bdbc0e3be0aae9db3ac.source"},"src/__tests__/search-modal-source.test.ts":{"existed":true,"snapshot":"8bf2bed74ad622a14eddf9a2483d87a41592d97fb515113f937fe00d18299caf.source"},"src/__tests__/selection-quote-target.test.ts":{"existed":true,"snapshot":"b97a6362b4752b298dc005bb052bc1dc524b14620a6767844a4dd7dcc64fee86.source"},"src/__tests__/session-context-layer-goal.test.tsx":{"existed":true,"snapshot":"f91590dfa560c4238b66fced36dfecedb70d973766913e2b4ddce47787a07675.source"},"src/__tests__/session-history-multi-select.test.tsx":{"existed":true,"snapshot":"f36b6fbecfeb2e7560ac62c9cd6efb7c654c60f785d25dfb6532d7c55159e062.source"},"src/__tests__/session-history-row-actions.test.tsx":{"existed":true,"snapshot":"a37955f1aac547faccb8a529d0a4278c8ae63bb31d9dbfadadfd968566cad173.source"},"src/__tests__/session-hover-card-copy.test.ts":{"existed":true,"snapshot":"e546ff8e790f6ca4502db01411bfe487022617f045a43d01ed606d73e49a5243.source"},"src/__tests__/shell-run-projection.test.ts":{"existed":true,"snapshot":"722c1149ffbb353f77428ae4a4fbb6ee72f93dc9a23fe7a8e5c3e2651bafb31d.source"},"src/__tests__/sidebar-footer.test.tsx":{"existed":true,"snapshot":"5870ee1cdbf586cd8edf6ba64f3d56dfcd7e814d5e42f3784c86130c6cff42bd.source"},"src/__tests__/skill-status.test.ts":{"existed":true,"snapshot":"321718db5944d88b6e488c958d7c33c45bfbc2dc9317df8ffe560d91c570cc53.source"},"src/__tests__/skills-panel-install-state.test.tsx":{"existed":true,"snapshot":"d98f11bf76b0d51deb81218d92ed965510911343d11673c5e32071ad3904b12f.source"},"src/__tests__/stream-delta.test.ts":{"existed":true,"snapshot":"b09ab44820f7d9c2e853d3fc4bc9a9a9ccd10dfe24ef2aede5636c56d32e461b.source"},"src/__tests__/streaming-display-redaction.test.ts":{"existed":true,"snapshot":"dc45a8fcb0d8182bc77feaa81d01e5eb63cc894107bb4f5ebee0ac54b6bd7476.source"},"src/__tests__/streaming-text.test.tsx":{"existed":true,"snapshot":"a55ae0879adfc4dbbb78e3bfe463f9be74e3fe220c437b3f7eeabb07d5bef7f8.source"},"src/__tests__/toast.test.ts":{"existed":true,"snapshot":"823b121b5da1b4b23a11a16a3e25d5a879bc4494c762d180bea309ea92ac8da1.source"},"src/__tests__/tool-activity-presentation.test.ts":{"existed":true,"snapshot":"742ff062a5d47232e235711d26d8a2dd523ee3a5d805ef8d21f26d6a9027a8e3.source"},"src/__tests__/tool-format.test.ts":{"existed":true,"snapshot":"20ecb828a3db71edd01e1b7f0e24c6e0bf7478f1078d66509f93aa834c3699f6.source"},"src/__tests__/transcript-history-notice.test.tsx":{"existed":true,"snapshot":"414ef07b0f2e589f53ccae1ab1fc0ff75bf0fd3a4b97fd71505eec7d60255300.source"},"src/__tests__/transcript-projection.test.ts":{"existed":true,"snapshot":"ddf80af14e7264a801dc72edadd351173e2f0ff615b0877154d19b352935e594.source"},"src/__tests__/transcript-row-projection.test.ts":{"existed":true,"snapshot":"898bdfbee2f582bf086836510734eaaf05fefb600baf8d6218c53737f832536c.source"},"src/__tests__/transcript-scroll-authority.test.ts":{"existed":true,"snapshot":"45941b0e486f19280157eedad78c065d41c8f412c2ed7dde6e5990ffb107608c.source"},"src/__tests__/turn-running-spinner.test.tsx":{"existed":true,"snapshot":"2f97521922af19df74c207a024db9680511bf6e15c652024f0c044d744ceea4b.source"},"src/__tests__/use-chat-scroll.test.tsx":{"existed":true,"snapshot":"64764677f1e7c0d4d117cb50897ccef48404bb93d4e4994a0beb823ba0a3325f.source"},"src/__tests__/use-pending-selection.test.tsx":{"existed":true,"snapshot":"a8ead6ad981ee72f6d4603744376c12b6ac89ee9718c1c1e6e2765e9a7073be8.source"},"src/__tests__/utils.test.ts":{"existed":true,"snapshot":"3c616be935d4f3f12995ccdc0d101840c0bb76665a29c61a090a71ac9be6b6b6.source"},"src/tool-activity/builtin-preview.ts":{"existed":true,"snapshot":"1e5cc75ca867b297dc691d9eea80a96d4d06ec8c50e5426bae5a0a11c9af2b0b.source"},"src/tool-activity/computer-action-label.ts":{"existed":true,"snapshot":"5b758cf1e9a3fd3b32b1e6876b928cd6958a9bec03b71f2e31f51ca835725163.source"},"src/tool-activity/copy.ts":{"existed":true,"snapshot":"a96ef4bf9cc07a257e313fe540fd5b67a1bf87e6a94e54b13eb1e19173939e20.source"},"src/tool-activity/diff-code-preview.tsx":{"existed":true,"snapshot":"3be427edb668a3f3d5d3ed0c50690b768b83da3d1f31a93c7be819384cfc154b.source"},"src/tool-activity/diff-syntax.ts":{"existed":true,"snapshot":"b30a51cad9ba3024d9b3a871f1ef7adcf3ed51cb4f3e692d2804976845edcea4.source"},"src/tool-activity/display-name.ts":{"existed":true,"snapshot":"faafa146cf5a2b1e9b98c0c935e6f3c112c47e64bc5df41d4bb97c7df5301e0d.source"},"src/tool-activity/preview-utils.ts":{"existed":true,"snapshot":"254f88036c67c177f9290f5112505d4d29061fd86d3c0b7e85d8a5fc294c9668.source"},"src/tool-activity/result-projection.ts":{"existed":true,"snapshot":"68f95c1f464896050ddd454486060d371f1329c4b03a8c92beeca36c71b293da.source"},"src/tool-activity/sandbox-denial.ts":{"existed":true,"snapshot":"2504a624802873c022abaf630a748004a5cfd43f2b9aaab78d6259c677749f59.source"},"src/tool-activity/tool-code-block.tsx":{"existed":true,"snapshot":"ffbd42d12e44d92bf47b20e8da4dd17d23f2ba95e3d534403d932d6dcbbc7716.source"},"src/tool-activity/tool-result-preview.tsx":{"existed":true,"snapshot":"64f247c85840090db9268201991231ccb27c37d1095474e20dd275b727765052.source"},"src/primitives/chat.tsx":{"existed":true,"snapshot":"fbf2e023543619fc51ff3cf72dd3e927e51480dce611d3362b25ad3e1508d8f3.source"},"src/primitives/module-page.tsx":{"existed":true,"snapshot":"bb74c72f940e3951db31044cbd9a68f65a1e8127fdec324e4e7d857ea2c763a5.source"},"src/primitives/stat-tile.tsx":{"existed":true,"snapshot":"a0ad159b89abbebb6e8d3ae7e5665ab384793df9a893f42056b2373a28f9cc77.source"}},"complete":true,"candidateLimit":5000,"discoveredFiles":212,"capturedFiles":212,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-10T13:31:05.065Z"} \ No newline at end of file diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/007c21b28483e994b03d4ca42651ac030cd231dc0b21a8a256f7cefced27706a.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/007c21b28483e994b03d4ca42651ac030cd231dc0b21a8a256f7cefced27706a.source new file mode 100644 index 0000000000..22e473d592 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/007c21b28483e994b03d4ca42651ac030cd231dc0b21a8a256f7cefced27706a.source @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Global input history, persisted to localStorage. + * + * Shares the same dedup + max-entry semantics as + * `rememberComposerHistoryEntry` from `composer-helpers.ts` (it delegates + * to that helper rather than reimplementing the trim/dedupe/cap rules), + * but survives page reloads and is shared across all Composer + * instances (and any other input surface in the app). + * + * The storage key is prefixed with `maka-` to namespace it in + * localStorage. + */ + +import { rememberComposerHistoryEntry } from './composer-helpers.js'; + +const STORAGE_KEY = 'maka-input-history'; + +function loadEntries(): string[] | null { + let raw: string | null; + try { + raw = localStorage.getItem(STORAGE_KEY); + } catch { + // localStorage unavailable (private browsing, SSR, quota lock) — signal + // failure so the caller keeps its in-memory history instead of + // clobbering it with empty. + return null; + } + if (raw === null) return []; // nothing stored yet + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; // unexpected shape — don't clobber memory + return parsed.filter((e): e is string => typeof e === 'string'); + } catch { + // Corrupt JSON — don't clobber in-memory history with empty. + return null; + } +} + +function saveEntries(entries: string[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + // localStorage full, quota exceeded, or unavailable (SSR, private + // browsing in some configurations) — silently ignore so the input + // experience is never degraded by storage failures. + } +} + +/** + * Read all global input history entries. Newest entry is last. + * + * Returns `null` when the storage read fails (localStorage unavailable, + * corrupt JSON, unexpected shape) so the caller can keep its in-memory + * history intact instead of treating a failure as "empty". Returns `[]` + * when nothing is stored yet (a legitimate empty state). + */ +export function readGlobalInputHistory(): string[] | null { + return loadEntries(); +} + +const listeners = new Set<() => void>(); + +function notifyListeners(): void { + // Every listener is told, and a throwing one is its own problem. Calling + // them bare let the first failure skip the rest and surface out of a `save` + // that had already succeeded — so a subscriber's bug would read as a storage + // error to the caller and leave the other holders stale. + for (const listener of listeners) { + try { + listener(); + } catch { + // A listener that cannot reconcile keeps whatever it had; the write it + // is being told about has already happened either way. + } + } +} + +/** + * Observe writes to the global history. + * + * A holder of the entries (`useComposerHistory`) needs to know when they + * change, and both writers live here — including the clear behind Settings · + * 数据, which happens while the composer stays mounted. Without this the only + * alternatives are a second cached copy of the list or a re-read on every + * keystroke, and the first is what lets a just-deleted prompt come back. + * + * Same-document only, which is the whole product: one renderer owns the + * composer, and `localStorage`'s cross-document `storage` event never fires + * for the document that performed the write anyway. + */ +export function subscribeGlobalInputHistory(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Persist a sent input text to the global history. + * + * Delegates to `rememberComposerHistoryEntry` so the trim / dedup / + * max-50 cap rules stay defined in exactly one place + * (`composer-helpers.ts`); this module only adds the localStorage glue. + */ +export function saveGlobalInputHistoryEntry(text: string): void { + // On storage read failure, seed from empty rather than clobbering — + // saveEntries will still attempt the write, and the Composer's in-memory + // history (updated separately via rememberComposerHistoryEntry) stays + // the source of truth until storage is readable again. + const next = rememberComposerHistoryEntry(loadEntries() ?? [], text); + saveEntries(next); + notifyListeners(); +} + +/** + * Remove every entry from the global input history. + * + * Provides the deletion story the persisted key needs so sensitive + * prompts don't linger across refreshes / workspaces / sessions with + * no way out. Called from Settings · 数据. + */ +export function clearGlobalInputHistory(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // localStorage unavailable (SSR, private browsing) — nothing to clear. + } + // After the write, and unconditionally: a holder re-reads on notify, so a + // `removeItem` that threw still ends with the holder agreeing with storage + // rather than with a guess about it. + notifyListeners(); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0128b2d6d8d06bff61a294effe7e0001382003e69719916b428af458f1c30bf4.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0128b2d6d8d06bff61a294effe7e0001382003e69719916b428af458f1c30bf4.source new file mode 100644 index 0000000000..9037b7db3a --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0128b2d6d8d06bff61a294effe7e0001382003e69719916b428af458f1c30bf4.source @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionBlockedReason, SessionStatus } from '@maka/core/session'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { StatusDotVariant } from '@astryxdesign/core/StatusDot'; +import { dotForStatus, type StatusSemantic } from './status-vocabulary.js'; +import { getConversationCopy } from './conversation-copy.js'; + +export interface SessionStatusPresentation { + label: string; + variant?: StatusDotVariant; +} + +/** + * What a status MEANS. The colour is not decided here. + * + * There used to be a `SessionStatusTone` in between — seven names of our own + * that the only caller mapped onto Astryx's five. Deleting that layer was + * right; replacing it with a private `SessionStatus -> StatusDotVariant` table + * was not, because `status-vocabulary.ts` already owns the one place a status + * word becomes a colour, and a second table there means the rail and Settings + * can disagree about the same fact. They did: a task waiting on a permission + * prompt drew `error` here while the permission centre drew `attention` for the + * identical condition. + * + * So the session enum maps to `StatusSemantic` and `dotForStatus` picks the + * colour, which also settles what `waiting_for_user` and `blocked` share. + * They are both `attention` — both are "waiting on a person", which is what + * that semantic is defined as. Giving `blocked` `error` to tell the two apart + * was using colour for a distinction colour cannot carry; `error` is reserved + * for "broken now". The two are told apart by their label and, for `blocked`, + * by `describeBlockedReason` in the tooltip. + * + * `undefined` means no dot: `active` is the resting state and the rail does not + * mark a task for being ordinary. Everything else gets one, including + * `aborted` — it was `muted` before this change and `muted` resolved to a real + * `neutral` dot, so dropping it to `undefined` was a behaviour change, not a + * consequence of collapsing the layer. Without a dot it fell through to the + * unread branch and an aborted task with unread text drew the same accent dot + * as one that is running. + */ +const STATUS_SEMANTIC: Record = { + active: undefined, + running: 'active', + waiting_for_user: 'attention', + blocked: 'attention', + aborted: 'neutral', +}; + +export function presentSessionStatus( + status: SessionStatus, + locale: UiLocale, +): SessionStatusPresentation { + const semantic = STATUS_SEMANTIC[status]; + return { + label: getConversationCopy(locale).sessions.status[status], + ...(semantic ? { variant: dotForStatus(semantic) } : {}), + }; +} + +/** + * The canonical translation of a blocked reason, and the contract that a UI + * label never exposes the raw `SessionBlockedReason` identifier (@kenji review). + * A new reason must extend the core enum AND the copy matrix together, or it + * reads as `unknown` — which is the intended failure, not a silent leak of the + * enum string into the interface. + */ +export function describeBlockedReason( + reason: SessionBlockedReason | undefined, + locale: UiLocale, +): string { + const copy = getConversationCopy(locale).sessions.blockedReason; + return reason ? copy[reason] : copy.unknown; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0145f9e818ab7d8a1c7e0af2cacfe38a920672726d8990cc9c0219a44e72d9c1.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0145f9e818ab7d8a1c7e0af2cacfe38a920672726d8990cc9c0219a44e72d9c1.source new file mode 100644 index 0000000000..9a72ad5a77 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0145f9e818ab7d8a1c7e0af2cacfe38a920672726d8990cc9c0219a44e72d9c1.source @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Chinese copy for Astryx's own message catalog, which ships no `zh`: without + * an override every `@astryx.*` string falls back to the shipped `en` catalog + * silently. Grouped by the component that renders it so a slice adopting a new + * Astryx surface can see at a glance whether its strings are already covered. + * + * Deliberately NOT exported from the package barrel (`index.ts`): the only + * consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the + * README's off-barrel convention a symbol earns barrel export only with a + * cross-package consumer. Strings that Maka's own components also render live + * in `shared-ui-copy.ts` instead and are referenced from the override map, so + * shared wording keeps one home. + * + * `zh` only: for `en`, `astryxMessageOverrides` overrides two drawer tooltips + * and otherwise resolves Astryx's shipped defaults — an `en` mirror here would + * be dead config drifting against upstream. + */ +export interface AstryxCopy { + appShell: { mobileNavigation: string; skipToContent: string }; + banner: { collapse: string; expand: string }; + breadcrumbs: { label: string }; + calendar: { + dayInRange: string; + dayRangeEnd: string; + dayRangeStart: string; + dayRangeStartAndEnd: string; + daySelected: string; + nextMonth: string; + previousMonth: string; + rangeCompleteAnnounce: string; + rangeStartAnnounce: string; + }; + chat: { + composerPlaceholder: string; + composerDrawerLabel: string; + composerInputLabel: string; + messageAriaLabel: string; + pastedTextExpand: string; + statusDelivered: string; + statusFailed: string; + statusRead: string; + statusSending: string; + statusSent: string; + drawerCollapse: string; + drawerExpand: string; + newMessages: string; + scrollToBottom: string; + toolCallsError: string; + toolCallsGroupLabel: string; + triggerSuggestions: string; + }; + commandPalette: { + emptyBootstrap: string; + emptySearch: string; + inputPlaceholder: string; + label: string; + noResultsFor: string; + resultCount: string; + }; + dateTime: { + closeCalendar: string; + openCalendar: string; + dialogLabel: string; + datePlaceholder: string; + timePlaceholder: string; + timeSuffix: string; + }; + inputStatus: { error: string; success: string; warning: string }; + lightbox: { mediaViewer: string; previous: string; next: string }; + menus: { dropdown: string; more: string }; + multiSelector: { clearAll: string; selectAll: string }; + /** Selector and MultiSelector render the same two search affordances. */ + search: { options: string; placeholder: string }; + sideNav: { + label: string; + resizeSidebar: string; + collapseSidebar: string; + expandSidebar: string; + itemCollapse: string; + itemExpand: string; + }; + tabList: { label: string }; + table: { label: string }; + thumbnail: { fallbackName: string; open: string; remove: string }; + token: { remove: string }; +} + +export const ASTRYX_COPY_ZH: AstryxCopy = { + appShell: { mobileNavigation: '移动端导航', skipToContent: '跳到主要内容' }, + banner: { collapse: '收起', expand: '展开' }, + breadcrumbs: { label: '面包屑导航' }, + calendar: { + dayInRange: '{date},在所选范围内', + dayRangeEnd: '{date},范围结束', + dayRangeStart: '{date},范围开始', + dayRangeStartAndEnd: '{date},范围开始与结束', + daySelected: '{date},已选择', + nextMonth: '下个月', + previousMonth: '上个月', + rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。', + rangeStartAnnounce: '开始日期 {date}。请选择结束日期。', + }, + chat: { + composerPlaceholder: '输入消息…', + composerDrawerLabel: '附加内容', + composerInputLabel: '消息输入框', + messageAriaLabel: '消息:{status}', + pastedTextExpand: '展开', + statusDelivered: '已送达', + statusFailed: '发送失败', + statusRead: '已读', + statusSending: '发送中', + statusSent: '已发送', + // «点击» is deliberate: the string doubles as the toggle band's visible + // hover tooltip (composer.css renders attr(aria-label)), where the click + // affordance is the whole point. + drawerCollapse: '点击收起{label}', + drawerExpand: '点击展开{label}', + newMessages: '跳到最新消息', + scrollToBottom: '滚动到底部', + toolCallsError: '错误:{message}', + toolCallsGroupLabel: '{count} 次工具调用', + triggerSuggestions: '建议', + }, + commandPalette: { + emptyBootstrap: '输入以搜索', + emptySearch: '无结果', + inputPlaceholder: '搜索…', + label: '命令面板', + noResultsFor: '没有与「{query}」匹配的结果', + resultCount: '{count, number} 条结果', + }, + dateTime: { + closeCalendar: '关闭日历', + openCalendar: '打开日历', + dialogLabel: '选择日期', + datePlaceholder: '选择日期', + timePlaceholder: '选择时间', + timeSuffix: '{label}时间', + }, + inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' }, + lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' }, + menus: { dropdown: '菜单', more: '更多选项' }, + multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' }, + search: { options: '搜索选项', placeholder: '搜索…' }, + sideNav: { + label: '侧边导航', + resizeSidebar: '调整侧边栏宽度', + collapseSidebar: '收起侧边栏', + expandSidebar: '展开侧边栏', + itemCollapse: '收起{label}', + itemExpand: '展开{label}', + }, + tabList: { label: '标签页' }, + table: { label: '表格' }, + thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' }, + token: { remove: '移除{label}' }, +}; + +export const ASTRYX_COPY_ZH_TW: AstryxCopy = { + appShell: { mobileNavigation: '移動端導航', skipToContent: '跳到主要內容' }, + banner: { collapse: '收起', expand: '展開' }, + breadcrumbs: { label: '麵包屑導航' }, + calendar: { + dayInRange: '{date},在所選範圍內', + dayRangeEnd: '{date},範圍結束', + dayRangeStart: '{date},範圍開始', + dayRangeStartAndEnd: '{date},範圍開始與結束', + daySelected: '{date},已選擇', + nextMonth: '下個月', + previousMonth: '上個月', + rangeCompleteAnnounce: '已選擇範圍:{start} 至 {end}。', + rangeStartAnnounce: '開始日期 {date}。請選擇結束日期。', + }, + chat: { + composerPlaceholder: '輸入訊息…', + composerDrawerLabel: '附加內容', + composerInputLabel: '訊息輸入框', + messageAriaLabel: '訊息:{status}', + pastedTextExpand: '展開', + statusDelivered: '已送達', + statusFailed: '傳送失敗', + statusRead: '已讀', + statusSending: '傳送中', + statusSent: '已傳送', + // «點選» is deliberate: the string doubles as the toggle band's visible + // hover tooltip (composer.css renders attr(aria-label)), where the click + // affordance is the whole point. + drawerCollapse: '點選收起{label}', + drawerExpand: '點選展開{label}', + newMessages: '跳到最新訊息', + scrollToBottom: '滾動到底部', + toolCallsError: '錯誤:{message}', + toolCallsGroupLabel: '{count} 次工具呼叫', + triggerSuggestions: '建議', + }, + commandPalette: { + emptyBootstrap: '輸入以搜尋', + emptySearch: '無結果', + inputPlaceholder: '搜尋…', + label: '命令面板', + noResultsFor: '沒有與「{query}」符合的結果', + resultCount: '{count, number} 條結果', + }, + dateTime: { + closeCalendar: '關閉日曆', + openCalendar: '開啟日曆', + dialogLabel: '選擇日期', + datePlaceholder: '選擇日期', + timePlaceholder: '選擇時間', + timeSuffix: '{label}時間', + }, + inputStatus: { error: '錯誤詳情', success: '成功詳情', warning: '警告詳情' }, + lightbox: { mediaViewer: '媒體檢視器', previous: '上一張', next: '下一張' }, + menus: { dropdown: '選單', more: '更多選項' }, + multiSelector: { clearAll: '清除全部{label}', selectAll: '全選' }, + search: { options: '搜尋選項', placeholder: '搜尋…' }, + sideNav: { + label: '側邊導航', + resizeSidebar: '調整側邊欄寬度', + collapseSidebar: '收起側邊欄', + expandSidebar: '展開側邊欄', + itemCollapse: '收起{label}', + itemExpand: '展開{label}', + }, + tabList: { label: '標籤頁' }, + table: { label: '表格' }, + thumbnail: { fallbackName: '縮圖', open: '開啟{accessibleName}', remove: '移除{accessibleName}' }, + token: { remove: '移除{label}' }, +}; diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/01f029a27a5ecff535e702e5fe631911b0da4c6438d06017115ca0ecd743fc9a.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/01f029a27a5ecff535e702e5fe631911b0da4c6438d06017115ca0ecd743fc9a.source new file mode 100644 index 0000000000..a26c6b29c5 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/01f029a27a5ecff535e702e5fe631911b0da4c6438d06017115ca0ecd743fc9a.source @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useId, useRef, useState } from 'react'; +import type { UserQuestionRequestEvent } from '@maka/core/events'; +import type { UserQuestionResponse } from '@maka/core/user-question'; +import { Button, RadioList, RadioListItem, TextInput } from '@astryxdesign/core'; +import { useMountedRef } from './use-mounted-ref.js'; +import { + buildUserQuestionResponse, + canLeaveQuestion, + createQuestionDrafts, + type QuestionAnswerDraft, +} from './user-question-prompt-state.js'; +import { useUiLocale } from './locale-context.js'; +import { getConversationCopy } from './conversation-copy.js'; + +export function UserQuestionPrompt(props: { + request: UserQuestionRequestEvent; + onRespond(response: UserQuestionResponse): void | Promise; + onStop(): void | Promise; + stopPending?: boolean; +}) { + const copy = getConversationCopy(useUiLocale()).questions; + const titleId = useId(); + const [questionIndex, setQuestionIndex] = useState(0); + const [drafts, setDrafts] = useState(() => createQuestionDrafts(props.request.questions)); + const [responsePending, setResponsePending] = useState(false); + const responsePendingRef = useRef(false); + const activeRequestIdRef = useRef(props.request.requestId); + const mountedRef = useMountedRef(); + + useEffect(() => { + activeRequestIdRef.current = props.request.requestId; + setQuestionIndex(0); + setDrafts(createQuestionDrafts(props.request.questions)); + responsePendingRef.current = false; + setResponsePending(false); + }, [props.request.requestId, props.request.questions]); + + const question = props.request.questions[questionIndex]; + if (!question) return null; + const draft = drafts[questionIndex] ?? null; + const selectedValue = draft?.kind === 'option' ? `option:${draft.optionIndex}` : draft?.kind === 'other' ? 'other' : ''; + const interactionDisabled = Boolean(props.stopPending) || responsePending; + const canContinue = canLeaveQuestion(draft) && !interactionDisabled; + const isLast = questionIndex === props.request.questions.length - 1; + + function updateDraft(next: QuestionAnswerDraft) { + setDrafts((current) => current.map((candidate, index) => index === questionIndex ? next : candidate)); + } + + function select(value: string) { + if (value === 'other') { + updateDraft({ kind: 'other', value: draft?.kind === 'other' ? draft.value : '' }); + return; + } + const optionIndex = Number(value.slice('option:'.length)); + updateDraft({ kind: 'option', optionIndex }); + } + + async function submit() { + if (responsePendingRef.current || !canLeaveQuestion(draft)) return; + const requestId = props.request.requestId; + responsePendingRef.current = true; + setResponsePending(true); + try { + await props.onRespond(buildUserQuestionResponse(props.request, drafts)); + } finally { + if (activeRequestIdRef.current === requestId) { + responsePendingRef.current = false; + if (mountedRef.current) setResponsePending(false); + } + } + } + + return ( +
+
+
+
+

{question.question}

+ {questionIndex + 1} / {props.request.questions.length} +
+
+ +
+ + {question.options.map((option, optionIndex) => ( + + ))} + + + {draft?.kind === 'other' ? ( +
+ updateDraft({ kind: 'other', value })} + width="100%" + hasAutoFocus + /> +
+ ) : null} +
+ +
+
+
+
+ ); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/04e90e010c6bf0704d0ad0afcb0c2c6d179c63dadc7ec470426f3e8a757bcc25.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/04e90e010c6bf0704d0ad0afcb0c2c6d179c63dadc7ec470426f3e8a757bcc25.source new file mode 100644 index 0000000000..37b6379298 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/04e90e010c6bf0704d0ad0afcb0c2c6d179c63dadc7ec470426f3e8a757bcc25.source @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { FormRequestEvent } from '@maka/core/events'; +import { FormInteractionPrompt } from '../form-interaction-prompt.js'; +import { LocaleProvider } from '../locale-context.js'; + +const request: FormRequestEvent = { + type: 'form_request', + id: 'event-1', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Configure release', + requester: { name: 'release' }, + fields: [ + { kind: 'string', name: 'version', label: 'Version', required: true }, + { kind: 'integer', name: 'replicas', label: 'Replicas', required: true, minimum: 997, maximum: 997 }, + { kind: 'string', name: 'when', label: 'When', required: true, format: 'date-time' }, + { kind: 'string', name: 'notes', label: 'Notes', required: false }, + ], +}; + +test('same-request recovery preserves drafts and renders accessible constraints', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + const render = async (next: FormRequestEvent) => { + await act(() => root.render( + + undefined} /> + , + )); + }; + + try { + await render(request); + const includeNotes = container.querySelector('input[type="checkbox"]'); + assert.ok(includeNotes); + assert.equal(includeNotes.checked, false); + await act(() => { + includeNotes.checked = true; + includeNotes.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + await render({ + ...request, + fields: request.fields.map((field) => ({ + ...field, + ...(field.kind === 'single_select' || field.kind === 'multi_select' + ? { options: field.options.map((option) => ({ ...option })) } + : {}), + })), + }); + + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, true); + assert.match(container.textContent ?? '', /997/); + assert.match(container.textContent ?? '', /date-time/); + for (const field of container.querySelectorAll('[aria-describedby]')) { + const describedBy = field.getAttribute('aria-describedby'); + if (describedBy) assert.ok(document.getElementById(describedBy)); + } + + await render({ ...request, requestId: 'form-2' }); + assert.equal(container.querySelector('input[type="checkbox"]')?.checked, false); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/061c423107b01244af2001da955c56d4a12b55010a066c5a210ed97e19417667.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/061c423107b01244af2001da955c56d4a12b55010a066c5a210ed97e19417667.source new file mode 100644 index 0000000000..93bcd9dd73 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/061c423107b01244af2001da955c56d4a12b55010a066c5a210ed97e19417667.source @@ -0,0 +1,349 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The transcript's scroll commands, and the seam that hands the scroller to the + * authority that owns it (`transcript-scroll-authority.ts`). + * + * A command is one-shot — jump to a turn the reader picked, ask for the history + * above them — and it releases the pin first, because the authority writes + * nothing while the pin is released and so a command can never be fighting a + * policy. That was the shape every previous round of this code had. + * + * What decides whether the reader wants either thing is never re-derived here. + * "They have left the tail" is the pin, and the pin has one owner. Nothing here + * compensates for content that lands above them either; `overflow-anchor: auto` + * does that continuously, and for free. + */ + +import { useEffect, useRef, useState, type RefObject } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; + +export function useChatScroll(input: { + scrollRef: RefObject; + sessionId?: string; + messages: readonly StoredMessage[]; + /** + * A turn to reveal, and where its requester wants it. `center` with the + * app's scroll motion is the reveal a search result wants; `start` is for a + * requester that is already aiming this turn itself and only needs the + * reveal to agree with it, instantly and at the same edge. + */ + target?: { turnId: string; nonce: number; align?: 'start' | 'center' }; + restoreTarget?: { turnId: string; unavailable?: boolean }; + onTargetHandled?(nonce: number): void; + viewportNavigation?: TranscriptViewportNavigation; + onReadingAnchorChange?(turnId?: string): void; + behavior: ScrollBehavior; + hasOlderHistory?: boolean; + onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; + hasNewerHistory?: boolean; + onLoadLaterHistory?(anchorTurnId?: string): Promise | void; +}) { + const [highlightedTurnId, setHighlightedTurnId] = useState(null); + const authority = useTranscriptScrollAuthority(); + const loadEarlierRef = useRef(input.onLoadEarlierHistory); + loadEarlierRef.current = input.onLoadEarlierHistory; + const canLoadEarlier = input.onLoadEarlierHistory !== undefined; + const loadLaterRef = useRef(input.onLoadLaterHistory); + loadLaterRef.current = input.onLoadLaterHistory; + const canLoadLater = input.onLoadLaterHistory !== undefined; + const handledTarget = useRef(null); + const anchorChangeRef = useRef(input.onReadingAnchorChange); + anchorChangeRef.current = input.onReadingAnchorChange; + const targetHandledRef = useRef(input.onTargetHandled); + targetHandledRef.current = input.onTargetHandled; + const reportReadingAnchor = useRef<(() => void) | undefined>(undefined); + const reportedAnchor = useRef<{ sessionId?: string; turnId?: string } | undefined>(undefined); + const activation = useRef<{ sessionId?: string; restoreTurnId?: string } | undefined>(undefined); + if (activation.current?.sessionId !== input.sessionId) { + handledTarget.current = null; + activation.current = { + sessionId: input.sessionId, + restoreTurnId: input.restoreTarget?.turnId, + }; + } + if (activation.current?.restoreTurnId + && activation.current.restoreTurnId !== input.restoreTarget?.turnId) { + // Clearing or replacing a bookmark cancels the captured command. A new + // bookmark within the same activation records reading, not navigation. + activation.current = { sessionId: input.sessionId }; + } + const restoreUnavailable = + input.restoreTarget?.turnId === activation.current?.restoreTurnId + && input.restoreTarget?.unavailable === true; + const commandTarget = useRef(null); + commandTarget.current = input.target?.turnId + ? `search:${input.sessionId ?? ''}:${input.target.turnId}:${input.target.nonce}` + : activation.current?.restoreTurnId + ? restoreCommandKey( + input.sessionId, + activation.current.restoreTurnId, + restoreUnavailable, + ) + : null; + + // A passive effect, not a layout one: the scroller is Astryx's layout root, + // an ancestor, and React attaches a parent's ref after its children's layout + // effects have already run. The growth signal is a ResizeObserver delivery, + // which lands after passive effects, so this is still installed in time. + useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); + + // A new conversation either resumes a semantic reading position or arrives + // at its tail. Releasing before an async fill is essential: an empty + // transcript clamps every pixel offset to zero, but it cannot erase a Turn + // identity. + useEffect(() => { + if (activation.current?.restoreTurnId) authority.releasePin(); + else authority.pinToTail(); + }, [input.sessionId]); + + useEffect(() => input.viewportNavigation?.subscribe((sessionId) => { + if (activation.current?.sessionId !== sessionId) return; + // A send supersedes both a captured bookmark and a search frame that has + // not landed yet. Consume that frame before the authority reports the pin. + handledTarget.current = commandTarget.current; + activation.current = { sessionId }; + commandTarget.current = null; + authority.pinToTail(); + }), [authority, input.viewportNavigation]); + + useEffect(() => { + const report = (): void => { + const snapshot = authority.getSnapshot(); + // A release is part of both navigation commands. Until the command has + // actually landed, neither an intermediate bounded range nor an empty + // one says anything new about where the reader intended to be. + if (commandTarget.current && handledTarget.current !== commandTarget.current) return; + const turnId = snapshot.pinned + ? undefined + : firstVisibleTurnId(input.scrollRef.current); + // An empty bounded range has no new reading position. In particular, + // releasing the pin before a remembered range loads must not erase the + // Turn that caused that range to be requested. + if (!snapshot.pinned && !turnId) return; + const previous = reportedAnchor.current; + if ( + previous !== undefined && + previous.sessionId === input.sessionId && + previous.turnId === turnId + ) return; + reportedAnchor.current = { sessionId: input.sessionId, turnId }; + anchorChangeRef.current?.(turnId); + }; + reportReadingAnchor.current = report; + report(); + let previousPin = authority.getSnapshot().pinned; + const stopWatchingPolicy = authority.subscribe(() => { + const pinned = authority.getSnapshot().pinned; + // Geometry can change the return-to-tail affordance without changing + // reading intent. Reporting its visible Turn would turn an arriving + // range into a new history command and cancel the range's own sender. + if (pinned === previousPin) return; + previousPin = pinned; + report(); + }); + const stopWatchingReader = authority.subscribeToReaderScroll(report); + return () => { + if (reportReadingAnchor.current === report) reportReadingAnchor.current = undefined; + stopWatchingPolicy(); + stopWatchingReader(); + }; + }, [authority, input.scrollRef, input.sessionId]); + + useEffect(() => { + const root = input.scrollRef.current; + if (!root) return; + const canLoad = (direction: 'up' | 'down'): boolean => direction === 'up' + ? input.hasOlderHistory === true && canLoadEarlier + : input.hasNewerHistory === true && canLoadLater; + // Asking twice is the loader's problem, not this one's: it refuses a + // request while one is in flight, and asking for history the reader + // already has is idempotent anyway. + const requestHistory = (direction: 'up' | 'down'): void => { + activation.current = { sessionId: input.sessionId }; + commandTarget.current = null; + authority.releasePin(); + // A wheel at either edge moves nothing, so no scroll event refreshes the + // anchor and the restore effect would load around an evicted Turn. + reportReadingAnchor.current?.(); + const anchorTurnId = direction === 'up' + ? firstVisibleTurnId(root) + : lastVisibleTurnId(root); + // The browser anchors the reader against everything that lands above + // them, with one exception: it declines while the scroller sits at zero, + // which is exactly where a wheel asks for history. One pixel is the whole + // fix — measured in Chromium, an insert of 501px above the reader moves + // `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0. + if (direction === 'up' && root.scrollTop < 1) root.scrollTop = 1; + const load = direction === 'up' ? loadEarlierRef.current : loadLaterRef.current; + void Promise.resolve(load?.(anchorTurnId)).catch(() => undefined); + }; + /** Close enough to the requested edge that the reader is about to reach it. */ + const nearEdge = (direction: 'up' | 'down'): boolean => + (direction === 'up' + ? root.scrollTop + : root.scrollHeight - root.clientHeight - root.scrollTop) + <= Math.max(640, root.clientHeight * 2); + // Nearness alone does not mean the reader wants history — on a transcript + // shorter than about three viewports the tail is inside this band too, so + // following it would ask on every write, and content landing above would + // ask again on every anchoring correction until there was no history left. + // Which movements were the reader's is not re-derived here; the authority + // watches the scroller and says so. + const stopWatchingReader = authority.subscribeToReaderScroll((direction) => { + if (canLoad(direction) && nearEdge(direction)) requestHistory(direction); + }); + // At either bounded edge a wheel cannot move the scroller, so no scroll + // event follows. The gesture still asks for the adjacent page. Do not steal + // a wheel from a nested tool output that can consume it itself. + const onWheel = (event: WheelEvent): void => { + if (event.deltaY === 0) return; + const direction = event.deltaY < 0 ? 'up' : 'down'; + if (!canLoad(direction) || !nearEdge(direction)) return; + for (const target of event.composedPath()) { + if (target === root) break; + if (!(target instanceof HTMLElement)) continue; + const overflowY = getComputedStyle(target).overflowY; + if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; + const remaining = direction === 'up' + ? target.scrollTop + : target.scrollHeight - target.clientHeight - target.scrollTop; + if (target.scrollHeight > target.clientHeight && remaining > 0) return; + } + requestHistory(direction); + }; + root.addEventListener('wheel', onWheel, { passive: true }); + return () => { + stopWatchingReader(); + root.removeEventListener('wheel', onWheel); + }; + }, [authority, input.hasOlderHistory, input.hasNewerHistory, canLoadEarlier, canLoadLater, + input.scrollRef, input.sessionId]); + + useEffect(() => { + const explicitTarget = input.target?.turnId + ? { + kind: 'search' as const, + turnId: input.target.turnId, + nonce: input.target.nonce, + align: input.target.align ?? ('center' as const), + } + : undefined; + const restoreTurnId = activation.current?.restoreTurnId; + const target = explicitTarget ?? (restoreTurnId + ? { + kind: 'restore' as const, + turnId: restoreTurnId, + unavailable: restoreUnavailable, + } + : undefined); + if (!target) return; + if (explicitTarget) activation.current = { sessionId: input.sessionId }; + // This effect re-runs on every transcript update so a target that arrives + // before its turn still lands. It stops for good once the turn is on + // screen — repeating the release afterwards would take the tail away from + // a reader who had already scrolled back to it. + const chosen = target.kind === 'search' + ? `search:${input.sessionId ?? ''}:${target.turnId}:${target.nonce}` + : restoreCommandKey(input.sessionId, target.turnId, target.unavailable); + if (handledTarget.current === chosen) return; + authority.releasePin(); + const frame = window.requestAnimationFrame(() => { + if (commandTarget.current !== chosen) return; + const root = input.scrollRef.current; + if (!root) return; + const element = root.querySelector(`[data-turn-id="${CSS.escape(target.turnId)}"]`); + if (!element || !('scrollIntoView' in element)) { + if (target.kind !== 'restore' || !target.unavailable) return; + handledTarget.current = chosen; + activation.current = { sessionId: input.sessionId }; + if (!firstVisibleTurnId(root)) authority.pinToTail(); + reportReadingAnchor.current?.(); + return; + } + handledTarget.current = chosen; + const targetElement = element as HTMLElement; + const alignToStart = target.kind !== 'search' || target.align === 'start'; + targetElement.scrollIntoView({ + // A reveal that agrees with a requester already aiming this turn has to + // be instant too: an animated one is a second writer moving the + // scroller for a second after the requester has landed it. + behavior: alignToStart ? 'auto' : input.behavior, + block: alignToStart ? 'start' : 'center', + }); + // A command can land at the browser's existing offset and therefore + // produce no scroll event. Reuse the authority-backed reporter so that + // switching away still retains the position the command established. + reportReadingAnchor.current?.(); + if (target.kind === 'restore') return; + targetElement.setAttribute('tabindex', '-1'); + targetElement.focus({ preventScroll: true }); + setHighlightedTurnId(target.turnId); + targetHandledRef.current?.(target.nonce); + }); + const clear = target.kind === 'search' + ? window.setTimeout(() => { + setHighlightedTurnId((current) => (current === target.turnId ? null : current)); + }, 2200) + : undefined; + return () => { + window.cancelAnimationFrame(frame); + if (clear !== undefined) window.clearTimeout(clear); + }; + }, [ + input.target?.turnId, + input.target?.nonce, + input.restoreTarget?.turnId, + input.restoreTarget?.unavailable, + input.behavior, + input.sessionId, + input.messages, + input.scrollRef, + ]); + + return { + highlightedTurnId, + }; +} + +function firstVisibleTurnId(root: HTMLElement | null): string | undefined { + if (!root) return undefined; + const rootTop = root.getBoundingClientRect().top; + return [...root.querySelectorAll('[data-turn-id]')] + .find((turn) => turn.getBoundingClientRect().bottom > rootTop) + ?.dataset.turnId; +} + +function lastVisibleTurnId(root: HTMLElement): string | undefined { + const rootBottom = root.getBoundingClientRect().bottom; + return [...root.querySelectorAll('[data-turn-id]')] + .findLast((turn) => turn.getBoundingClientRect().top < rootBottom) + ?.dataset.turnId; +} + +function restoreCommandKey( + sessionId: string | undefined, + turnId: string, + unavailable: boolean, +): string { + return `restore:${sessionId ?? ''}:${turnId}:${unavailable ? 'unavailable' : 'pending'}`; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0644ff5f46a9b1259034ec0f7bb1cd1faaf4d68d608fb1004cc1fc556140c586.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0644ff5f46a9b1259034ec0f7bb1cd1faaf4d68d608fb1004cc1fc556140c586.source new file mode 100644 index 0000000000..ac8bfd421a --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0644ff5f46a9b1259034ec0f7bb1cd1faaf4d68d608fb1004cc1fc556140c586.source @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SETTINGS_SECTIONS, type SettingsSection } from '@maka/core/settings'; + +const ALLOWED_SETTINGS_SECTIONS = new Set(SETTINGS_SECTIONS); +const RAW_HREF_MAX_LENGTH = 4096; +const COMPOSE_TEXT_MAX_LENGTH = 4096; + +/** Closed internal navigation surface; it never executes actions. */ +export type MakaUriDest = + | { kind: 'settings'; section: SettingsSection } + | { kind: 'compose'; text: string }; + +/** + * Parse an exact lowercase internal URI. Unsupported namespaces and malformed + * inputs return null; callers must never pass them to external navigation. + */ +export function parseMakaUri(href: string): MakaUriDest | null { + if (typeof href !== 'string') return null; + if (href.length === 0 || href.length > RAW_HREF_MAX_LENGTH) return null; + if (!href.startsWith('maka:')) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (url.protocol !== 'maka:') return null; + if (url.username !== '' || url.password !== '') return null; + if (url.port !== '') return null; + if (url.hash !== '') return null; + + switch (url.hostname) { + case 'settings': { + if (url.search !== '') return null; + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + if (segments.length !== 1) return null; + const section = segments[0]!; + if (!isSettingsSection(section)) return null; + return { kind: 'settings', section }; + } + case 'compose': { + if (url.pathname !== '' && url.pathname !== '/') return null; + const text = url.searchParams.get('text'); + if (text === null || text.length === 0 || text.length > COMPOSE_TEXT_MAX_LENGTH) return null; + return { kind: 'compose', text }; + } + default: + return null; + } +} + +/** + * Case-insensitive probe used to keep internal-looking links out of the + * external navigation path. Parsing remains lowercase-only. + */ +export function isMakaUriCandidate(href: string): boolean { + return typeof href === 'string' && /^maka:/i.test(href); +} + +/** External links are restricted to browser and mail destinations. */ +export function isSafeExternalScheme(href: string): boolean { + if (typeof href !== 'string') return false; + let parsed: URL; + try { + parsed = new URL(href); + } catch { + return false; + } + return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'mailto:'; +} + +function isSettingsSection(value: string): value is SettingsSection { + return ALLOWED_SETTINGS_SECTIONS.has(value as SettingsSection); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/076f407ce17b1a472d4af8e2ebba13ea61d2538fe13814d47819284be2e4ea8a.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/076f407ce17b1a472d4af8e2ebba13ea61d2538fe13814d47819284be2e4ea8a.source new file mode 100644 index 0000000000..d7ac2758da --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/076f407ce17b1a472d4af8e2ebba13ea61d2538fe13814d47819284be2e4ea8a.source @@ -0,0 +1,489 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import type { ProviderType } from '@maka/core/llm-connections'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { SessionSummary } from '@maka/core/session'; +import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; +import { + exactModelChoiceValue, + type ChatModelChoice, +} from '../src/chat-model-helpers.js'; +import { ModelPicker } from '../src/model-picker.js'; +import { getConversationCopy } from '../src/conversation-copy.js'; +import { useUiLocale } from '../src/locale-context.js'; + +// Fidelity convention (#1433): every story below names the real app path +// that reaches it. See apps/desktop/stories/FIDELITY.md. + +const meta = { + title: 'Product/Model Picker', + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function choice( + connectionSlug: string, + providerType: ChatModelChoice['providerType'], + providerLabel: string, + model: string, + label: string, +): ChatModelChoice { + return { connectionId: `connection-${connectionSlug}`, connectionSlug, providerType, providerLabel, model, label, isDefault: false, thinkingLevels: [] }; +} + +const CHOICES: ChatModelChoice[] = [ + choice('openai-main', 'openai', 'OpenAI', 'gpt-5', 'GPT-5'), + choice('openai-main', 'openai', 'OpenAI', 'gpt-5-mini', 'GPT-5 mini'), + choice('openai-main', 'openai', 'OpenAI', 'o3', 'o3'), + choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-opus-4-1', 'Claude Opus 4.1'), + choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-sonnet-4', 'Claude Sonnet 4'), + choice('google-lab', 'google', 'Google Gemini', 'gemini-3-pro', 'Gemini 3 Pro'), + choice('openrouter', 'openai-compatible', 'Custom relay', 'vendor/a-very-long-model-name-with-reasoning-and-tools-preview', 'A very long model name with reasoning and tools preview'), +]; + +// Canonical user-facing ladder when a model offers the common set. +const THINKING_LEVELS: ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'xhigh']; + +// A workspace with far more connections than any reference screen probes. Two +// OpenAI keys share a provider, so `modelMenuGroups` disambiguates their +// headings with the connection slug. +const MANY_CHOICES: ChatModelChoice[] = ( + [ + { slug: 'openai-main', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'o3', 'o4-mini', 'gpt-4.1'] }, + { slug: 'openai-alt', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'o3'] }, + { slug: 'anthropic-team', type: 'anthropic', label: 'Anthropic', models: ['claude-opus-4-1', 'claude-sonnet-4', 'claude-haiku-4-5'] }, + { slug: 'google-lab', type: 'google', label: 'Google Gemini', models: ['gemini-3-pro', 'gemini-3-flash'] }, + { slug: 'deepseek-main', type: 'deepseek', label: 'DeepSeek', models: ['deepseek-chat', 'deepseek-reasoner'] }, + { slug: 'moonshot-main', type: 'moonshot', label: 'Moonshot', models: ['kimi-k2-0711', 'kimi-k1-8k'] }, + { slug: 'relay', type: 'openai-compatible', label: 'Custom relay', models: ['vendor/alpha', 'vendor/beta', 'vendor/gamma'] }, + ] satisfies Array<{ slug: string; type: ProviderType; label: string; models: string[] }> +).flatMap((group) => group.models.map((model) => choice(group.slug, group.type, group.label, model, model))); + +// A relay whose connection name, model ids, and descriptions all overflow the +// trigger and option widths — the "very long text" state truncation must honour. +const LONG_MODEL_LABEL = + 'A very long model name that keeps going well past any reasonable trigger width so wrapping and truncation get exercised'; +const LONG_CHOICES: ChatModelChoice[] = [ + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/a-very-long-model-identifier-with-reasoning-tools-and-a-2026-preview-suffix', + label: LONG_MODEL_LABEL, + description: + 'A deliberately verbose description that runs onto several lines so the option body’s overflow handling stays legible instead of pushing the menu wider.', + knowledgeCutoff: '2026-01', + isDefault: false, + thinkingLevels: [], + }, + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/second-extremely-long-model-identifier-preview-with-an-extended-context-window', + label: 'Another exhaustively named preview model with an extended context window and a trailing note', + isDefault: false, + thinkingLevels: [], + }, +]; + +function providerMark(type: ProviderType) { + const labels: Partial> = { + openai: 'O', + anthropic: 'A', + google: 'G', + 'openai-compatible': 'R', + }; + return {labels[type] ?? 'M'}; +} + +function choiceValue(choice: ChatModelChoice) { + return exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model); +} + +function selectedLabel(value: string) { + return CHOICES.find((choice) => choiceValue(choice) === value)?.label ?? value; +} + +function choiceForTarget(input: { llmConnectionId: string; llmConnectionSlug: string; model: string }) { + return CHOICES.find( + (choice) => + choice.connectionId === input.llmConnectionId && + choice.connectionSlug === input.llmConnectionSlug && + choice.model === input.model, + ); +} + +function ModelPickerFrame(props: { initialValue?: string }) { + const [value, setValue] = useState(props.initialValue ?? choiceValue(CHOICES[4]!)); + return ( +
+ { + const nextChoice = choiceForTarget(next); + if (nextChoice) setValue(choiceValue(nextChoice)); + }} + /> +
+ ); +} + +// Real path: chat → composer footer model control. +export const Default: Story = { + render: () => , +}; + +// Real path: an existing conversation -> composer footer model control. The +// cache notice belongs inside this picker's open decision surface; the resting +// trigger and the new-chat picker below stay quiet. +export const ExistingConversation: Story = { + render: function ExistingConversationRender() { + const [activeChoice, setActiveChoice] = useState(CHOICES[4]!); + const activeSession = { + id: 'storybook-model-switch', + name: 'Model switch warning', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionId: activeChoice.connectionId, + llmConnectionSlug: activeChoice.connectionSlug, + connectionLocked: true, + model: activeChoice.model, + permissionMode: 'ask', + } satisfies SessionSummary; + return ( +
+ { + const nextChoice = choiceForTarget(next); + if (nextChoice) setActiveChoice(nextChoice); + }} + /> +
+ ); + }, + play: async ({ canvasElement, globals }) => { + const english = globals.locale === 'en'; + const warning = english + ? 'Switching may rebuild the provider prompt cache, making the next request slower or more expensive.' + : '切换模型可能需要重建服务商提示缓存,使下一次请求更慢或成本更高。'; + const trigger = within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + }); + const announcement = canvasElement.querySelector('.maka-model-switch-announcement'); + await expect(announcement).toHaveAttribute('role', 'status'); + await expect(trigger).not.toHaveAttribute('aria-description'); + await expect(announcement).toBeEmptyDOMElement(); + await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + + await userEvent.hover(trigger); + await within(document.body).findByText( + english ? 'Switch model for this task' : '切换当前任务模型', + ); + await userEvent.unhover(trigger); + + await userEvent.click(trigger); + const notice = document.body.querySelector('.maka-model-switch-notice'); + await expect(notice).toBeInTheDocument(); + await expect(notice).toHaveAttribute('aria-hidden', 'true'); + await expect(notice).toHaveTextContent(warning); + await expect(announcement).toHaveTextContent(warning); + await expect(announcement).toHaveAttribute('aria-live', 'polite'); + await expect(announcement).toHaveAttribute('aria-atomic', 'true'); + const menu = within(document.body).getByRole('menu'); + await expect(menu).not.toContainElement(announcement); + + await userEvent.keyboard('{Escape}'); + await expect(announcement).toBeEmptyDOMElement(); + await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + + await userEvent.keyboard('{ArrowDown}'); + await expect(announcement).toHaveTextContent(warning); + await expect(document.body.querySelector('.maka-model-switch-notice')).toBeInTheDocument(); + }, +}; + +// Real path: an existing but still-empty Session. There is no conversation +// prefix to abandon yet, so this stays as quiet as the new-chat picker. +export const EmptyConversation: Story = { + render: () => ( +
+ undefined} + /> +
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + }); + const announcement = canvasElement.querySelector('.maka-model-switch-announcement'); + await expect(announcement).toHaveAttribute('role', 'status'); + await expect(trigger).not.toHaveAttribute('aria-description'); + await expect(announcement).toBeEmptyDOMElement(); + await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + + await userEvent.click(trigger); + await expect(announcement).toBeEmptyDOMElement(); + await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument(); + }, +}; + +// Real path: Settings → 通用 before any provider exposes a model choice. +// The 260px frame stands in for the one part that cannot be imported from +// this package: the desktop's `select.css` sizes the trigger to 260px via +// `.settingsRows .settingsModelPickerTrigger`, which only exists in the +// renderer's stylesheet. Size and state are the production ones. +export const EmptyCatalog: Story = { + render: () => ( +
+ {}} + /> +
+ ), +}; + +// Real path: quiet composer left footer — model + adjacent thinking menu. +export const ThinkingLevelSeparate: Story = { + render: function ThinkingLevelSeparateRender() { + const [value, setValue] = useState(choiceValue(CHOICES[4]!)); + const [thinkingLevel, setThinkingLevel] = useState('medium'); + return ( +
+ { + const nextChoice = choiceForTarget(next); + if (nextChoice) setValue(choiceValue(nextChoice)); + }} + /> + +
+ ); + }, + play: async ({ canvasElement }) => { + const thinking = within(canvasElement).getByRole('button', { name: /思考级别/ }); + await userEvent.click(thinking); + const medium = await within(document.body).findByRole('menuitemradio', { name: '中' }); + await expect(medium).toHaveAttribute('aria-checked', 'true'); + }, +}; + +// Real path: composer left footer when no connection yields a usable model — +// what a failed / offline / unauthorised catalog fetch all collapse to. The +// picker cannot exist without choices, so the composer swaps in an honest +// "configure a connection" chip (ModelChipStatic's onOpenSettings button) +// rather than a dropdown with nothing behind it. +export const NoModelsAvailable: Story = { + render: function NoModelsAvailableRender() { + const copy = getConversationCopy(useUiLocale()).composer; + return ( +
+ {}} /> +
+ ); + }, + play: async ({ canvasElement }) => { + // It is a real button into Settings, not inert text wearing a dead chevron. + await expect( + within(canvasElement).getByRole('button', { + name: /配置模型连接|Configure model connections/, + }), + ).toBeInTheDocument(); + }, +}; + +// Real path: home / new-chat model control for a workspace with many configured +// connections — the breadth #3446 F5 says a single reference screen never +// exercises. Two OpenAI keys land in the same provider, so their headings carry +// the disambiguating slug suffix. +export const ManyConnections: Story = { + render: function ManyConnectionsRender() { + const [value, setValue] = useState(choiceValue(MANY_CHOICES[0]!)); + return ( +
+ choiceValue(candidate) === value)?.label ?? value} + choices={MANY_CHOICES} + currentValue={value} + currentProviderType="openai" + renderProviderMark={providerMark} + onPick={(next) => { + const picked = MANY_CHOICES.find( + (candidate) => + candidate.connectionId === next.llmConnectionId && + candidate.connectionSlug === next.llmConnectionSlug && + candidate.model === next.model, + ); + if (picked) setValue(choiceValue(picked)); + }} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // Every connection is its own labelled group and the last group's model is + // reachable in the menu's accessibility tree. This drives the visual state; + // selection behaviour and scroll geometry are contracts left to focused + // tests / e2e, not asserted here. + const groups = await menu.findAllByRole('group'); + await expect(groups.length).toBeGreaterThanOrEqual(7); + await menu.findByRole('menuitemradio', { name: 'vendor/gamma' }); + }, +}; + +// Real path: a custom relay connection exposing verbose model identifiers with +// a long user-set connection name — very long text in the trigger, the option +// labels, and the descriptions at once. +export const LongModelNames: Story = { + render: () => ( +
+ undefined} + /> +
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + // Verifies the long-labelled model is reachable as a radio menu item. Whether the + // long text truncates or wraps within the menu bounds is a visual check, + // not asserted here. + await within(document.body).findByRole('menuitemradio', { + name: /A very long model name that keeps going/, + }); + }, +}; + +// Real path: an existing Session whose connection is still configured but whose +// pinned model was dropped from that connection's catalog. ChatModelSwitcher +// surfaces the unknown current model as a leading row above the connection's +// remaining models (the `leadingOption` branch), labelled with the raw model id +// the session carries — not a hand-written label, and without removing the +// connection itself. +export const StaleCurrentModel: Story = { + render: function StaleCurrentModelRender() { + return ( +
+ undefined} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // The dropped model leads the menu as the current selection… + await menu.findByRole('menuitemradio', { name: /claude-opus-3-retired/ }); + // …while its connection's remaining models still follow underneath. + await menu.findByRole('menuitemradio', { name: 'Claude Sonnet 4' }); + }, +}; diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/08665a01dcfce23afceada9ccb744769ae6fb077f9b6bd1146747a034cc40ac9.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/08665a01dcfce23afceada9ccb744769ae6fb077f9b6bd1146747a034cc40ac9.source new file mode 100644 index 0000000000..de48b84baa --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/08665a01dcfce23afceada9ccb744769ae6fb077f9b6bd1146747a034cc40ac9.source @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createChatInputActionOwner, + fileTransferContainsFiles, + composerWireText, + createTriggerSearchSource, + isChatInputComposing, + skillMentionQuery, + slashCommandQuery, +} from '../chat-input-behavior.js'; + +describe('shared chat input behavior', () => { + it('strips the token anchor from the wire text without touching real spaces', () => { + // U+00A0 is what `insertToken` puts after a chip; the editor must keep it + // (upstream's backspace-eats-the-token check keys on that codepoint) and + // only the send path normalizes it. Asserted on codepoints because every + // text matcher in the E2E layer folds U+00A0 into a plain space. + assert.equal(composerWireText('a\u00a0b'), 'a b'); + assert.equal(composerWireText('@path\u00a0tail\u00a0more'), '@path tail more'); + assert.equal(composerWireText('a b'), 'a b'); + assert.equal(composerWireText(' \u00a0trim\u00a0 '), 'trim'); + assert.equal(composerWireText('line\none'), 'line\none'); + }); + + it('answers the sync/async probe without searching, and abandons a superseded search', async () => { + const calls: string[] = []; + let settle: ((items: string[]) => void) | undefined; + const source = createTriggerSearchSource((query) => { + calls.push(query); + return new Promise((resolve) => { + settle = resolve; + }); + }); + + // `useTriggerMenu` probes with search('') before every keystroke's real + // search and uses only `instanceof Promise`. It must not cost a lookup. + const probe = source.search(''); + assert.ok(probe instanceof Promise); + assert.deepEqual(calls, []); + assert.deepEqual(await probe, []); + + // A real search always follows cancel(). + source.cancel(); + const first = source.search('a'); + assert.deepEqual(calls, ['a']); + + // The next query supersedes it. The older promise must never settle, or a + // slow `a` landing after a fast `ab` would repopulate the menu behind the + // query the user can see. + source.cancel(); + const resolveFirst = settle!; + const second = source.search('ab'); + assert.deepEqual(calls, ['a', 'ab']); + resolveFirst(['stale']); + settle!(['fresh']); + + // Drain the microtask queue first: racing against an already-resolved + // promise would win on tick count alone and prove nothing. + await new Promise((resolve) => setTimeout(resolve, 0)); + const sentinel = Symbol('pending'); + const raced = await Promise.race([first, Promise.resolve(sentinel)]); + assert.equal(raced, sentinel, 'superseded search must never settle'); + assert.deepEqual(await second, ['fresh']); + }); + + it('recognizes composition and file transfers across browser event shapes', () => { + assert.equal(isChatInputComposing({ key: 'Enter', nativeEvent: { isComposing: true } }), true); + assert.equal(isChatInputComposing({ key: 'Process', nativeEvent: {} }), true); + assert.equal(isChatInputComposing({ nativeEvent: {} }, true), true); + assert.equal(isChatInputComposing({ key: 'Enter', nativeEvent: {} }), false); + // The bare-native shape: the composer's IME guard is a native listener, so + // it hands this function a real KeyboardEvent with no `nativeEvent` of its + // own. Reading `isComposing` off the event itself is the only reason this + // helper takes both shapes. + assert.equal(isChatInputComposing({ key: 'Enter', isComposing: true } as never), true); + assert.equal(isChatInputComposing({ key: 'Enter', isComposing: false } as never), false); + assert.equal(fileTransferContainsFiles(['text/plain', 'Files'], 0), true); + assert.equal(fileTransferContainsFiles(['text/plain'], 1), true); + assert.equal(fileTransferContainsFiles(['text/plain'], 0), false); + }); + + it('serializes async actions and releases only their owned pending state', async () => { + const states: Array = []; + const owner = createChatInputActionOwner((action) => states.push(action)); + let release!: () => void; + const first = owner.run( + 'drop', + () => new Promise((resolve) => (release = () => resolve('done'))), + ); + assert.equal(await owner.run('paste', async () => 'ignored'), undefined); + release(); + assert.equal(await first, 'done'); + assert.equal(owner.pending, null); + assert.deepEqual(states, ['drop', null]); + }); + + // The `/` trigger serves two catalogs from one menu. A Skill matches wherever + // the trigger is legal, a command only when the slash opens the draft's first + // token — otherwise `请看 /Users/me` and `修一下 /compact` would both read as + // an instruction to run something. + it('offers commands only for a slash that opens the draft', () => { + // Caret right after a leading `/`, then after four typed characters. + assert.equal(slashCommandQuery('/', '', ''), ''); + assert.equal(slashCommandQuery('/comp', '', 'comp'), 'comp'); + // Leading whitespace is still an empty first token. + assert.equal(slashCommandQuery(' /comp', '', 'comp'), 'comp'); + // A slash later in the draft is prose or a path, never a command. + assert.equal(slashCommandQuery('explain /', '', ''), null); + assert.equal(slashCommandQuery('first line\n/', '', ''), null); + // `/skill:` is the explicit Skill grammar and addresses no command. + assert.equal(slashCommandQuery('/skill:compact', '', 'skill:compact'), null); + assert.equal(slashCommandQuery('/SKILL:compact', '', 'SKILL:compact'), null); + // Text after the caret means the user is editing inside a word, not + // starting a command — `/side` with the caret between `/` and `side`. + assert.equal(slashCommandQuery('/', 'side', ''), null); + // A space after the caret is not text the command would swallow. + assert.equal(slashCommandQuery('/comp', ' tail', 'comp'), 'comp'); + // The query must actually sit against the trigger the menu reports. + assert.equal(slashCommandQuery('comp', '', 'comp'), null); + }); + + it('reads `/skill:` and a bare `/` as the same Skill search', () => { + assert.equal(skillMentionQuery('skill:comp'), 'comp'); + assert.equal(skillMentionQuery('SKILL:Comp'), 'Comp'); + assert.equal(skillMentionQuery('comp'), 'comp'); + assert.equal(skillMentionQuery('skill:'), ''); + }); + + it('does not let late completion clear state after reset', async () => { + const states: Array = []; + const owner = createChatInputActionOwner((action) => states.push(action)); + let release!: () => void; + const action = owner.run('drop', () => new Promise((resolve) => (release = resolve))); + owner.reset(); + release(); + await action; + assert.deepEqual(states, ['drop']); + }); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/087c9ec376ffc7e04a7fa2669d6a810a1a06989cc04ec1eb32e8213d4789d0a6.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/087c9ec376ffc7e04a7fa2669d6a810a1a06989cc04ec1eb32e8213d4789d0a6.source new file mode 100644 index 0000000000..ed40dca398 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/087c9ec376ffc7e04a7fa2669d6a810a1a06989cc04ec1eb32e8213d4789d0a6.source @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from './locale-helpers.js'; +import { redactSecrets } from './redact.js'; +import { getToolActivityCopy } from './tool-activity/copy.js'; + +/** Locale-aware display name for the tool-discovery connector. */ +export function loadToolDisplayName(locale: UiLocale): string { + return getToolActivityCopy(locale).loadTools.displayName; +} + +export type LoadToolGroupKind = + | 'browser' + | 'computer_use' + | 'mcp' + | 'rive' + | 'agent' + | 'settings' + | 'generic'; + +export interface LoadToolResultDescription { + kind: LoadToolGroupKind; + actionLabel: string; + title: string; + description: string; + label: string; + countLabel: string; + groupId?: string; + toolIds: string[]; +} + +/** + * Turn a `tool_search` result or historical `load_tools` result into friendly, + * locale-aware card copy. Returns `null` for unexpected shapes. + */ +export function describeLoadToolResult( + args: unknown, + value: unknown, + locale: UiLocale, +): LoadToolResultDescription | null { + const record = value as { activated?: unknown; loaded?: unknown } | null | undefined; + const loaded = record?.activated ?? record?.loaded; + if (!Array.isArray(loaded) || !loaded.every((name) => typeof name === 'string')) { + return null; + } + const tools = (loaded as string[]).map(safeDisplayText).filter(Boolean); + const argRecord = args as { group?: unknown; namespace?: unknown } | null | undefined; + const rawGroup = argRecord?.group ?? argRecord?.namespace; + const resultGroup = (value as { group?: unknown }).group; + const groupRecord = + resultGroup && typeof resultGroup === 'object' + ? resultGroup as { id?: unknown; label?: unknown; description?: unknown } + : undefined; + const groupId = firstSafeText(groupRecord?.id, rawGroup); + const suppliedLabel = firstSafeText(groupRecord?.label); + const suppliedDescription = firstSafeText(groupRecord?.description); + const kind = loadToolGroupKind(groupId, suppliedLabel, tools); + const n = tools.length; + const copy = getToolActivityCopy(locale).loadTools; + if (kind !== 'generic') { + const groupCopy = copy.groups[kind]; + return { + kind, + actionLabel: groupCopy.action, + title: groupCopy.title, + description: groupCopy.description, + label: groupCopy.label, + countLabel: copy.count(n), + ...(groupId ? { groupId } : {}), + toolIds: tools, + }; + } + + const label = suppliedLabel ?? copy.fallbackLabel; + return { + kind, + actionLabel: suppliedLabel ? copy.namedAction(suppliedLabel) : copy.genericAction, + title: suppliedLabel ? copy.namedTitle(suppliedLabel) : copy.genericTitle, + description: suppliedDescription ?? copy.genericDescription, + label, + countLabel: copy.count(n), + ...(groupId ? { groupId } : {}), + toolIds: tools, + }; +} + +function firstSafeText(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue; + const safe = safeDisplayText(value); + if (safe) return safe; + } + return undefined; +} + +function safeDisplayText(value: string): string { + return redactSecrets(value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()); +} + +function loadToolGroupKind( + groupId: string | undefined, + label: string | undefined, + tools: readonly string[], +): LoadToolGroupKind { + const id = groupId?.toLowerCase() ?? ''; + const normalizedLabel = label?.toLowerCase() ?? ''; + const names = tools.map((name) => name.toLowerCase()); + const hasTool = (name: string) => + names.some((candidate) => candidate === name || candidate.endsWith(`__${name}`)); + + if ( + id === 'computer_use' + || id.endsWith('_desktop_computer_use') + || normalizedLabel === 'computer use' + || hasTool('maka_computer') + ) return 'computer_use'; + if ( + id === 'browser' + || id.endsWith('_desktop_browser') + || normalizedLabel === 'browser' + || hasTool('browser_navigate') + ) return 'browser'; + if ( + id === 'rive' + || id.endsWith('_desktop_rive') + || normalizedLabel === 'rive' + || hasTool('riveworkflow') + ) return 'rive'; + if ( + id === 'agent' + || normalizedLabel === 'agent' + || hasTool('agent_spawn') + ) return 'agent'; + if ( + id.endsWith('_desktop_settings') + || normalizedLabel === 'client settings' + || hasTool('makasettingsget') + ) return 'settings'; + if (id.endsWith('_desktop_mcp') || normalizedLabel === 'mcp') return 'mcp'; + return 'generic'; +} + +export function formatRedactedJson(value: unknown): string { + try { + return redactSecrets(JSON.stringify(value, null, 2)); + } catch { + return redactSecrets(String(value)); + } +} + +export function formatToolIntent(intent: string): string { + const safe = redactSecrets(intent.replace(/\s+/g, ' ').trim()); + return safe.length > 240 ? `${safe.slice(0, 240)}…` : safe; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0ba74e65bc041ec5d40ee134af6f4bfdf689b0bf4f7bc84b7495e5501426540b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0ba74e65bc041ec5d40ee134af6f4bfdf689b0bf4f7bc84b7495e5501426540b.source new file mode 100644 index 0000000000..b9c76193a5 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0ba74e65bc041ec5d40ee134af6f4bfdf689b0bf4f7bc84b7495e5501426540b.source @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Who owns the composer's caret, and who owns focus. + * + * A restored draft owes the caret the end of its content, or the next keystroke + * prepends to it. But the only way to place a caret is a selection, and a + * selection inside a `contenteditable` focuses that element — whoever held focus + * before — and moves the point sequential focus navigation resumes from. So the + * restore claimed focus nobody directed at it: on a cold start, past the skip + * link with no `focus()` call to explain it, so Tab from the document start + * began in the composer; and on a session swap, out from under the sidebar row + * the user had just activated. Both are pinned here. + * + * The caret is therefore owed rather than placed whenever the editor is not + * focused, and lands on its next real focus — the first moment the offset is the + * only thing being decided. A pointer press places the caret itself and drops + * the claim. + * + * linkedom carries no selection, no focus and no `Range` motion, so the harness + * models what the composer uses: `createRange` records where a caret was aimed, + * `getSelection` records the live selection and reproduces the focus a selection + * takes, and `focus()` sets `document.activeElement` and dispatches the `focusin` + * a browser would. It also lowercases `contentEditable` on the way into the DOM, because linkedom stores + * attribute names verbatim where HTML folds them — without that the composer's + * own `[contenteditable="true"]` lookup misses its editor here and nowhere else. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { Composer } from '../composer.js'; +import { LocaleProvider } from '../locale-context.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; +const restoreDom: (() => void)[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + for (const restore of restoreDom.splice(0)) restore(); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function computedStyle(): CSSStyleDeclaration { + return { + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + } as unknown as CSSStyleDeclaration; +} + +/** Where a caret was aimed: `selectNodeContents` then `collapse` and no more. */ +interface AimedRange { + container: Node | null; + offset: number; + collapsed: boolean; +} + +function harness() { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => computedStyle(); + const setAttribute = window.Element.prototype.setAttribute; + window.Element.prototype.setAttribute = function normalized(name: string, value: string) { + return setAttribute.call(this, name === 'contentEditable' ? 'contenteditable' : name, value); + }; + restoreDom.push(() => { + window.Element.prototype.setAttribute = setAttribute; + }); + document.createRange = () => { + const range: AimedRange & { + selectNodeContents(node: Node): void; + collapse(toStart: boolean): void; + } = { + container: null, + offset: 0, + collapsed: false, + selectNodeContents(node) { + range.container = node; + range.offset = node.childNodes.length; + }, + collapse(toStart) { + range.offset = toStart ? 0 : range.offset; + range.collapsed = true; + }, + }; + return range as unknown as Range; + }; + let active: Element | null = null; + const selected: AimedRange[] = []; + const selection = { + get anchorNode(): Node | null { + return selected.at(-1)?.container ?? null; + }, + removeAllRanges() { + selected.length = 0; + }, + addRange(range: Range) { + const aimed = range as unknown as AimedRange; + selected.push(aimed); + // The whole point: a selection inside a `contenteditable` focuses it, + // whoever held focus before. Without this the harness would let a caret + // placed on a blurred editor look free. + const container = aimed.container as Element & { closest?: Element['closest'] }; + active = container?.closest?.('[contenteditable="true"]') ?? active; + }, + }; + document.getSelection = () => selection as unknown as Selection; + window.getSelection = () => selection as unknown as Selection; + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => active }); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { + /** The live selection: what `removeAllRanges` cleared and `addRange` added. */ + selected: selected as readonly AimedRange[], + editable() { + const editable = document.querySelector('[contenteditable="true"]'); + assert.ok(editable, 'the composer rendered no editable node'); + return editable as unknown as HTMLElement; + }, + /** A focusable outside the composer — the sidebar row that swaps the draft. */ + outside() { + const existing = document.querySelector('#outside'); + if (existing) return existing as unknown as HTMLElement; + const button = document.createElement('button'); + button.id = 'outside'; + document.documentElement.appendChild(button); + return button as unknown as HTMLElement; + }, + focused: () => active, + /** Focus an element the way a browser does: activate it, then announce it. */ + async focus(element: HTMLElement) { + active = element as unknown as Element; + await act(() => { + element.dispatchEvent(new window.Event('focusin', { bubbles: true })); + }); + }, + async pointerDown(element: HTMLElement) { + await act(() => { + element.dispatchEvent(new window.Event('pointerdown', { bubbles: true })); + }); + }, + async render(props: Parameters[0]) { + await act(() => { + root.render( + + + , + ); + }); + }, + }; +} + +const base = { + onSend: () => undefined, + onStop: () => undefined, +}; + +/** A host that hands the named session back an unsent draft, as a cold start does. */ +function withDraft(key: string, draft: string) { + return { + ...base, + draftPersistence: { + read: (draftKey: string | undefined) => (draftKey === key ? draft : ''), + write: () => undefined, + }, + }; +} + +/** The end of the content, which is where every restored draft owes its caret. */ +function assertCaretAtEnd(selected: readonly AimedRange[], editable: HTMLElement): void { + const caret = selected.at(-1); + assert.ok(caret, 'the composer placed no caret'); + assert.equal(caret.collapsed, true, 'the caret must be a collapsed selection'); + assert.equal(caret.container, editable); + assert.equal(caret.offset, editable.childNodes.length); +} + +test('a draft restored while nothing holds focus places no selection', async () => { + const dom = harness(); + await dom.render({ ...withDraft('session-a', 'restored draft'), draftKey: 'session-a' }); + assert.equal(dom.editable().textContent, 'restored draft'); + assert.equal( + dom.selected.length, + 0, + 'the restored caret took a selection inside the contenteditable, which focuses it and moves ' + + 'the point Tab resumes from off the top of the document', + ); + assert.equal(dom.focused(), null, 'the restored caret focused the composer'); +}); + +test('the owed caret lands at the end of the draft on the next focus', async () => { + const dom = harness(); + await dom.render({ ...withDraft('session-a', 'restored draft'), draftKey: 'session-a' }); + await dom.focus(dom.editable()); + assertCaretAtEnd(dom.selected, dom.editable()); +}); + +test('a pointer press into the composer drops the owed caret', async () => { + const dom = harness(); + await dom.render({ ...withDraft('session-a', 'restored draft'), draftKey: 'session-a' }); + await dom.pointerDown(dom.editable()); + await dom.focus(dom.editable()); + assert.equal( + dom.selected.length, + 0, + 'a click places the caret where it lands; the owed caret must not overrule it', + ); +}); + +test('a session swap leaves focus on the row that caused it', async () => { + const dom = harness(); + const props = withDraft('session-b', 'other draft'); + await dom.render({ ...props, draftKey: 'session-a' }); + await dom.focus(dom.outside()); + await dom.render({ ...props, draftKey: 'session-b' }); + assert.equal(dom.editable().textContent, 'other draft'); + assert.equal( + dom.focused(), + dom.outside(), + 'the restored caret took focus out from under the row the user activated', + ); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0d0371a169779eff8fd03c48c53f454a18a1b5dbbb0d451989fbef4d670fd5f9.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0d0371a169779eff8fd03c48c53f454a18a1b5dbbb0d451989fbef4d670fd5f9.source new file mode 100644 index 0000000000..7ffb412374 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0d0371a169779eff8fd03c48c53f454a18a1b5dbbb0d451989fbef4d670fd5f9.source @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export interface ResumeParkToastCopy { + title: string; + description: string; +} + +/** + * Park reasons are locale-independent wire tokens; this record only supplies + * their presentation copy. `resume_candidate_missing` is not a parked-reason + * entry — it takes its own title/description pair below. + */ +interface ResumeParkReasonCopy { + dangling_tool_state: string; + pending_permission: string; + background_operation_pending: string; + workspace_identity_mismatch: string; + workspace_identity_missing: string; + workspace_cwd_mismatch: string; + workspace_ref_missing: string; + tool_catalog_mismatch: string; + checkpoint_restore_failed: string; + source_run_unreadable: string; + runtime_ledger_unreadable: string; + runtime_ledger_empty: string; + terminal_repair_failed: string; + provider_resume_head_unsupported: string; + provider_resume_boundary_unsupported: string; + provider_replay_non_suffix_gap: string; + provider_replay_unsupported: string; + runtime_lineage_cycle: string; + runtime_lineage_depth_exceeded: string; + runtime_lineage_missing: string; + runtime_lineage_start_mismatch: string; + runtime_lineage_replay_mismatch: string; + runtime_lineage_claim_mismatch: string; + source_prefix_digest_mismatch: string; + continuation_already_exists: string; + continuation_claim_repair_required: string; + continuation_started_indeterminate: string; + continuation_authority_unavailable: string; + resume_feature_disabled: string; +} + +interface ResumeParkCopy { + title: string; + fallbackDescription: string; + missingCandidateTitle: string; + missingCandidateDescription: string; + reasons: ResumeParkReasonCopy; +} + +const RESUME_PARK_COPY = { + 'zh-CN': { + title: '暂时无法继续这一轮', + fallbackDescription: '当前任务不满足继续的条件。', + missingCandidateTitle: '没有可恢复的任务', + missingCandidateDescription: '任务已是最新状态。', + reasons: { + dangling_tool_state: '上次工具执行中断,记录已保留,暂时不能自动继续。', + pending_permission: '上次执行仍在等待权限确认。', + background_operation_pending: '仍有后台操作没有结束,暂时不能继续。', + workspace_identity_mismatch: '当前工作区与中断时不一致。', + workspace_identity_missing: '无法确认中断时的工作区。', + workspace_cwd_mismatch: '当前工作目录与中断时不一致。', + workspace_ref_missing: '中断时的工作区已不可用。', + tool_catalog_mismatch: '可用工具已发生变化,无法安全继续。', + checkpoint_restore_failed: '工作区检查点恢复失败。', + source_run_unreadable: '上次运行记录无法完整读取。', + runtime_ledger_unreadable: '上次运行账本无法完整读取。', + runtime_ledger_empty: '上次运行没有可回放的记录。', + terminal_repair_failed: '上次运行记录修复失败。', + provider_resume_head_unsupported: '当前模型不支持这个恢复起点。', + provider_resume_boundary_unsupported: '当前模型不支持这个恢复边界。', + provider_replay_non_suffix_gap: '上次模型输出的中断位置无法安全裁剪。', + provider_replay_unsupported: '上次运行历史无法按当前模型协议安全回放。', + runtime_lineage_cycle: '续跑链存在循环引用,已停止恢复。', + runtime_lineage_depth_exceeded: '续跑链过长,已停止自动恢复。', + runtime_lineage_missing: '续跑链缺少必要的历史记录。', + runtime_lineage_start_mismatch: '续跑链的起点记录不一致,已停止恢复。', + runtime_lineage_replay_mismatch: '续跑链记录的模型上下文与当前重建结果不一致。', + runtime_lineage_claim_mismatch: '续跑链缺少匹配的恢复所有权记录,已停止恢复。', + source_prefix_digest_mismatch: '上次运行的不可变边界已发生变化。', + continuation_already_exists: '该中断任务已经创建过续跑。', + continuation_claim_repair_required: '恢复所有权已保留,但续跑记录需要先修复。', + continuation_started_indeterminate: '续跑已经开始,但尚未形成可证明的终态。', + continuation_authority_unavailable: '当前存储不支持安全的续跑所有权。', + resume_feature_disabled: '继续中断任务的功能尚未启用。', + }, + }, + 'zh-TW': { + title: '暫時無法繼續這一輪', + fallbackDescription: '目前任務不滿足繼續的條件。', + missingCandidateTitle: '沒有可恢復的任務', + missingCandidateDescription: '任務已是最新狀態。', + reasons: { + dangling_tool_state: '上次工具執行中斷,記錄已保留,暫時不能自動繼續。', + pending_permission: '上次執行仍在等待權限確認。', + background_operation_pending: '仍有後台操作沒有結束,暫時不能繼續。', + workspace_identity_mismatch: '目前工作區與中斷時不一致。', + workspace_identity_missing: '無法確認中斷時的工作區。', + workspace_cwd_mismatch: '目前工作目錄與中斷時不一致。', + workspace_ref_missing: '中斷時的工作區已無法使用。', + tool_catalog_mismatch: '可用工具已變更,無法安全繼續。', + checkpoint_restore_failed: '工作區檢查點恢復失敗。', + source_run_unreadable: '上次執行的記錄無法完整讀取。', + runtime_ledger_unreadable: '上次執行的帳本無法完整讀取。', + runtime_ledger_empty: '上次執行沒有可回放的記錄。', + terminal_repair_failed: '上次執行記錄修復失敗。', + provider_resume_head_unsupported: '目前模型不支援這個恢復起點。', + provider_resume_boundary_unsupported: '目前模型不支援這個恢復邊界。', + provider_replay_non_suffix_gap: '上次模型輸出的中斷位置無法安全裁剪。', + provider_replay_unsupported: '上次執行歷史無法按目前模型協定安全回放。', + runtime_lineage_cycle: '續跑鏈存在循環引用,已停止恢復。', + runtime_lineage_depth_exceeded: '續跑鏈過長,已停止自動恢復。', + runtime_lineage_missing: '續跑鏈缺少必要的歷史記錄。', + runtime_lineage_start_mismatch: '續跑鏈的起點記錄不一致,已停止恢復。', + runtime_lineage_replay_mismatch: '續跑鏈記錄的模型上下文與目前重建結果不一致。', + runtime_lineage_claim_mismatch: '續跑鏈缺少匹配的恢復所有權記錄,已停止恢復。', + source_prefix_digest_mismatch: '上次執行的不可變邊界已變更。', + continuation_already_exists: '該中斷任務已經建立過續跑。', + continuation_claim_repair_required: '恢復所有權已保留,但續跑記錄需要先修復。', + continuation_started_indeterminate: '續跑已經開始,但尚未形成可證明的終態。', + continuation_authority_unavailable: '目前儲存不支援安全的續跑所有權。', + resume_feature_disabled: '繼續中斷任務的功能尚未啟用。', + }, + }, + en: { + title: 'This round cannot be resumed yet', + fallbackDescription: 'This task does not currently meet the conditions to continue.', + missingCandidateTitle: 'Nothing to resume', + missingCandidateDescription: 'This task is already up to date.', + reasons: { + dangling_tool_state: + 'The previous tool run was interrupted; its records are preserved, so it cannot continue automatically yet.', + pending_permission: 'The previous run is still waiting for a permission approval.', + background_operation_pending: 'Background operations are still running, so this round cannot continue yet.', + workspace_identity_mismatch: 'The current workspace does not match the one from the interrupted run.', + workspace_identity_missing: 'The workspace from the interrupted run could not be identified.', + workspace_cwd_mismatch: 'The current working directory does not match the one from the interrupted run.', + workspace_ref_missing: 'The workspace from the interrupted run is no longer available.', + tool_catalog_mismatch: 'The available tools have changed, so it is not safe to continue.', + checkpoint_restore_failed: 'Restoring the workspace checkpoint failed.', + source_run_unreadable: "The previous run's record could not be read in full.", + runtime_ledger_unreadable: "The previous run's ledger could not be read in full.", + runtime_ledger_empty: 'The previous run has no records to replay.', + terminal_repair_failed: "Repairing the previous run's record failed.", + provider_resume_head_unsupported: 'The current model does not support this resume point.', + provider_resume_boundary_unsupported: 'The current model does not support this resume boundary.', + provider_replay_non_suffix_gap: 'The interruption point in the previous model output cannot be trimmed safely.', + provider_replay_unsupported: + "The previous run's history cannot be replayed safely under the current model protocol.", + runtime_lineage_cycle: 'The resume chain contains a cycle; resuming was stopped.', + runtime_lineage_depth_exceeded: 'The resume chain is too long; automatic resuming was stopped.', + runtime_lineage_missing: 'The resume chain is missing required history records.', + runtime_lineage_start_mismatch: "The resume chain's starting record is inconsistent; resuming was stopped.", + runtime_lineage_replay_mismatch: + "The resume chain's recorded model context does not match what was rebuilt here.", + runtime_lineage_claim_mismatch: + 'The resume chain lacks a matching resume-ownership record; resuming was stopped.', + source_prefix_digest_mismatch: "The previous run's immutable boundary has changed.", + continuation_already_exists: 'A continuation for this interrupted task already exists.', + continuation_claim_repair_required: + 'Resume ownership was preserved, but the continuation record needs repair first.', + continuation_started_indeterminate: + 'The continuation already started, but has not reached a provable terminal state.', + continuation_authority_unavailable: 'The current storage does not support safe resume ownership.', + resume_feature_disabled: 'Resuming interrupted tasks is not enabled.', + }, + }, +} satisfies UiCatalog; + +export function resumeParkToastCopy(reasons: readonly string[], locale: UiLocale): ResumeParkToastCopy { + const copy = RESUME_PARK_COPY[locale]; + if (reasons.length === 1 && reasons[0] === 'resume_candidate_missing') { + return { + title: copy.missingCandidateTitle, + description: copy.missingCandidateDescription, + }; + } + + const descriptions = [...new Set( + reasons + .map((reason) => copy.reasons[reason as keyof ResumeParkReasonCopy]) + .filter((description): description is string => description !== undefined), + )]; + + return { + title: copy.title, + description: descriptions.length > 0 + ? descriptions.join(' ') + : copy.fallbackDescription, + }; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0fe53240f934e53c9c89a8765dc9b92aa09b05cc3c5023eba7058d744cd8938a.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0fe53240f934e53c9c89a8765dc9b92aa09b05cc3c5023eba7058d744cd8938a.source new file mode 100644 index 0000000000..98de36ac69 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/0fe53240f934e53c9c89a8765dc9b92aa09b05cc3c5023eba7058d744cd8938a.source @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveRailAlignedTarget } from '../chat-view.js'; + +test('a rail claim aims its own navigation and nothing after it', () => { + // The click, before the shell has published anything. + let claim = resolveRailAlignedTarget({ turnId: 'a' }, undefined).claim; + assert.deepEqual(claim, { turnId: 'a' }); + + // The load the click asked for. The reveal has to agree with the rail. + let resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // Still the same command, re-rendered while the loaded range settles. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // A later search for the same Turn is a different command, and wants the + // search contract back. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 2 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search for another Turn spends an unconsumed rail claim', () => { + const resolved = resolveRailAlignedTarget({ turnId: 'a' }, { turnId: 'b', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search with no rail claim behind it is centred', () => { + const resolved = resolveRailAlignedTarget(undefined, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.deepEqual(resolved.target, { turnId: 'a', nonce: 1, align: 'center' }); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/14a4a5dc3195f39bb397f93dd56e988cbfc67095d24ad2dad261f3e33adbb89d.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/14a4a5dc3195f39bb397f93dd56e988cbfc67095d24ad2dad261f3e33adbb89d.source new file mode 100644 index 0000000000..40b665e4dc --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/14a4a5dc3195f39bb397f93dd56e988cbfc67095d24ad2dad261f3e33adbb89d.source @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { ProviderRetryScheduledEvent } from '@maka/core/events'; +import type { LiveProviderRetry } from '../live-turn-projection.js'; +import { ModelProviderRetryIndicator } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + // Unmount before restoring globals: React's cleanup reads `document`. + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + // linkedom's window.setInterval resolves to globalThis.setInterval at call + // time, so `t.mock.timers` (which patches the global) drives the banner's + // one-second interval too — no adapter needed here. + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +function scheduledRetry( + overrides: Partial = {}, +): ProviderRetryScheduledEvent { + return { + type: 'provider_retry', + id: 'retry-1', + turnId: 'turn-1', + ts: 1, + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 10_000, + reason: 'rate_limit', + ...overrides, + }; +} + +async function renderRetry(root: ReturnType, retry: LiveProviderRetry) { + await act(() => + root.render( + + + , + ), + ); +} + +/** + * #3393: a subscription quota window can hand the runtime an hours-long + * Retry-After. The banner counts down against the CLIENT-local receipt time — + * a single clock domain, immune to skew between the client and a possibly + * remote Runtime Host clock. + */ +test('provider retry banner subtracts the time already waited since receipt', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const { container, root } = domRoot(); + + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now }); + assert.match(container.textContent ?? '', /Retrying in 10s \(2\/10\)/); + + // Four seconds into the wait the same event renders the remaining six. + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now - 4_000 }); + assert.match(container.textContent ?? '', /Retrying in 6s \(2\/10\)/); +}); + +test('provider retry banner never shows a negative countdown', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const { container, root } = domRoot(); + + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now - 60_000 }); + // Floors at 1s (formatRetryDelay uses Math.max(1, …)) until the `started` + // event replaces it. + assert.match(container.textContent ?? '', /Retrying in 1s \(2\/10\)/); +}); + +test('reduced motion keeps a correct static value at mount without ticking', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); + const { container, root } = domRoot(); + // The reduced-motion preference freezes the per-second tick, but the + // initial measurement still lands: four seconds into the wait the banner + // reads 6s from the start instead of pinning the full delay. + document.documentElement.dataset.makaReducedMotion = 'true'; + + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now - 4_000 }); + assert.match(container.textContent ?? '', /Retrying in 6s \(2\/10\)/); + + await act(() => t.mock.timers.tick(2_000)); + assert.match(container.textContent ?? '', /Retrying in 6s \(2\/10\)/); +}); + +test('provider retry banner counts down from remainingMs when the host provides it', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const { container, root } = domRoot(); + + // A mid-wait host re-projection (reconnect) recomputes the remaining + // duration; the banner counts THAT down instead of restarting at delayMs. + await renderRetry(root, { + event: scheduledRetry({ delayMs: 3_600_000, remainingMs: 300_000 }), + receivedAtMs: now, + }); + assert.match(container.textContent ?? '', /Retrying in 5m \(2\/10\)/); +}); + +test('a mounted provider retry banner actually ticks once per second', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); + const { container, root } = domRoot(); + + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now }); + assert.match(container.textContent ?? '', /Retrying in 10s \(2\/10\)/); + + await act(() => t.mock.timers.tick(1_000)); + assert.match(container.textContent ?? '', /Retrying in 9s \(2\/10\)/); + + await act(() => t.mock.timers.tick(2_000)); + assert.match(container.textContent ?? '', /Retrying in 7s \(2\/10\)/); +}); + +test('the ticking countdown stays hidden from the live region, which keeps a stable label', async (t) => { + const now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); + const { container, root } = domRoot(); + + await renderRetry(root, { event: scheduledRetry(), receivedAtMs: now }); + const banner = container.querySelector('.maka-turn-provider-retry'); + assert.ok(banner); + assert.equal(banner.getAttribute('role'), 'status'); + // The stable accessible name carries reason + attempt — no countdown. + assert.equal(banner.getAttribute('aria-label'), 'Model rate limit reached · Waiting to retry (2/10)'); + // The visible countdown lives inside an aria-hidden subtree (the banner's + // status icon is aria-hidden too, so find the node carrying the text). + const tickingText = () => + [...banner.querySelectorAll('[aria-hidden="true"]')] + .map((node) => node.textContent ?? '') + .find((text) => /Retrying in/.test(text)); + assert.match(tickingText() ?? '', /Retrying in 10s/); + + // One second later the visual text ticks, the accessible name does not. + await act(() => t.mock.timers.tick(1_000)); + assert.equal(banner.getAttribute('aria-label'), 'Model rate limit reached · Waiting to retry (2/10)'); + assert.match(tickingText() ?? '', /Retrying in 9s/); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/176c9c46302904c5d7bf0b61ad4b67f116f28c6bb55e190c8cb224a883a4aaad.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/176c9c46302904c5d7bf0b61ad4b67f116f28c6bb55e190c8cb224a883a4aaad.source new file mode 100644 index 0000000000..62bb6c9d7e --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/176c9c46302904c5d7bf0b61ad4b67f116f28c6bb55e190c8cb224a883a4aaad.source @@ -0,0 +1,531 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { it } from 'node:test'; +import { + applyMermaidRenderBudget, + MarkdownBody, + MAX_AUTOMATIC_MERMAID_DIAGRAMS, + MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH, + MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH, +} from '../markdown-body.js'; +import { AstryxLocaleProvider } from '../astryx-i18n.js'; +import { MakaUriContext, Markdown } from '../markdown.js'; +import { LocaleProvider } from '../locale-context.js'; +import { + createMermaidConfig, + MAX_MERMAID_EDGES, + MAX_MERMAID_SOURCE_LENGTH, +} from '../mermaid-diagram.js'; + +it('keeps raw HTML inert instead of expanding the Markdown trust surface', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '
Clickpayload
', + })); + + assert.match(markup, /<details open>/); + assert.doesNotMatch(markup, /
{ + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '**Calculating CRT Solution**\n\nSet \\( n \\equiv 3 \\pmod 7 \\) and \\( a = 5 \\).', + density: 'compact', + })); + + assert.match(markup, /]*>Calculating CRT Solution<\/strong>/); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); + assert.doesNotMatch(markup, /\*\*Calculating/); + assert.doesNotMatch(markup, /\\\\\\\(/); +}); + +it('keeps URL, email, and Markdown markers atomic inside math', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: [ + 'URL \\( \\texttt{https://example.com} \\)', + 'Email \\( \\text{person@example.com} \\)', + 'Markers \\( x \\left[y\\right] * z \\)', + ].join('\n\n'), + streaming: true, + settledText: [ + 'URL \\( \\texttt{https://example.com} \\)', + 'Email \\( \\text{person@example.com} \\)', + 'Markers \\( x \\left[y\\right] * z \\)', + ].join('\n\n'), + }), + })); + + assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 3); + assert.equal((markup.match(/class="katex"/g) ?? []).length, 3); + assert.doesNotMatch(markup, / { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Empty \\( \\) end', + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.doesNotMatch(markup, /\\\(|\\\)/); + assert.match(markup, /Empty \( \) end/); +}); + +it('keeps literal math transport syntax as prose', () => { + const literalToken = '\uE000MAKA_MATH:0:78\uE001'; + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: `Literal ${literalToken} end`, + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.match(markup, new RegExp(literalToken)); +}); + +it('leaves LaTeX delimiters untouched inside inline and fenced code', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['Use `\\( x + 1 \\)` literally.', '', '```tex', '\\( y + 2 \\)', '```'].join('\n'), + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.match(markup, /\\\( x \+ 1 \\\)/); + assert.match(markup, /\\\( y \+ 2 \\\)/); +}); + +it('does not let an unmatched inline backtick hide later math', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Unmatched ` prose.\n\nMath \\(x + 1\\)', + })); + + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); +}); + +it('does not let an unmatched math delimiter hide a later formula', () => { + for (const text of [ + 'bad \\( then \\[x\\]', + 'bad $$ then \\(x\\)', + 'bad \\[ then \\(x\\)', + ]) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + assert.match(markup, /class="maka-math/); + assert.match(markup, /class="katex/); + } +}); + +it('lets a formula own backticks that occur inside its delimiters', () => { + for (const formula of ['\\(x ` y\\)', '\\(x \\text{`foo`}\\)']) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text: formula })); + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); + } +}); + +it('keeps scanning after a malformed math transport prefix', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: `bad \uE000MAKA_MATH:bad then \\(x\\)`, + })); + + assert.match(markup, /MAKA_MATH:bad/); + assert.match(markup, /class="maka-math maka-math-inline"/); +}); + +it('renders display math while leaving ordinary currency alone', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Budget: $5 and $10. Range: $5–$10.\n\n\\[ x^2 + y^2 = z^2 \\]', + })); + + assert.match(markup, /Budget: \$5 and \$10\. Range: \$5–\$10/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); + assert.doesNotMatch(markup, /class="maka-math maka-math-inline"/); +}); + +it('does not treat shell variables, currency, or inline code as dollar-delimited math', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: [ + 'Home: $HOME/$USER', + 'Path: $PATH:$HOME', + 'Prices: $5 and $10; range $5–$10; paired $5 and $10.', + 'Literal: `$x$`', + 'Explicit: \\( x + 1 \\)', + ].join('\n\n'), + })); + + assert.match(markup, /\$HOME\/\$USER/); + assert.match(markup, /\$PATH:\$HOME/); + assert.match(markup, /\$5 and \$10; range \$5–\$10; paired \$5 and \$10/); + assert.match(markup, /]*>\$x\$<\/code>/); + assert.equal((markup.match(/class="maka-math maka-math-inline"/g) ?? []).length, 1); + assert.match(markup, /class="katex"/); +}); + +it('renders multiline display math outside code for both supported delimiters', () => { + for (const [text, mathNode] of [ + [['Before', '', '$$', 'E = mc^2', '$$', '', 'After'].join('\n'), ''], + [['Before', '', '\\[', 'x_1 + x_2 = y', '\\]', '', 'After'].join('\n'), ''], + ]) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { text })); + + assert.match(markup, /Before/); + assert.match(markup, /After/); + assert.match(markup, /class="maka-math maka-math-display"/); + assert.match(markup, /class="katex-display"/); + assert.doesNotMatch(markup, /\$\$/); + assert.doesNotMatch(markup, /\\\[/); + assert.match(markup, new RegExp(mathNode)); + assert.doesNotMatch(markup, /]*>1<\/em>/); + } +}); + +it('keeps display math intact across Markdown-looking block boundaries', () => { + const bodies = [ + ['x + 1', '', 'y + 2'], + ['x + 1', '# heading-shaped'], + ['x + 1', '- list-shaped'], + ['x + 1', '| table | shaped |', '| --- | --- |'], + ]; + + for (const [open, close] of [['$$', '$$'], ['\\[', '\\]']]) { + for (const body of bodies) { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['Before', '', open, ...body, close, '', 'After'].join('\n'), + })); + + assert.match(markup, /class="maka-math maka-math-display"/); + assert.doesNotMatch(markup, / { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['$$', 'outside', '```tex', 'inside', '```', '$$'].join('\n'), + })); + + assert.doesNotMatch(markup, /class="maka-math/); + assert.match(markup, /\$\$/); + assert.match(markup, /inside/); +}); + +it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: ['```', `ssh-ed25519 ${'A'.repeat(200)}`, '```'].join('\n'), + }), + })); + + const toolbarIndex = markup.indexOf('astryx-codeblock-header'); + const copyButtonIndex = markup.indexOf('astryx-codeblock-copy-button'); + const scrollViewportIndex = markup.indexOf('role="group"'); + + assert.match(markup, /data-maka-code-layout="single-line"/); + assert.ok(toolbarIndex >= 0); + assert.ok(copyButtonIndex > toolbarIndex); + assert.ok(scrollViewportIndex > copyButtonIndex); +}); + +it('does not force the single-line scrollbar layout on multiline code', () => { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale: 'en', + children: createElement(MarkdownBody, { + text: ['```ts', 'const first = 1;', 'const second = 2;', '```'].join('\n'), + }), + })); + + assert.match(markup, /data-maka-code-layout="multi-line"/); + assert.match(markup, /astryx-codeblock-header/); + assert.match(markup, /astryx-codeblock-copy-button/); +}); + +it('gives collapsible plaintext code a localized accessible name', () => { + const code = Array.from({ length: 10 }, (_, index) => `line ${index + 1}`); + + for (const [locale, label] of [['en', 'Code'], ['zh-CN', '代码']] as const) { + const markup = renderToStaticMarkup(createElement(LocaleProvider, { + locale, + children: createElement(AstryxLocaleProvider, { + children: createElement(MarkdownBody, { + text: ['```', ...code, '```'].join('\n'), + }), + }), + })); + + assert.match(markup, /role="button"/); + assert.match(markup, /aria-expanded="true"/); + assert.match(markup, new RegExp(`>${label}`)); + } +}); + +it('keeps standalone MarkdownBody compatible for collapsible plaintext code', () => { + const code = Array.from({ length: 10 }, (_, index) => `line ${index + 1}`); + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['```', ...code, '```'].join('\n'), + })); + + assert.match(markup, /role="button"/); + assert.match(markup, /aria-expanded="true"/); + assert.match(markup, />Code<\/span>/); +}); + +it('keeps a lazy live stream behind the display cursor', () => { + const markup = renderToStaticMarkup(createElement(Markdown, { + text: 'live output that has not reached the display cursor', + streaming: true, + })); + + assert.doesNotMatch(markup, /live output/); +}); + +it('redacts secrets before even the lazy Markdown fallback reaches the rendered tree', () => { + const markup = renderToStaticMarkup(createElement(Markdown, { + text: 'Authorization: Bearer sk-live-1234567890abcdef', + })); + + assert.doesNotMatch(markup, /sk-live-1234567890abcdef/); + assert.match(markup, /<redacted>/); +}); + + + +it('preserves allowlisted Maka navigation links through sanitization', () => { + const markup = renderToStaticMarkup( + createElement( + LocaleProvider, + { + locale: 'en', + children: createElement( + MakaUriContext.Provider, + { value: () => {} }, + createElement(MarkdownBody, { + text: '[Models](maka://settings/models)', + }), + ), + }, + ), + ); + + assert.match(markup, / { + for (const href of [ + 'file:///Users/example/.ssh/id_rsa', + 'custom://private-resource', + 'javascript:alert(1)', + 'data:text/html,private', + ]) { + const markup = renderToStaticMarkup( + createElement( + LocaleProvider, + { + locale: 'en', + children: createElement(MarkdownBody, { + text: `[unsafe](${href})`, + }), + }, + ), + ); + + assert.doesNotMatch(markup, / { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: [ + '![standalone](file:///Users/example/.ssh/id_rsa)', + '', + 'caption ![inline](custom://private-resource)', + '', + '![data](data:image/png;base64,aW1n)', + '', + '![reference][avatar]', + '', + '[avatar]: file:///Users/example/private.png', + ].join('\n'), + })); + + assert.doesNotMatch(markup, / { + const oversized = ['```mermaid', 'x'.repeat(MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH + 1), '```'].join('\n'); + assert.match( + applyMermaidRenderBudget(oversized), + /```makamermaiddeferred/, + ); + + const nearHalfTotal = 'x'.repeat(Math.floor(MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH / 2) - 100); + const source = [nearHalfTotal, nearHalfTotal, 'x'.repeat(250)] + .map((code) => ['```mermaid', code, '```'].join('\n')) + .join('\n\n'); + assert.equal( + applyMermaidRenderBudget(source).match(/```makamermaiddeferred/g)?.length, + 1, + ); +}); + +it('does not render Mermaid while the assistant turn is streaming', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: ['```mermaid', 'flowchart LR', 'A --> B', '```'].join('\n'), + streaming: true, + })); + + assert.doesNotMatch(markup, /data-maka-contract="mermaid"/); +}); + +it('pins Mermaid security and complexity limits for untrusted assistant output', () => { + const config = createMermaidConfig('dark'); + + assert.equal(config.startOnLoad, false); + assert.equal(config.securityLevel, 'strict'); + assert.equal(config.suppressErrorRendering, true); + assert.equal(config.htmlLabels, false); + assert.equal(config.maxTextSize, MAX_MERMAID_SOURCE_LENGTH); + assert.equal(config.maxEdges, MAX_MERMAID_EDGES); + assert.equal(config.theme, 'dark'); +}); + +it('keeps a new stream behind the display cursor on its first render', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'new output that has not been presented yet', + streaming: true, + })); + + assert.doesNotMatch(markup, /new output that has not been presented yet/); +}); + +it('shows only the restored prefix on its first streaming render', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '**output restored** with a new delta', + streaming: true, + settledText: '**output restored**', + })); + + assert.match(markup, /]*>output restored<\/strong>/); + assert.doesNotMatch(markup, /new delta/); +}); + +it('renders settled math while keeping the live tail behind the display cursor', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'Stable \\( x + 1 \\) with a new delta', + streaming: true, + settledText: 'Stable \\( x + 1 \\)', + })); + + assert.match(markup, /class="maka-math maka-math-inline"/); + assert.match(markup, /class="katex"/); + assert.doesNotMatch(markup, /new delta/); +}); + +it('settles only the verified prefix when restored content was rewritten', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'prefix NEW', + streaming: true, + settledText: 'prefix sk-123456789012345', + })); + + assert.match(markup, />prefix { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: 'same 😃 NEW', + streaming: true, + settledText: 'same 😀 old', + })); + + assert.match(markup, />same IMAGE_PAYLOAD_MAX_BASE64_LENGTH; +} + +export function formatPreviewSize(sizeBytes: number | undefined, locale: UiLocale): string { + if (sizeBytes === undefined || sizeBytes < 0 || !Number.isFinite(sizeBytes)) return getSharedUiCopy(locale).artifact.unknownSize; + if (sizeBytes < 1024) return `${sizeBytes} B`; + if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`; + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1e5cc75ca867b297dc691d9eea80a96d4d06ec8c50e5426bae5a0a11c9af2b0b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1e5cc75ca867b297dc691d9eea80a96d4d06ec8c50e5426bae5a0a11c9af2b0b.source new file mode 100644 index 0000000000..c491e95973 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/1e5cc75ca867b297dc691d9eea80a96d4d06ec8c50e5426bae5a0a11c9af2b0b.source @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Re-export the shared quiet-panel formatting from `@maka/core` (#1065). + * + * `formatToolInvocationLine` and `formatQuietJsonValue` are pure functions + * extracted from this module into `@maka/core` so the CLI/TUI can consume + * the same path. Desktop passes the resolved locale from `LocaleProvider`. + * + * The desktop `ToolActivityItem`-typed signature is adapted here so existing + * call sites (`tool-activity.tsx`, `tool-result-preview.tsx`) keep their + * `Pick` parameter without depending on the core + * `ToolInvocationInput` type. + */ +import { + formatQuietJsonValue as coreFormatQuietJsonValue, + formatToolInvocationLine as coreFormatToolInvocationLine, +} from '@maka/core/tool-quiet-preview'; +import { type UiLocale } from '@maka/core/ui-locale'; +import type { ToolActivityItem } from '../materialize.js'; + +/** Desktop-adapted wrapper with an explicit resolved locale. */ +export function formatToolInvocationLine( + item: Pick, + locale: UiLocale, +): string | undefined { + // Live Runtime Host frames carry only the bounded args preview; the durable + // transcript supplies full args at turn end. Format from whichever exists. + return coreFormatToolInvocationLine( + { toolName: item.toolName, args: item.args ?? item.argsPreview }, + locale, + ); +} + +/** Desktop-adapted wrapper with an explicit resolved locale. */ +export function formatQuietJsonValue( + value: unknown, + locale: UiLocale, +): import('@maka/core/tool-quiet-preview').QuietPreview { + return coreFormatQuietJsonValue(value, locale); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2095a48aefbb0e500af735c89e977e2f141be36bd5e7127000cb72318315c13b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2095a48aefbb0e500af735c89e977e2f141be36bd5e7127000cb72318315c13b.source new file mode 100644 index 0000000000..f41077d488 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2095a48aefbb0e500af735c89e977e2f141be36bd5e7127000cb72318315c13b.source @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ElementType } from 'react'; +import * as Icons from '../src/icons.js'; +import { BOT_BRAND, BotBrandLogo } from '../src/index.js'; + +const meta = { + title: 'Design System/Icons', + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +interface IconEntry { + name: string; + Comp: ElementType<{ size?: number | string; strokeWidth?: number | string; 'aria-hidden'?: boolean }>; +} + +// The icon seam also exports shared metadata such as ICON_SIZE. Keep the story +// self-updating without assuming every runtime export can be rendered. +function isIconComponent(value: unknown): value is IconEntry['Comp'] { + return ( + typeof value === 'function' || + (typeof value === 'object' && + value !== null && + 'render' in value && + typeof value.render === 'function') + ); +} + +const LUCIDE_ICONS: IconEntry[] = Object.entries(Icons) + .flatMap(([name, value]) => (isIconComponent(value) ? [{ name, Comp: value }] : [])) + .sort((a, b) => a.name.localeCompare(b.name)); + +// Derived from BOT_BRAND, the registry BotBrandLogo itself reads. A hand-kept +// list here is satisfied by any subset, so a newly supported channel would +// silently never render. +const BOT_BRAND_PROVIDERS = Object.keys(BOT_BRAND) as Array; + +export const LucideIcons: Story = { + render: () => ( +
+
+

Lucide Icons

+

+ {LUCIDE_ICONS.length} 个通用 UI 图标,通过 icons.tsx 的 lucide-react re-export 自动追踪。业务代码仍只从 @maka/ui/icons 取图标。 +

+
+
+ {LUCIDE_ICONS.map(({ name, Comp }) => ( +
+ + {name} +
+ ))} +
+
+ ), +}; + +export const BotBrandIcons: Story = { + render: () => ( +
+
+

Bot Brand Icons

+

+ {BOT_BRAND_PROVIDERS.length} 个 IM 渠道品牌图标,本地 React SVG,零运行时 CDN 依赖。 +

+
+
+ {BOT_BRAND_PROVIDERS.map((provider) => ( +
+ + {provider} +
+ ))} +
+
+ ), +}; diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/20ecb828a3db71edd01e1b7f0e24c6e0bf7478f1078d66509f93aa834c3699f6.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/20ecb828a3db71edd01e1b7f0e24c6e0bf7478f1078d66509f93aa834c3699f6.source new file mode 100644 index 0000000000..5bfd6d93b4 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/20ecb828a3db71edd01e1b7f0e24c6e0bf7478f1078d66509f93aa834c3699f6.source @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { describeLoadToolResult } from '../tool-format.js'; + +test('custom load-tool groups use Traditional Chinese action copy', () => { + const result = describeLoadToolResult( + { group: 'custom' }, + { + activated: ['custom_tool'], + group: { id: 'custom', label: '自訂工具', description: '專案工具' }, + }, + 'zh-TW', + ); + assert.equal(result?.actionLabel, '啟用 自訂工具'); + assert.equal(result?.title, '自訂工具 已啟用'); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/211bb033357d7d014a7292d4e4cdf1049abb349719f6c241ac9ee68ace5f2bad.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/211bb033357d7d014a7292d4e4cdf1049abb349719f6c241ac9ee68ace5f2bad.source new file mode 100644 index 0000000000..0fa9a18702 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/211bb033357d7d014a7292d4e4cdf1049abb349719f6c241ac9ee68ace5f2bad.source @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SearchErrorReason } from '@maka/core/search'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export type ThreadSearchErrorReason = Extract< + SearchErrorReason, + 'incognito_active' | 'invalid_query' | 'aborted' | 'disabled' | 'provider_error' +>; + +type ShellControlsCopy = { + shared: { + close: string; + }; + navigation: { + mainLabel: string; + newTask: string; + automations: string; + extensions: string; + settings: string; + updateDownloaded(version: string): string; + updateFailed(version: string): string; + pendingTasks(count: number): string; + }; + search: { + title: string; + conversationsLabel: string; + placeholder: string; + unavailable: string; + errorByReason: Record; + errorFallback: string; + introduction: string; + empty: string; + resultsLabel: string; + }; +}; + +const SHELL_CONTROLS_COPY_BY_LOCALE = { + 'zh-CN': { + shared: { close: '关闭' }, + navigation: { + mainLabel: '主导航', + newTask: '新任务', + automations: '定时任务', + extensions: '扩展', + settings: '设置', + updateDownloaded: (version: string) => `新版本 ${version} 已下载,重启后安装`, + updateFailed: (version: string) => `新版本 ${version} 更新失败,点击重试或手动下载`, + pendingTasks: (count: number) => `定时任务,${count} 条进行中`, + }, + search: { + title: '搜索', + conversationsLabel: '搜索任务', + placeholder: '搜索任务标题和内容…', + unavailable: '当前环境无法连接搜索后端,请稍后重试。', + errorByReason: { + incognito_active: '关闭隐私模式后可以继续按关键词查找历史任务。', + invalid_query: '搜索词无效,请缩短内容或移除凭据后重试。', + aborted: '搜索已取消。', + disabled: '搜索当前不可用。', + provider_error: '搜索服务出错,请重试。', + }, + errorFallback: '搜索服务需要刷新,请重试。', + introduction: '开始输入以按关键词查找历史任务。结果只包含任务标题和内容文本,不进入网络。', + empty: '没有匹配的任务标题或内容。换个关键词试试。', + resultsLabel: '搜索结果', + }, + }, + 'zh-TW': { + shared: { close: '關閉' }, + navigation: { + mainLabel: '主導航', + newTask: '新任務', + automations: '定時任務', + extensions: '擴充套件', + settings: '設定', + updateDownloaded: (version: string) => `新版本 ${version} 已下載,重啟後安裝`, + updateFailed: (version: string) => `新版本 ${version} 更新失敗,點選重試或手動下載`, + pendingTasks: (count: number) => `定時任務,${count} 條進行中`, + }, + search: { + title: '搜尋', + conversationsLabel: '搜尋任務', + placeholder: '搜尋任務標題和內容…', + unavailable: '目前環境無法連線搜尋後端,請稍後重試。', + errorByReason: { + incognito_active: '關閉隱私模式後可以繼續按關鍵詞查詢歷史任務。', + invalid_query: '搜尋詞無效,請縮短內容或移除憑證後重試。', + aborted: '搜尋已取消。', + disabled: '搜尋目前無法使用。', + provider_error: '搜尋服務發生錯誤,請重試。', + }, + errorFallback: '搜尋服務需要重新整理,請重試。', + introduction: '開始輸入以按關鍵詞查詢歷史任務。結果只包含任務標題和內容文本,不進入網路。', + empty: '沒有符合的任務標題或內容。換個關鍵詞試試。', + resultsLabel: '搜尋結果', + }, + }, + en: { + shared: { close: 'Close' }, + navigation: { + mainLabel: 'Main navigation', + newTask: 'New task', + automations: 'Scheduled tasks', + extensions: 'Extensions', + settings: 'Settings', + updateDownloaded: (version: string) => `Update ${version} downloaded. Restart to install.`, + updateFailed: (version: string) => `Update ${version} failed. Click to retry or download manually.`, + pendingTasks: (count: number) => `Scheduled tasks, ${count} active`, + }, + search: { + title: 'Search', + conversationsLabel: 'Search tasks', + placeholder: 'Search task titles and content…', + unavailable: 'Search is unavailable in the current environment. Try again later.', + errorByReason: { + incognito_active: 'Turn off privacy mode to search previous tasks by keyword.', + invalid_query: 'Invalid search query. Shorten it or remove credential material and try again.', + aborted: 'Search was canceled.', + disabled: 'Search is unavailable right now.', + provider_error: 'Search failed. Try again.', + }, + errorFallback: 'Search needs to be refreshed. Try again.', + introduction: + 'Start typing to search previous tasks by keyword. Results include local task titles and content only and are not sent over the network.', + empty: 'No matching task titles or content. Try another keyword.', + resultsLabel: 'Search results', + }, + }, +} satisfies UiCatalog; + +export function getShellControlsCopy(locale: UiLocale): ShellControlsCopy { + return SHELL_CONTROLS_COPY_BY_LOCALE[locale]; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/231fc80237755e05a7cf759c063c6664b56b20ebbd9c2a32cdf05a469d4ba9cd.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/231fc80237755e05a7cf759c063c6664b56b20ebbd9c2a32cdf05a469d4ba9cd.source new file mode 100644 index 0000000000..298ba3167b --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/231fc80237755e05a7cf759c063c6664b56b20ebbd9c2a32cdf05a469d4ba9cd.source @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DailyReviewArchive } from '@maka/core/daily-review'; + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +type ArchiveSectionKey = keyof DailyReviewArchive['sections']; + +export interface DailyReviewCopy { + archive: { + section: Record; + status: Record; + trigger: Record; + title: (date: string, range: string) => string; + range: Record; + generated: (trigger: string, time: string) => string; + sessionCount: (count: number) => string; + defaultModel: string; + opening: string; + noContent: string; + /** The panel-empty (tier 2) sentence under `noContent`. */ + noContentHelp: string; + }; + date: { + today: string; + yesterday: string; + daysAgo: (count: number) => string; + recent7Days: string; + recent30Days: string; + shiftedRange: (range: string, days: number) => string; + unit: { day: string; week: string; month: string }; + earlier: (unit: string) => string; + later: (unit: string) => string; + }; + emptyOverview: { + todayTitle: string; + rangeTitle: (label: string) => string; + todayBody: string; + rangeBody: (label: string) => string; + }; + export: { + ariaLabel: string; + copyTitle: string; + copying: string; + copy: string; + appendTitle: string; + appending: string; + append: string; + saveTitle: string; + saving: string; + save: string; + }; + page: { + title: string; + generateAnalysis: string; + retryAnalysis: string; + viewAnalysis: string; + backToActivity: string; + timeRange: string; + rangeOptions: ReadonlyArray; + rangeSwitch: string; + }; + overview: { + ariaLabel: (label: string) => string; + refreshFailed: (error: string) => string; + retry: string; + conversations: string; + requests: string; + tokens: string; + cost: string; + activeConversations: string; + }; + errorFallback: string; + markdown: { + separator: ':' | ':'; + title: (dayLabel: string) => string; + conversations: string; + requests: string; + tokens: string; + cost: string; + errors: string; + activeConversations: string; + modelUsage: string; + toolCalls: string; + requestCount: (count: number) => string; + }; +} + +const DAILY_REVIEW_COPY = { + 'zh-CN': { + archive: { + section: { summary: '任务摘要', gaps: '遗漏提醒', usage: '使用洞察', code: '代码建议' }, + status: { ok: '已生成', no_model: '缺少模型', no_data: '无数据', failed: '生成失败', skipped: '已跳过' }, + trigger: { cron: '定时', manual: '手动' }, + title: (date, mode) => `${date} · ${mode}`, + range: { 1: '单日', 7: '7 天', 30: '30 天' }, + generated: (trigger, time) => `${trigger}生成 ${time}`, + sessionCount: (count) => `${count} 任务`, + defaultModel: '默认任务模型', + opening: '正在打开这份报告…', + noContent: '这份报告没有生成正文内容。', + noContentHelp: '这一天没有归档内容。', + }, + date: { + today: '今天', yesterday: '昨天', daysAgo: (count) => `${count} 天前`, recent7Days: '最近 7 天', recent30Days: '最近 30 天', shiftedRange: (range, days) => `${range}(往前 ${days} 天)`, + unit: { day: '天', week: '周', month: '月' }, earlier: (unit) => `查看更早一${unit}`, later: (unit) => `查看更晚一${unit}`, + }, + emptyOverview: { + todayTitle: '等待记录今天活动', rangeTitle: (label) => `${label}无活动`, todayBody: '今天还没有发起任务,也没有调用模型。', rangeBody: (label) => `${label}范围内没有发起任务,也没有调用模型。`, + }, + export: { + ariaLabel: '回顾导出操作', copyTitle: '复制为 Markdown 摘要,方便分享 / 贴到笔记', copying: '复制中…', copy: '复制', appendTitle: '追加到当前输入框草稿', appending: '追加中…', append: '粘到输入框', saveTitle: '保存为 Markdown 文件', saving: '保存中…', save: '保存', + }, + page: { + title: '每日回顾', generateAnalysis: '生成分析', retryAnalysis: '重新生成', viewAnalysis: '查看分析', backToActivity: '返回活动', timeRange: '时间范围', rangeOptions: [['1', '今日'], ['7', '最近 7 天'], ['30', '最近 30 天']], rangeSwitch: '时间范围切换', + }, + overview: { + ariaLabel: (label) => `${label}概览`, refreshFailed: (error) => `每日回顾刷新失败:${error}`, retry: '重试', conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', activeConversations: '活跃任务', + }, + errorFallback: '每日回顾暂时不可用,请稍后重试。', + markdown: { + separator: ':', title: (dayLabel) => `# Maka · 每日回顾 · ${dayLabel}`, conversations: '任务', requests: '模型调用', tokens: 'Token', cost: '费用', errors: '错误', activeConversations: '活跃任务', modelUsage: '模型使用', toolCalls: '工具调用', requestCount: (count) => `${count} 次`, + }, + }, + 'zh-TW': { + archive: { + section: { summary: '任務摘要', gaps: '遺漏提醒', usage: '使用洞察', code: '程式碼建議' }, + status: { ok: '已生成', no_model: '缺少模型', no_data: '無資料', failed: '生成失敗', skipped: '已跳過' }, + trigger: { cron: '定時', manual: '手動' }, + title: (date, mode) => `${date} · ${mode}`, + range: { 1: '單日', 7: '7 天', 30: '30 天' }, + generated: (trigger, time) => `${trigger}生成 ${time}`, + sessionCount: (count) => `${count} 任務`, + defaultModel: '預設任務模型', + opening: '正在開啟這份報告…', + noContent: '這份報告沒有生成正文內容。', + noContentHelp: '這一天沒有歸檔內容。', + }, + date: { + today: '今天', yesterday: '昨天', daysAgo: (count) => `${count} 天前`, recent7Days: '最近 7 天', recent30Days: '最近 30 天', shiftedRange: (range, days) => `${range}(往前 ${days} 天)`, + unit: { day: '天', week: '周', month: '月' }, earlier: (unit) => `檢視更早一${unit}`, later: (unit) => `檢視更晚一${unit}`, + }, + emptyOverview: { + todayTitle: '等待記錄今天活動', rangeTitle: (label) => `${label}無活動`, todayBody: '今天還沒有發起任務,也沒有呼叫模型。', rangeBody: (label) => `${label}範圍內沒有發起任務,也沒有呼叫模型。`, + }, + export: { + ariaLabel: '回顧匯出操作', copyTitle: '複製為 Markdown 摘要,方便分享 / 貼到筆記', copying: '複製中…', copy: '複製', appendTitle: '追加到目前輸入框草稿', appending: '追加中…', append: '貼到輸入框', saveTitle: '儲存為 Markdown 檔案', saving: '儲存中…', save: '儲存', + }, + page: { + title: '每日回顧', generateAnalysis: '生成分析', retryAnalysis: '重新生成', viewAnalysis: '檢視分析', backToActivity: '返回活動', timeRange: '時間範圍', rangeOptions: [['1', '今日'], ['7', '最近 7 天'], ['30', '最近 30 天']], rangeSwitch: '時間範圍切換', + }, + overview: { + ariaLabel: (label) => `${label}概覽`, refreshFailed: (error) => `每日回顧重新整理失敗:${error}`, retry: '重試', conversations: '任務', requests: '請求', tokens: 'Token', cost: '費用', activeConversations: '活躍任務', + }, + errorFallback: '每日回顧暫時不可用,請稍後重試。', + markdown: { + separator: ':', title: (dayLabel) => `# Maka · 每日回顧 · ${dayLabel}`, conversations: '任務', requests: '請求', tokens: 'Token', cost: '費用', errors: '錯誤', activeConversations: '活躍任務', modelUsage: '模型使用', toolCalls: '工具呼叫', requestCount: (count) => `${count} 次`, + }, + }, + en: { + archive: { + section: { summary: 'Task summary', gaps: 'Missed items', usage: 'Usage insights', code: 'Code suggestions' }, + status: { ok: 'Generated', no_model: 'Model unavailable', no_data: 'No data', failed: 'Generation failed', skipped: 'Skipped' }, + trigger: { cron: 'Scheduled', manual: 'Manual' }, + title: (date, mode) => `${date} · ${mode}`, + range: { 1: '1 day', 7: '7 days', 30: '30 days' }, + generated: (trigger, time) => `${trigger} · ${time}`, + sessionCount: (count) => `${count} ${count === 1 ? 'task' : 'tasks'}`, + defaultModel: 'Default task model', + opening: 'Opening this report…', + noContent: 'This report has no generated content.', + noContentHelp: 'Nothing archived for this day.', + }, + date: { + today: 'Today', yesterday: 'Yesterday', daysAgo: (count) => `${count} days ago`, recent7Days: 'Last 7 days', recent30Days: 'Last 30 days', shiftedRange: (range, days) => `${range} (${days} days earlier)`, + unit: { day: 'day', week: 'week', month: 'month' }, earlier: (unit) => `View previous ${unit}`, later: (unit) => `View next ${unit}`, + }, + emptyOverview: { + todayTitle: "Waiting for today's activity", rangeTitle: (label) => `No activity for ${label.toLowerCase()}`, todayBody: 'No tasks or model requests have started today.', rangeBody: (label) => `No tasks or model requests were made during ${label.toLowerCase()}.`, + }, + export: { + ariaLabel: 'Review export actions', copyTitle: 'Copy a Markdown summary to share or add to notes', copying: 'Copying…', copy: 'Copy', appendTitle: 'Append to the current composer draft', appending: 'Appending…', append: 'Add to composer', saveTitle: 'Save as a Markdown file', saving: 'Saving…', save: 'Save', + }, + page: { + title: 'Daily review', generateAnalysis: 'Generate analysis', retryAnalysis: 'Generate again', viewAnalysis: 'View analysis', backToActivity: 'Back to activity', timeRange: 'Time range', rangeOptions: [['1', 'Today'], ['7', 'Last 7 days'], ['30', 'Last 30 days']], rangeSwitch: 'Change time range', + }, + overview: { + ariaLabel: (label) => `${label} overview`, refreshFailed: (error) => `Failed to refresh daily review: ${error}`, retry: 'Retry', conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', activeConversations: 'Active tasks', + }, + errorFallback: 'Daily review is temporarily unavailable. Try again later.', + markdown: { + separator: ':', title: (dayLabel) => `# Maka · Daily review · ${dayLabel}`, conversations: 'Tasks', requests: 'Model calls', tokens: 'Tokens', cost: 'Cost', errors: 'Errors', activeConversations: 'Active tasks', modelUsage: 'Model usage', toolCalls: 'Tool calls', requestCount: (count) => `${count} ${count === 1 ? 'call' : 'calls'}`, + }, + }, +} satisfies UiCatalog; + +export function getDailyReviewCopy(locale: UiLocale): DailyReviewCopy { + return DAILY_REVIEW_COPY[locale]; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2504a624802873c022abaf630a748004a5cfd43f2b9aaab78d6259c677749f59.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2504a624802873c022abaf630a748004a5cfd43f2b9aaab78d6259c677749f59.source new file mode 100644 index 0000000000..c5391477eb --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2504a624802873c022abaf630a748004a5cfd43f2b9aaab78d6259c677749f59.source @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ToolResultContent } from '@maka/core/events'; +import type { ToolActivityItem } from '../materialize.js'; + +export function isSandboxDeniedToolResult( + result: ToolResultContent | undefined, +): boolean { + return ( + result !== undefined && + 'sandboxDenial' in result && + result.sandboxDenial?.likely === true + ); +} + +export function isSandboxDeniedTool(item: ToolActivityItem): boolean { + return item.status === 'errored' && isSandboxDeniedToolResult(item.result); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/254f88036c67c177f9290f5112505d4d29061fd86d3c0b7e85d8a5fc294c9668.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/254f88036c67c177f9290f5112505d4d29061fd86d3c0b7e85d8a5fc294c9668.source new file mode 100644 index 0000000000..61a1db266f --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/254f88036c67c177f9290f5112505d4d29061fd86d3c0b7e85d8a5fc294c9668.source @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from '@maka/core/ui-locale'; +import { getToolActivityCopy } from './copy.js'; + +export const TOOL_LINE_CAP = 500; + +export function capLines(text: string): { body: string; capped: number } { + const lines = text.split('\n'); + if (lines.length <= TOOL_LINE_CAP) return { body: text, capped: 0 }; + return { + body: lines.slice(0, TOOL_LINE_CAP).join('\n'), + capped: lines.length - TOOL_LINE_CAP, + }; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDuration(ms: number | undefined): string | null { + if (ms === undefined || ms < 0) return null; + if (ms < 1000) return `${ms} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + return `${minutes}m ${seconds}s`; +} + +export function formatUserVisibleToolText(text: string, locale: UiLocale): string { + return text.replace(/\bUser denied permission(?: request)?\b|用户已拒绝权限请求/g, getToolActivityCopy(locale).permissionDenied); +} + +/** One concise default summary of a tool failure: cap both characters and + * logical lines so a multi-line validation error cannot grow the banner to + * the ~2631px the issue tracked (a 240-char slice kept newlines, so 180 lines + * still rendered ~161 lines). The full redacted text stays in the disclosure + * for copy. */ +export function summarizeErrorText(text: string): string { + const MAX_CHARS = 240; + const MAX_LINES = 4; + const lines = text.split('\n'); + if (text.length <= MAX_CHARS && lines.length <= MAX_LINES) return text; + const trimmed = lines.slice(0, MAX_LINES).join('\n').slice(0, MAX_CHARS); + return `${trimmed}…`; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/25e8e6777a4f5d16f278c0897658aea085bbc4a62ea59f7b005a86585aa35f64.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/25e8e6777a4f5d16f278c0897658aea085bbc4a62ea59f7b005a86585aa35f64.source new file mode 100644 index 0000000000..135b862cf4 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/25e8e6777a4f5d16f278c0897658aea085bbc4a62ea59f7b005a86585aa35f64.source @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo, useRef, useState } from 'react'; +import type { MessageQueueEntryProjection } from '@maka/core/events'; +import { Button, IconButton } from '@astryxdesign/core'; +import { List, ListItem } from '@astryxdesign/core/List'; +import type { ConversationCopy } from './conversation-copy.js'; +import { Check, GripVertical, ICON_SIZE, Trash2, X } from './icons.js'; +import { useMountedRef } from './use-mounted-ref.js'; + +/** + * The pending plate above the composer card. It lists both pending steering + * and follow-up entries so a submitted message stays editable, reorderable and + * deletable while it waits for the active Turn to reach a steering boundary. + * Each row is a one-line preview: the transcript owns the full message text. + */ +export interface ComposerMessageQueueProps { + queuedMessages: readonly MessageQueueEntryProjection[]; + queueRevision?: number; + copy: ConversationCopy['composer']; + onPromoteEntry?(entryId: string): void | Promise; + onUpdateEntry?(entryId: string, expectedQueueRevision: number, text: string): void | Promise; + onDeleteEntry?(entryId: string): void | Promise; + onReorderEntries?(entryIds: readonly string[]): void | Promise; +} + +export const ComposerMessageQueue = memo(function ComposerMessageQueue( + props: ComposerMessageQueueProps, +) { + const [pendingEntryId, setPendingEntryId] = useState(null); + const [editingEntryId, setEditingEntryId] = useState(null); + const [editingQueueRevision, setEditingQueueRevision] = useState(0); + const [editingText, setEditingText] = useState(''); + const dragEntryId = useRef(null); + const mountedRef = useMountedRef(); + const copy = props.copy; + + const entries = props.queuedMessages; + const followup = entries.filter((entry) => entry.placement === 'next_turn'); + + async function runEntryAction( + entryId: string, + action: (() => void | Promise) | undefined, + ): Promise { + if (!action || pendingEntryId) return false; + setPendingEntryId(entryId); + try { + // The caller (app shell) surfaces failures itself; the projection is + // unchanged on failure, so there is nothing to settle here. + await action(); + return true; + } catch { + // surfaced by the caller + return false; + } finally { + if (mountedRef.current) setPendingEntryId(null); + } + } + + function dropOn(targetEntryId: string) { + const fromId = dragEntryId.current; + dragEntryId.current = null; + if (!fromId || fromId === targetEntryId || !props.onReorderEntries) return; + const ids = followup.map((entry) => entry.entryId); + const from = ids.indexOf(fromId); + const to = ids.indexOf(targetEntryId); + if (from === -1 || to === -1) return; + ids.splice(from, 1); + ids.splice(to, 0, fromId); + // The Host projection is the only rendered order. Keep other queue actions + // pending until this request settles instead of maintaining a local overlay. + void runEntryAction(fromId, () => props.onReorderEntries?.(ids)); + } + + function beginEdit(entry: MessageQueueEntryProjection) { + if (pendingEntryId || !props.onUpdateEntry || props.queueRevision === undefined) return; + setEditingEntryId(entry.entryId); + const text = entry.content.displayText ?? entry.content.text; + setEditingQueueRevision(props.queueRevision); + setEditingText(text); + } + + async function commitEdit(entryId: string) { + const text = editingText.trim(); + if (!text) return; + const updated = await runEntryAction(entryId, () => + props.onUpdateEntry?.(entryId, editingQueueRevision, text) + ); + if (updated && mountedRef.current) { + setEditingEntryId(null); + setEditingQueueRevision(0); + setEditingText(''); + } + } + + function cancelEdit() { + setEditingEntryId(null); + setEditingQueueRevision(0); + setEditingText(''); + } + + return ( +
+ + {entries.map((entry) => { + const editing = editingEntryId === entry.entryId; + const reorderable = + entry.placement === 'next_turn' + && entry.state === 'queued' + && !editing + && Boolean(props.onReorderEntries) + && pendingEntryId === null; + return ( +
{ + if (reorderable && dragEntryId.current) event.preventDefault(); + }} + onDrop={reorderable ? () => dropOn(entry.entryId) : undefined} + > + setEditingText(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' + && !event.shiftKey + && !event.nativeEvent.isComposing + ) { + event.preventDefault(); + void commitEdit(entry.entryId); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } + }} + /> + ) : ( + // The transcript renders the queued message in full; the plate + // only needs enough of it to tell the rows apart. + + {entry.content.displayText ?? entry.content.text} + + )} + style={{ minHeight: 28, paddingBlock: 0 }} + startContent={entry.placement === 'next_turn' ? ( + { + dragEntryId.current = entry.entryId; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', entry.entryId); + }} + onDragEnd={() => { + dragEntryId.current = null; + }} + > + + ) : undefined} + endContent={( + + {editing ? ( + <> + void commitEdit(entry.entryId)} + icon={
+ ); + })} +
+
+ ); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/26268a09bfd55c524a462853e5dfc0284de801caed488e15549bd9ed97729489.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/26268a09bfd55c524a462853e5dfc0284de801caed488e15549bd9ed97729489.source new file mode 100644 index 0000000000..529795332f --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/26268a09bfd55c524a462853e5dfc0284de801caed488e15549bd9ed97729489.source @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Markdown rendering layer — eager entry. + * + * This module is intentionally lightweight: it only owns the + * `MakaUriContext` (which the renderer installs once at the App root) + * and a thin `Markdown` wrapper that `React.lazy`-loads the heavy + * Astryx Markdown renderer from `./markdown-body.js` on first use. + * + * Why the split: the markdown pipeline is by far the heaviest thing the + * chat shell transitively imports, yet it's only needed once a message + * actually renders. On a fresh launch (no active session) nothing ever + * mounts ``, so forcing the browser to parse hundreds of KB + * the Markdown component before first paint was pure overhead. With the + * lazy split, that code is parsed on demand the first time a message appears, + * and cached for every subsequent render. + * + * Secret redaction happens eagerly in this wrapper so the Suspense fallback + * is safe. The remaining trust-boundary contract (URI allowlist, safe-scheme + * external gate, broken-link inline errors) lives in `markdown-body.tsx`; + * see that file for the routing rationale. + * + * PR-UI-LIB-EXTRACT-6 (WAWQAQ msg `510fef52`, round 7/10): pulled out + * of `components.tsx`. `MakaUriContext` was already a public export + * (the renderer's main.tsx provides the dispatcher), so `index.ts` + * re-exports the new module to keep the `@maka/ui` surface identical. + * `Markdown` and its rendering helpers remain package-private — only consumed + * within `@maka/ui`. + */ + +import { createContext, lazy, Suspense } from 'react'; +import { redactSecrets } from './redact.js'; +import { isProgressiveStreamingEnabled } from './streaming-presentation.js'; + +// Heavy pipeline — parsed on first `` mount, not at app boot. +const MarkdownBody = lazy(() => import('./markdown-body.js').then((m) => ({ default: m.MarkdownBody }))); + +export function Markdown(props: { + text: string; + streaming?: boolean; + settledText?: string; + /** Block rhythm. Transcript turns pass `compact`; documents leave it. */ + density?: 'default' | 'compact'; +}) { + const safeText = redactSecrets(props.text); + const safeSettledText = props.settledText === undefined + ? undefined + : redactSecrets(props.settledText); + const streaming = isProgressiveStreamingEnabled(props.streaming); + return ( + + {safeText} + + )} + > + + + ); +} + +/** + * PR-UI-RENDER-2 — context for the internal-link dispatcher. + * + * The desktop renderer installs the dispatcher once at the App root + * (see `apps/desktop/src/renderer/main.tsx`). The dispatcher takes a + * typed `MakaUriDest` and routes to whatever real navigation surface + * the app uses (e.g. `setNavSelection({section: 'settings', tab: ...})` + * for `kind: 'settings'`, or `composer.prefill(text)` for `kind: + * 'compose'`). The Markdown link renderer never invokes navigation + * directly — that's the dispatcher's job, and the dispatcher is the + * single chokepoint to add observability / consent prompts later. + * + * Defined here (eager) rather than in `markdown-body.tsx` (lazy) so the + * context identity is stable across the eager/lazy boundary — the + * renderer installs the provider against THIS module's export, and the + * lazy body reads it via `useContext(MakaUriContext)` imported back + * from here. + */ +export const MakaUriContext = createContext<((dest: import('./maka-uri.js').MakaUriDest) => void) | undefined>(undefined); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2672d57c088c7e2b50e8ea796b0a468053da90de72112615122298db001545b0.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2672d57c088c7e2b50e8ea796b0a468053da90de72112615122298db001545b0.source new file mode 100644 index 0000000000..a0920d2200 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2672d57c088c7e2b50e8ea796b0a468053da90de72112615122298db001545b0.source @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@astryxdesign/core/Button'; +import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu'; +import { IconButton } from '@astryxdesign/core/IconButton'; +import { ArrowLeft, Folder, MoreHorizontal } from './icons.js'; +import { getConversationCopy } from './conversation-copy.js'; +import { InlineRenameInput } from './inline-rename-input.js'; +import { useClipboardCopyFeedback } from './clipboard-feedback.js'; +import { useUiLocale } from './locale-context.js'; + +export interface TitlebarProject { + name: string; + path?: string; + onOpenFolder?(): void; +} + +export interface TitlebarParentSession { + name: string; + onOpen(): void; +} + +export function TitlebarSessionIdentity(props: { + sessionName: string; + onRenameSession(name: string): void; + project?: TitlebarProject; + parentSession?: TitlebarParentSession; + readOnly?: boolean; + action?: { readonly label: string; onClick(): void }; +}) { + const copy = getConversationCopy(useUiLocale()); + const clipboard = useClipboardCopyFeedback(undefined, { redact: false }); + const [renaming, setRenaming] = useState(false); + const nameRef = useRef(null); + const handBackFocusRef = useRef(false); + + function endRename(handBackFocus: boolean) { + handBackFocusRef.current = handBackFocus; + setRenaming(false); + } + + useEffect(() => { + if (renaming || !handBackFocusRef.current) return; + handBackFocusRef.current = false; + nameRef.current?.focus(); + }, [renaming]); + + const path = props.project?.path; + const copyPhase = path ? clipboard.phaseFor(path) : null; + const copyLabel = copyPhase === 'pending' ? copy.messages.copying + : copyPhase === 'failed' ? copy.messages.copyFailed + : copyPhase === 'copied' ? copy.messages.copied : copy.chat.copyProjectPath; + const projectContent = props.project ? ( +
+
+
{props.project.name}
+ {path && path !== props.project.name ?
{path}
: null} +
+ {props.project.onOpenFolder ? ( + + ) : null} + {path ? ( + { void clipboard.copy(path, path); }} + /> + ) : null} +
+ ) : null; + + return ( +
+ {props.parentSession ? ( + } + variant="ghost" + size="sm" + onClick={props.parentSession.onOpen} + /> + ) : props.project ? ( + + , isIconOnly: true, variant: 'ghost', size: 'sm' }} + hasChevron={false} + alignment="start" + > + {projectContent} + + + ) : null} + {renaming ? ( + { + endRename(via === 'keyboard'); + if (name && name !== props.sessionName) props.onRenameSession(name); + }} + onCancel={() => endRename(true)} + /> + ) : props.readOnly ? ( + + {props.sessionName} + + ) : ( + + )} + {!props.readOnly || props.action || (props.parentSession && props.project) ? ( + + , isIconOnly: true, variant: 'ghost', size: 'sm' }} + hasChevron={false} + alignment="end" + > + {!props.readOnly ? setRenaming(true)} /> : null} + {props.action ? : null} + {props.parentSession ? projectContent : null} + + + ) : null} + {copyPhase === 'failed' || copyPhase === 'copied' ? copyLabel : null} +
+ ); +} + +// The menu names a registered project, falling back to the session directory. +export function deriveTitlebarProjectName(options: { + projectName?: string; + projectPath?: string; +}): string | undefined { + if (options.projectName) return options.projectName; + const path = options.projectPath?.replace(/[/\\]+$/, ''); + if (!path) return undefined; + return path.split(/[/\\]/).pop() || undefined; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2766473a6a84f835580b8b1da07ad78b00dc10dc5510d051500c0a1c6909a596.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2766473a6a84f835580b8b1da07ad78b00dc10dc5510d051500c0a1c6909a596.source new file mode 100644 index 0000000000..9ee443a079 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2766473a6a84f835580b8b1da07ad78b00dc10dc5510d051500c0a1c6909a596.source @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getConversationCopy } from '../conversation-copy.js'; + +test('labels the Chinese default thinking level as default', () => { + assert.equal(getConversationCopy('zh-CN').model.defaultLevel, '默认'); + assert.equal(getConversationCopy('zh-TW').model.defaultLevel, '預設'); +}); + +test('explains why folder-reference messages cannot be edited and resent', () => { + assert.equal( + getConversationCopy('zh-CN').messages.editMessageDisabledDirectoryReferences, + '包含文件夹引用的历史消息暂不支持编辑并重发', + ); + assert.equal( + getConversationCopy('en').messages.editMessageDisabledDirectoryReferences, + 'Edit & resend does not yet support messages with folder references', + ); +}); + +test('labels incomplete transcript boundaries without inventing missing Turn counts', () => { + assert.deepEqual(getConversationCopy('zh-CN').chat.transcriptGap, { + olderDescription: '上方还有未加载的较早消息', + olderAction: '加载较早消息', + newerDescription: '下方还有未加载的较新消息', + newerAction: '加载较新消息', + }); + assert.deepEqual(getConversationCopy('zh-TW').chat.transcriptGap, { + olderDescription: '上方還有未載入的較早訊息', + olderAction: '載入較早訊息', + newerDescription: '下方還有未載入的較新訊息', + newerAction: '載入較新訊息', + }); + assert.deepEqual(getConversationCopy('en').chat.transcriptGap, { + olderDescription: 'Earlier messages above are not loaded.', + olderAction: 'Load earlier messages', + newerDescription: 'Newer messages below are not loaded.', + newerAction: 'Load newer messages', + }); +}); + +test('context usage explains missing data without exposing provider internals', () => { + assert.equal( + getConversationCopy('zh-CN').messages.systemNotes.contextUsageUnavailable, + '暂无用量数据', + ); + assert.equal( + getConversationCopy('en').messages.systemNotes.contextUsageUnavailable, + 'No usage data is available for this request.', + ); +}); + +test('context usage tooltip leads with the measured share', () => { + assert.equal( + getConversationCopy('zh-CN').messages.systemNotes.contextUsageShare(12_345, 128_000), + '已用 12,345 / 128,000 token(10%)', + ); + assert.equal( + getConversationCopy('en').messages.systemNotes.contextUsageShare(12_345, 128_000), + 'This request used 12,345 / 128,000 tokens (10%).', + ); +}); + +test('context usage tooltip keeps measured usage when the limit is unknown', () => { + assert.equal( + getConversationCopy('zh-CN').messages.systemNotes.contextUsageNoWindow(12_345), + '已用 12,345 token;上下文上限未知', + ); + assert.equal( + getConversationCopy('en').messages.systemNotes.contextUsageNoWindow(12_345), + 'This request used 12,345 tokens; no context limit is available for this model.', + ); +}); + +/** + * A subscription quota window can hand the runtime an hour-scale Retry-After; + * the banner must count down in humanized d/h/m/s units rather than a raw + * five-digit second count that reads as a frozen hang (#3401). + */ +test('providerRetryScheduled humanizes hour-scale delays in both locales', () => { + const zh = getConversationCopy('zh-CN').messages.providerRetryScheduled; + const zhTw = getConversationCopy('zh-TW').messages.providerRetryScheduled; + const en = getConversationCopy('en').messages.providerRetryScheduled; + + // Sub-second and zero inputs still read as one second (never "0秒后重试"). + assert.equal(zh(0, 2, 10), '1秒后重试(2/10)'); + assert.equal(en(0, 2, 10), 'Retrying in 1s (2/10)'); + + // Short delays keep the compact seconds-only form. + assert.equal(zh(1, 2, 10), '1秒后重试(2/10)'); + assert.equal(en(1, 2, 10), 'Retrying in 1s (2/10)'); + assert.equal(zh(45, 2, 10), '45秒后重试(2/10)'); + assert.equal(en(45, 2, 10), 'Retrying in 45s (2/10)'); + + // Minute-, hour-, and day-scale delays spell out the units. + assert.equal(zh(75, 2, 10), '1分 15秒后重试(2/10)'); + assert.equal(en(75, 2, 10), 'Retrying in 1m 15s (2/10)'); + assert.equal(zh(16_083, 2, 10), '4小时 28分 3秒后重试(2/10)'); + assert.equal(zhTw(16_083, 2, 10), '4小時 28分 3秒後重試(2/10)'); + assert.equal(en(16_083, 2, 10), 'Retrying in 4h 28m 3s (2/10)'); + assert.equal(zh(90_061, 2, 10), '1天 1小时 1分 1秒后重试(2/10)'); + assert.equal(en(90_061, 2, 10), 'Retrying in 1d 1h 1m 1s (2/10)'); + + // Zero-order units are skipped, not rendered as "0分". + assert.equal(zh(3_600, 2, 10), '1小时后重试(2/10)'); + assert.equal(en(86_400, 2, 10), 'Retrying in 1d (2/10)'); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/28c41e7337f4f6b312d746b60549012f367ff4b6dc4f95f522ccbe2b0f287551.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/28c41e7337f4f6b312d746b60549012f367ff4b6dc4f95f522ccbe2b0f287551.source new file mode 100644 index 0000000000..4b68a4ead4 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/28c41e7337f4f6b312d746b60549012f367ff4b6dc4f95f522ccbe2b0f287551.source @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { CapabilityAuditReport } from '@maka/core/capability-audit'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { useUiLocale } from './locale-context.js'; +import { Banner } from '@astryxdesign/core/Banner'; +import { getSharedUiCopy } from './shared-ui-copy.js'; + +/** + * Designer audit P1-7: this used to be a full-width "能力审计" band on both + * the Skills and Scheduled tasks pages — engineering jargon ("3 类声明工具", + * "自动化 0/0 启用") plus counts the page tabs already show. Healthy state + * carried zero new information, so the strip now reports by exception: + * render a single warning line when something needs attention (sources + * waiting for auth / erroring, scheduled tasks that failed or were skipped + * last run), and render nothing at all when everything is fine. + */ +export function CapabilityAuditStrip(props: { report: CapabilityAuditReport }) { + const locale = useUiLocale(); + const copy = getSharedUiCopy(locale).capabilityAudit; + const issues = capabilityAuditIssues(props.report, locale); + if (issues.length === 0) return null; + return ( + + ); +} + +export function capabilityAuditIssues(report: CapabilityAuditReport, locale: UiLocale): string[] { + const copy = getSharedUiCopy(locale).capabilityAudit; + const issues: string[] = []; + if (report.summary.needsAuthSourceCount > 0) issues.push(copy.needsAuthorization(report.summary.needsAuthSourceCount)); + if (report.summary.errorSourceCount > 0) issues.push(copy.sourceErrors(report.summary.errorSourceCount)); + if (report.summary.failedScheduledTaskCount > 0) issues.push(copy.failedScheduledTasks(report.summary.failedScheduledTaskCount)); + if (report.summary.skippedScheduledTaskCount > 0) issues.push(copy.skippedScheduledTasks(report.summary.skippedScheduledTaskCount)); + return issues; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/29ea9fbb5cd1c5af9e6688e629e108aa02326779fbb4b512c27ff5ff6c13b622.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/29ea9fbb5cd1c5af9e6688e629e108aa02326779fbb4b512c27ff5ff6c13b622.source new file mode 100644 index 0000000000..8385e95e73 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/29ea9fbb5cd1c5af9e6688e629e108aa02326779fbb4b512c27ff5ff6c13b622.source @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import type { ManagedSkillCategory, SkillEntry } from './module-panel-types.js'; + +type ManagedUpdateStatus = NonNullable; + +export interface SkillsCopy { + categories: Record; + market: { + categoryAll: string; + sortName: string; + sortRecent: string; + controls: string; + categoryFilter: string; + sortAriaLabel: string; + ariaLabel: string; + importLocal: string; + emptySearchTitle: string; + emptyTitle: string; + emptySearchBody: string; + emptyBody: string; + emptyFilterBody: string; + clearSearch: string; + clearFilters: string; + sourceFallback: string; + }; + tabs: { ariaLabel: string; market: string; builtin: string; installed: string }; + install: { + action: (name: string) => string; + installedAction: (name: string) => string; + installedTitle: string; + installed: string; + notInstalled: string; + }; + builtin: { + ariaLabel: string; + emptyTitle: string; + emptyBody: string; + noMatchTitle: string; + noMatchBody: string; + fallback: string; + /** How many tools a built-in skill declares, shown as row metadata. */ + toolCount(count: number): string; + }; + installed: { + emptySearchTitle: string; + emptyTitle: string; + emptySearchBody: string; + emptyBodyBeforeCode: string; + emptyBodyAfterCode: string; + refreshPending: string; + refresh: string; + listAriaLabel: string; + }; + context: { + scope: Record<'project' | 'workspace' | 'user' | 'custom', string>; + decision: Record< + 'advertised' | 'disabled' | 'invalid' | 'host_incompatible' | 'shadowed' | 'budget', + string + >; + needsReview: string; + discoverySource: (scope: string, source: string) => string; + discoveryDiagnostic: Record<'blocked_path' | 'read_failed', string>; + }; + row: { + opening: string; + reviewing: string; + use: string; + openTitle: string; + pinTitle: string; + unpinTitle: string; + viewDiff: string; + viewUpdate: string; + confirmDeleteAriaLabel: (name: string) => string; + deleteDescription: string; + cancel: string; + delete: string; + }; + review: { + ariaLabel: string; + title: string; + source: (id: string) => string; + managedSource: string; + hasBaseline: string; + missingBaseline: string; + lineTransition: (current: number, source: number) => string; + changedLines: (count: number) => string; + warning: string; + workspace: string; + sourceVersion: string; + cancel: string; + overwrite: string; + update: string; + }; + /** Product copy for bundled skills, keyed by BUNDLED_SKILL_CATALOG id. */ + bundledDescription: Partial>; + status: { + metadataError: string; + managed: Record; + modified: string; + bundled: string; + local: string; + stateError: string; + enabled: string; + disabled: string; + }; + page: { + title: string; + toolbarAria: string; + metaInstalled: (count: number) => string; + metaUpdates: (count: number) => string; + /** How many skills the marketplace and built-in catalogs offer. */ + metaAvailable: (count: number) => string; + searchMatches: (count: number) => string; + search: string; + openFolder: string; + moreActions: string; + refreshing: string; + refresh: string; + }; + detail: { + label: string; + enabled: string; + pinned: string; + inspectorOpened: (name: string) => string; + idLabel: string; + scopeLabel: string; + sourceLabel: string; + contextLabel: string; + runtimeLabel: string; + toolsLabel: string; + pathLabel: string; + }; +} + +const SKILLS_COPY = { + 'zh-CN': { + categories: { '内容创作': '内容创作', '数据与AI': '数据与 AI', '设计与UI': '设计与 UI', 'DevOps与部署': 'DevOps 与部署', '文档与写作': '文档与写作', '效率工具': '效率工具', '研究与分析': '研究与分析' }, + market: { categoryAll: '全部分类', sortName: '排序:名称', sortRecent: '排序:最近', controls: '市场筛选与排序', categoryFilter: '按分类筛选市场技能', sortAriaLabel: '市场技能排序方式', ariaLabel: '技能市场', importLocal: '导入本地 Skill', emptySearchTitle: '没有匹配的市场技能', emptyTitle: '来源库还是空的', emptySearchBody: '换一个关键词,或清空搜索查看全部来源。', emptyBody: '导入一个含 SKILL.md 的本地文件,它会作为可安装的来源出现在这里。', emptyFilterBody: '换一个分类或关键词,或清空筛选查看全部来源。', clearSearch: '清空搜索', clearFilters: '清空筛选', sourceFallback: '本地来源库 Skill。' }, + tabs: { ariaLabel: '技能视图', market: '市场', builtin: '内置', installed: '已安装' }, + install: { action: (name) => `安装 ${name}`, installedAction: (name) => `${name} 已安装到当前工作区`, installedTitle: '已安装到当前工作区', installed: '已安装', notInstalled: '未安装' }, + builtin: { ariaLabel: '内置技能', emptyTitle: '暂无内置技能', emptyBody: '应用自带的技能会出现在这里。', noMatchTitle: '没有匹配的内置技能', noMatchBody: '换一个关键词,或清空搜索查看全部内置技能。', fallback: '应用自带 Skill。', toolCount: (count: number) => `${count} 个工具` }, + installed: { emptySearchTitle: '没有匹配的 Skill', emptyTitle: '等待添加 Skill', emptySearchBody: '换一个关键词,或清空搜索查看全部本地技能。', emptyBodyBeforeCode: '把一个含', emptyBodyAfterCode: '的文件夹放到工作区的 skills/ 目录下,刷新后会出现在这里。', refreshPending: '刷新中…', refresh: '刷新技能', listAriaLabel: '技能列表' }, + context: { scope: { project: '项目', workspace: '工作区', user: '用户', custom: '自定义' }, decision: { advertised: '已进入上下文', disabled: '已停用', invalid: '元数据无效', host_incompatible: '主机不兼容', shadowed: '被高优先级覆盖', budget: '因预算省略' }, needsReview: '待确认', discoverySource: (scope, source) => `${scope}/${source} 发现源`, discoveryDiagnostic: { blocked_path: '路径被安全策略阻止', read_failed: '来源不可读取' } }, + row: { opening: '打开中…', reviewing: '审查中…', use: '使用', openTitle: '打开 SKILL.md', pinTitle: '固定到技能上下文', unpinTitle: '取消固定', viewDiff: '查看差异', viewUpdate: '查看更新', confirmDeleteAriaLabel: (name) => `确认删除 ${name}`, deleteDescription: '此操作会删除这个 Skill 的文件,且无法撤销。', cancel: '取消', delete: '删除' }, + review: { ariaLabel: 'Skill 更新审查', title: '更新审查', source: (id) => `来源 ${id}`, managedSource: '受管理来源', hasBaseline: '已有基线', missingBaseline: '缺少基线', lineTransition: (current, source) => `${current} → ${source} 行`, changedLines: (count) => `${count} 行不同`, warning: '工作区副本已有本地修改。继续更新会用来源库版本覆盖当前 SKILL.md。', workspace: '当前工作区', sourceVersion: '来源库版本', cancel: '取消', overwrite: '覆盖本地修改', update: '更新到来源版本' }, + bundledDescription: { 'computer-use': '查看并操作本机桌面应用的界面。' }, + status: { metadataError: '元数据异常', managed: { source_missing: '来源缺失', update_available: '可更新', local_modified: '本地已修改', metadata_error: '元数据异常', up_to_date: '受管理', not_managed: '受管理' }, modified: '已修改', bundled: '内置', local: '本地', stateError: '状态异常', enabled: '已启用', disabled: '已停用' }, + page: { title: '技能', toolbarAria: '技能筛选与视图', metaInstalled: (count) => `${count} 个已安装`, metaUpdates: (count) => `${count} 个可更新`, metaAvailable: (count) => `${count} 个可安装`, searchMatches: (count) => `${count} 个匹配`, search: '搜索技能', openFolder: '打开目录', moreActions: '更多技能操作', refreshing: '刷新中…', refresh: '刷新' }, + detail: { label: '技能详情', enabled: '启用', pinned: '已固定', inspectorOpened: (name) => `已打开 ${name} 的详情`, idLabel: '标识', scopeLabel: '范围', sourceLabel: '来源', contextLabel: '上下文', runtimeLabel: '运行状态', toolsLabel: '声明工具', pathLabel: '路径' }, + }, + 'zh-TW': { + categories: { '内容创作': '內容創作', '数据与AI': '資料與 AI', '设计与UI': '設計與 UI', 'DevOps与部署': 'DevOps 與部署', '文档与写作': '文件與寫作', '效率工具': '效率工具', '研究与分析': '研究與分析' }, + market: { categoryAll: '全部分類', sortName: '排序:名稱', sortRecent: '排序:最近', controls: '市場篩選與排序', categoryFilter: '按分類篩選市場技能', sortAriaLabel: '市場技能排序方式', ariaLabel: '技能市場', importLocal: '匯入本地 Skill', emptySearchTitle: '沒有符合的市場技能', emptyTitle: '來源庫還是空的', emptySearchBody: '換一個關鍵詞,或清空搜尋檢視全部來源。', emptyBody: '匯入一個含 SKILL.md 的本地檔案,它會作為可安裝的來源出現在這裡。', emptyFilterBody: '換一個分類或關鍵詞,或清空篩選檢視全部來源。', clearSearch: '清空搜尋', clearFilters: '清空篩選', sourceFallback: '本地來源庫 Skill。' }, + tabs: { ariaLabel: '技能檢視', market: '市場', builtin: '內建', installed: '已安裝' }, + install: { action: (name) => `安裝 ${name}`, installedAction: (name) => `${name} 已安裝到目前工作區`, installedTitle: '已安裝到目前工作區', installed: '已安裝', notInstalled: '未安裝' }, + builtin: { ariaLabel: '內建技能', emptyTitle: '暫無內建技能', emptyBody: '應用自帶的技能會出現在這裡。', noMatchTitle: '沒有符合的內建技能', noMatchBody: '換一個關鍵詞,或清空搜尋檢視全部內建技能。', fallback: '應用自帶 Skill。', toolCount: (count: number) => `${count} 個工具` }, + installed: { emptySearchTitle: '沒有符合的 Skill', emptyTitle: '等待新增 Skill', emptySearchBody: '換一個關鍵詞,或清空搜尋檢視全部本地技能。', emptyBodyBeforeCode: '把一個含', emptyBodyAfterCode: '的資料夾放到工作區的 skills/ 目錄下,重新整理後會出現在這裡。', refreshPending: '重新整理中…', refresh: '重新整理技能', listAriaLabel: '技能列表' }, + context: { scope: { project: '專案', workspace: '工作區', user: '使用者', custom: '自訂' }, decision: { advertised: '已進入上下文', disabled: '已停用', invalid: '後設資料無效', host_incompatible: '主機不相容', shadowed: '被高優先順序覆蓋', budget: '因預算省略' }, needsReview: '待確認', discoverySource: (scope, source) => `${scope}/${source} 發現源`, discoveryDiagnostic: { blocked_path: '路徑被安全策略阻止', read_failed: '來源不可讀取' } }, + row: { opening: '開啟中…', reviewing: '審查中…', use: '使用', openTitle: '開啟 SKILL.md', pinTitle: '固定到技能上下文', unpinTitle: '取消固定', viewDiff: '檢視差異', viewUpdate: '檢視更新', confirmDeleteAriaLabel: (name) => `確認刪除 ${name}`, deleteDescription: '此操作會刪除這個 Skill 的檔案,且無法撤銷。', cancel: '取消', delete: '刪除' }, + review: { ariaLabel: 'Skill 更新審查', title: '更新審查', source: (id) => `來源 ${id}`, managedSource: '受管理來源', hasBaseline: '已有基線', missingBaseline: '缺少基線', lineTransition: (current, source) => `${current} → ${source} 行`, changedLines: (count) => `${count} 行不同`, warning: '工作區副本已有本地修改。繼續更新會用來源庫版本覆蓋目前 SKILL.md。', workspace: '目前工作區', sourceVersion: '來源庫版本', cancel: '取消', overwrite: '覆蓋本地修改', update: '更新到來源版本' }, + bundledDescription: { 'computer-use': '檢視並操作本機桌面應用的介面。' }, + status: { metadataError: '後設資料異常', managed: { source_missing: '來源缺失', update_available: '可更新', local_modified: '本地已修改', metadata_error: '後設資料異常', up_to_date: '受管理', not_managed: '受管理' }, modified: '已修改', bundled: '內建', local: '本地', stateError: '狀態異常', enabled: '已啟用', disabled: '已停用' }, + page: { title: '技能', toolbarAria: '技能篩選與檢視', metaInstalled: (count) => `${count} 個已安裝`, metaUpdates: (count) => `${count} 個可更新`, metaAvailable: (count) => `${count} 個可安裝`, searchMatches: (count) => `${count} 個符合`, search: '搜尋技能', openFolder: '開啟目錄', moreActions: '更多技能操作', refreshing: '重新整理中…', refresh: '重新整理' }, + detail: { label: '技能詳情', enabled: '啟用', pinned: '已固定', inspectorOpened: (name) => `已開啟 ${name} 的詳情`, idLabel: '標識', scopeLabel: '範圍', sourceLabel: '來源', contextLabel: '上下文', runtimeLabel: '執行狀態', toolsLabel: '宣告工具', pathLabel: '路徑' }, + }, + en: { + categories: { '内容创作': 'Content creation', '数据与AI': 'Data & AI', '设计与UI': 'Design & UI', 'DevOps与部署': 'DevOps & deployment', '文档与写作': 'Documents & writing', '效率工具': 'Productivity', '研究与分析': 'Research & analysis' }, + market: { categoryAll: 'All categories', sortName: 'Sort: Name', sortRecent: 'Sort: Recent', controls: 'Marketplace filters and sorting', categoryFilter: 'Filter marketplace skills by category', sortAriaLabel: 'Marketplace skill sort order', ariaLabel: 'Skill marketplace', importLocal: 'Import local Skill', emptySearchTitle: 'No matching marketplace skills', emptyTitle: 'The source library is empty', emptySearchBody: 'Try another keyword or clear search to see all sources.', emptyBody: 'Import a local file containing SKILL.md to make it available as an installable source.', emptyFilterBody: 'Try another category or keyword, or clear the filters.', clearSearch: 'Clear search', clearFilters: 'Clear filters', sourceFallback: 'Local source-library Skill.' }, + tabs: { ariaLabel: 'Skill views', market: 'Marketplace', builtin: 'Built in', installed: 'Installed' }, + install: { action: (name) => `Install ${name}`, installedAction: (name) => `${name} is installed in this workspace`, installedTitle: 'Installed in this workspace', installed: 'Installed', notInstalled: 'Not installed' }, + builtin: { ariaLabel: 'Built-in skills', emptyTitle: 'No built-in skills', emptyBody: 'Skills included with the app appear here.', noMatchTitle: 'No matching built-in skills', noMatchBody: 'Try another keyword or clear search to see all built-in skills.', fallback: 'Skill included with the app.', toolCount: (count: number) => (count === 1 ? '1 tool' : `${count} tools`) }, + installed: { emptySearchTitle: 'No matching Skills', emptyTitle: 'Waiting for a Skill', emptySearchBody: 'Try another keyword or clear search to see all local skills.', emptyBodyBeforeCode: 'Place a folder containing', emptyBodyAfterCode: 'in the workspace skills/ directory, then refresh to show it here.', refreshPending: 'Refreshing…', refresh: 'Refresh skills', listAriaLabel: 'Skill list' }, + context: { scope: { project: 'Project', workspace: 'Workspace', user: 'User', custom: 'Custom' }, decision: { advertised: 'In context', disabled: 'Disabled', invalid: 'Invalid metadata', host_incompatible: 'Host incompatible', shadowed: 'Shadowed', budget: 'Budget omitted' }, needsReview: 'Needs review', discoverySource: (scope, source) => `${scope}/${source} discovery source`, discoveryDiagnostic: { blocked_path: 'Path blocked by the safety policy', read_failed: 'Source could not be read' } }, + row: { opening: 'Opening…', reviewing: 'Reviewing…', use: 'Use', openTitle: 'Open SKILL.md', pinTitle: 'Pin to the skill context', unpinTitle: 'Unpin', viewDiff: 'View diff', viewUpdate: 'View update', confirmDeleteAriaLabel: (name) => `Delete ${name}?`, deleteDescription: 'This removes the Skill files and cannot be undone.', cancel: 'Cancel', delete: 'Delete' }, + review: { ariaLabel: 'Skill update review', title: 'Update review', source: (id) => `Source ${id}`, managedSource: 'Managed source', hasBaseline: 'Baseline available', missingBaseline: 'No baseline', lineTransition: (current, source) => `${current} → ${source} lines`, changedLines: (count) => `${count} ${count === 1 ? 'line differs' : 'lines differ'}`, warning: 'The workspace copy has local changes. Continuing will replace the current SKILL.md with the source version.', workspace: 'Current workspace', sourceVersion: 'Source version', cancel: 'Cancel', overwrite: 'Overwrite local changes', update: 'Update to source version' }, + bundledDescription: { 'computer-use': 'Inspect and operate local desktop app interfaces.' }, + status: { metadataError: 'Metadata error', managed: { source_missing: 'Source missing', update_available: 'Update available', local_modified: 'Locally modified', metadata_error: 'Metadata error', up_to_date: 'Managed', not_managed: 'Managed' }, modified: 'Modified', bundled: 'Built in', local: 'Local', stateError: 'State error', enabled: 'Enabled', disabled: 'Disabled' }, + page: { title: 'Skills', toolbarAria: 'Skill filters and views', metaInstalled: (count) => `${count} installed`, metaUpdates: (count) => count === 1 ? '1 update available' : `${count} updates available`, metaAvailable: (count) => count === 1 ? '1 available to install' : `${count} available to install`, searchMatches: (count) => `${count} ${count === 1 ? 'match' : 'matches'}`, search: 'Search skills', openFolder: 'Open folder', moreActions: 'More Skill actions', refreshing: 'Refreshing…', refresh: 'Refresh' }, + detail: { label: 'Skill details', enabled: 'Enabled', pinned: 'Pinned', inspectorOpened: (name) => `${name} details opened`, idLabel: 'ID', scopeLabel: 'Scope', sourceLabel: 'Source', contextLabel: 'Context', runtimeLabel: 'Runtime', toolsLabel: 'Declared tools', pathLabel: 'Path' }, + }, +} satisfies UiCatalog; + +export function getSkillsCopy(locale: UiLocale): SkillsCopy { + return SKILLS_COPY[locale]; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2a46d13b979cb083654c4e80ca4ce8d905fc49cb148463dbbfc5ee49188651e8.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2a46d13b979cb083654c4e80ca4ce8d905fc49cb148463dbbfc5ee49188651e8.source new file mode 100644 index 0000000000..7d749cd1a4 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2a46d13b979cb083654c4e80ca4ce8d905fc49cb148463dbbfc5ee49188651e8.source @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DirectoryReference } from '@maka/core/events'; +import { Token, Tooltip } from '@astryxdesign/core'; +import { FolderOpen, ICON_SIZE } from './icons.js'; + +/** The same reference chip before and after send; a path is not a saved attachment. */ +export function DirectoryReferenceChip(props: { + reference: DirectoryReference; + onRemove?(): void; +}) { + const path = props.reference.path; + const label = path.split(/[\\/]/).filter(Boolean).at(-1) ?? path; + return ( + + + ); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2abb26767a8e043226b8dd40348e3d9af676edb8f3630f833de039e3b5e971f1.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2abb26767a8e043226b8dd40348e3d9af676edb8f3630f833de039e3b5e971f1.source new file mode 100644 index 0000000000..87f4eaea08 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2abb26767a8e043226b8dd40348e3d9af676edb8f3630f833de039e3b5e971f1.source @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SandboxBoundaryRequestEvent } from '@maka/core/events'; +import { useEffect, useId, useRef, useState } from 'react'; + +import { getConversationCopy } from './conversation-copy.js'; +import { useUiLocale } from './locale-context.js'; +import { Button } from '@astryxdesign/core'; +import { useMountedRef } from './use-mounted-ref.js'; + +export interface SandboxBoundaryPromptProps { + request: SandboxBoundaryRequestEvent; + onRespond(response: { requestId: string; decision: 'allow' | 'deny' }): void | Promise; +} + +export function SandboxBoundaryPrompt({ + request, + onRespond, +}: SandboxBoundaryPromptProps) { + const copy = getConversationCopy(useUiLocale()).sandboxBoundary; + const titleId = useId(); + const [responsePending, setResponsePending] = useState(false); + const responsePendingRef = useRef(false); + const activeRequestIdRef = useRef(request.requestId); + const rejectButtonRef = useRef(null); + const mountedRef = useMountedRef(); + + useEffect(() => { + activeRequestIdRef.current = request.requestId; + responsePendingRef.current = false; + setResponsePending(false); + const frame = window.requestAnimationFrame(() => rejectButtonRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [request.requestId]); + + async function respond(decision: 'allow' | 'deny'): Promise { + if (responsePendingRef.current) return; + const requestId = request.requestId; + responsePendingRef.current = true; + setResponsePending(true); + try { + await onRespond({ requestId, decision }); + } finally { + if (activeRequestIdRef.current === requestId) { + responsePendingRef.current = false; + if (mountedRef.current) setResponsePending(false); + } + } + } + + const entries = request.expansion.filesystem?.entries ?? []; + return ( +
+
+
+

{copy.title}

+

{request.justification}

+
+
    + {entries.map((entry) => ( +
  • + {entry.path} + + {copy.access[entry.access]} · {copy.scope[entry.scope]} + +
  • + ))} + {request.expansion.network?.enabled ? ( +
  • + {copy.network} + {copy.enabled} +
  • + ) : null} +
+
+
+
+
+ ); +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ad5ab6567176e491769da4ccfc07769a8d32d2ad572031ebb9876a561e4a524.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ad5ab6567176e491769da4ccfc07769a8d32d2ad572031ebb9876a561e4a524.source new file mode 100644 index 0000000000..376760932a --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ad5ab6567176e491769da4ccfc07769a8d32d2ad572031ebb9876a561e4a524.source @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + isInteractionFormFieldValueValid, + type InteractionFormField, + type InteractionFormResponse, + type InteractionFormValue, +} from '@maka/core/interaction'; +import type { FormRequestEvent } from '@maka/core/events'; + +export interface InteractionFormFieldDraft { + /** Optional fields need an explicit presence bit so omitted and false/empty stay distinct. */ + readonly included: boolean; + readonly value: string | boolean | readonly string[]; +} + +export function createInteractionFormDrafts( + fields: readonly InteractionFormField[], +): InteractionFormFieldDraft[] { + return fields.map((field) => ({ + included: field.required || field.default !== undefined, + value: initialDraftValue(field), + })); +} + +export function interactionFormFieldDraftIsValid( + field: InteractionFormField, + draft: InteractionFormFieldDraft, +): boolean { + if (!draft.included) return !field.required; + const value = interactionFormDraftValue(field, draft); + return value !== undefined && isInteractionFormFieldValueValid(field, value); +} + +export function buildInteractionFormResponse( + request: FormRequestEvent, + drafts: readonly InteractionFormFieldDraft[], +): InteractionFormResponse | null { + const entries: Array<[string, InteractionFormValue]> = []; + for (const [index, field] of request.fields.entries()) { + const draft = drafts[index]; + if (!draft || !interactionFormFieldDraftIsValid(field, draft)) return null; + if (!draft.included) continue; + const value = interactionFormDraftValue(field, draft); + if (value === undefined) return null; + entries.push([field.name, value]); + } + return { requestId: request.requestId, action: 'accept', values: Object.fromEntries(entries) }; +} + +function initialDraftValue(field: InteractionFormField): InteractionFormFieldDraft['value'] { + if (field.default !== undefined) { + if (field.kind === 'number' || field.kind === 'integer') return String(field.default); + return field.default; + } + if (field.kind === 'boolean') return false; + if (field.kind === 'multi_select') return []; + return ''; +} + +function interactionFormDraftValue( + field: InteractionFormField, + draft: InteractionFormFieldDraft, +): InteractionFormValue | undefined { + if (field.kind === 'number' || field.kind === 'integer') { + if (typeof draft.value !== 'string' || draft.value.trim().length === 0) return undefined; + const value = Number(draft.value); + return Number.isFinite(value) ? value : undefined; + } + if (field.kind === 'boolean') return typeof draft.value === 'boolean' ? draft.value : undefined; + if (field.kind === 'multi_select') return Array.isArray(draft.value) ? draft.value : undefined; + return typeof draft.value === 'string' ? draft.value : undefined; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2c6a406d6db9d8b16a06607b75647989f96699971978bb0bf5a2d1de30b63ae0.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2c6a406d6db9d8b16a06607b75647989f96699971978bb0bf5a2d1de30b63ae0.source new file mode 100644 index 0000000000..df3208c456 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2c6a406d6db9d8b16a06607b75647989f96699971978bb0bf5a2d1de30b63ae0.source @@ -0,0 +1,269 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { TurnView } from '../chat-turn.js'; +import { + SessionAttachmentProvider, + type ReadAttachmentBytes, +} from '../attachment-image.js'; +import { LocaleProvider } from '../locale-context.js'; +import { MarkdownBody } from '../markdown-body.js'; +import type { TurnViewModel } from '../materialize.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +async function renderAttachmentMarkdown(text: string, readBytes: ReadAttachmentBytes) { + const { container, root } = domRoot(); + await act(async () => { + root.render( + + + , + ); + }); + return { container, root }; +} + +const TURN_WITH_IMAGE: TurnViewModel = { + turnId: 'turn-1', + status: 'completed', + user: { + id: 'ask', + role: 'user', + text: 'show this', + ts: 1, + attachments: [{ + kind: 'image', + name: 'preview.png', + mimeType: 'image/png', + bytes: 3, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-123' }, + }], + }, + tools: [], + notes: [], + startedAt: 1, + timeline: [], +}; + +test('renders a user thumbnail admitted by the shared preview policy', async () => { + const { container, root } = domRoot(); + await act(async () => { + root.render( + + ({ ok: true, base64: 'aW1n', mimeType: 'image/png' })} + > + + + , + ); + }); + + const image = container.querySelector('.maka-user-attachment-thumbnail img'); + assert.ok(image); + assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n'); +}); + +test('rejects an oversized user thumbnail before reading it', async () => { + const { container, root } = domRoot(); + let reads = 0; + await act(async () => { + root.render( + + { + reads += 1; + return { ok: false, reason: 'not_found' }; + }} + > + + + , + ); + }); + + assert.equal(container.querySelector('.maka-user-attachment-thumbnail img'), null); + assert.equal(reads, 0); +}); + +test('renders a session attachment referenced by assistant Markdown', async () => { + let readRef: { sessionId: string; artifactId: string } | undefined; + const { container } = await renderAttachmentMarkdown( + '![preview](maka://runtime/attachments/attachment-123)', + async (sessionId, artifactId) => { + readRef = { sessionId, artifactId }; + return { ok: true, base64: 'aW1n', mimeType: 'image/png' }; + }, + ); + + const image = container.querySelector('img[alt="preview"]'); + assert.ok(image); + assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n'); + assert.deepEqual(readRef, { sessionId: 'session-1', artifactId: 'attachment-123' }); +}); + +test('keeps unreadable assistant attachments as named placeholders', async () => { + const cases: Array<[string, ReadAttachmentBytes]> = [ + ['missing', async () => ({ ok: false, reason: 'not_found' })], + ['document', async () => ({ ok: true, base64: 'cGRm', mimeType: 'application/pdf' })], + [ + 'large', + async () => ({ + ok: true, + base64: 'a'.repeat(3 * 1024 * 1024), + mimeType: 'image/png', + }), + ], + ]; + for (const [name, readBytes] of cases) { + const { container } = await renderAttachmentMarkdown( + `![${name}](maka://runtime/attachments/attachment-${name})`, + readBytes, + ); + assert.equal(container.querySelector('img'), null); + assert.ok(container.textContent.includes(`[${name}]`)); + } +}); + +test('shares one attachment read across repeated Markdown image refs', async () => { + let reads = 0; + const { container } = await renderAttachmentMarkdown( + [ + '![first](maka://runtime/attachments/attachment-123)', + '![second](maka://runtime/attachments/attachment-123)', + ].join('\n\n'), + async () => { + reads += 1; + return { ok: true, base64: 'aW1n', mimeType: 'image/png' }; + }, + ); + + assert.equal(container.querySelectorAll('img').length, 2); + assert.equal(reads, 1); +}); + +test('retries an attachment image after a transient read failure', async () => { + const markdown = '![preview](maka://runtime/attachments/attachment-123)'; + let reads = 0; + const readBytes: ReadAttachmentBytes = async () => { + reads += 1; + return reads === 1 + ? { ok: false, reason: 'read_failed' } + : { ok: true, base64: 'cmVjb3ZlcmVk', mimeType: 'image/png' }; + }; + const { container, root } = await renderAttachmentMarkdown(markdown, readBytes); + assert.equal(container.querySelector('img'), null); + await act(async () => { + root.render( + + + , + ); + }); + + const image = container.querySelector('img[alt="preview"]'); + assert.ok(image); + assert.equal(image.getAttribute('src'), 'data:image/png;base64,cmVjb3ZlcmVk'); + assert.equal(reads, 2); +}); + +test('renders an attachment when a streaming Markdown image becomes complete', async () => { + const { container, root } = domRoot(); + const markdown = '![preview](maka://runtime/attachments/attachment-123)'; + const readBytes: ReadAttachmentBytes = async () => ({ + ok: true, + base64: 'c3RyZWFt', + mimeType: 'image/png', + }); + await act(async () => { + root.render( + + + , + ); + }); + assert.equal(container.querySelector('img'), null); + + await act(async () => { + root.render( + + + , + ); + }); + + const image = container.querySelector('img[alt="preview"]'); + assert.ok(image); + assert.equal(image.getAttribute('src'), 'data:image/png;base64,c3RyZWFt'); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d796f382af17681fb172aedba663ab5fcaf203a392d2fb88d3f53a1bb46579b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d796f382af17681fb172aedba663ab5fcaf203a392d2fb88d3f53a1bb46579b.source new file mode 100644 index 0000000000..0bfbda1d24 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d796f382af17681fb172aedba663ab5fcaf203a392d2fb88d3f53a1bb46579b.source @@ -0,0 +1,706 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { StoredMessage } from '@maka/core/session'; +import { + materializeChat, + materializeTools, + materializeTurns, + overlayLiveTurn, + type TurnTimelineItem, +} from "../materialize.js"; +import { applyLiveTurnEvent } from './live-turn-zh.js'; +import { armLiveTurn } from "../live-turn-projection.js"; + +const originalUser = { + type: "user" as const, + id: "original", + turnId: "t1", + ts: 1, + text: "request", +}; +const beforeAssistant = { + type: "assistant" as const, + id: "before-steer", + turnId: "t1", + ts: 2, + text: "before", + modelId: "fixture", +}; +const steeringUser = { + type: "user" as const, + id: "steer-1", + turnId: "t1", + ts: 3, + text: "steer", +}; + +function timelineText(turn: ReturnType[number] | undefined): string[] { + return turn?.timeline.map((item) => + item.kind === "user" ? `user:${item.message.text}` : `${item.kind}:${"text" in item ? item.text : ""}`, + ) ?? []; +} + +describe("steering timeline", () => { + test("keeps a steering message at its conversational position", () => { + const [turn] = materializeTurns([ + originalUser, + beforeAssistant, + steeringUser, + { + type: "assistant", + id: "after-steer", + turnId: "t1", + ts: 4, + text: "after", + modelId: "fixture", + }, + ], "en"); + + assert.deepEqual(timelineText(turn), [ + "text:before", + "user:steer", + "text:after", + ]); + }); + + test("renders one live steering message while its persisted row catches up", () => { + const settled = materializeTurns([originalUser], "en"); + const before = applyLiveTurnEvent(armLiveTurn("t1"), { + type: "text_complete", + id: "event-before", + messageId: "before-steer", + turnId: "t1", + ts: 1, + text: "before", + }); + const live = applyLiveTurnEvent(before, { + type: "steering_message", + id: "event-steer", + messageId: "steer-1", + turnId: "t1", + ts: 2, + content: { text: "inserted instruction" }, + }); + + const [overlaid] = overlayLiveTurn(settled, live, "en"); + assert.deepEqual(timelineText(overlaid), ["text:before", "user:inserted instruction"]); + + const persisted = materializeTurns([ + originalUser, + beforeAssistant, + { type: "user", id: "steer-1", turnId: "t1", ts: 2, text: "inserted instruction" }, + ], "en"); + const [deduplicated] = overlayLiveTurn(persisted, live, "en"); + assert.deepEqual(timelineText(deduplicated), ["text:before", "user:inserted instruction"]); + }); + + test("keeps the current answer ahead of a durable steering event that arrives first", () => { + const persisted = materializeTurns([ + originalUser, + { + type: "user", + id: "steer-1", + turnId: "t1", + ts: 2, + text: "inserted instruction", + steeringEventId: "event-steer", + }, + ], "en"); + const live = applyLiveTurnEvent(armLiveTurn("t1"), { + type: "text_delta", + id: "event-before", + messageId: "before-steer", + turnId: "t1", + ts: 1, + text: "before", + }); + + const [overlaid] = overlayLiveTurn(persisted, live, "en"); + + assert.deepEqual(timelineText(overlaid), ["text:before", "user:inserted instruction"]); + }); + + test("keeps a persisted tool before live steering during handoff", () => { + const persisted = materializeTurns([ + originalUser, + { + type: "tool_call", + id: "tool-1", + turnId: "t1", + stepId: "tool-step", + ts: 2, + toolName: "Read", + args: {}, + }, + steeringUser, + ], "en"); + const tool = applyLiveTurnEvent(armLiveTurn("t1"), { + type: "tool_start", + id: "tool-event", + turnId: "t1", + stepId: "tool-step", + toolUseId: "tool-1", + toolName: "Read", + args: {}, + ts: 2, + }); + const steering = applyLiveTurnEvent(tool, { + type: "steering_message", + id: "steer-event", + messageId: "steer-1", + turnId: "t1", + ts: 3, + content: { text: "steer" }, + }); + const live = applyLiveTurnEvent(steering, { + type: "text_delta", + id: "text-event", + messageId: "after-steer", + turnId: "t1", + ts: 4, + text: "after", + }); + + const [overlaid] = overlayLiveTurn(persisted, live, "en"); + assert.deepEqual(timelineText(overlaid), ["tools:", "user:steer", "text:after"]); + assert.deepEqual( + overlaid?.timeline.flatMap((item) => + item.kind === "tools" ? item.items.map((tool) => tool.toolUseId) : []), + ["tool-1"], + ); + }); +}); + +describe("materializeChat message metadata", () => { + test("localizes visible system notes", () => { + const messages: StoredMessage[] = [ + { + type: "system_note", + id: "note-1", + turnId: "t1", + ts: 1, + kind: "context_compacted", + }, + ]; + + assert.equal( + materializeChat(messages, "en")[0]?.text, + "Earlier context compacted.", + ); + assert.equal( + materializeChat(messages, "zh-CN")[0]?.text, + "已压缩较早的上下文。", + ); + assert.equal( + materializeTurns(messages, "zh-CN")[0]?.notes[0]?.text, + "已压缩较早的上下文。", + ); + }); + + test("preserves an explicit empty reference projection as the new-format marker", () => { + const messages: StoredMessage[] = [ + { + type: "user", + id: "m-empty", + turnId: "t-empty", + ts: 1, + text: "plain text", + inlineReferences: [], + }, + ]; + assert.deepEqual(materializeChat(messages, "en")[0]?.inlineReferences, []); + assert.deepEqual(materializeTurns(messages, "en")[0]?.user?.inlineReferences, []); + }); + + test("preserves Host provenance on a Goal continuation", () => { + const messages: StoredMessage[] = [ + { + type: "user", + id: "m1", + turnId: "t1", + ts: 1, + text: "[Goal continuation] Keep working.", + origin: { kind: "goal", goalId: "goal-1" }, + }, + ]; + + assert.deepEqual(materializeChat(messages, "en")[0]?.hostOrigin, { + kind: "goal", + goalId: "goal-1", + }); + assert.deepEqual(materializeTurns(messages, "en")[0]?.user?.hostOrigin, { + kind: "goal", + goalId: "goal-1", + }); + }); +}); + +// ── #1307: the timeline model stays flat (fold is a render concern) ────────── + +function userMsg(turnId: string, ts: number, text: string): StoredMessage { + return { type: "user", id: `u-${turnId}`, turnId, ts, text }; +} + +test('retains persisted nested tool activity identity', () => { + const [tool] = materializeTools([{ + type: 'tool_call', + id: 'nested-1', + turnId: 'turn-1', + ts: 1, + toolName: 'Read', + args: { path: 'README.md' }, + origin: 'code_mode', + modelVisibility: 'hidden', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-operation-1', + }]); + + assert.deepEqual(tool, { + toolUseId: 'nested-1', + toolName: 'Read', + activityKind: undefined, + displayName: undefined, + intent: undefined, + status: 'interrupted', + args: { path: 'README.md' }, + result: undefined, + durationMs: undefined, + origin: 'code_mode', + modelVisibility: 'hidden', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-operation-1', + }); +}); + +function shellRunResult(revision: number) { + return { + kind: "shell_run" as const, + ref: "maka://runtime/background-tasks/pty-1", + mode: "pty" as const, + status: "running" as const, + cwd: "/repo", + cmd: "job", + startedAt: 1, + updatedAt: revision, + revision, + output: { + mode: "pty" as const, + screen: "ready", + scrollback: "", + cols: 80, + rows: 24, + cursor: { x: 0, y: 0, visible: true }, + alternateScreen: false, + truncated: false, + redacted: false, + }, + }; +} + +describe("flat timeline under tool projection (#1307 P1 regression)", () => { + test("shell-run folding away a turn’s only tool leaves a flat thinking-only timeline", () => { + // Turn t1 owns the Bash ShellRun parent; the live turn t2's ONLY tool is a + // Read carrying a shell_run result with the same ref, so foldShellRunTurns + // merges it into t1's Bash and drops it from t2 entirely. With the fold + // living in the model this used to strand an illegal thinking-only + // "processing" block with an empty summary; the flat model simply drops + // the emptied tools group. + const settled = materializeTurns([ + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 1, + toolName: "Bash", + args: { command: "job", pty: true }, + }, + { + type: "tool_result", + id: "r-bash-1", + turnId: "t1", + ts: 2, + toolUseId: "bash-1", + isError: false, + content: shellRunResult(1), + }, + userMsg("t2", 3, "q"), + ], "en"); + const turns = overlayLiveTurn(settled, { + turnId: "t2", + phase: "streamed", + steps: [ + { + stepId: "a1", + thinking: { + text: "watching the background job", + truncated: false, + complete: false, + }, + tools: [ + { + toolUseId: "read-1", + toolName: "Read", + stepId: "a1", + status: "completed", + args: {}, + result: shellRunResult(2), + }, + ], + }, + ], + }, "en"); + const liveTurn = turns.find((turn) => turn.turnId === "t2"); + assert.deepEqual( + liveTurn?.timeline.map((item: TurnTimelineItem) => item.kind), + ["thinking"], + ); + }); +}); + +describe("live content over persisted partial rows", () => { + test("does not create an empty renderer turn for a waiting send", () => { + assert.deepEqual(overlayLiveTurn([], armLiveTurn("t1"), "en"), []); + }); + + test("replaces persisted thinking with its live projection instead of rendering it twice", () => { + const settled = materializeTurns([ + userMsg("t1", 1, "inspect it"), + { + type: "turn_state", + id: "state-1", + turnId: "t1", + ts: 2, + status: "running", + }, + { + type: "assistant", + id: "assistant-1", + turnId: "t1", + ts: 3, + text: "", + modelId: "test-model", + thinking: { text: "persisted partial" }, + contentOrder: ["thinking"], + }, + ], "en"); + const turns = overlayLiveTurn(settled, { + turnId: "t1", + phase: "streamed", + steps: [ + { + stepId: "assistant-1", + thinking: { + text: "complete live reasoning", + truncated: false, + complete: false, + }, + contentOrder: ["thinking"], + tools: [], + }, + ], + }, "en"); + const thinking = turns[0]?.timeline.filter( + (item) => item.kind === "thinking", + ); + + assert.equal(thinking?.length, 1); + assert.equal( + thinking?.[0]?.kind === "thinking" ? thinking[0].text : undefined, + "complete live reasoning", + ); + }); +}); + +describe("unfinished tools take their status from the turn", () => { + // A missing tool_result is the absence of evidence, not evidence of a + // terminal state. The turn record says which: a running turn means the call + // is still in flight. This is the persisted-only path — no live projection — + // which is what a reader sees after a renderer reload or when it re-attaches + // to a session running in the background. + test("reads an unfinished call in a running turn as running", () => { + const [turn] = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "running", + }, + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 3, + toolName: "Bash", + args: { command: "sleep 600" }, + }, + ], "en"); + assert.equal(turn?.status, "running"); + assert.equal(turn?.tools[0]?.status, "running"); + }); + + test("reads an unfinished call in a terminal turn as interrupted", () => { + const [turn] = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "failed", + }, + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 3, + toolName: "Bash", + args: { command: "sleep 600" }, + }, + ], "en"); + assert.equal(turn?.tools[0]?.status, "interrupted"); + }); +}); + +describe("live tool status over persisted", () => { + // Runtime appends turn_state when the turn opens, before any tool_call, so a + // turn that can have a live projection always has one on disk. + test("keeps a still-running tool running when persisted has no result yet", () => { + const settled = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "running", + }, + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 3, + toolName: "Bash", + args: { command: "sleep 60" }, + }, + ], "en"); + const turns = overlayLiveTurn(settled, { + turnId: "t1", + phase: "streamed", + steps: [ + { + stepId: "a1", + tools: [ + { + toolUseId: "bash-1", + toolName: "Bash", + stepId: "a1", + status: "running", + args: { command: "sleep 60" }, + }, + ], + }, + ], + }, "en"); + const tools = turns + .find((turn) => turn.turnId === "t1") + ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); + assert.equal( + tools?.kind === "tools" ? tools.items[0]?.status : undefined, + "running", + ); + }); + + test("a stale live running loses to a terminal turn", () => { + const settled = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "failed", + }, + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 3, + toolName: "Bash", + args: { command: "sleep 60" }, + }, + ], "en"); + const turns = overlayLiveTurn(settled, { + turnId: "t1", + phase: "streamed", + steps: [ + { + stepId: "a1", + tools: [ + { + toolUseId: "bash-1", + toolName: "Bash", + stepId: "a1", + status: "running", + args: { command: "sleep 60" }, + outputChunks: [ + { + seq: 0, + stream: "stdout", + text: "partial output", + redacted: false, + createdAt: 4, + }, + ], + }, + ], + }, + ], + }, "en"); + const tools = turns + .find((turn) => turn.turnId === "t1") + ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); + const tool = tools?.kind === "tools" ? tools.items[0] : undefined; + assert.equal(tool?.status, "interrupted"); + assert.equal(tool?.outputChunks?.length, 1); + }); + + test("keeps durable tool detail while a Runtime Host Turn is still live", () => { + const settled = materializeTurns([ + userMsg("t1", 1, "use the computer"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "running", + }, + { + type: "tool_call", + id: "computer-1", + turnId: "t1", + ts: 3, + toolName: "maka_computer", + args: { action: "click_element", element_id: "615" }, + }, + { + type: "tool_result", + id: "result-1", + turnId: "t1", + ts: 4, + toolUseId: "computer-1", + isError: false, + content: { kind: "text", text: "unsupported_action" }, + }, + ], "en"); + + const live = applyLiveTurnEvent(undefined, { + type: "tool_start", + id: "start-1", + turnId: "t1", + toolUseId: "computer-1", + toolName: "maka_computer", + args: undefined, + ts: 5, + }); + const turns = overlayLiveTurn(settled, live, "en"); + + const toolGroup = turns + .find((turn) => turn.turnId === "t1") + ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); + const tool = toolGroup?.kind === "tools" ? toolGroup.items[0] : undefined; + assert.deepEqual(tool?.args, { + action: "click_element", + element_id: "615", + }); + assert.deepEqual(tool?.result, { + kind: "text", + text: "unsupported_action", + }); + assert.equal(tool?.status, "running"); + }); + + // Deleting the merge exception rests entirely on the live side carrying its + // own interrupted signal, so drive the real chain — a tool_start followed by + // an abort — rather than hand-building an already-interrupted projection, + // which would pass on the spread alone. + test("an aborted turn interrupts its in-flight tool through the live chain", () => { + const settled = materializeTurns([ + userMsg("t1", 1, "run it"), + { + type: "turn_state", + id: "s1", + turnId: "t1", + ts: 2, + status: "running", + }, + { + type: "tool_call", + id: "bash-1", + turnId: "t1", + ts: 3, + toolName: "Bash", + args: { command: "sleep 60" }, + }, + ], "en"); + const started = applyLiveTurnEvent(armLiveTurn("t1"), { + type: "tool_start", + id: "event-1", + turnId: "t1", + toolUseId: "bash-1", + toolName: "Bash", + args: { command: "sleep 60" }, + ts: 4, + }); + const running = applyLiveTurnEvent(started, { + type: "tool_output_delta", + id: "event-2", + turnId: "t1", + sessionId: "s", + toolCallId: "bash-1", + toolUseId: "bash-1", + seq: 0, + stream: "stdout", + chunk: "still going\n", + redacted: false, + createdAt: 5, + ts: 5, + }); + assert.equal(running?.steps[0]?.tools[0]?.status, "running"); + + const aborted = applyLiveTurnEvent(running, { + type: "abort", + id: "event-3", + turnId: "t1", + reason: "user_stop", + ts: 6, + }); + const tools = overlayLiveTurn(settled, aborted!, "en") + .find((turn) => turn.turnId === "t1") + ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); + assert.equal( + tools?.kind === "tools" ? tools.items[0]?.status : undefined, + "interrupted", + ); + }); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d98ef2486c54c77f8a58d27babbf5d1cb1ea7fdfc469835ed1e950e07420cdd.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d98ef2486c54c77f8a58d27babbf5d1cb1ea7fdfc469835ed1e950e07420cdd.source new file mode 100644 index 0000000000..9fcf749ac1 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2d98ef2486c54c77f8a58d27babbf5d1cb1ea7fdfc469835ed1e950e07420cdd.source @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createContext, + useContext, + useLayoutEffect, + type ReactNode, +} from 'react'; +import type { UiLocale } from '@maka/core/ui-locale'; + +const UiLocaleContext = createContext(undefined); + +export function syncUiLocaleDocument( + locale: UiLocale, + override?: UiLocale | null, +): void { + if (typeof document === 'undefined') return; + + const root = document.documentElement; + root.setAttribute('lang', locale); + root.setAttribute('data-maka-locale', locale); + if (override) { + root.setAttribute('data-maka-e2e-fixture-locale', override); + } else { + root.removeAttribute('data-maka-e2e-fixture-locale'); + } +} + +export function LocaleProvider(props: { + locale: UiLocale; + override?: UiLocale | null; + children: ReactNode; +}) { + useLayoutEffect(() => { + syncUiLocaleDocument(props.locale, props.override); + }, [props.locale, props.override]); + + return ( + + {props.children} + + ); +} + +export function useUiLocale(): UiLocale { + const locale = useContext(UiLocaleContext); + if (!locale) { + throw new Error('useUiLocale must be used within LocaleProvider'); + } + return locale; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2de7ad4c6cd4c988d31c720489eb39122f6d4b3b650d6edd3b94ace8fcd06573.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2de7ad4c6cd4c988d31c720489eb39122f6d4b3b650d6edd3b94ace8fcd06573.source new file mode 100644 index 0000000000..08f0e06fa2 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2de7ad4c6cd4c988d31c720489eb39122f6d4b3b650d6edd3b94ace8fcd06573.source @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useContext, useMemo, type ReactNode } from 'react'; +import { + InternationalizationContext, + InternationalizationProvider, +} from '@astryxdesign/core/i18n'; +import type { Overrides } from '@astryxdesign/core/i18n'; +import { getSharedUiCopy } from './shared-ui-copy.js'; +import { ASTRYX_COPY_ZH, ASTRYX_COPY_ZH_TW } from './astryx-copy.js'; +import { useUiLocale } from './locale-context.js'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +/** + * Astryx ships no `zh` message catalog: its built-in strings ("Copy code", + * "Task list", "(opens in new tab)", checkbox/table ARIA names) render in + * English. On a Chinese-first product every Astryx component we adopt would + * otherwise leak English into the accessibility tree. + * + * This provider sits at the renderer root so EVERY Astryx subtree inherits the + * catalog — scoping it per feature does not scale: each new slice would have + * to remember to re-wrap. Overrides are keyed off our own shared copy + * catalogue, so translations keep one home. + * + * The map covers the components whose copy sources exist today. A slice that + * adopts a new Astryx surface appends its `@astryx.*` keys here in the same + * PR that adds the copy they resolve from (the Markdown catalog lands with + * PR 7, for example) — an override for a component nothing renders is dead + * config, not coverage. + * + * `en` needs no overrides — it resolves to Astryx's shipped defaults. + * + * `@astryx.field.required` / `@astryx.field.optional` are the one pair Astryx + * does not ship: upstream hard-codes those two words in `FieldLabel`. The keys + * exist because `patches/@astryxdesign+core+0.2.0.patch` routes the marker + * through this catalog — see that patch's entry in `patches/README.md`. + */ +export function AstryxLocaleProvider({ + children, + overrides: scopedOverrides, +}: { + children: ReactNode; + overrides?: Record; +}) { + const locale = useUiLocale(); + const ambient = useContext(InternationalizationContext); + // Referentially stable per locale: the provider memoises its context value + // on the overrides object, so a fresh map every render would re-render + // every Astryx i18n consumer on every AppShell render. + const overrides = useMemo(() => { + const base = astryxMessageOverrides(locale)[locale]; + const inherited = ambient.locale === locale + ? ambient.overrides?.[locale] + : undefined; + const merged = { ...base, ...inherited, ...scopedOverrides }; + if (Object.keys(merged).length === 0) return undefined; + return { + ...(ambient.locale === locale ? ambient.overrides : undefined), + [locale]: merged, + }; + }, [ambient.locale, ambient.overrides, locale, scopedOverrides]); + const inheritAmbient = ambient.locale === locale; + return ( + + {children} + + ); +} + +const OVERRIDES_BY_LOCALE = { + // The drawer aria-label also serves as a tooltip; all other English copy uses Astryx defaults. + en: { + en: { + '@astryx.chatComposerDrawer.collapse': 'Click to collapse {label}', + '@astryx.chatComposerDrawer.expand': 'Click to expand {label}', + }, + }, + 'zh-CN': chineseOverrides('zh-CN', ASTRYX_COPY_ZH), + 'zh-TW': chineseOverrides('zh-TW', ASTRYX_COPY_ZH_TW), +} satisfies UiCatalog; + +export function astryxMessageOverrides(locale: UiLocale): Overrides { + return OVERRIDES_BY_LOCALE[locale]; +} + +// The catalogues live off-barrel in astryx-copy.ts because nothing outside +// this map consumes them. +function chineseOverrides(locale: 'zh-CN' | 'zh-TW', astryx: typeof ASTRYX_COPY_ZH): Overrides { + const shared = getSharedUiCopy(locale); + const form = shared.formControls; + return { + [locale]: { + '@astryx.codeBlock.copyCode': shared.markdown.copyCode, + '@astryx.codeBlock.copied': shared.markdown.copiedCode, + '@astryx.codeBlock.code': shared.markdown.code, + '@astryx.markdown.taskList': shared.markdown.taskList, + '@astryx.markdown.table': shared.markdown.table, + '@astryx.checkboxList.item.checkbox': shared.markdown.checkbox, + '@astryx.link.newTab': shared.markdown.opensInNewTab, + '@astryx.dialog.close': shared.primitives.close, + '@astryx.resizable.handle.label': shared.primitives.resizeHandle, + '@astryx.popover.close': shared.primitives.close, + '@astryx.toast.dismiss': shared.toast.closeNotification, + '@astryx.toast.viewport': shared.toast.notifications, + '@astryx.field.required': form.required, + '@astryx.field.optional': form.optional, + '@astryx.selector.placeholder': form.selectPlaceholder, + '@astryx.selector.clearLabel': form.clear, + '@astryx.numberInput.clearLabel': form.clear, + + // App shell — the skip link is always the first focusable control, so an + // untranslated fallback pollutes every Chinese Computer Use observation. + '@astryx.appShell.mobileNavigation': astryx.appShell.mobileNavigation, + '@astryx.appShell.skipToContent': astryx.appShell.skipToContent, + + // Chat — the transcript, composer and scroll affordances Astryx owns + // since #1795 moved the chat surfaces onto ChatLayout. + '@astryx.chat.composer.placeholder': astryx.chat.composerPlaceholder, + '@astryx.chat.composerDrawer.label': astryx.chat.composerDrawerLabel, + '@astryx.chat.composerInput.label': astryx.chat.composerInputLabel, + '@astryx.chat.messageAriaLabel': astryx.chat.messageAriaLabel, + '@astryx.chat.pastedText.expand': astryx.chat.pastedTextExpand, + '@astryx.chat.status.delivered': astryx.chat.statusDelivered, + '@astryx.chat.status.failed': astryx.chat.statusFailed, + '@astryx.chat.status.read': astryx.chat.statusRead, + '@astryx.chat.status.sending': astryx.chat.statusSending, + '@astryx.chat.status.sent': astryx.chat.statusSent, + '@astryx.chatComposerDrawer.collapse': astryx.chat.drawerCollapse, + '@astryx.chatComposerDrawer.expand': astryx.chat.drawerExpand, + '@astryx.chatLayout.newMessages': astryx.chat.newMessages, + '@astryx.chatLayoutScrollButton.scrollToBottom': astryx.chat.scrollToBottom, + '@astryx.chatToolCalls.error': astryx.chat.toolCallsError, + '@astryx.chatToolCalls.groupLabel': astryx.chat.toolCallsGroupLabel, + '@astryx.chatTriggerMenu.suggestions': astryx.chat.triggerSuggestions, + + // Command palette — `list.label` stays a call-site override because each + // palette names its own result list. + '@astryx.commandPalette.emptyBootstrap': astryx.commandPalette.emptyBootstrap, + '@astryx.commandPalette.emptySearch': astryx.commandPalette.emptySearch, + '@astryx.commandPalette.input.placeholder': astryx.commandPalette.inputPlaceholder, + '@astryx.commandPalette.label': astryx.commandPalette.label, + '@astryx.commandPalette.loading': shared.primitives.loading, + '@astryx.commandPalette.noResultsFor': astryx.commandPalette.noResultsFor, + '@astryx.commandPalette.resultCount': astryx.commandPalette.resultCount, + + // DateTimeInput and the Calendar it opens. + '@astryx.dateInput.clear': form.clear, + '@astryx.dateInput.closeCalendar': astryx.dateTime.closeCalendar, + '@astryx.dateInput.openCalendar': astryx.dateTime.openCalendar, + '@astryx.dateInput.toggleCalendarClose': astryx.dateTime.closeCalendar, + '@astryx.dateTimeInput.dialogLabel': astryx.dateTime.dialogLabel, + '@astryx.dateTimeInput.placeholder': astryx.dateTime.datePlaceholder, + '@astryx.dateTimeInput.timePlaceholder': astryx.dateTime.timePlaceholder, + '@astryx.dateTimeInput.timeSuffix': astryx.dateTime.timeSuffix, + '@astryx.calendar.dayInRange': astryx.calendar.dayInRange, + '@astryx.calendar.dayRangeEnd': astryx.calendar.dayRangeEnd, + '@astryx.calendar.dayRangeStart': astryx.calendar.dayRangeStart, + '@astryx.calendar.dayRangeStartAndEnd': astryx.calendar.dayRangeStartAndEnd, + '@astryx.calendar.daySelected': astryx.calendar.daySelected, + '@astryx.calendar.nextMonth': astryx.calendar.nextMonth, + '@astryx.calendar.previousMonth': astryx.calendar.previousMonth, + '@astryx.calendar.rangeCompleteAnnounce': astryx.calendar.rangeCompleteAnnounce, + '@astryx.calendar.rangeStartAnnounce': astryx.calendar.rangeStartAnnounce, + + // Menus, selectors and inputs. + '@astryx.dropdownMenu.label': astryx.menus.dropdown, + '@astryx.moreMenu.label': astryx.menus.more, + '@astryx.selector.searchOptions': astryx.search.options, + '@astryx.selector.searchPlaceholder': astryx.search.placeholder, + '@astryx.multiSelector.searchOptions': astryx.search.options, + '@astryx.multiSelector.searchPlaceholder': astryx.search.placeholder, + '@astryx.multiSelector.selectPlaceholder': form.selectPlaceholder, + '@astryx.multiSelector.clearAll': astryx.multiSelector.clearAll, + '@astryx.multiSelector.selectAll': astryx.multiSelector.selectAll, + '@astryx.textInput.clearLabel': form.clear, + '@astryx.input.statusButton.error': astryx.inputStatus.error, + '@astryx.input.statusButton.success': astryx.inputStatus.success, + '@astryx.input.statusButton.warning': astryx.inputStatus.warning, + + // Lightbox — reached through useLightbox in chat-turn.tsx (image + // preview), not a element; JSX-tag scans miss it. + '@astryx.lightbox.mediaViewer': astryx.lightbox.mediaViewer, + '@astryx.lightbox.close': shared.primitives.close, + '@astryx.lightbox.previous': astryx.lightbox.previous, + '@astryx.lightbox.next': astryx.lightbox.next, + + // Shell chrome: side nav, tabs, banners, breadcrumbs, resize handles. + '@astryx.banner.collapse': astryx.banner.collapse, + '@astryx.banner.expand': astryx.banner.expand, + '@astryx.banner.dismiss': shared.primitives.close, + '@astryx.breadcrumbs.label': astryx.breadcrumbs.label, + '@astryx.sideNav.label': astryx.sideNav.label, + '@astryx.sideNav.resizeSidebar': astryx.sideNav.resizeSidebar, + '@astryx.sideNavCollapseButton.collapseSidebar': astryx.sideNav.collapseSidebar, + '@astryx.sideNavCollapseButton.expandSidebar': astryx.sideNav.expandSidebar, + '@astryx.sideNavItem.collapse': astryx.sideNav.itemCollapse, + '@astryx.sideNavItem.expand': astryx.sideNav.itemExpand, + '@astryx.tabList.label': astryx.tabList.label, + + // Table (usage settings) and the chat transcript's attachment chrome. + '@astryx.table.label': astryx.table.label, + '@astryx.thumbnail.fallbackName': astryx.thumbnail.fallbackName, + '@astryx.thumbnail.open': astryx.thumbnail.open, + '@astryx.thumbnail.remove': astryx.thumbnail.remove, + '@astryx.token.remove': astryx.token.remove, + }, + }; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ee00c94498c9000ac0dc2a314c192f9ac58e39283448676e34a76f8bdfbcbb4.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ee00c94498c9000ac0dc2a314c192f9ac58e39283448676e34a76f8bdfbcbb4.source new file mode 100644 index 0000000000..ab667eac79 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2ee00c94498c9000ac0dc2a314c192f9ac58e39283448676e34a76f8bdfbcbb4.source @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface TranscriptTurnRow { + kind: 'turn'; + turn: Turn; +} + +export interface TranscriptGapRow { + kind: 'gap'; + direction: 'older' | 'newer'; +} + +export type TranscriptRow = TranscriptTurnRow | TranscriptGapRow; + +export interface TranscriptRowProjectionInput { + turns: readonly Turn[]; + hasOlder: boolean; + hasNewer: boolean; + activeTurnId?: string; +} + +export function projectTranscriptRows( + input: TranscriptRowProjectionInput, +): readonly TranscriptRow[] { + const rows: TranscriptRow[] = input.turns.map((turn) => ({ kind: 'turn', turn })); + + if (input.hasNewer) { + const activeTurnIndex = input.activeTurnId + ? rows.findIndex((row) => row.kind === 'turn' && row.turn.turnId === input.activeTurnId) + : -1; + const gapIndex = activeTurnIndex >= 0 ? activeTurnIndex : rows.length; + rows.splice(gapIndex, 0, { kind: 'gap', direction: 'newer' }); + } + + if (input.hasOlder) { + rows.unshift({ kind: 'gap', direction: 'older' }); + } + + return rows; +} diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f97521922af19df74c207a024db9680511bf6e15c652024f0c044d744ceea4b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f97521922af19df74c207a024db9680511bf6e15c652024f0c044d744ceea4b.source new file mode 100644 index 0000000000..5cdbe3a20b --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f97521922af19df74c207a024db9680511bf6e15c652024f0c044d744ceea4b.source @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; +import { TurnView } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { TurnViewModel } from '../materialize.js'; + +function statusHasSpinner(toolStatuses: readonly ('running' | 'completed')[]): boolean { + const tools = toolStatuses.map((status, index) => ({ + toolUseId: `tool-${index + 1}`, + toolName: 'Bash', + status, + args: {}, + } as const)); + const turn: TurnViewModel = { + turnId: 'turn-1', + status: 'running', + tools, + notes: [], + startedAt: 1, + timeline: [{ kind: 'tools', items: tools }], + }; + const markup = renderToStaticMarkup( + + + , + ); + const { document } = parseHTML(markup); + return document.querySelector('.maka-turn-processing .astryx-spinner') !== null; +} + +function runningStatusText(locale: 'en' | 'zh-CN'): string { + const turn: TurnViewModel = { + turnId: 'turn-1', + status: 'running', + tools: [], + notes: [], + startedAt: 1, + timeline: [], + }; + const markup = renderToStaticMarkup( + + + , + ); + return parseHTML(markup).document.querySelector('.maka-turn-processing')?.textContent ?? ''; +} + +test('hands the spinner to the turn status after the tool settles', () => { + assert.equal(statusHasSpinner(['running']), false); + assert.equal(statusHasSpinner(['completed']), true); +}); + +test('keeps the turn spinner when a collapsed group hides the running tool', () => { + assert.equal(statusHasSpinner(['running', 'completed']), true); +}); + +test('describes provider silence without inventing semantic progress', () => { + assert.equal(runningStatusText('zh-CN'), '等待模型输出…'); + assert.equal(runningStatusText('en'), 'Waiting for model output…'); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f9a99cf9d8d3ef0b5df137ce8e8da009ac5ef61ccf3a4634f3147b2ed5ea7c3.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f9a99cf9d8d3ef0b5df137ce8e8da009ac5ef61ccf3a4634f3147b2ed5ea7c3.source new file mode 100644 index 0000000000..515e959ab1 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/2f9a99cf9d8d3ef0b5df137ce8e8da009ac5ef61ccf3a4634f3147b2ed5ea7c3.source @@ -0,0 +1,149 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// SPDX-License-Identifier: MIT + +/** + * Astryx ChatReasoning 0.1.9, ejected from the official lab package. + * + * Source: packages/lab/src/ChatReasoning/ChatReasoning.tsx at Astryx v0.1.9 + * (commit c9fe437). The lab package is canary-only and declares an exact + * canary core peer even though this release is the stable 0.1.9 source. Maka + * therefore uses Astryx's supported swizzle/eject seam instead of forcing an + * invalid dependency tree. DOM, state, keyboard behavior, icons, and compiled + * StyleX atoms below are the official component; only the build-time StyleX + * call has already been compiled, matching the published package output. + * + * Product dialect lives in chat-message.css (cursor default, hover wash, + * chevron size). This file keeps the ejected lab DOM/behavior, except that + * the chevron is Astryx `Icon` rather than the lab's own 12-viewBox SVG: at + * the 10x10 chat-message.css forces, that glyph drew 1.25px of stroke beside + * the tool rows' 0.73px. One registry, one chevron. + */ +import { useCallback, useState, type HTMLAttributes, type ReactNode } from 'react'; +import { Icon } from '@astryxdesign/core/Icon'; +import { mergeProps, themeProps } from '@astryxdesign/core/utils'; + +export interface ChatReasoningProps extends HTMLAttributes { + children: ReactNode; + label?: string; + duration?: string; + previewText?: string; + isStreaming?: boolean; + isExpanded?: boolean; + defaultIsExpanded?: boolean; + onExpandedChange?: (isExpanded: boolean) => void; +} + +function ThinkingIcon() { + return ( + + ); +} + +export function ChatReasoning(props: ChatReasoningProps) { + const { + children, + label = 'Thinking', + duration, + previewText: explicitPreviewText, + isStreaming = false, + isExpanded: controlledExpanded, + defaultIsExpanded = false, + onExpandedChange, + className, + style, + ...rest + } = props; + const [internalExpanded, setInternalExpanded] = useState(defaultIsExpanded); + const isControlled = controlledExpanded !== undefined; + const isExpanded = isControlled ? controlledExpanded : internalExpanded; + const toggle = useCallback(() => { + const next = !isExpanded; + if (!isControlled) setInternalExpanded(next); + onExpandedChange?.(next); + }, [isExpanded, isControlled, onExpandedChange]); + const previewText = explicitPreviewText ?? (typeof children === 'string' ? children : null); + + return ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }} + className="maka-activity-card-header x78zum5 x6s0dn4 x1s4dlld x1ypdohk x87ps6o xjwf9q1 x13f7esw" + > + + + +
+ + {label} + + {duration != null && !isStreaming ? ( + <> + · + {duration} + + ) : null} + {!isExpanded && previewText && !isStreaming ? ( + <> + + {previewText} + + ) : null} +
+ + + +
+
+
+ {/* Product class on the reasoning body: the official component's + atoms deliberately own no white-space (children are assumed + pre-rendered), so without it the inherited `white-space: normal` + collapses every newline in the thinking text. Maka restores the + pre-wrap reading contract on this class — see + `.maka-chat-reasoning-content` in styles.css. */} +
{children}
+
+
+
+ ); +} + +ChatReasoning.displayName = 'ChatReasoning'; diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/30e094816254aa5acad651adc1663e96a9202b0ac946119824d6fec93d535464.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/30e094816254aa5acad651adc1663e96a9202b0ac946119824d6fec93d535464.source new file mode 100644 index 0000000000..26a00ef403 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/30e094816254aa5acad651adc1663e96a9202b0ac946119824d6fec93d535464.source @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The + menu's mode rows and the divider above them. + * + * Each row gets the control its field is. Plan is a Session field of its own + * and an independent switch. Swarm and Graph are the two values of one other + * field, so they are one group and picking one is picking away from the other + * — announced as a set rather than left for a screen reader to miss. Neither + * is chosen at rest, and no row stands for that; every prop that feeds them is + * optional, so a host can wire the modes alone, and then there is nothing + * above the divider to divide. + * + * Astryx mounts DropdownMenu layers from a client ref, so the rows are not in + * server markup. The assertions observe the same document after that mount. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { Composer } from '../composer.js'; +import { LocaleProvider } from '../locale-context.js'; + +const originalGlobals = { + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function computedStyle(): CSSStyleDeclaration { + return { + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + } as unknown as CSSStyleDeclaration; +} + +async function render(props: Parameters[0]): Promise { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => computedStyle(); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + await act(() => { + root.render( + + + , + ); + }); + return document.documentElement.innerHTML; +} + +async function plusMenu(props: Parameters[0]): Promise { + const markup = await render(props); + // The marker stays in the composer chrome while menu rows portal elsewhere + // in the same document. + assert.ok(markup.includes('maka-composer-plus-menu'), 'the composer rendered no + menu'); + return markup; +} + +function count(markup: string, needle: string): number { + return markup.split(needle).length - 1; +} + +/** Opening tags carrying every one of these attributes, in any order. */ +function tagsWith(markup: string, ...attributes: readonly string[]): readonly string[] { + return (markup.match(/<[a-z]+[^>]*>/g) ?? []).filter( + (tag) => attributes.every((attribute) => tag.includes(attribute)), + ); +} + +const base = { + onSend: () => undefined, + onStop: () => undefined, + planModeActive: false, + onPlanModeChange: () => undefined, + orchestrationMode: 'default' as const, + onOrchestrationModeChange: () => undefined, +}; + +test('the mode controls alone open the menu on a row, not on a rule', async () => { + assert.equal((await plusMenu(base)).includes('astryx-dropdown-menu-divider'), false); +}); + +test('an action row above the mode controls keeps the divider', async () => { + const withAction = await plusMenu({ ...base, onPickAttachments: () => undefined }); + assert.equal(withAction.includes('astryx-dropdown-menu-divider'), true); +}); + +test('file and folder actions have distinct labels and folder references remain removable', async () => { + const menu = await plusMenu({ + ...base, + onPickAttachments: () => undefined, + onPickDirectory: () => undefined, + pendingDirectories: [{ hostId: 'host-a', path: '/workspace/source' }], + onRemoveDirectory: () => undefined, + }); + assert.ok(menu.includes('Add files')); + assert.ok(menu.includes('Reference folder')); + assert.ok(menu.includes('source')); + assert.ok(menu.includes('aria-label="Remove source"')); + assert.equal((await plusMenu(base)).includes('Reference folder'), false); +}); + +test('each mode row is the control its field is, and none of them is on', async () => { + const menu = await plusMenu(base); + assert.equal(count(menu, 'role="menuitemcheckbox"'), 1, 'Plan alone is a switch'); + // Two rows, not three: the field's third value is this group holding none. + assert.equal(count(menu, 'role="menuitemradio"'), 2, 'Swarm and Graph, no neutral row'); + assert.equal( + tagsWith(menu, 'role="group"', 'aria-label="Orchestration mode"').length, + 1, + 'the exclusive pair is announced as one named set', + ); + assert.equal(count(menu, 'aria-checked="true"'), 0, 'nothing on is nothing checked'); +}); + +/** The Skills entry is the menu's only plain-menuitem row under these props. */ +function skillsRow(menu: string): string { + const rows = tagsWith(menu, 'role="menuitem"'); + assert.equal(rows.length, 1, 'expected the Skills row and nothing else'); + return rows[0] ?? ''; +} + +test('a refreshing skill catalog is not "no skills": the row stays put', async () => { + // The host clears `mentionSkills` while it re-fetches the projection (a + // Plan toggle or model change does that with this menu open) but holds its + // settled verdict steady. Painting the transient `[]` as "no skills + // available" grows the row by a description line and grays it, then snaps + // back — the menu visibly jumps. + const menu = await plusMenu({ ...base, mentionSkills: [], mentionSkillsUnavailable: false }); + assert.equal(count(menu, 'Choose skills'), 1, 'the Skills row is rendered'); + assert.equal(count(menu, 'No skills available'), 0, 'no transient empty-state line'); + assert.equal( + skillsRow(menu).includes('aria-disabled="true"'), + false, + 'the row does not gray out mid-refresh', + ); +}); + +test('a settled empty skill catalog still says why the row is unavailable', async () => { + for (const props of [ + // A host that never clears the list mid-flight wires no verdict; the row + // falls back to the list itself. + { ...base, mentionSkills: [] }, + { ...base, mentionSkills: [], mentionSkillsUnavailable: true }, + ]) { + const menu = await plusMenu(props); + assert.ok(count(menu, 'No skills available') > 0, 'the empty state says why'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), true); + } +}); + +test('a populated skill catalog renders the row enabled with no caveat', async () => { + const menu = await plusMenu({ + ...base, + mentionSkills: [{ id: 'demo', name: 'Demo' }], + }); + assert.equal(count(menu, 'Choose skills'), 1); + assert.equal(count(menu, 'No skills available'), 0); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); + assert.equal(menu.includes('maka-composer-skills-loading'), false); +}); + +test('a loading catalog holds the row still and marks the held state', async () => { + // Mid-refresh the row keeps the previous catalog's look (here: populated). + // `aria-busy` is what assistive technology gets instead of a geometry + // change: activation is deferred, and a row that still announced plain + // "available" would silently ignore it. The class is the same contract for + // tests and styling. + const menu = await plusMenu({ + ...base, + mentionSkills: [], + mentionSkillsUnavailable: false, + mentionSkillsLoading: true, + }); + assert.equal(count(menu, 'No skills available'), 0, 'geometry does not grow mid-refresh'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); + assert.equal( + skillsRow(menu).includes('aria-busy="true"'), + true, + 'the deferred activation is announced', + ); + assert.equal( + skillsRow(menu).includes('maka-composer-skills-loading'), + true, + 'the loading state is observable on the row', + ); +}); + +test('a settled catalog carries no busy announcement', async () => { + for (const props of [ + { ...base, mentionSkills: [{ id: 'demo', name: 'Demo' }], mentionSkillsLoading: false }, + { ...base, mentionSkills: [{ id: 'demo', name: 'Demo' }] }, + ]) { + assert.equal((await plusMenu(props)).includes('aria-busy'), false); + } +}); + +test('a loading refresh from a settled-empty catalog holds the empty look', async () => { + const menu = await plusMenu({ + ...base, + mentionSkills: [], + mentionSkillsUnavailable: true, + mentionSkillsLoading: true, + }); + assert.ok(count(menu, 'No skills available') > 0, 'the settled caveat stays put'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), true); +}); + +test('Plan and an orchestration mode are both on at once', async () => { + const markup = await render({ ...base, planModeActive: true, orchestrationMode: 'swarm' }); + assert.ok(markup.includes('maka-composer-plus-menu'), 'the composer rendered no + menu'); + assert.equal( + tagsWith(markup, 'role="menuitemcheckbox"', 'aria-checked="true"').length, + 1, + 'Plan is not checked', + ); + assert.equal( + tagsWith(markup, 'role="menuitemradio"', 'aria-checked="true"').length, + 1, + 'Swarm is not checked, or Graph is checked with it', + ); + // Each one keeps its own readout and its own way out, so neither hides the + // other: a Plan excursion does not clear the orchestration default. + assert.equal(count(markup, 'maka-composer-mode-button'), 2); + assert.ok(markup.includes('data-mode="plan"')); + assert.ok(markup.includes('data-mode="swarm"')); +}); diff --git a/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3105028fa6b54262aae3beef4d69754276fe8b5bef433ca05fc9cfa880615f8b.source b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3105028fa6b54262aae3beef4d69754276fe8b5bef433ca05fc9cfa880615f8b.source new file mode 100644 index 0000000000..9ac3366735 --- /dev/null +++ b/packages/ui/.mimosa/hook-state/sess_df0ba8ff-b766-4dfb-b495-da7c17a31a3d.mtvjekfz-17172-55196b44f0.baseline/3105028fa6b54262aae3beef4d69754276fe8b5bef433ca05fc9cfa880615f8b.source @@ -0,0 +1,2319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + forwardRef, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type ClipboardEvent, + type DragEvent, + type FormEvent, + type KeyboardEvent, + type ReactNode, +} from 'react'; +import type { LucideIcon } from './icons.js'; +import { useMountedRef } from './use-mounted-ref.js'; +import { + ICON_SIZE, + ArrowUp, + CircleGauge, + FileText, + ListTodo, + Network, + Pencil, + Plus, + Square, + Sparkles, + Target, + Upload, + Workflow, +} from './icons.js'; +import { + ChatModelSwitcher, + ModelChipStatic, + NewChatModelPicker, + ThinkingLevelSelector, +} from './chat-model-switcher.js'; +import { useUiLocale } from './locale-context.js'; +import { getConversationCopy } from './conversation-copy.js'; +import { type ChatModelChoice, exactModelChoiceValue } from './chat-model-helpers.js'; +import { + appendPromptContextDraft, + deriveComposerModelSwitchAvailability, + isReferenceSizedPaste, + type ComposerModelSwitchAvailability, +} from './composer-helpers.js'; +import { stripQuoteHeadingMarkers } from './quote-ref-chip.js'; +import { DirectoryReferenceChip } from './directory-reference-chip.js'; +import { FolderOpen } from './icons.js'; +import { WorkspacePicker, type WorkspacePickerModel } from './workspace-picker.js'; +import { useComposerDraft, type ComposerDraftPersistence } from './use-composer-draft.js'; +import { useComposerHistory } from './use-composer-history.js'; +import { + composerWireText, + createChatInputActionOwner, + createTriggerSearchSource, + fileTransferContainsFiles, + isChatInputComposing, + mentionQueryMatches, + slashCommandQuery, + skillMentionQuery, + type ChatInputActionOwner, + type ComposerTextPort, +} from './chat-input-behavior.js'; +import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; +import type { + AttachmentRef, + FollowUpMode, + MessageQueueEntryProjection, + QuoteRef, +} from '@maka/core/events'; +import type { PermissionMode } from '@maka/core/permission'; +import type { OrchestrationMode } from '@maka/core/orchestration'; +import type { ProviderType } from '@maka/core/llm-connections'; +import type { SessionSummary } from '@maka/core/session'; +import { + Button as UiButton, + ChatComposer as AstryxChatComposer, + ChatComposerDrawer, + ChatComposerInput, + IconButton, + Lightbox, + Token, + Tooltip, + useChatPasteAsToken, + type ChatComposerInputHandle, + type ChatComposerToken, + type ChatComposerTrigger, + type SearchableItem, + type SearchSource, +} from '@astryxdesign/core'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuDivider, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@astryxdesign/core/DropdownMenu'; +import { useIndicator } from '@astryxdesign/core/Indicator'; +import { PermissionModeSelect } from './permission-mode-menu.js'; +import { AttachmentKindIcon } from './attachment-kinds.js'; +import { formatPreviewSize } from './artifact-preview-registry.js'; +import { + inlineReferenceFileBasename, + inlineReferenceToken, + workspaceFileInlineReference, + workspaceFileReferencePositions, + type WorkspaceFileReferencePosition, +} from './inline-reference.js'; +import { ComposerMessageQueue } from './composer-message-queue.js'; + +/** A Skill as the composer offers it: what the `/` menu lists and what a + * chosen entry writes into the draft. */ +export interface ComposerSkillOption { + /** The id the `/skill:` token carries, and what Runtime resolves. */ + id: string; + name: string; + description?: string; +} + +export interface ComposerSlashCommandOption { + id: string; + name: string; + description?: string; + keywords?: readonly string[]; + Icon?: LucideIcon; +} + +type ComposerSlashSuggestion = + | { kind: 'command'; command: ComposerSlashCommandOption; group: string } + | { kind: 'skill'; skill: ComposerSkillOption; group: string }; + +/** + * The draft text a chosen Skill becomes. This is the product-wide invocation + * grammar (`SKILL_INVOCATION_TOKEN_SOURCE` in `@maka/core`), the same one + * the TUI submits and the same one a user can type by hand — the chip is a + * rendering of it, not a second channel beside it. + * + * By id, not by the scope-aware ref: the ref cannot be spelled in this grammar + * (`project:.maka/skills:writer` would parse as `project`), and ids are unique + * within a scan — `scanSkills` drops shadowed duplicates before the picker ever + * sees them. What the structured channel bought was pinning the exact file + * across the gap between choosing and sending; in that gap a ref is no less + * stale than an id, it is only differently stale, and resolving at send time is + * what `/skill:` means everywhere else in the product. + * + * A controlled `value` set rebuilds the editor from this string and drops the + * chip spans with it; `redrawSkillTokens` puts them back, so a draft restored + * by session switch, prompt history or revision rollback reads the same as the + * one that was staged. + */ +function skillTokenValue(id: string): string { + return `/skill:${id}`; +} + +/** + * Rows the input grows to before it scrolls. `ChatComposerInput` prices this in + * its own hardcoded 22px line, so the cap is 220px — one line under the 240px + * the hand-rolled textarea auto-resize enforced. Our type override sets a + * shorter line than 22px, so the editor shows slightly more than `maxRows` + * rows before it scrolls; rows, not pixels, is the knob upstream exposes. + */ +const COMPOSER_MAX_ROWS = 10; + +/** Uppercased extension for the staged-file card's meta line ("EPUB · 621.0 KB"). + * Null when the name has no usable extension, so the meta line is size-only. */ +function attachmentExtensionLabel(name: string): string | null { + const idx = name.lastIndexOf('.'); + if (idx <= 0 || idx === name.length - 1) return null; + const ext = name.slice(idx + 1); + return ext.length > 8 ? null : ext.toUpperCase(); +} + +/** + * PR-UI-15 (@yuejing 2026-05-22): Composer copy is locale-aware. + * + * Audit §3.5 — placeholder + state copy were hardcoded zh and drifted + * stylistically from the first-run input that used to sit beside this + * one. That second input is gone (#1433), so this placeholder is the + * only one a user ever reads: one short, action-oriented line. + */ +export interface ComposerHandle { + /** Replace the input text, leaving focus on the input with the caret at the end. */ + setText(text: string): void; + /** Append a prompt/context fragment after the existing draft instead of replacing it. */ + appendText(text: string): void; + /** Read the current input text (inline tokens serialized to their values). */ + getText(): string; + /** Clear one persisted draft without affecting another session's. */ + clearDraft(draftKey: string): void; + /** Write a specific session draft before navigation changes the active key. */ + setDraft(draftKey: string, text: string): void; + /** Read a specific draft without changing the active input. */ + getDraft(draftKey: string): string; + /** Append to a specific session draft without replacing newer text. */ + appendDraft?(draftKey: string, text: string): void; + /** Move focus to the input without changing its content. */ + focus(): void; + /** Open the active Session's existing account-and-model picker. */ + openModelPicker(): void; +} + +export interface ComposerSendMetadata { + workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; + followUpMode?: FollowUpMode; +} + +type ComposerImportActionId = 'pick' | 'attach' | 'directory'; + +export interface ComposerGoalProps { + /** + * Open the host's Goal dialog. The composer offers the entry and nothing + * else: a Goal names a condition and two budgets, which is a form, and the + * + menu is a menu. Absent handler, no entry — the same rule the other + * + entries follow. + */ + onSetGoal?(): void | Promise; + /** + * A Goal is already running here. Arming refuses a second one, so the + * entry says so up front instead of spending the user's click on an error. + */ + goalActive?: boolean; +} + +export const Composer = forwardRef< + ComposerHandle, + { + disabled?: boolean; + placeholder?: string; + /** + * Prevent submission while leaving the draft and recovery controls usable. + * Hosts use this for configuration failures that the model picker can fix. + */ + sendBlocked?: boolean; + hidden?: boolean; + /** + * When true, a turn is in flight — live output OR the pre-first-token wait. + * Send becomes Stop while the draft is empty. The + menu and permission + * control stay reachable (#1444); the model and thinking menus stay + * mounted but lock with an explanatory tooltip, so the footer row never + * reflows mid-turn. Attachment import remains blocked unless the host opts + * in via `allowAttachmentImportWhileStreaming`. + */ + streaming?: boolean; + /** + * Keep attachment paste, drop, and picker imports available during a + * running turn. Only hosts whose running-turn submission carries staged + * attachments into a follow-up should opt in; text-only steering hosts + * must retain the default gate so text cannot leave its attachment behind. + */ + allowAttachmentImportWhileStreaming?: boolean; + /** + * #646: retained for hosts that still track first-token wait vs mid-turn + * lull. Quiet composer no longer surfaces long status copy from these; + * streaming is communicated only by Send → Stop. + */ + processing?: boolean; + /** + * #646: retained for host wait-state projection; not rendered as chrome. + */ + continuing?: boolean; + /** True while the current streaming session is processing a stop request. */ + stopPending?: boolean; + queuedMessages?: readonly MessageQueueEntryProjection[]; + queuedMessageRevision?: number; + /** Promote a queued follow-up into the active Turn (调整方向). */ + onPromoteQueuedEntry?(entryId: string): void | Promise; + /** Update one queued entry in place without changing its order or placement. */ + onUpdateQueuedEntry?( + entryId: string, + expectedQueueRevision: number, + text: string, + ): void | Promise; + /** Remove one queued entry without restoring it. */ + onDeleteQueuedEntry?(entryId: string): void | Promise; + /** Reorder the follow-up queue; entryIds is the full intended order. */ + onReorderQueuedEntries?(entryIds: readonly string[]): void | Promise; + /** Runtime-only key used to keep unsent drafts isolated per session. */ + draftKey?: string; + /** Optional host persistence for reload-safe draft scopes. */ + draftPersistence?: ComposerDraftPersistence; + /** + * The composer's one submit. Mid-turn the host decides what handing the + * draft over means there — steering, a control command — because that is a + * reading of the same action, not a second control on this surface. + */ + onSend( + text: string, + metadata?: ComposerSendMetadata, + ): boolean | void | Promise; + onStop(): void | Promise; + onPickAttachments?(): void | Promise; + onPickDirectory?(): void | Promise; + pendingDirectories?: readonly import('@maka/core/events').DirectoryReference[]; + onRemoveDirectory?(index: number): void; + onAttachFilePaths?(files: File[]): void | Promise; + /** Hosts that can submit context without a text prompt opt in. */ + allowAttachmentOnlySend?: boolean; + pendingAttachments?: readonly { + displayName: string; + kind: AttachmentRef['kind']; + mimeType?: string; + size: number; + /** Renderer-resolvable image source (object/data URL) for `kind: 'image'` + * previews. When set, the chip is clickable and opens the image in a + * Lightbox; while absent (still loading, or preview failed) the chip is + * inert like any other kind. */ + previewUrl?: string; + }[]; + onRemoveAttachment?(index: number): void; + /** Quoted excerpts staged for the next send; rendered as removable chips. */ + pendingQuotes?: readonly QuoteRef[]; + onRemoveQuote?(index: number): void; + /** Start staged context collapsed on compact secondary composer surfaces. */ + contextDrawerDefaultCollapsed?: boolean; + /** Hide the unavailable dot when an inherited model is intentionally read-only. */ + showStaticModelUnavailableStatus?: boolean; + /** + * Stage a reference-sized paste as a quote chip rather than letting it + * flood the textarea. Omitted by hosts that don't compose quotes, in which + * case a large paste behaves like any other paste. + */ + onPasteAsQuote?(input: { text: string; label?: string }): void; + modelLabel?: string; + activeSession?: SessionSummary; + activeModelConnectionId?: string; + activeModelConnectionSlug?: string; + activeModel?: string; + activeModelLabel?: string; + activeProviderType?: ProviderType; + modelChoices?: ChatModelChoice[]; + /** Inline browsing for compact windows that cannot fit a popup menu. */ + modelPickerPresentation?: 'menu' | 'wheel'; + /** Maximum input height in the upstream editor's row units. */ + maxInputRows?: number; + /** Whether this Session already has conversation history whose provider prompt cache may be rebuilt by a switch. */ + modelSwitchHasHistory?: boolean; + /** Identity recovery must not present the stale target as a checked, selectable row. */ + hideUnavailableCurrentModel?: boolean; + /** Renders the provider brand mark beside each model option; + * injected by the desktop app to keep the provider SVG library out of @maka/ui. */ + renderProviderMark?(type: ProviderType): ReactNode; + /** Host-projected availability when another recovery surface opens this picker. */ + modelSwitchAvailability?: ComposerModelSwitchAvailability; + onModelChange?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; + /** Per-model thinking-level variants for the active model; empty/undefined hides the switcher. */ + activeThinkingLevels?: readonly import('@maka/core/model-thinking').ThinkingLevel[]; + activeThinkingLevel?: import('@maka/core/model-thinking').ThinkingLevel; + onThinkingLevelChange?(level: import('@maka/core/model-thinking').ThinkingLevel | undefined): void | Promise; + newChatThinkingLevels?: readonly import('@maka/core/model-thinking').ThinkingLevel[]; + newChatThinkingLevel?: import('@maka/core/model-thinking').ThinkingLevel; + onNewChatThinkingLevelChange?(level: import('@maka/core/model-thinking').ThinkingLevel | undefined): void | Promise; + /** + * Home / empty-state composer only (no active session yet): the model + * the next new chat will start with, and the picker callback. When set, + * the otherwise-static model chip becomes a real dropdown so the user can + * choose the new-chat model inline instead of only via Settings · 模型. + */ + newChatModel?: { llmConnectionId: string; llmConnectionSlug: string; model: string }; + newChatProviderType?: ProviderType; + onPickNewChatModel?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; + /** + * Empty-state only: no models are configured yet, so the model chip is a + * non-interactive label. When provided, the chip becomes a button into + * Settings · 模型 instead of wearing a dropdown chevron it cannot honor. + */ + onOpenModelSettings?(): void; + /** + * U3: no model connection exists at all (e.g. right after an onboarding + * skip). Send is blocked with an explanatory title and an inline hint + * mounts above the composer box pointing at Settings · 模型, so the user + * is never left at a dead end with a disabled Send and no guidance. + * The hint sits OUTSIDE the
so it never grows the composer's + * constant footprint (#740). + */ + noModelConnection?: boolean; + /** Optional Host-aware replacement for the generic no-model hint. */ + noModelHint?: string; + /** Read-only usage indicator for the active model's latest request. */ + contextUsage?: { + usageTokens?: number; + declaredContextWindow?: number; + /** + * The window the usage number was metered against, frozen at call time. + * When present it outranks the metadata window, so a live reading keeps + * its numerator and denominator from the same request. + */ + meteredContextWindow?: number; + metadataContextWindow?: number; + /** Open the Host-owned trace surface for this readout. */ + onOpen(): void; + }; + /** + * Optional edit-and-resend banner above the composer. Desktop owns the + * revision draft; Composer only renders the notice + cancel affordance. + */ + revisionNotice?: { + /** Short primary status, e.g. "修改已发送消息". */ + title: string; + /** Optional quieter secondary line under the title. */ + detail?: string; + cancelLabel: string; + onCancel(): void; + }; + /** + * Where a NEW chat starts. Rendered at the end of the footer's send-context + * group and only while no session owns the composer: the project is fixed + * the moment the first message creates the session. + */ + workspacePicker?: WorkspacePickerModel; + /** Host actions that share the composer's existing footer. */ + footerAccessory?: ReactNode; + /** + * PR-MOVE-PERMISSION-MODE (WAWQAQ 47fe0d0e + a667cf6c): the + * permission mode picker lives inside the composer left-controls + * instead of the chat header. Composer renders a dropdown labelled + * by the mode the session's boundary is actually in (只读 / 自动 / + * 完全权限); selecting an option fires `onPermissionModeChange`. + * A read-only session displays 只读 without it becoming a third + * option (#1611). + */ + permissionMode?: PermissionMode; + permissionModeDisabledReason?: string; + onPermissionModeChange?(mode: PermissionMode): void | Promise; + /** + * Plan mode — a temporary collaboration excursion, and a toggle because + * that is what it is. Agent is the implicit default, so the composer only + * carries whether Plan is on. Runtime ends the excursion by itself when a + * proposal is approved or abandoned, which is why nothing here treats Plan + * as a resting mode the user must leave by hand. + */ + planModeActive?: boolean; + planModeDisabledReason?: string; + onPlanModeChange?(active: boolean): void | Promise; + /** + * The Session's standing orchestration default. Of the field's three + * values only Swarm and Graph name a way to fan a turn out; `default` is + * the absence of one, so this is an optional choice between two rather + * than a choice among three. The two are exclusive — a run carries one + * orchestration — which is why the menu offers them as a radio group with + * no selection at rest, not as two switches that would silently turn each + * other off. + * + * Independent of Plan on purpose. The two are different fields with + * different lifetimes: Plan gates which tools a turn gets, this names how + * a turn fans out by default, and Runtime resolves the overlap by + * stripping the subagent and agent-graph tools while planning. So "plan + * with Swarm armed for afterwards" is a state the Session can hold, and + * neither control writes the other's field. + */ + orchestrationMode?: OrchestrationMode; + orchestrationModeDisabledReason?: string; + onOrchestrationModeChange?(mode: OrchestrationMode): void | Promise; + /** + * Why a Goal cannot be set right now — a running Turn, typically. A Goal + * takes hold on the Turn after it is armed, so arming during one reads as + * having done nothing; the host names the reason and the entry shows it. + */ + goalDisabledReason?: string; + /** + * Composer mention popups. Both are optional and the whole feature no-ops + * when absent (SSR contracts render Composer with minimal props): + * - `mentionSkills` powers the `/` popup, which the + menu's Skills entry + * opens by typing the trigger — one menu, one entry point in code. Pass + * only ENABLED skills; the composer filters them client-side and writes + * the chosen one into the draft as a `/skill:` chip (human-in-the- + * loop, never auto-send). + * - `onSearchMentionFiles` powers the `@` popup. + */ + mentionSkills?: ReadonlyArray; + /** + * The host's SETTLED verdict on whether the catalog has anything to offer, + * held steady across refreshes. The host clears `mentionSkills` while + * re-fetching it (the `/` popup must stay fail-closed), so the list being + * empty cannot tell "refreshing" from "no skills" — and reading the + * transient `[]` as the latter disables the + menu's Skills row and grows + * it by a description line for the length of the round trip, blinking the + * open menu's geometry on every mode or model change. Hosts that never + * clear the list mid-flight can omit this; the row then falls back to + * `mentionSkills.length === 0`. + */ + mentionSkillsUnavailable?: boolean; + /** + * True while the host is re-fetching `mentionSkills`. The row's LOOK is + * governed by `mentionSkillsUnavailable` and does not move during a + * refresh; this flag governs what a click DOES. Mid-refresh the enabled + * look is a held presentation of the previous catalog, not a promise the + * current one can honor — acting on it would write a stray `/` into the + * draft and pop an empty menu — so the row ignores clicks (and stops + * closing the menu, so the click can simply be retried) until the catalog + * settles. + */ + mentionSkillsLoading?: boolean; + slashCommands?: ReadonlyArray; + onSearchMentionFiles?(query: string): Promise>; + } & ComposerGoalProps +>(function Composer(props, ref) { + const formRef = useRef(null); + /** Astryx's imperative handle on the contentEditable input. */ + const inputHandleRef = useRef(null); + /** ChatComposerInput's root, from which the editable node is resolved. */ + const inputRootRef = useRef(null); + function editableNode(): HTMLElement | null { + return inputRootRef.current?.querySelector('[contenteditable="true"]') ?? null; + } + const [dragActive, setDragActive] = useState(false); + const [sendPending, setSendPending] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const modelSwitchAvailability = + props.modelSwitchAvailability ?? + deriveComposerModelSwitchAvailability({ + streaming: props.streaming, + sessionStatus: props.activeSession?.status, + }); + const modelSwitchAvailabilityRef = useRef(modelSwitchAvailability); + modelSwitchAvailabilityRef.current = modelSwitchAvailability; + useLayoutEffect(() => setModelPickerOpen(false), [props.activeSession?.id, props.modelPickerPresentation]); + const [pendingImportAction, setPendingImportAction] = useState(null); + const composerMountedRef = useMountedRef(); + const sendPendingRef = useRef(false); + const compositionActiveRef = useRef(false); + const plainTextPasteInputActiveRef = useRef(false); + const importActionOwnerRef = useRef | null>(null); + if (!importActionOwnerRef.current) { + importActionOwnerRef.current = createChatInputActionOwner((action) => { + if (composerMountedRef.current) setPendingImportAction(action); + }); + } + // The input is controlled: `text` is the serialized draft (inline tokens + // collapse to their values), mirrored into a ref so the imperative handle — + // memoized with an empty dep list — always reads the live value. + const [text, setText] = useState(''); + const textRef = useRef(''); + function applyText(next: string) { + // Every value passes through here, which makes this the one place an + // external write can be defined against: whatever the last write owed, a + // newer value cancels. `textPort.setValue` re-arms both flags right after. + // Without the clear, a `setValue` React bails out of (the new draft equals + // the old one, so no commit and no effect) leaves them armed until some + // unrelated later render — where the caret jumps to the end mid-word, and a + // half-typed `/skill:` seizes into a chip under it. + caretToEndRef.current = false; + redrawPendingRef.current = false; + textRef.current = next; + setText(next); + } + /** + * The two operations the draft / history hooks need from the input. Stable + * identity so neither hook re-runs an effect when the draft changes. + */ + const caretToEndRef = useRef(false); + /** A caret-to-end owed to an editor that was not focused when it came due. */ + const caretPendingRef = useRef(false); + const redrawPendingRef = useRef(false); + const textPortRef = useRef(null); + if (!textPortRef.current) { + textPortRef.current = { + getValue: () => textRef.current, + setValue: (value: string) => { + applyText(value); + caretToEndRef.current = true; + redrawPendingRef.current = true; + }, + }; + } + const textPort = textPortRef.current; + /** + * ChatComposerInput restores the caret to the end of the content when a + * controlled update lands on a *focused* editor, so callers that want the old + * "focus at end" behavior focus first, then set the value. + * + * A draft that was rewritten while the editor was blurred never got that + * restore — switching sessions from the sidebar swaps the draft with focus + * elsewhere, and the next programmatic focus (Esc out of the artifact pane, + * say) then landed the caret at offset 0, so typing prepended to the restored + * draft. Collapse to the end here when the editor holds no selection of its + * own, which is what the retired `focusTextInputAtEnd` did unconditionally. + * + * Only on a focused editor, though. A selection inside a `contenteditable` is + * never only a caret: the browser focuses the element to carry it, whatever + * held focus before — measured in the shipping runtime, a selection placed + * here takes focus from a focused button exactly as it takes it from `body` — + * and sequential focus navigation then resumes from the selection rather than + * from the top of the document. So a restored draft claimed focus nobody + * directed at it: on a cold start, tens of milliseconds in, past the skip link + * and with no `focus()` call to explain it; and on a session swap, out from + * under the sidebar row the user had just activated. Hold the caret while the + * editor is not focused and land it on the editor's next real focus, which is + * the first moment the offset is the only thing being decided. + */ + function caretToContentEnd() { + const editable = editableNode(); + if (!editable) return; + if (document.activeElement !== editable) { + caretPendingRef.current = true; + return; + } + caretPendingRef.current = false; + const selection = document.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editable); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + function focusInput() { + inputHandleRef.current?.focus(); + const editable = editableNode(); + const selection = document.getSelection(); + if (!editable || (selection?.anchorNode && editable.contains(selection.anchorNode))) return; + caretToContentEnd(); + } + /** + * Settle a held caret when focus reaches the editor for real. On the component + * root, like the other native listeners here: `focusin` and `pointerdown` + * bubble, and a disabled composer renders no editable to look up at mount. + * + * A pointer press places the caret itself and is the more specific intent, so + * it drops the claim rather than being overruled by it. + */ + useEffect(() => { + const root = inputRootRef.current; + if (!root) return undefined; + const land = () => { + if (caretPendingRef.current) caretToContentEnd(); + }; + const drop = () => { + caretPendingRef.current = false; + }; + root.addEventListener('focusin', land); + root.addEventListener('pointerdown', drop); + return () => { + root.removeEventListener('focusin', land); + root.removeEventListener('pointerdown', drop); + }; + }, []); + /** + * The + menu's Skills entry opens the same `/` menu the keyboard opens: it + * types the trigger for the user. There is no second Skill surface to keep in + * step with this one, because there is no second surface. + * + * `useTriggerMenu` only recognizes a trigger at a line start or after a space + * or newline (`findActiveTrigger`), so a draft ending in a word — or in a chip, + * which `insertToken` anchors with U+00A0 — needs a space in front of the + * slash or the menu silently never opens. One `insertText` carries both, so + * the editor sees a single input event and a single undo step. + * + * Deferred a frame: the DropdownMenu returns focus to + as it closes, and a + * focus call racing that lands the caret nowhere. + * + * `caretToContentEnd` unconditionally, not `focusInput`: the latter keeps a + * selection the editor already owns, and the menu round trip leaves a stale + * one collapsed at offset 0 — measured, the slash landed in front of the + * draft rather than after it. Appending from + is the predictable read + * anyway. With the caret at the end, the character before it is the last + * character of the content, chips included (U+00A0, which is not a space, so + * it takes the space too). + */ + function openSkillMenu() { + window.requestAnimationFrame(() => { + inputHandleRef.current?.focus(); + caretToContentEnd(); + const previous = (editableNode()?.textContent ?? '').at(-1); + const needsSpace = previous !== undefined && previous !== ' ' && previous !== '\n'; + document.execCommand('insertText', false, needsSpace ? ' /' : '/'); + }); + } + /** + * Redraw the chips a controlled write flattened. + * + * `ChatComposerInput` rebuilds the editor from the string on every external + * value change (`editable.textContent = controlledValue`), which is correct + * for text and lossy for tokens: the chip spans go, and the draft comes back + * as the `/skill:` text they serialize to. Upstream declares a + * `deserialize` hook for exactly this and never calls it (facebook/astryx + * #4655), so until it does, we re-insert the chips ourselves. + * + * Nothing is recovered here that was not already in the string — the draft + * stays the single source of truth, and this only restores its rendering. + * That is what keeps it deletable in one piece: when upstream deserializes, + * this function and its one call site go, and no state goes with them. + * + * Three preconditions, all cheap, all necessary: + * + * - Only for an external write. `redrawPendingRef` is set by `textPort.setValue` + * — the sole funnel for draft swap, history recall and the imperative handle + * — and cleared by `applyText`, so a user who has taken the draft back never + * watches a half-typed `/skill:` seize into a chip under the caret. + * - Only on the DOM shape that write produces: exactly one text node equal to + * the draft. Anything else means upstream skipped the rewrite (the chips are + * still there) or the editor is in a shape whose offsets we cannot trust. + * - Never mid-composition. The rewrite already broke the IME's composition; + * moving the selection on top of that makes it worse. + * + * A pending redraw survives a failed attempt, and that is the whole reason it + * is a flag rather than a call at the write. The two inputs do not arrive + * together: switching sessions swaps the draft on the spot while the Skill + * catalog for the newly active session lands a render or two later, still + * holding the previous session's Skills. Clearing on the first attempt would + * read that stale catalog as proof the token is unresolvable and give up for + * good. Retrying costs one regex over a short draft on renders where a write + * is outstanding, and every other precondition — mid-composition, an + * unexpected DOM — gets the same second chance for free. + * + * Back to front, because `Range.deleteContents` inside a text node leaves the + * original node holding the text before the range: earlier offsets stay valid, + * later ones would not. The token's own matched text becomes the chip value, + * not a value rebuilt from the catalog id, so a differently-cased token comes + * back spelled the way the draft spells it. + * + * `insertToken` anchors each chip with a U+00A0, so we take the following + * space into the replaced range to keep one separator rather than two. The + * draft therefore serializes with U+00A0 where it had a space, and with one + * extra U+00A0 when a token ends the draft. That is the same text a chip + * picked from the `/` menu produces, `composerWireText` normalizes it on send, + * and upstream's sync effect is keyed on the controlled value rather than on + * the serialization, so the difference cannot loop back as a rewrite. + * + * A token whose id is not in the live catalog stays text: no chip should claim + * a Skill that will not resolve. + */ + function redrawSkillTokens(): boolean { + if (compositionActiveRef.current) return false; + const skills = props.mentionSkills; + if (!skills?.length) return false; + const draft = textRef.current; + if (!draft.includes('/skill:')) return false; + const editable = editableNode(); + const node = editable?.firstChild; + if (!editable || editable.childNodes.length !== 1) return false; + if (!(node instanceof Text) || node.data !== draft) return false; + const byId = new Map(skills.map((skill) => [skill.id.toLowerCase(), skill])); + const matches = [...draft.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))]; + const selection = document.getSelection(); + if (!selection) return false; + let redrew = false; + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + const skill = byId.get(match[1].toLowerCase()); + if (!skill) continue; + const start = match.index; + let end = start + match[0].length; + const next = draft[end]; + if (next === ' ' || next === '\u00A0') end += 1; + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + selection.removeAllRanges(); + selection.addRange(range); + inputHandleRef.current?.insertToken( + inlineReferenceToken({ kind: 'skill', value: match[0], label: skill.name }), + ); + redrew = true; + } + return redrew; + } + /** + * Every port write lands the caret at the end, which is what the textarea's + * `setSelectionRange(end, end)` did unconditionally on the same two paths + * (draft swap, prompt-history recall). Upstream only restores the caret when + * the update hits a *focused* editor, so without this a draft restored while + * the composer was blurred — switching sessions from the sidebar — left the + * caret at offset 0 and the next keystroke prepended to the draft. + * + * The redraw gets the same treatment for the same reason, and can land a + * render later than the write that owed it: `insertToken` parks the selection + * after the last chip it wrote, so the caret has to be collected again. + * + * A held caret is suspended across the redraw rather than left armed. The + * redraw drives `insertToken` through the document selection, and its first + * range focuses the editor — which would otherwise fire the focus lander onto + * the very range the redraw is holding, collapsing it to the end so the chip + * landed at the end and its source text stayed in the draft. The redraw ends + * by collecting the caret itself, so on success the claim is settled; on a + * pass that redrew nothing it is handed back untouched. + */ + useEffect(() => { + let restoreCaret = caretToEndRef.current; + caretToEndRef.current = false; + if (redrawPendingRef.current) { + const heldCaret = caretPendingRef.current; + caretPendingRef.current = false; + const redrew = redrawSkillTokens(); + caretPendingRef.current = redrew ? false : heldCaret; + if (redrew) { + redrawPendingRef.current = false; + restoreCaret = true; + } + } + if (restoreCaret) caretToContentEnd(); + }); + // Draft persistence + prompt-history navigation live in dedicated hooks + // (issue #1044). `resetPromptHistoryNavigation` is a hoisted wrapper so the + // draft hook's swap effect can reset history navigation even though the + // history hook is created one line below it. + const { + saveCurrentDraft, + clearDraft, + setDraft, + getDraft, + appendDraft, + activeDraftKey, + } = useComposerDraft({ + text: textPort, + draftKey: props.draftKey, + onDraftKeyChange: resetPromptHistoryNavigation, + persistence: props.draftPersistence, + }); + const { resetNavigation, rememberSentEntry, handleArrowKey } = useComposerHistory({ + text: textPort, + saveCurrentDraft, + }); + // PR-UI-15: locale-aware copy for placeholder + toolbar states. + const locale = useUiLocale(); + const copy = getConversationCopy(locale).composer; + const mentionCopy = getConversationCopy(locale).mentions; + + useEffect(() => { + return () => { + sendPendingRef.current = false; + importActionOwnerRef.current?.reset(); + }; + }, []); + + /** + * Nothing may act on a keystroke the IME is still using. + * + * ChatComposerInput runs its own trigger-menu key handling *before* the + * `onKeyDown` we pass it, and that handler takes Enter as "accept the + * highlighted suggestion" without checking `isComposing` — so a guard inside + * our handler would arrive too late to stop a CJK candidate commit from + * inserting a file mention. A native listener on the component root fires + * before React dispatches at its own root container, so stopping propagation + * here takes the key away from every React handler at once, theirs and ours. + */ + useEffect(() => { + const root = inputRootRef.current; + if (!root) return undefined; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (isChatInputComposing(event, compositionActiveRef.current)) event.stopPropagation(); + }; + root.addEventListener('keydown', onKeyDown); + return () => root.removeEventListener('keydown', onKeyDown); + }, []); + + /** + * A multi-line insert has to survive the editor, and Chrome's answer doesn't: + * an `insertText` carrying newlines lands as one text node with the newlines + * dropped outright. Measured, not inferred — with this listener disabled, + * inserting `one\ntwo\nthree` yields `onetwothree`. Replay it as the + * browser's own line-break command, which produces the `
` Astryx's + * serializer does understand and which the controlled round trip then stores + * as a real newline; unlike a scripted Range insertion it also keeps the + * caret and the undo stack intact. Typed line breaks don't come through here; + * `onInputKeyDown` owns Enter, and `insertLineBreak` already serializes + * correctly. + * + * Reached by any programmatic multi-line insert: dictation and IME block + * commits in the product, and `fill()` in the E2E suite (which is what the + * Markdown code-paste journey exercises). + * + * Listen on the component root, not the editable: `beforeinput` bubbles, and + * a host that mounts the composer disabled renders `contenteditable="false"`, + * so an editable lookup here would miss and never retry. + */ + useEffect(() => { + const root = inputRootRef.current; + if (!root) return undefined; + const onBeforeInput = (event: InputEvent) => { + if (event.inputType !== 'insertText') return; + const lines = (event.data ?? '').split('\n'); + if (lines.length < 2) return; + event.preventDefault(); + for (const [index, line] of lines.entries()) { + if (index > 0) document.execCommand('insertLineBreak'); + if (line) document.execCommand('insertText', false, line); + } + }; + root.addEventListener('beforeinput', onBeforeInput); + return () => root.removeEventListener('beforeinput', onBeforeInput); + }, []); + + function resetPromptHistoryNavigation() { + resetNavigation(); + } + + // The `@` / `/` menus are Astryx trigger menus now. `useTriggerMenu` compares + // the active trigger by identity on every input event, so the trigger objects + // and their sources must not be rebuilt per render — they read live props + // through this ref instead. + const mentionSourceRef = useRef({ + mentionSkills: props.mentionSkills, + slashCommands: props.slashCommands, + onSearchMentionFiles: props.onSearchMentionFiles, + commandsGroup: mentionCopy.commandsGroup, + skillsGroup: mentionCopy.skillsGroup, + }); + mentionSourceRef.current = { + mentionSkills: props.mentionSkills, + slashCommands: props.slashCommands, + onSearchMentionFiles: props.onSearchMentionFiles, + commandsGroup: mentionCopy.commandsGroup, + skillsGroup: mentionCopy.skillsGroup, + }; + + const searchSourcesRef = useRef<{ files: SearchSource; skills: SearchSource }>(null); + if (!searchSourcesRef.current) { + const runFileSearch = (query: string): Promise => { + const search = mentionSourceRef.current.onSearchMentionFiles; + return (search ? search(query) : Promise.resolve([])).then((files) => + files + .filter((file) => mentionQueryMatches(query, file.relativePath)) + .slice(0, 50) + .map((file) => ({ id: file.relativePath, label: file.relativePath })), + ); + }; + const files = createTriggerSearchSource(runFileSearch); + const listSlashSuggestions = (rawQuery: string): SearchableItem[] => { + const source = mentionSourceRef.current; + const skills = source.mentionSkills ?? []; + const editable = editableNode(); + const selection = document.getSelection(); + let textBeforeCaret = textPort.getValue(); + let textAfterCaret = ''; + if ( + editable && + selection?.focusNode && + editable.contains(selection.focusNode) + ) { + const range = document.createRange(); + range.selectNodeContents(editable); + range.setEnd(selection.focusNode, selection.focusOffset); + textBeforeCaret = range.toString(); + range.selectNodeContents(editable); + range.setStart(selection.focusNode, selection.focusOffset); + textAfterCaret = range.toString(); + } + const commandQuery = slashCommandQuery(textBeforeCaret, textAfterCaret, rawQuery); + const query = skillMentionQuery(rawQuery); + const commandItems = commandQuery === null + ? [] + : (source.slashCommands ?? []) + .filter((command) => + mentionQueryMatches( + commandQuery, + `${command.id} ${command.name} ${command.description ?? ''} ${(command.keywords ?? []).join(' ')}`, + ), + ) + .map((command) => ({ + id: `command:${command.id}`, + label: command.name, + auxiliaryData: { + kind: 'command', + command, + group: source.commandsGroup, + } satisfies ComposerSlashSuggestion, + })); + const skillItems = skills + .filter((skill) => + mentionQueryMatches(query, `${skill.id} ${skill.name} ${skill.description ?? ''}`), + ) + .map((skill) => ({ + id: `skill:${skill.id}`, + label: skill.name, + auxiliaryData: { + kind: 'skill', + skill, + group: source.skillsGroup, + } satisfies ComposerSlashSuggestion, + })); + return [...commandItems, ...skillItems].slice(0, 50); + }; + // `bootstrap` is required by SearchSource but never called by + // `useTriggerMenu`; the menu opens straight into `search`. + searchSourcesRef.current = { + files, + skills: { + bootstrap: () => listSlashSuggestions(''), + search: listSlashSuggestions, + }, + }; + } + + // Rebuilt only when the locale changes (the menus carry localized labels); + // a closed menu is the only state a rebuild can disturb. + const triggers = useMemo(() => { + const sources = searchSourcesRef.current!; + const list: ChatComposerTrigger[] = []; + if (props.onSearchMentionFiles) { + list.push({ + character: '@', + searchSource: sources.files, + menuLabel: mentionCopy.filesAriaLabel, + emptySearchResultsText: mentionCopy.noFiles, + loadingText: mentionCopy.loading, + renderItem: (item) => ( + <> +