diff --git a/docs/architecture/runtime-managed-mutation-pure-transform-v1.zh-CN.md b/docs/architecture/runtime-managed-mutation-pure-transform-v1.zh-CN.md new file mode 100644 index 0000000000..2063a5d746 --- /dev/null +++ b/docs/architecture/runtime-managed-mutation-pure-transform-v1.zh-CN.md @@ -0,0 +1,58 @@ + + +# Runtime managed mutation pure transform v1 + +## 1. 不变量 + +managed Write/Edit 不读写 live checkout。Runtime 使用自己的原始工具参数和 Host 提供的 immutable accepted-tree base content,计算唯一 result content 与 provider result。 + +该转换允许 recovery 在 T2 前根据完全相同的 durable input 重新计算;它不拥有 filesystem、network 或 process 副作用。跨进程安全性来自 deterministic input/output 与后续 at-most-one acceptance,而不是 invocation count。 + +```text +Runtime-owned args + T1 expectedPath + accepted base content + -> pure Write/Edit transform + -> immutable mutation result proof +``` + +Host 不能返回完整 `executionArgs`,也不能替换 `content`、`old_string` 或 `new_string`。它只能提供 accepted-tree base content;路径来自已经与 durable function call 严格匹配的 T1 `expectedPath`。 + +## 2. Owner + +- Runtime:参数、transform、provider result、strict JSON snapshot; +- Host admission:accepted-tree base content 与 terminal proof; +- Gitoxide candidate owner:后续消费 mutation result proof; +- SQLite:后续决定 terminal/accepted truth。 + +## 3. 失败状态 + +- Write 参数无效; +- Edit 目标缺失; +- Edit 匹配缺失或不唯一; +- immutable base envelope 畸形。 + +前三类是“operation completed with no workspace effect”,由 Runtime 转成 error proof,后续 owner 在确认没有 candidate 后提交 `operation_failed_no_effect`。Host envelope 畸形在 T1 前拒绝。 + +## 4. 非目标 + +- 不发布 candidate receipt; +- 不推进 accepted head; +- 不物化 projection; +- 不接 Desktop/CLI; +- 不允许 filesystem worker 获得 managed worktree 写权限。 diff --git a/docs/architecture/runtime-managed-mutation-settlement-proof-v1.zh-CN.md b/docs/architecture/runtime-managed-mutation-settlement-proof-v1.zh-CN.md new file mode 100644 index 0000000000..7b96cb4b42 --- /dev/null +++ b/docs/architecture/runtime-managed-mutation-settlement-proof-v1.zh-CN.md @@ -0,0 +1,62 @@ + + +# Runtime managed mutation settlement proof v1 + +## 1. 主要不变量 + +在一次存活的 Runtime execution 内,managed Write/Edit 的 provider result 与 durable response 由 Runtime 从同一个 strict-JSON snapshot 构造,并且只构造一个 response event。Host 可以选择并提交 workspace terminal,但不能替换或重新解释 provider 结果。 + +本切片不承诺跨进程的 transform invocation exactly-once。T2 前进程退出后,后续 recovery owner 可以基于同一个 durable operation、accepted base 与冻结参数重新计算无外部副作用的 deterministic transform;安全属性是“最多接受一个精确 successor”,不是“纯函数只调用一次”。 + +```text +Runtime-owned strict JSON result + -> immutable RuntimeEvent outcome proof + -> Host-owned Git/SQLite terminal commit + -> Runtime exact adoption +``` + +## 2. Owner 与权限 + +- Runtime 拥有原始 tool args、pure transform、provider result 和 response envelope。 +- Runtime 向 settlement owner 暴露只读 `RuntimeManagedMutationOperationProof`;其中的 `durableOutcome` 是冻结的精确事件。 +- no-change 或明确业务失败时,Runtime 额外签发与 T1 identity 绑定的 `terminalOutcome`。 +- Host 不能返回 execution args,也不能要求 Runtime 采用另一个 success result。 +- Storage 只通过 owner-bound execution-stores capability 暴露 head/version/reservation 读取和 successor/terminal 原子写入;裸 SQLite writer 不进入 Host API。 + +## 3. 原子性边界 + +- T1:function call、dispatch 和 durable reservation 在一个 SQLite transaction 中提交。 +- T2:tool response 与 successor/head,或 tool response 与 no-effect terminal/reservation release,在一个 SQLite transaction 中提交。 +- Git candidate receipt 与 SQLite 不是一个事务;候选 ref/receipt 只能作为派生证明,不能自行推进 accepted head。 + +## 4. 失败状态与回滚 + +- T1 前失败:不产生 reservation,可直接返回拒绝。 +- T1 后 proof 缺失、被修改或 owner 抛错:`unsettled`,禁止 generic T2 fallback。 +- no-change / failed-no-effect:使用 Runtime-issued terminal event 原子释放 reservation。 +- candidate 已产生但 SQLite 未接受:保持未接受派生物;如何 reopen、重新计算或 park 由后续 managed-recovery owner 明确定义。 +- SQLite 已接受但 Git accepted ref 尚未投影:由后续 accepted-ref projection slice 直接采用 durable candidate evidence 幂等推进,不依赖当前 transform 实现。 + +## 5. 平台承诺 + +本切片只改变 Runtime/SQLite capability seam,不执行平台文件 mutation: + +- Linux、macOS、Windows:同一 strict RuntimeEvent 与 SQLite transaction 合同。 +- Git ref promotion、filesystem projection 和 crash reconciliation 由后续切片分别提供平台证据。 diff --git a/packages/runtime/src/__tests__/managed-mutation-transform.test.ts b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts new file mode 100644 index 0000000000..9c7d3338f1 --- /dev/null +++ b/packages/runtime/src/__tests__/managed-mutation-transform.test.ts @@ -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 assert from 'node:assert/strict'; +import test from 'node:test'; +import { transformManagedMutation } from '../managed-mutation-transform.js'; + +test('derives Write from the immutable Git base without touching a checkout', () => { + const result = transformManagedMutation({ + toolName: 'Write', + canonicalPath: 'docs/hello.txt', + baseContent: 'before\n', + args: { path: 'docs/hello.txt', content: 'after\n' }, + }); + assert.equal(result.content, 'after\n'); + assert.equal(result.changed, true); + assert.equal((result.providerResult as { kind: string }).kind, 'file_diff'); +}); + +test('uses the production Edit matcher and rejects an absent target', () => { + const result = transformManagedMutation({ + toolName: 'Edit', + canonicalPath: 'src/value.ts', + baseContent: 'const value = 1;\n', + args: { + path: 'src/value.ts', + old_string: 'const value = 1;', + new_string: 'const value = 2;', + }, + }); + assert.equal(result.content, 'const value = 2;\n'); + assert.equal(result.changed, true); + assert.throws( + () => + transformManagedMutation({ + toolName: 'Edit', + canonicalPath: 'src/missing.ts', + baseContent: null, + args: { path: 'src/missing.ts', old_string: 'a', new_string: 'b' }, + }), + /does not exist/u, + ); +}); + +test('keeps the durable provider result bounded independently of file size', () => { + const content = `${'x'.repeat(2 * 1024 * 1024)}\n`; + const result = transformManagedMutation({ + toolName: 'Write', + canonicalPath: 'artifacts/large.txt', + baseContent: 'before\n', + args: { path: 'artifacts/large.txt', content }, + }); + + assert.equal(result.content, content); + assert.ok(Buffer.byteLength(JSON.stringify(result.providerResult), 'utf8') <= 512); +}); diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index e6a56f7c66..d47c1ab057 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -33,6 +33,7 @@ import { ToolRuntime, type MakaTool, type RuntimeManagedMutationAdmission, + type RuntimeManagedMutationSettlement, type ToolRuntimeInput, } from '../tool-runtime.js'; @@ -264,6 +265,166 @@ describe('ToolRuntime durable boundary', () => { ); }); + it('gives the settlement owner a Runtime-issued immutable outcome instead of result authority', async () => { + let observedOutcome: unknown; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => ({ + durableDispatch: managedMutationDispatch(), + immutableBase: Object.freeze({ content: 'before\n' }), + execute: async (operation) => { + const proof = await operation(); + observedOutcome = proof.durableOutcome; + assert.equal(proof.durableOutcome.content?.kind, 'function_response'); + assert.equal(proof.durableOutcome.content?.result, proof.content); + assert.equal(Object.isFrozen(proof.durableOutcome), true); + return { + kind: 'workspace_successor_committed', + durableOutcome: proof.durableOutcome, + }; + }, + dispose: async () => undefined, + }), + }, + ); + const managedTool = tool(() => { + throw new Error('ordinary mutable implementation must not run'); + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await harness.executeWithInput(managedTool, { path: 'notes.txt', content: 'after\n' }); + assert.ok(observedOutcome); + }); + + it('issues the exact no-change terminal fact from the Runtime-owned transform result', async () => { + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => ({ + durableDispatch: managedMutationDispatch(), + immutableBase: Object.freeze({ content: 'same\n' }), + execute: async (operation) => { + const proof = await operation(); + assert.equal(proof.mutationResult?.changed, false); + assert.equal(proof.terminalOutcome?.kind, 'no_workspace_change'); + assert.equal( + proof.durableOutcome, + proof.terminalOutcome?.durableOutcome, + 'one operation must expose one canonical durable outcome event', + ); + assert.deepEqual(proof.terminalOutcome?.durableOutcome.actions, { + stateDelta: { durationMs: proof.durationMs }, + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1', + operationId: proof.durableOutcome.refs?.operationId, + dispatchEventId: `${proof.durableOutcome.refs?.operationId}_dispatch`, + workspaceInstanceId: managedMutationDispatch().workspaceInstanceId, + terminalKind: 'no_workspace_change', + }, + }); + return { + kind: 'no_workspace_change_committed', + durableOutcome: proof.terminalOutcome!.durableOutcome, + }; + }, + dispose: async () => undefined, + }), + }, + ); + const managedTool = tool(() => { + throw new Error('ordinary mutable implementation must not run'); + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = await harness.executeWithInput(managedTool, { + path: 'notes.txt', + content: 'same\n', + }); + assert.equal((result as { kind?: unknown }).kind, 'file_write'); + }); + + it('ignores owner-supplied execution args while retaining Runtime-owned managed arguments', async () => { + let operationId = ''; + let mutationResult: unknown; + const canonicalPath = 'notes.txt'; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + return { + durableDispatch: managedMutationDispatch(canonicalPath), + immutableBase: Object.freeze({ content: 'BEFORE\n' }), + canonicalPath, + // Deliberately shaped like the old over-broad Host seam. Runtime + // must ignore every owner-supplied argument except canonicalPath. + executionArgs: { + path: canonicalPath, + content: 'HOST REPLACED CONTENT', + }, + execute: async (operation) => { + const proof = await operation(); + mutationResult = proof.mutationResult; + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }, + dispose: async () => undefined, + } as RuntimeManagedMutationAdmission & { readonly executionArgs: unknown }; + }, + }, + ); + const managedTool = tool(() => { + throw new Error('ordinary mutable implementation must not run'); + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.managedMutationTransform = () => { + throw new Error('Host-owned immutable base must select the Runtime transform'); + }; + + const result = await harness.executeWithInput(managedTool, { + path: 'notes.txt', + content: 'RUNTIME ORIGINAL CONTENT', + }); + assert.equal((result as { kind?: unknown }).kind, 'file_diff'); + assert.deepEqual(mutationResult, { + path: canonicalPath, + content: 'RUNTIME ORIGINAL CONTENT', + changed: true, + }); + }); + it('does not replace a committed managed result when admission cleanup fails', async () => { let operationId = ''; const harness = makeHarness( @@ -577,22 +738,17 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); + assert.equal(proof.terminalOutcome?.kind, 'operation_failed_no_effect'); return { kind: 'operation_failed_no_effect_committed', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + durableOutcome: proof.terminalOutcome!.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ({ error: 'candidate was safely discarded' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -620,19 +776,17 @@ describe('ToolRuntime durable boundary', () => { { admitManagedMutation: async (input) => { operationId = input.operationId; - return managedAdmission(async (operation) => { - await operation(); - const result = { ok: true, changed: false }; - return { - kind: 'no_workspace_change_committed', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - false, - ), - }; - }); + return { + ...managedAdmission(async (operation) => { + const proof = await operation(); + assert.equal(proof.terminalOutcome?.kind, 'no_workspace_change'); + return { + kind: 'no_workspace_change_committed', + durableOutcome: proof.terminalOutcome!.durableOutcome, + }; + }), + immutableBase: Object.freeze({ content: 'same' }), + }; }, }, ); @@ -641,13 +795,16 @@ describe('ToolRuntime durable boundary', () => { managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; - assert.deepEqual(await harness.execute(managedTool), { ok: true, changed: false }); + assert.deepEqual( + await harness.executeWithInput(managedTool, { path: 'notes.txt', content: 'same' }), + { kind: 'file_write', path: 'notes.txt', bytes: 4 }, + ); const published = harness.events.at(-1); assert.equal(published?.type, 'tool_result'); assert.equal(published?.type === 'tool_result' && published.isError, false); }); - it('snapshots a safe-discard result before its owner can mutate it', async () => { + it('ignores a mutable provider result smuggled across the owner boundary', async () => { let operationId = ''; const ownerResult = { error: 'discarded-A' }; const appendedMessages: StoredMessage[] = []; @@ -668,21 +825,18 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); + assert.equal(proof.terminalOutcome?.kind, 'operation_failed_no_effect'); return { kind: 'operation_failed_no_effect_committed', providerResult: ownerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded-A' } }, - true, - ), - }; + durableOutcome: proof.terminalOutcome!.durableOutcome, + } as unknown as RuntimeManagedMutationSettlement; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ({ error: 'runtime-owned-A' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -691,11 +845,11 @@ describe('ToolRuntime durable boundary', () => { const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); assert.equal(ownerResult.error, 'mutated-B'); - assert.deepEqual(result, { error: 'discarded-A' }); + assert.deepEqual(result, { error: 'runtime-owned-A' }); assert.equal(Object.isFrozen(result), true); assert.deepEqual(storedResult?.type === 'tool_result' ? storedResult.content : undefined, { kind: 'json', - value: { error: 'discarded-A' }, + value: { error: 'runtime-owned-A' }, }); }); @@ -717,15 +871,11 @@ describe('ToolRuntime durable boundary', () => { operationId = input.operationId; return managedAdmission(async (operation) => { retainedOperation = operation; - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); + assert.equal(proof.terminalOutcome?.kind, 'operation_failed_no_effect'); return { kind: 'operation_failed_no_effect_committed', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + durableOutcome: proof.terminalOutcome!.durableOutcome, }; }); }, @@ -733,7 +883,7 @@ describe('ToolRuntime durable boundary', () => { ); const managedTool = tool(() => { implementationCalls += 1; - return { ok: true }; + return { error: 'candidate was safely discarded' }; }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; @@ -744,7 +894,7 @@ describe('ToolRuntime durable boundary', () => { }); assert.ok(retainedOperation); await assert.rejects(retainedOperation(), /operation capability is closed/i); - assert.equal(implementationCalls, 0); + assert.equal(implementationCalls, 1); }); it('does not accept terminal settlement while a detached operation is running', async () => { @@ -770,7 +920,6 @@ describe('ToolRuntime durable boundary', () => { const result = { error: 'candidate was safely discarded' }; return { kind: 'operation_failed_no_effect_committed', - providerResult: result, durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: result }, @@ -807,7 +956,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('rejects a safe discard whose live error differs from its durable result', async () => { + it('rejects a terminal proof whose durable result differs from the Runtime result', async () => { let operationId = ''; const harness = makeHarness( { @@ -825,7 +974,6 @@ describe('ToolRuntime durable boundary', () => { await operation(); return { kind: 'operation_failed_no_effect_committed', - providerResult: { error: 'live provider error A' }, durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: { error: 'durable replay error B' } }, @@ -848,108 +996,6 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('fail-stops safe-discard canonicalization without writing generic T2', async () => { - let genericOutcomeCalls = 0; - let operationId = ''; - const providerResult = Object.defineProperty({}, 'kind', { - enumerable: true, - get: () => { - throw new Error('provider result getter exploded'); - }, - }); - const harness = makeHarness( - { - commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), - commitToolOutcome: async () => { - genericOutcomeCalls += 1; - return { created: true, runtimeEventSeq: 2 }; - }, - }, - undefined, - 'run-1', - { - admitManagedMutation: async (input) => { - operationId = input.operationId; - return managedAdmission(async (operation) => { - await operation(); - return { - kind: 'operation_failed_no_effect_committed', - providerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded' } }, - true, - ), - }; - }); - }, - }, - ); - const managedTool = tool(() => ({ ok: true })); - managedTool.name = 'Write'; - managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; - - await assert.rejects( - harness.execute(managedTool), - /strict JSON.*accessor|provider result getter exploded|byte limit exceeded/i, - ); - assert.equal(genericOutcomeCalls, 0); - assert.equal( - harness.events.some((event) => event.type === 'tool_result'), - false, - ); - }); - - it('fail-stops an oversized safe discard before durable publication', async () => { - let genericOutcomeCalls = 0; - let operationId = ''; - const oversized = { error: 'x'.repeat(128) }; - const harness = makeHarness( - { - commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), - commitToolOutcome: async () => { - genericOutcomeCalls += 1; - return { created: true, runtimeEventSeq: 2 }; - }, - }, - undefined, - 'run-1', - { - admitManagedMutation: async (input) => { - operationId = input.operationId; - return managedAdmission(async (operation) => { - await operation(); - return { - kind: 'operation_failed_no_effect_committed', - providerResult: oversized, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: oversized }, - true, - { - origin: 'code_mode', - modelVisibility: 'hidden', - toolCallId: 'nested-call-1', - parentToolCallId: 'exec-1', - parentOperationId: 'exec-op-1', - }, - ), - }; - }); - }, - }, - ); - const managedTool = tool(() => ({ ok: true })); - managedTool.name = 'Write'; - managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; - - await assert.rejects(harness.executeNested(managedTool, 32), /byte limit exceeded/i); - assert.equal(genericOutcomeCalls, 0); - assert.equal(JSON.stringify(harness.events).includes(oversized.error), false); - }); - it('stops snapshot traversal as soon as a managed result exceeds its byte budget', async () => { let genericOutcomeCalls = 0; let lateGetterReads = 0; @@ -1585,6 +1631,22 @@ function makeHarness( }, }) ).result, + executeWithInput: async (target: MakaTool, input: unknown) => + ( + await runtime.settleToolCall({ + tool: target, + turnId: 'turn-1', + toolCallId: 'provider-call-1', + input, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }) + ).result, executeNested: async (target: MakaTool, maxResultBytes?: number) => ( await runtime.settleToolCall({ @@ -1632,6 +1694,7 @@ function managedOutcomeEvent( toolCallId?: string; parentToolCallId?: string; parentOperationId?: string; + terminalKind?: 'no_workspace_change' | 'operation_failed_no_effect'; } = {}, ) { const toolCallId = options.toolCallId ?? 'provider-call-1'; @@ -1660,11 +1723,24 @@ function managedOutcomeEvent( ...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}), ...(options.parentOperationId ? { parentOperationId: options.parentOperationId } : {}), }, - actions: { stateDelta: { durationMs: options.durationMs ?? 0 } }, + actions: { + stateDelta: { durationMs: options.durationMs ?? 0 }, + ...(options.terminalKind + ? { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1' as const, + operationId, + dispatchEventId: `${operationId}_dispatch`, + workspaceInstanceId: managedMutationDispatch().workspaceInstanceId, + terminalKind: options.terminalKind, + }, + } + : {}), + }, }; } -function managedMutationDispatch() { +function managedMutationDispatch(expectedPath = 'notes.txt') { return { protocol: 'managed_mutation_v2' as const, repositoryId: 'repository_11111111111111111111111111111111', @@ -1677,7 +1753,7 @@ function managedMutationDispatch() { baseHeadRevision: 1, baseCommitOid: '1'.repeat(40), baseTreeOid: '2'.repeat(40), - expectedPath: 'notes.txt', + expectedPath, pathPolicyVersion: 3 as const, executionProfileDigest: 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, diff --git a/packages/runtime/src/managed-mutation-transform.ts b/packages/runtime/src/managed-mutation-transform.ts new file mode 100644 index 0000000000..b24785b36d --- /dev/null +++ b/packages/runtime/src/managed-mutation-transform.ts @@ -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 { computeEditedSource } from './edit-replace.js'; +import { createUnifiedDiff } from './unified-diff.js'; + +export interface ManagedMutationTransformResult { + readonly content: string; + readonly providerResult: unknown; + readonly changed: boolean; +} + +export const MANAGED_MUTATION_CANDIDATE_REJECTED_MESSAGE = + 'Managed workspace candidate was rejected before publication' as const; + +/** + * Pure Write/Edit transform for Git-backed managed workspaces. It never reads + * or writes a checkout: the accepted Git tree supplies the sole base content. + */ +export function transformManagedMutation(input: { + readonly toolName: 'Write' | 'Edit'; + readonly canonicalPath: string; + readonly baseContent: string | null; + readonly args: unknown; +}): ManagedMutationTransformResult { + const args = requireArgs(input.args); + if (args.path !== input.canonicalPath) { + throw new Error('Managed mutation path does not match its canonical path'); + } + if (input.toolName === 'Write') { + if (typeof args.content !== 'string') throw new Error('Managed Write content is invalid'); + const diff = createUnifiedDiff( + input.canonicalPath, + input.baseContent ?? undefined, + args.content, + ); + return Object.freeze({ + content: args.content, + changed: input.baseContent !== args.content, + providerResult: + diff === undefined + ? Object.freeze({ + kind: 'file_write' as const, + path: input.canonicalPath, + bytes: Buffer.byteLength(args.content, 'utf8'), + }) + : Object.freeze({ + kind: 'file_diff' as const, + paths: Object.freeze([input.canonicalPath]), + diff, + }), + }); + } + if (input.baseContent === null) throw new Error('Managed Edit target does not exist'); + if (typeof args.old_string !== 'string' || typeof args.new_string !== 'string') { + throw new Error('Managed Edit arguments are invalid'); + } + const edited = computeEditedSource( + input.baseContent, + args.old_string, + args.new_string, + input.canonicalPath, + ); + const diff = createUnifiedDiff(input.canonicalPath, input.baseContent, edited.content); + return Object.freeze({ + content: edited.content, + changed: edited.content !== input.baseContent, + providerResult: + diff === undefined + ? Object.freeze({ + ok: true, + path: input.canonicalPath, + replacements: 1, + matchedVia: edited.matchedVia, + startLine: edited.startLine, + endLine: edited.endLine, + }) + : Object.freeze({ + kind: 'file_diff' as const, + paths: Object.freeze([input.canonicalPath]), + diff, + }), + }); +} + +function requireArgs(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed mutation arguments are invalid'); + } + return value as Record; +} diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a5d612072a..7ca3f0a571 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -93,6 +93,7 @@ import { import { AdmissionLimiter } from './admission-limiter.js'; import type { AgentProfile } from './agent-catalog.js'; import type { SubagentExecutionRef } from './subagent-execution.js'; +import { transformManagedMutation } from './managed-mutation-transform.js'; import { SandboxCommandError, sandboxErrorMetadata, @@ -405,6 +406,13 @@ interface RuntimeManagedMutationOperationValue { readonly isError: boolean; readonly durationMs: number; }; + readonly mutationResult?: RuntimeManagedMutationResultProof; +} + +export interface RuntimeManagedMutationResultProof { + readonly path: string; + readonly content: string; + readonly changed: boolean; } /** @@ -416,6 +424,14 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + readonly mutationResult?: RuntimeManagedMutationResultProof; + /** Exact immutable provider outcome issued by Runtime for atomic owner commit. */ + readonly durableOutcome: RuntimeEvent; + /** Exact no-effect terminal fact, present only when Runtime can prove it. */ + readonly terminalOutcome?: Readonly<{ + kind: 'no_workspace_change' | 'operation_failed_no_effect'; + durableOutcome: RuntimeEvent; + }>; } export type RuntimeManagedMutationSettlement = @@ -425,14 +441,14 @@ export type RuntimeManagedMutationSettlement = } | { readonly kind: 'no_workspace_change_committed' | 'operation_failed_no_effect_committed'; - /** Exact value returned to the provider and canonicalized for durable replay. */ - readonly providerResult: unknown; readonly durableOutcome: RuntimeEvent; } | { readonly kind: 'unsettled'; readonly error: unknown }; export interface RuntimeManagedMutationAdmission { readonly durableDispatch: Readonly; + /** Immutable accepted-tree input. It grants no permission to rewrite tool arguments. */ + readonly immutableBase?: Readonly<{ content: string | null }>; execute( operation: () => Promise, ): Promise; @@ -443,6 +459,12 @@ export interface RuntimeManagedMutationAdmission { interface DurableToolAttempt { operationId: string; responseEventId: string; + prepareOutcome( + result: unknown, + isError: boolean, + durationMs?: number, + terminalKind?: 'no_workspace_change' | 'operation_failed_no_effect', + ): RuntimeEvent; commitOutcome( result: unknown, isError: boolean, @@ -453,6 +475,7 @@ interface DurableToolAttempt { result: ToolResultContent, isError: boolean, durationMs: number, + terminalKind?: 'no_workspace_change' | 'operation_failed_no_effect', ): { id: string; operationId: string; ts: number }; } @@ -1362,7 +1385,6 @@ export class ToolRuntime { if ( (tool.name !== 'Write' && tool.name !== 'Edit') || tool.recoveryMode !== 'reconcile' || - !tool.managedMutationTransform || !dispatchOperationId || !this.input.runtimeCommitSink || !this.input.admitManagedMutation @@ -1379,6 +1401,17 @@ export class ToolRuntime { persistedArgs: structuredClone(persistedArgs), abortSignal: ctx.abortSignal, }); + const immutableBaseContent = managedMutationAdmission.immutableBase?.content; + if ( + managedMutationAdmission.immutableBase && + immutableBaseContent !== null && + typeof immutableBaseContent !== 'string' + ) { + throw new Error('Managed workspace mutation immutable base is invalid'); + } + if (!managedMutationAdmission.immutableBase && !tool.managedMutationTransform) { + throw new Error('Managed workspace mutation has no immutable transform input'); + } } catch (error) { const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; await refuseBeforeDispatch(reason); @@ -1530,12 +1563,36 @@ export class ToolRuntime { queue, ), }); - const invokeManagedTransform = () => - tool.managedMutationTransform!(structuredClone(executionArgs) as never); const prepareOperationValue = async ( immutableSnapshot = false, ): Promise> => { - const rawResult = await (immutableSnapshot ? invokeManagedTransform() : invokeTool()); + let mutationResult: RuntimeManagedMutationResultProof | undefined; + let rawResult: unknown; + if (immutableSnapshot && managedMutationAdmission?.immutableBase) { + try { + if (tool.name !== 'Write' && tool.name !== 'Edit') { + throw new Error('Managed mutation transform is unavailable for this tool'); + } + const transformed = transformManagedMutation({ + toolName: tool.name, + canonicalPath: managedMutationAdmission.durableDispatch.expectedPath, + baseContent: managedMutationAdmission.immutableBase.content, + args: structuredClone(executionArgs), + }); + rawResult = transformed.providerResult; + mutationResult = Object.freeze({ + path: managedMutationAdmission.durableDispatch.expectedPath, + content: transformed.content, + changed: transformed.changed, + }); + } catch (error) { + rawResult = this.errorReturn(formatSyntheticToolErrorText(error)); + } + } else { + rawResult = await (immutableSnapshot + ? tool.managedMutationTransform!(structuredClone(executionArgs) as never) + : invokeTool()); + } const result = immutableSnapshot ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) : rawResult; @@ -1557,6 +1614,7 @@ export class ToolRuntime { const value = { result, outcome: immutableSnapshot ? Object.freeze(outcome) : outcome, + ...(mutationResult ? { mutationResult } : {}), }; return immutableSnapshot ? Object.freeze(value) : value; }; @@ -1565,6 +1623,7 @@ export class ToolRuntime { kind: 'managed'; value: RuntimeManagedMutationOperationValue; durableOutcome: RuntimeEvent; + terminalKind?: 'no_workspace_change' | 'operation_failed_no_effect'; } | { kind: 'generic'; value: RuntimeManagedMutationOperationValue }; if (managedMutationAdmission) { @@ -1587,12 +1646,36 @@ export class ToolRuntime { try { const value = await prepareOperationValue(true); runtimeOwnedValue = value; + if (!durableAttempt) { + throw new Error('Managed mutation operation has no durable T1 attempt'); + } + const terminalKind = value.outcome.isError + ? ('operation_failed_no_effect' as const) + : value.mutationResult && !value.mutationResult.changed + ? ('no_workspace_change' as const) + : undefined; + const durableOutcome = durableAttempt.prepareOutcome( + value.outcome.content, + value.outcome.isError, + value.outcome.durationMs, + terminalKind, + ); return { // The canonical content is already recursively immutable, so // the owner can read it without receiving a mutable alias. content: value.outcome.content, isError: value.outcome.isError, durationMs: value.outcome.durationMs, + ...(value.mutationResult ? { mutationResult: value.mutationResult } : {}), + durableOutcome, + ...(terminalKind + ? { + terminalOutcome: Object.freeze({ + kind: terminalKind, + durableOutcome, + }), + } + : {}), }; } finally { if (operationLifecycle.state === 'running') { @@ -1638,7 +1721,7 @@ export class ToolRuntime { // discarded; every other failure remains unsettled for recovery. throw new RuntimeManagedMutationUnsettledError(ownerError); } - const normalized = normalizeManagedMutationSettlement(settlement, ctx.maxResultBytes); + const normalized = normalizeManagedMutationSettlement(settlement); if (normalized.kind === 'workspace_successor_committed') { if (!runtimeOwnedValue) { throw new RuntimeManagedMutationUnsettledError( @@ -1653,10 +1736,19 @@ export class ToolRuntime { durableOutcome: normalized.durableOutcome, }; } else { + if (!runtimeOwnedValue) { + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation owner committed a terminal state without execution'), + ); + } settledExecution = { kind: 'managed', - value: normalized.value, + value: runtimeOwnedValue, durableOutcome: normalized.durableOutcome, + terminalKind: + normalized.kind === 'no_workspace_change_committed' + ? 'no_workspace_change' + : 'operation_failed_no_effect', }; } } else { @@ -1682,6 +1774,7 @@ export class ToolRuntime { content, outcome.isError, durationMs, + settledExecution.terminalKind, ); } else { durableOutcome = await durableAttempt?.commitOutcome( @@ -2111,6 +2204,7 @@ export class ToolRuntime { isError: boolean, durationMs: number | undefined, ts: number, + terminalKind?: 'no_workspace_change' | 'operation_failed_no_effect', ): RuntimeEvent => ({ id: `${operationId}_response`, invocationId, @@ -2140,12 +2234,50 @@ export class ToolRuntime { ? { parentOperationId: input.startEvent.parentOperationId } : {}), }, - ...(durationMs !== undefined ? { actions: { stateDelta: { durationMs } } } : {}), + ...(durationMs !== undefined || terminalKind + ? { + actions: { + ...(durationMs !== undefined ? { stateDelta: { durationMs } } : {}), + ...(terminalKind + ? { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1' as const, + operationId, + dispatchEventId: `${operationId}_dispatch`, + workspaceInstanceId: input.managedMutation!.workspaceInstanceId, + terminalKind, + }, + } + : {}), + }, + } + : {}), }); let committedOutcome: { id: string; operationId: string; ts: number } | undefined; return { operationId, responseEventId: `${operationId}_response`, + prepareOutcome: (result, isError, durationMs, terminalKind) => { + if (terminalKind && !input.managedMutation) { + throw new Error('Managed mutation terminal outcome has no durable mutation identity'); + } + const responseEvent = buildResponseEvent( + result, + isError, + durationMs, + this.input.now(), + terminalKind, + ); + decodeRuntimeEvent(responseEvent); + if (responseEvent.content) Object.freeze(responseEvent.content); + if (responseEvent.refs) Object.freeze(responseEvent.refs); + if (responseEvent.actions?.stateDelta) Object.freeze(responseEvent.actions.stateDelta); + if (responseEvent.actions?.managedMutationTerminal) { + Object.freeze(responseEvent.actions.managedMutationTerminal); + } + if (responseEvent.actions) Object.freeze(responseEvent.actions); + return Object.freeze(responseEvent); + }, commitOutcome: async (result, isError, durationMs) => { if (committedOutcome) return committedOutcome; const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); @@ -2169,9 +2301,9 @@ export class ToolRuntime { ); return committedOutcome; }, - adoptCommittedOutcome: (event, result, isError, durationMs) => { + adoptCommittedOutcome: (event, result, isError, durationMs, terminalKind) => { if (committedOutcome) return committedOutcome; - const expected = buildResponseEvent(result, isError, durationMs, event.ts); + const expected = buildResponseEvent(result, isError, durationMs, event.ts, terminalKind); if (!Number.isFinite(event.ts) || !isDeepStrictEqual(event, expected)) { throw new RuntimeCommitBoundaryError( 'T2', @@ -3194,17 +3326,13 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } -function normalizeManagedMutationSettlement( - settlement: unknown, - maxResultBytes: number | undefined, -): +function normalizeManagedMutationSettlement(settlement: unknown): | { kind: 'workspace_successor_committed'; durableOutcome: RuntimeEvent; } | { kind: 'no_workspace_change_committed' | 'operation_failed_no_effect_committed'; - value: RuntimeManagedMutationOperationValue; durableOutcome: RuntimeEvent; } { if (!settlement || typeof settlement !== 'object' || Array.isArray(settlement)) { @@ -3241,10 +3369,6 @@ function normalizeManagedMutationSettlement( }; } - if (!Object.hasOwn(record, 'providerResult')) { - throw new Error('Managed no-effect settlement has no provider result'); - } - const providerResult = snapshotManagedToolResult(record.providerResult, maxResultBytes); const response = durableOutcome.content; const expectedError = kind === 'operation_failed_no_effect_committed'; if ( @@ -3253,21 +3377,8 @@ function normalizeManagedMutationSettlement( ) { throw new Error('Managed no-effect settlement has the wrong durable outcome state'); } - const content = Object.freeze(coerceResultContent(providerResult)); - const outcome = Object.freeze({ - content, - isError: expectedError, - durationMs: - typeof durableOutcome.actions?.stateDelta?.durationMs === 'number' - ? durableOutcome.actions.stateDelta.durationMs - : 0, - }); return { kind, - value: Object.freeze({ - result: providerResult, - outcome, - }), durableOutcome, }; } diff --git a/packages/storage/package.json b/packages/storage/package.json index cbcbaddc18..e6e19eeaa7 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -17,6 +17,7 @@ "./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", + "./execution-stores-workspace-authority-internal": "./dist/execution-stores-workspace-authority-internal.js", "./external-sessions": "./dist/external-sessions.js", "./file-lifetime-owner": "./dist/file-lifetime-owner.js", "./file-update-lock": "./dist/file-update-lock.js", diff --git a/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts new file mode 100644 index 0000000000..0642a0bcb5 --- /dev/null +++ b/packages/storage/src/__tests__/execution-stores-workspace-authority-internal.test.ts @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES 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 { RuntimeEvent } from '@maka/core/runtime-event'; +import { openInteractiveExecutionStoresForWrite } from '../execution-stores.js'; +import { + issueExecutionStoresWorkspaceMutationAuthorityInternal, + requireExecutionStoresWorkspaceMutationAuthorityInternal, +} from '../execution-stores-workspace-authority-internal.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; + +test('binds workspace mutation persistence to one execution-stores owner capability', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-execution-workspace-authority-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + if (!rootOwner) return; + const stores = await openInteractiveExecutionStoresForWrite(rootOwner.lease); + try { + const ownerToken = {}; + const authorityCapability = issueExecutionStoresWorkspaceMutationAuthorityInternal({ + ownerToken, + stores, + verifyCandidate: () => { + throw new Error('not used'); + }, + }); + assert.throws( + () => requireExecutionStoresWorkspaceMutationAuthorityInternal({}, authorityCapability), + /capability is invalid/i, + ); + const authority = requireExecutionStoresWorkspaceMutationAuthorityInternal( + ownerToken, + authorityCapability, + ); + assert.equal( + await authority.readHead( + 'workspace_'.concat('1'.repeat(32)), + 'epoch_'.concat('2'.repeat(32)), + ), + undefined, + ); + } finally { + await stores.sessionStore.close?.(); + await rootOwner.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('rejects a no-effect proof issued by another execution store', async () => { + const rootA = await mkdtemp(join(tmpdir(), 'maka-no-effect-authority-a-')); + const rootB = await mkdtemp(join(tmpdir(), 'maka-no-effect-authority-b-')); + const [capabilityA, capabilityB] = await Promise.all([ + resolveStorageRoot({ path: rootA, kind: 'interactive' }), + resolveStorageRoot({ path: rootB, kind: 'interactive' }), + ]); + const [rootOwnerA, rootOwnerB] = await Promise.all([ + tryAcquireInteractiveRootOwner(capabilityA), + tryAcquireInteractiveRootOwner(capabilityB), + ]); + assert.ok(rootOwnerA); + assert.ok(rootOwnerB); + if (!rootOwnerA || !rootOwnerB) return; + const [storesA, storesB] = await Promise.all([ + openInteractiveExecutionStoresForWrite(rootOwnerA.lease), + openInteractiveExecutionStoresForWrite(rootOwnerB.lease), + ]); + try { + const ownerTokenA = {}; + const ownerTokenB = {}; + const authorityA = requireExecutionStoresWorkspaceMutationAuthorityInternal( + ownerTokenA, + issueExecutionStoresWorkspaceMutationAuthorityInternal({ + ownerToken: ownerTokenA, + stores: storesA, + verifyCandidate: () => { + throw new Error('not used'); + }, + }), + ); + const authorityB = requireExecutionStoresWorkspaceMutationAuthorityInternal( + ownerTokenB, + issueExecutionStoresWorkspaceMutationAuthorityInternal({ + ownerToken: ownerTokenB, + stores: storesB, + verifyCandidate: () => { + throw new Error('not used'); + }, + }), + ); + const claim = { + operationId: 'operation-cross-store', + dispatchEventId: 'dispatch-cross-store', + workspaceInstanceId: `instance_${'8'.repeat(32)}`, + terminalKind: 'no_workspace_change' as const, + }; + const runtimeEvent: RuntimeEvent = { + id: 'outcome-cross-store', + sessionId: 'session-cross-store', + invocationId: 'run-cross-store', + runId: 'run-cross-store', + turnId: 'turn-cross-store', + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'call-cross-store', + name: 'Write', + result: 'unchanged', + }, + actions: { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1', + ...claim, + }, + }, + refs: { operationId: claim.operationId, toolCallId: 'call-cross-store' }, + }; + + await assert.rejects( + async () => + authorityB.commitTerminal({ + noEffectOutcome: authorityA.issueNoEffectOutcome(claim), + toolOutcome: { + operationId: claim.operationId, + journalEventId: 'journal-cross-store', + runtimeEvent, + committedAt: 2, + }, + }), + /no-effect proof is invalid/i, + ); + } finally { + await Promise.all([storesA.sessionStore.close?.(), storesB.sessionStore.close?.()]); + await Promise.all([rootOwnerA.close(), rootOwnerB.close()]); + await Promise.all([ + rm(rootA, { recursive: true, force: true }), + rm(rootB, { recursive: true, force: true }), + ]); + } +}); diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 26fecc1d58..31611f44d0 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -43,6 +43,7 @@ import { commitWorkspaceBaselineInternal, commitWorkspaceSuccessorInternal, readActiveManagedMutationInternal, + readWorkspaceVersionInternal, registerManagedMutationNoEffectVerifierInternal, registerWorkspaceSuccessorCandidateVerifierInternal, type ManagedMutationNoEffectClaimV1, @@ -154,6 +155,10 @@ describe('workspace version persistence authority', () => { (await store.readWorkspaceVersion(input.baseline.workspaceVersionId))?.origin.kind, 'baseline', ); + assert.equal( + (await readWorkspaceVersionInternal(store, input.baseline.workspaceVersionId))?.origin.kind, + 'baseline', + ); assert.deepEqual( await store.readWorkspaceHead(input.epoch.workspaceId, input.epoch.workspaceEpochId), first.head, diff --git a/packages/storage/src/execution-stores-workspace-authority-internal.ts b/packages/storage/src/execution-stores-workspace-authority-internal.ts new file mode 100644 index 0000000000..5495a8ce80 --- /dev/null +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES 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 { + WorkspaceHeadRecordV1, + WorkspaceSuccessorAuthorityInput, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; +import { + adoptWorkspaceBaselineAuthorityStoreRootInternal, + commitManagedMutationTerminalInternal, + commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, + readWorkspaceHeadInternal, + readWorkspaceVersionInternal, + registerManagedMutationNoEffectVerifierInternal, + registerWorkspaceSuccessorCandidateVerifierInternal, + type ManagedMutationNoEffectClaimV1, + type ManagedMutationReservationRecordV1, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, + type WorkspaceSuccessorCommitInput, + type WorkspaceSuccessorCommitResult, +} from './workspace-version-authority-internal.js'; + +export interface ExecutionStoresWorkspaceMutationAuthorityCapabilityInternal { + readonly kind: 'execution_stores_workspace_mutation_authority_v1'; +} + +export interface ExecutionStoresWorkspaceMutationAuthorityInternal { + readHead( + workspaceId: string, + workspaceEpochId: string, + ): Promise; + readVersion(workspaceVersionId: string): Promise; + readActiveMutation( + workspaceInstanceId: string, + ): Promise; + issueNoEffectOutcome(claim: ManagedMutationNoEffectClaimV1): object; + commitSuccessor(input: WorkspaceSuccessorCommitInput): Promise; + commitTerminal( + input: ManagedMutationTerminalCommitInput, + ): Promise; +} + +interface AuthoritySource { + readonly store: object; + readonly rootId: string; +} + +interface AuthorityCapabilityRecord extends AuthoritySource { + readonly ownerToken: object; +} + +interface NoEffectCapabilityRecord extends AuthoritySource { + readonly ownerToken: object; + readonly claim: ManagedMutationNoEffectClaimV1; +} + +const sources = new WeakMap(); +const capabilities = new WeakMap(); +const noEffectCapabilities = new WeakMap(); + +export function registerExecutionStoresWorkspaceMutationSourceInternal( + stores: object, + store: object, + rootId: string, +): void { + if (sources.has(stores) || !/^[0-9a-f]{64}$/u.test(rootId)) { + throw new Error('Execution stores workspace mutation source is invalid'); + } + sources.set(stores, Object.freeze({ store, rootId })); + registerManagedMutationNoEffectVerifierInternal(store, (capability) => { + const record = noEffectCapabilities.get(capability); + if (!record || record.store !== store || record.rootId !== rootId) { + throw new Error('Managed mutation no-effect proof is invalid'); + } + return record.claim; + }); +} + +export function issueExecutionStoresWorkspaceMutationAuthorityInternal(input: { + readonly ownerToken: object; + readonly stores: object; + readonly verifyCandidate: (candidateOutcome: object) => WorkspaceSuccessorAuthorityInput; +}): ExecutionStoresWorkspaceMutationAuthorityCapabilityInternal { + const source = sources.get(input.stores); + if (!source) throw new Error('Execution stores workspace mutation source is unavailable'); + adoptWorkspaceBaselineAuthorityStoreRootInternal(source.store, source.rootId); + registerWorkspaceSuccessorCandidateVerifierInternal(source.store, input.verifyCandidate); + const capability = Object.freeze({ + kind: 'execution_stores_workspace_mutation_authority_v1' as const, + }); + capabilities.set( + capability, + Object.freeze({ ownerToken: input.ownerToken, store: source.store, rootId: source.rootId }), + ); + return capability; +} + +export function requireExecutionStoresWorkspaceMutationAuthorityInternal( + ownerToken: object, + capability: ExecutionStoresWorkspaceMutationAuthorityCapabilityInternal, +): ExecutionStoresWorkspaceMutationAuthorityInternal { + const record = capabilities.get(capability); + if (!record || record.ownerToken !== ownerToken) { + throw new Error('Execution stores workspace mutation authority capability is invalid'); + } + const store = record.store; + return Object.freeze({ + readHead: (workspaceId: string, workspaceEpochId: string) => + readWorkspaceHeadInternal(store, workspaceId, workspaceEpochId), + readVersion: (workspaceVersionId: string) => + readWorkspaceVersionInternal(store, workspaceVersionId), + readActiveMutation: (workspaceInstanceId: string) => + readActiveManagedMutationInternal(store, workspaceInstanceId), + issueNoEffectOutcome: (claim: ManagedMutationNoEffectClaimV1) => { + const noEffectOutcome = Object.freeze({}); + noEffectCapabilities.set( + noEffectOutcome, + Object.freeze({ + ownerToken, + store, + rootId: record.rootId, + claim: structuredClone(claim), + }), + ); + return noEffectOutcome; + }, + commitSuccessor: (input: WorkspaceSuccessorCommitInput) => + commitWorkspaceSuccessorInternal(store, input), + commitTerminal: (input: ManagedMutationTerminalCommitInput) => { + const noEffect = noEffectCapabilities.get(input.noEffectOutcome); + if ( + !noEffect || + noEffect.ownerToken !== ownerToken || + noEffect.store !== store || + noEffect.rootId !== record.rootId + ) { + throw new Error('Managed mutation no-effect proof is invalid'); + } + return commitManagedMutationTerminalInternal(store, input); + }, + }); +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 33bdf087e7..2cbc0463d0 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -73,6 +73,7 @@ import type { ToolCommitResult, ToolOperationRecord, } from './sqlite-runtime-store.js'; +import { registerExecutionStoresWorkspaceMutationSourceInternal } from './execution-stores-workspace-authority-internal.js'; const executionStoresWriterBrand: unique symbol = Symbol('ExecutionStoresWriter'); const executionStoresReaderBrand: unique symbol = Symbol('ExecutionStoresReader'); @@ -562,6 +563,11 @@ async function createExecutionStoresForWrite runtimePersistence.runtimeCommitStore.listUnsettledToolOperations(sessionId)), }, }; + registerExecutionStoresWorkspaceMutationSourceInternal( + stores, + runtimePersistence.runtimeCommitStore, + lease.rootId, + ); freezeExecutionStoresFacade(stores); executionStoresWriterKinds.set(stores, kind); executionStoresWritersByLease.set(lease, stores); diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index c060f26833..7845c5f7e5 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -1547,13 +1547,16 @@ export class SqliteRuntimeStore private registerWorkspaceBaselineAuthorityWriter(): void { const readWorkspaceHead = this.readWorkspaceHead.bind(this); + const readWorkspaceVersion = this.readWorkspaceVersion.bind(this); registerWorkspaceBaselineAuthorityWriterInternal( this, (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), (input, rootId) => this.#commitWorkspaceSuccessor(input, rootId), (input, rootId) => this.#commitManagedMutationTerminal(input, rootId), (rootId) => this.#bindWorkspaceStorageRoot(rootId), + (rootId) => this.#adoptWorkspaceStorageRoot(rootId), readWorkspaceHead, + readWorkspaceVersion, (workspaceInstanceId) => this.#readActiveManagedMutation(workspaceInstanceId), ); } @@ -1621,6 +1624,35 @@ export class SqliteRuntimeStore }); } + #adoptWorkspaceStorageRoot(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; + } + const workspaceStateExists = [ + 'runtime_workspace_epochs', + 'runtime_workspace_versions', + 'runtime_workspace_heads', + 'runtime_managed_mutation_reservations', + ].some((table) => this.db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined); + if (workspaceStateExists) { + throw new Error('Unbound workspace authority facts cannot be adopted'); + } + 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) { diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 2ecf8985e8..8e8a0e4f1d 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -22,6 +22,7 @@ import type { WorkspaceBaselineCommitResult, WorkspaceHeadRecordV1, WorkspaceSuccessorAuthorityInput, + WorkspaceVersionRecordV1, } from '@maka/core/workspace-version-authority'; import type { RuntimeEvent } from '@maka/core/runtime-event'; @@ -30,6 +31,7 @@ type WorkspaceBaselineAuthorityWriter = ( rootId: string, ) => Promise; type WorkspaceStorageRootBinder = (rootId: string) => void; +type WorkspaceStorageRootAdopter = (rootId: string) => void; export interface WorkspaceSuccessorCommitInput { /** Opaque capability issued by the repository candidate owner. */ candidateOutcome: object; @@ -85,6 +87,9 @@ type WorkspaceHeadReader = ( workspaceId: string, workspaceEpochId: string, ) => Promise; +type WorkspaceVersionReader = ( + workspaceVersionId: string, +) => Promise; export interface ManagedMutationReservationRecordV1 { readonly workspaceInstanceId: string; readonly repositoryId: string; @@ -112,8 +117,10 @@ interface WorkspaceBaselineAuthorityRegistration { noEffectVerifier?: ManagedMutationNoEffectVerifier; readonly terminalWriter: ManagedMutationTerminalAuthorityWriter; readonly readHead: WorkspaceHeadReader; + readonly readVersion: WorkspaceVersionReader; readonly readActiveManagedMutation: ManagedMutationReservationReader; readonly bindStorageRoot: WorkspaceStorageRootBinder; + readonly adoptStorageRoot: WorkspaceStorageRootAdopter; boundRootId?: string; } @@ -128,7 +135,9 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( successorWriter: WorkspaceSuccessorAuthorityWriter, terminalWriter: ManagedMutationTerminalAuthorityWriter, bindStorageRoot: WorkspaceStorageRootBinder, + adoptStorageRoot: WorkspaceStorageRootAdopter, readHead: WorkspaceHeadReader, + readVersion: WorkspaceVersionReader, readActiveManagedMutation: ManagedMutationReservationReader, ): void { if (workspaceBaselineAuthorityWriters.has(store)) { @@ -139,8 +148,10 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( successorWriter, terminalWriter, readHead, + readVersion, readActiveManagedMutation, bindStorageRoot, + adoptStorageRoot, }); } @@ -163,6 +174,15 @@ export function readWorkspaceHeadInternal( return registration.readHead(workspaceId, workspaceEpochId); } +export function readWorkspaceVersionInternal( + store: object, + workspaceVersionId: string, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace version authority reader is unavailable'); + return registration.readVersion(workspaceVersionId); +} + /** * Storage-internal authority seam. This module is deliberately absent from the * @maka/storage package exports. The schema-9 reader, migration, and projection @@ -259,3 +279,22 @@ export function bindWorkspaceBaselineAuthorityStoreRootInternal( registration.bindStorageRoot(rootId); registration.boundRootId = rootId; } + +/** + * Adopts ordinary pre-authority runtime state for the root identity already + * proven by the ExecutionStores lease. Existing workspace authority facts are + * rejected by the SQLite owner, so the operation cannot re-home a managed + * ledger under a different root. + */ +export function adoptWorkspaceBaselineAuthorityStoreRootInternal( + 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.adoptStorageRoot(rootId); + registration.boundRootId = rootId; +}