diff --git a/docs/architecture/runtime-managed-workspace-mutation-lifecycle-authority-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-lifecycle-authority-v1.zh-CN.md new file mode 100644 index 0000000000..3c852e24a6 --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-mutation-lifecycle-authority-v1.zh-CN.md @@ -0,0 +1,182 @@ + + +# Managed Mutation Lifecycle Authority v1 + +## 1. 交付边界 + +本切片只证明一个主要不变量: + +> 从 managed Write/Edit 的 T1 持久化开始,到可信 terminal state 为止,同一个 workspace instance +> 只能存在一个 durable mutation owner;成功 outcome、successor workspace version 与 canonical head +> 必须同时可见或同时不可见;T1 以后 Runtime 不得静默回退到 generic T2。 + +本切片是后续 Git candidate 与 Write/Edit 生产接线的 persistence/runtime authority。它不创建 managed +worktree、不执行文件写入、不引入 Git 或 npm 数据面,也不向 Desktop/CLI 开放新模式。没有直接产品消费者 +不是本切片的 correctness 缺陷;后续消费者必须通过这里的 internal capability,而不能复制写入逻辑。 + +## 2. Owner + +| 事实或能力 | 唯一 owner | 代码约束 | +| --- | --- | --- | +| immutable T1 与 managed mutation identity | `SqliteRuntimeStore.commitToolPrepared()` | T1 与 reservation 在同一 SQLite transaction 中提交 | +| active mutation ownership | SQLite reservation projection | `workspace_instance_id` 主键跨进程仲裁;投影可从 RuntimeEvents 重建 | +| accepted successor 与 canonical head | workspace successor authority writer | 只消费 repository owner 验证后的 opaque candidate capability;generic writer 不能写 reserved fact | +| live provider result | `ToolRuntime` | 在 operation 返回边界生成 bounded strict-JSON immutable snapshot | +| no-effect terminal proof | managed mutation owner | 先签发 opaque no-effect capability;Storage verifier 还原并逐字段绑定 terminal identity,普通 outcome 无权释放 reservation | +| terminal durable proof | managed admission owner | Runtime 只 adopt 已提交的 exact outcome envelope,不代写 managed T2 | + +`RuntimeEvents` 是 durable truth;workspace head 与 reservation 表只是可删除、可重建的投影。internal writer +通过未导出的 registration/capability seam 暴露,不能依靠注释约束调用者。 + +当前切片没有生产消费者,在线 authority 校验继续直接读取 immutable RuntimeEvents,避免为了尚未确定的 +生产查询形状提前增加 SQLite schema/index 合同。接入真实 Write/Edit consumer 前,必须基于实际访问模式 +引入不依赖可重建 projection 的有界索引读取;完整 rebuild 仍以 RuntimeEvents 为唯一事实源。 + +## 3. T1 冻结内容 + +`toolDispatch.managedMutation` 在 T1 前一次性冻结: + +- repository/workspace/epoch/instance identity; +- base workspace version、accepted event、head revision、commit/tree; +- 与 durable Write/Edit `args.path` 完全相同的单一 canonical path; +- Gitoxide path policy v3; +- Runtime 固定的 managed transform/profile digest;该 digest 绑定一份 canonical profile spec,结果预算等运行时限制直接读取同一 spec,不能作为孤立标签修改; +- v2 当前唯一支持的 SHA-1 object format。 + +Storage 在同一个 T1 transaction 内重新比较 function call path 与授权 path,caller 不能自报另一组路径。 +完整路径语义由 Gitoxide policy v3 owner 在后续组合层签发;本切片只持久化其版本和 canonical 结果,不复制 +另一套 Git path parser。durable mode 在 T1 前确定;如果 admission 不可用,必须在 T1 前拒绝。T1 后缺失 +terminal proof、proof 畸形或 owner 异常一律 fail-stop,禁止 generic fallback。 + +## 4. 原子性边界 + +### 4.1 T1 与 reservation + +单个 `BEGIN IMMEDIATE` transaction 完成: + +1. 校验 immutable function call 与 dispatch; +2. 校验 T1 所引用的 workspace base 正是当前 canonical head; +3. 写入 call/dispatch RuntimeEvents; +4. 写入 tool projection; +5. 获取 `workspace_instance_id` 的唯一 reservation。 + +冲突 transaction 不产生部分 T1,也不会覆盖已有 owner。 + +### 4.2 terminal successor + +单个 SQLite transaction 完成: + +1. 验证 success `function_response` 与原 operation identity; +2. 通过注册的一次性 verifier 消费 opaque candidate capability;raw commit/tree/path 描述没有提交权限; +3. 验证 successor 的 parent/base/path/profile 与 T1 完全一致; +4. 写入 outcome RuntimeEvent; +5. 写入 successor workspace fact; +6. 更新 disposable version/head projections; +7. 释放 durable reservation。 + +任一步失败则整组 rollback。失败 outcome 不得推进 head。旧 operation 的 exact retry 根据 immutable +successor 返回 `committedSuccessor` 历史快照,不把它命名或解释为当前 head,也不要求它仍是当前最新 head。 + +### 4.3 no-effect terminal + +`no_workspace_change` 与 `operation_failed_no_effect` 各自拥有明确的 terminal kind。专用 writer 只消费 +mutation owner 签发的 opaque no-effect capability;内部 verifier 将其还原为 operation、dispatch、workspace +instance 与 terminal kind,并与 durable event 逐字段匹配。普通 outcome 或自报 descriptor 无权释放 +reservation。验证通过后,writer 才在一个 transaction 中写入 function response + reserved terminal action; +workspace head 保持不变。generic T2 无权写该 action。projection rebuild 根据 immutable terminal 判断 +reservation 已结束,不会把它重新激活。 + +## 5. Runtime 线性状态机 + +managed operation capability 由 Runtime 持有,生命周期为: + +```text +open -> running -> settled -> closed +``` + +- capability 只能执行一次;terminal settlement 后 retained callback 失效; +- detached operation 必须 join,不能先结算再产生副作用; +- Runtime 只执行工具单独提供的 pure managed transform;普通可变 `tool.impl` 在该路径永远不会调用; +- Runtime 在一次有界 strict-JSON traversal 中复制并冻结结果;默认上限为 1 MiB、64 层、65,536 nodes、 + 65,536 properties 和 65,536 array items;caller 只能进一步收紧,不能放宽; +- `undefined`、稀疏数组、非有限数字、原型污染形状和超出 byte budget 的结果在 durable publication 前拒绝; +- live provider result、message、telemetry 与 durable envelope 都读取同一个 snapshot; +- successor success、no-change success、failed-no-effect 与 unsettled 都不能触发 generic `commitOutcome()`。 + +## 6. 失败状态与回滚 + +| 失败点 | durable 状态 | 行为 | +| --- | --- | --- | +| T1 transaction 前或内部 | 无 managed T1/reservation | rollback;允许重新 admission | +| T1 commit 后、terminal 前 | prepared + active reservation | fail-stop;禁止另一 mutation 获得 ownership | +| terminal transaction 内 | prepared + active reservation | rollback;不发布 provider result | +| terminal commit 后响应丢失 | immutable outcome + successor + released reservation | exact retry/adoption,不重写 generic T2 | +| no-effect terminal commit 后响应丢失 | immutable outcome + terminal + released reservation | exact retry/adoption;head 不变 | +| projection 缺失 | immutable facts 保留 | 从 RuntimeEvents rebuild | +| projection 与 facts 不一致 | corruption | fail closed,不猜测修复 | + +本切片不定义文件副作用恢复;当 T1 已提交但外部 execution/candidate owner无法证明 terminal 时,reservation +保留是有意的 parked 状态,交由后续 recovery owner 处理。 + +## 7. 平台能力矩阵 + +本切片只依赖 SQLite transaction、WAL 与进程间数据库锁,不执行平台文件 mutation。 + +| 能力 | Linux | macOS | Windows | +| --- | --- | --- | --- | +| T1 + reservation 原子提交 | 承诺;Linux storage stress lane | 实现预期;当前 PR 无独立 macOS recovery gate | 承诺;Windows recovery lane | +| 同 workspace instance 多进程唯一 owner | 承诺;真实双进程测试 | 实现预期;发布证据待补 | 承诺;真实双进程测试 | +| terminal successor bundle 原子提交 | 承诺 | 实现预期;发布证据待补 | 承诺 | +| process-crash 后从 facts 重建 | 承诺;真实 child process | 实现预期;发布证据待补 | 承诺;真实 child process | +| power-loss 后硬件级持久顺序 | 继承 SQLite/OS 合同,不额外扩大承诺 | 同左 | 同左 | + +这里不把“代码跨平台”冒充“平台恢复证据”。macOS 在加入独立 recovery gate 前不构成发布承诺;普通单元 +测试也不能替代真实 child-process kill/reopen 与双进程竞争。 + +## 8. Production-shaped evidence + +当前测试覆盖: + +- child process 在 T1 transaction 内退出:整组 rollback; +- child process 在 T1 commit 后退出:reservation 保留; +- 两个真实进程竞争同一 workspace instance:只有一个获得 reservation; +- child process 在 successor transaction 内退出:outcome/successor/head 全部 rollback; +- child process 在 successor commit 后退出:exact retry 返回已接受结果; +- child process 在 no-effect terminal transaction 内/commit 后退出:分别 rollback 或保留 terminal 并释放 reservation; +- plain no-effect outcome 缺少 owner-issued capability 时拒绝,并保留 active reservation; +- 删除 projection 后从 immutable RuntimeEvents 重建 head 与 reservation; +- Runtime owner 的 retained callback、detached operation、mutable result、oversized/non-JSON result 对抗测试; +- managed T1 后所有异常路径的 generic T2 调用次数为零。 + +## 9. 后续消费者合同 + +后续 Git candidate/Write/Edit PR 必须: + +1. 在 T1 前取得并冻结 Gitoxide policy v3 path admission,并提供独立 pure Write/Edit transform; +2. 只通过本 authority 提交 terminal successor; +3. 对 T1 后的不确定 effect 返回 unsettled,不能自行清理 reservation; +4. 用 production-shaped Host/worker crash test 证明不会重放副作用; +5. 不把本切片的 internal writer 提升为通用 public append API。 + +## 10. 回滚方式 + +在尚无 production consumer 时,代码回滚可整体移除 managed dispatch extension、reservation projection 与 +Runtime settlement seam;schema migration 保持 append-only,不复用版本号。若已有数据库升级,降级 binary +必须按既有 newer-schema 防护 fail closed,不能尝试写入未知 schema。 diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index 97514d6113..00d5adb00e 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -549,6 +549,10 @@ projection reader/rebuild 与升级合同继续保留;它们是历史事实 au 不能恢复旧 Git CLI、receipt、quarantine、write owner 或 `managed_worktree_v1` profile,也不能让 Gitoxide foundation 在尚无 Desktop/CLI/T1 consumer 时冒充产品能力。 +当前先行的 persistence/runtime authority 只定义 managed T1 reservation、terminal settlement 与 +accepted successor 的原子事实边界,不执行 Git mutation,也不恢复旧 worktree owner。完整合同见 +[Managed Mutation Lifecycle Authority v1](./runtime-managed-workspace-mutation-lifecycle-authority-v1.zh-CN.md)。 + ## 5. 依赖顺序 ```text diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 120d2eac3a..e3d4646125 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -19,6 +19,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { expect } from './test-helpers.js'; import { decodeMessageContent, @@ -30,6 +31,8 @@ import { INTERACTION_ID_MAX_BYTES, INTERACTION_TOOL_NAME_MAX_BYTES } from '../in import { decodeRuntimeEvent, isTerminalRuntimeEvent, + MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC, runtimeEventHasModelVisibleContent, type RuntimeEvent, type RuntimeEventActions, @@ -457,6 +460,88 @@ describe('RuntimeEvent content variants', () => { }); describe('RuntimeEvent actions', () => { + test('binds the managed mutation digest to its canonical execution semantics', () => { + const canonicalProfile = JSON.stringify({ + protocol: 'managed_mutation_execution_profile_v1', + toolNames: ['Write', 'Edit'], + transform: 'pure_frozen_args_only_v1', + objectFormat: 'sha1', + pathPolicyVersion: 3, + resultSnapshot: { + maxBytes: 1_048_576, + maxDepth: 64, + maxNodes: 65_536, + maxProperties: 65_536, + maxArrayLength: 65_536, + format: 'strict_json_v1', + }, + terminalAuthority: 'owner_committed_exact_outcome_v1', + genericFallback: 'forbidden', + }); + + assert.equal( + MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + `sha256:${createHash('sha256').update(canonicalProfile).digest('hex')}`, + ); + assert.equal(JSON.stringify(MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC), canonicalProfile); + }); + + test('decodes only a platform-independent T1-frozen managed mutation identity', () => { + const managedMutation = { + protocol: 'managed_mutation_v2', + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1', + baseWorkspaceVersionId: 'version_55555555555555555555555555555555', + baseAcceptedEventId: 'baseline-event-1', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'src/a.ts', + pathPolicyVersion: 3, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + } as const; + const toolDispatch = { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'call-1', + toolName: 'Write', + canonicalArgsHash: `sha256:${'b'.repeat(64)}`, + recoveryMode: 'reconcile', + managedMutation, + } as const; + + assert.deepEqual( + decodeRuntimeEvent(baseEvent({ role: 'system', author: 'system', actions: { toolDispatch } })) + .actions?.toolDispatch?.managedMutation, + managedMutation, + ); + for (const invalid of [ + { ...managedMutation, expectedPath: 'src/../secrets.txt' }, + { ...managedMutation, expectedPath: 'NoDe_MoDuLeS/pkg/index.js' }, + { ...managedMutation, expectedPath: '.GiT/config' }, + { ...managedMutation, expectedPath: 'x'.repeat(4097) }, + { ...managedMutation, pathPolicyVersion: 2 }, + { ...managedMutation, executionProfileDigest: `sha256:${'a'.repeat(64)}` }, + { ...managedMutation, baseAcceptedEventId: 'event id with spaces' }, + { ...managedMutation, baseHeadRevision: 0 }, + { ...managedMutation, baseTreeOid: 'not-an-oid' }, + { ...managedMutation, extra: true }, + ]) { + assert.throws(() => + decodeRuntimeEvent( + baseEvent({ + role: 'system', + author: 'system', + actions: { toolDispatch: { ...toolDispatch, managedMutation: invalid } as never }, + }), + ), + ); + } + }); + test('permission and user-question interactions are first-class actions', () => { const actions: RuntimeEventActions = { permissionRequest: { diff --git a/packages/core/src/__tests__/workspace-version-authority.test.ts b/packages/core/src/__tests__/workspace-version-authority.test.ts index 78774f9736..7d80a2fd0b 100644 --- a/packages/core/src/__tests__/workspace-version-authority.test.ts +++ b/packages/core/src/__tests__/workspace-version-authority.test.ts @@ -22,13 +22,61 @@ import { describe, it } from 'node:test'; import { decodeRuntimeEvent, type RuntimeEvent } from '../runtime-event.js'; import { buildWorkspaceBaselineAuthorityEvents, + buildWorkspaceSuccessorAuthorityEvent, scanWorkspaceBaselineAuthority, validateWorkspaceFactEventLane, workspaceAuthorityIdentity, type WorkspaceBaselineAuthorityInput, + type WorkspaceSuccessorAuthorityInput, } from '../workspace-version-authority.js'; describe('workspace version authority contract', () => { + it('decodes a causal tool-mutation successor fact on the authority lane', () => { + const workspaceEpochId = 'epoch_33333333333333333333333333333333'; + const event = { + id: 'workspace-successor-event-1', + ...workspaceAuthorityIdentity(workspaceEpochId), + ts: 1_700_000_000_001, + partial: false, + role: 'system', + author: 'system', + actions: { + workspaceFact: { + kind: 'maka.workspace.version_accepted', + version: 1, + payload: { + protocol: 'workspace_version_accepted_v1', + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId, + workspaceVersionId: 'version_77777777777777777777777777777777', + objectFormat: 'sha1', + parents: ['version_44444444444444444444444444444444'], + origin: { + kind: 'tool_mutation', + operationId: 'operation-successor-1', + dispatchEventId: 'dispatch-successor-1', + outcomeEventId: 'outcome-successor-1', + }, + baseAcceptedEventId: 'workspace-baseline-event-1', + baseHeadRevision: 1, + commitOid: '7'.repeat(40), + treeOid: '8'.repeat(40), + policyHash: `sha256:${'6'.repeat(64)}`, + treeDeltaDigest: `sha256:${'9'.repeat(64)}`, + changedPaths: ['notes.txt'], + changedFileCount: 1, + deletedFileCount: 0, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }, + }, + }, + }; + + assert.deepEqual(decodeRuntimeEvent(event), event); + }); + it('decodes only exact v1 baseline facts on the store-owned semantic lane', () => { const { epochOpenedEvent, baselineAcceptedEvent } = buildWorkspaceBaselineAuthorityEvents( baselineInput(), @@ -191,8 +239,73 @@ describe('workspace version authority contract', () => { assert.equal(orphan.hasCorruption, true); assert.equal(orphan.issues[0]?.code, 'orphan_baseline_version'); }); + + it('advances one canonical head through a causal successor fact', () => { + const baseline = buildWorkspaceBaselineAuthorityEvents(baselineInput()); + const successor = buildWorkspaceSuccessorAuthorityEvent(successorInput()); + assert.deepEqual(decodeRuntimeEvent(successor), successor); + const missingChangedPaths = structuredClone(successor) as unknown as { + actions: { workspaceFact: { payload: Record } }; + }; + delete missingChangedPaths.actions.workspaceFact.payload.changedPaths; + assert.throws(() => decodeRuntimeEvent(missingChangedPaths), /Invalid RuntimeEvent schema/); + + const scan = scanWorkspaceBaselineAuthority([ + { event: baseline.epochOpenedEvent, eventSeq: 1 }, + { event: baseline.baselineAcceptedEvent, eventSeq: 2 }, + { event: successor, eventSeq: 3 }, + ]); + + assert.equal(scan.hasCorruption, false); + assert.equal(scan.successors.length, 1); + assert.deepEqual(scan.successors[0]?.successor.changedPaths, ['notes.txt']); + assert.deepEqual(scan.heads, [ + { + repositoryId: baselineInput().epoch.repositoryId, + workspaceId: baselineInput().epoch.workspaceId, + workspaceEpochId: baselineInput().epoch.workspaceEpochId, + workspaceVersionId: successorInput().successor.workspaceVersionId, + acceptedEventId: successorInput().acceptedEventId, + commitOid: successorInput().successor.commitOid, + treeOid: successorInput().successor.treeOid, + revision: 2, + }, + ]); + }); }); +function successorInput(): WorkspaceSuccessorAuthorityInput { + const baseline = baselineInput(); + return { + acceptedEventId: 'workspace-successor-event-1', + committedAt: baseline.committedAt + 1, + successor: { + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceVersionId: 'version_77777777777777777777777777777777', + objectFormat: baseline.epoch.objectFormat, + parentWorkspaceVersionId: baseline.baseline.workspaceVersionId, + baseAcceptedEventId: baseline.baselineAcceptedEventId, + baseHeadRevision: 1, + commitOid: '7'.repeat(40), + treeOid: '8'.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: 'operation-successor-1', + dispatchEventId: 'dispatch-successor-1', + outcomeEventId: 'outcome-successor-1', + }, + }; +} + function baselineInput( overrides: Partial = {}, ): WorkspaceBaselineAuthorityInput { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9a80ea99b0..644f3c89fe 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -254,6 +254,59 @@ export interface RuntimeEventToolDispatch { toolName: string; canonicalArgsHash: string; recoveryMode: ToolRecoveryMode; + /** T1-frozen managed workspace mutation identity. */ + managedMutation?: RuntimeEventManagedWorkspaceMutationV2; +} + +/** + * Canonical semantics bound by the managed mutation execution-profile digest. + * Runtime consumes these limits directly, so changing the execution contract + * requires changing this representation and its digest together. + */ +export const MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC = Object.freeze({ + protocol: 'managed_mutation_execution_profile_v1', + toolNames: Object.freeze(['Write', 'Edit'] as const), + transform: 'pure_frozen_args_only_v1', + objectFormat: 'sha1', + pathPolicyVersion: 3, + resultSnapshot: Object.freeze({ + maxBytes: 1_048_576, + maxDepth: 64, + maxNodes: 65_536, + maxProperties: 65_536, + maxArrayLength: 65_536, + format: 'strict_json_v1', + }), + terminalAuthority: 'owner_committed_exact_outcome_v1', + genericFallback: 'forbidden', +} as const); + +export const MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST = + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const; + +export interface RuntimeEventManagedWorkspaceMutationV2 { + protocol: 'managed_mutation_v2'; + repositoryId: string; + workspaceId: string; + workspaceEpochId: string; + workspaceInstanceId: string; + objectFormat: 'sha1'; + baseWorkspaceVersionId: string; + baseAcceptedEventId: string; + baseHeadRevision: number; + baseCommitOid: string; + baseTreeOid: string; + expectedPath: string; + pathPolicyVersion: 3; + executionProfileDigest: typeof MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST; +} + +export interface RuntimeEventManagedMutationTerminalV1 { + protocol: 'managed_mutation_terminal_v1'; + operationId: string; + dispatchEventId: string; + workspaceInstanceId: string; + terminalKind: 'no_workspace_change' | 'operation_failed_no_effect'; } export interface RuntimeEventProtocolMarker { @@ -331,6 +384,8 @@ export interface RuntimeEventActions { continuationStart?: RuntimeEventContinuationStartV2; /** Reserved workspace authority fact; only its atomic SQLite writer may persist it. */ workspaceFact?: RuntimeEventWorkspaceFactEnvelope; + /** Reserved no-effect terminal; only the managed mutation terminal writer may persist it. */ + managedMutationTerminal?: RuntimeEventManagedMutationTerminalV1; } // ============================================================================ @@ -518,8 +573,14 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( 'runtimeProtocol', 'continuationStart', 'workspaceFact', + 'managedMutationTerminal', ], ); +const RUNTIME_MANAGED_MUTATION_TERMINAL_SHAPE = + defineObjectShape()( + ['protocol', 'operationId', 'dispatchEventId', 'workspaceInstanceId', 'terminalKind'], + [], + ); const ANSWER_ACCEPTED_IDENTITY_SHAPE = defineObjectShape()( ['requestId'], [], @@ -540,8 +601,28 @@ const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape( 'canonicalArgsHash', 'recoveryMode', ], - [], + ['managedMutation'], ); +const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = + defineObjectShape()( + [ + 'protocol', + 'repositoryId', + 'workspaceId', + 'workspaceEpochId', + 'workspaceInstanceId', + 'objectFormat', + 'baseWorkspaceVersionId', + 'baseAcceptedEventId', + 'baseHeadRevision', + 'baseCommitOid', + 'baseTreeOid', + 'expectedPath', + 'pathPolicyVersion', + 'executionProfileDigest', + ], + [], + ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( ['toolBoundary'], [], @@ -749,7 +830,26 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { (value.runtimeProtocol === undefined || isRuntimeProtocolMarker(value.runtimeProtocol)) && (value.continuationStart === undefined || isRuntimeContinuationStart(value.continuationStart)) && - (value.workspaceFact === undefined || isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) + (value.workspaceFact === undefined || + isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) && + (value.managedMutationTerminal === undefined || + isRuntimeManagedMutationTerminal(value.managedMutationTerminal)) + ); +} + +function isRuntimeManagedMutationTerminal( + value: unknown, +): value is RuntimeEventManagedMutationTerminalV1 { + return ( + isRecord(value) && + hasExactShape(value, RUNTIME_MANAGED_MUTATION_TERMINAL_SHAPE) && + value.protocol === 'managed_mutation_terminal_v1' && + typeof value.operationId === 'string' && + typeof value.dispatchEventId === 'string' && + typeof value.workspaceInstanceId === 'string' && + /^instance_[0-9a-f]{32}$/u.test(value.workspaceInstanceId) && + (value.terminalKind === 'no_workspace_change' || + value.terminalKind === 'operation_failed_no_effect') ); } @@ -805,10 +905,70 @@ function isRuntimeToolDispatch(value: unknown): value is RuntimeEventToolDispatc value.recoveryMode === 'reconcile' || value.recoveryMode === 'reattach' || value.recoveryMode === 'outcome_unknown' || - value.recoveryMode === 'never_auto_retry') + value.recoveryMode === 'never_auto_retry') && + (value.managedMutation === undefined || + isRuntimeManagedWorkspaceMutation(value.managedMutation)) ); } +function isRuntimeManagedWorkspaceMutation( + value: unknown, +): value is RuntimeEventManagedWorkspaceMutationV2 { + if ( + !isRecord(value) || + !hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE) || + value.protocol !== 'managed_mutation_v2' || + typeof value.repositoryId !== 'string' || + !/^repository_[0-9a-f]{32}$/u.test(value.repositoryId) || + typeof value.workspaceId !== 'string' || + !/^workspace_[0-9a-f]{32}$/u.test(value.workspaceId) || + typeof value.workspaceEpochId !== 'string' || + !/^epoch_[0-9a-f]{32}$/u.test(value.workspaceEpochId) || + typeof value.workspaceInstanceId !== 'string' || + !/^instance_[0-9a-f]{32}$/u.test(value.workspaceInstanceId) || + value.objectFormat !== 'sha1' || + typeof value.baseWorkspaceVersionId !== 'string' || + !/^version_[0-9a-f]{32}$/u.test(value.baseWorkspaceVersionId) || + typeof value.baseAcceptedEventId !== 'string' || + !/^[A-Za-z0-9_-]{1,128}$/u.test(value.baseAcceptedEventId) || + typeof value.baseHeadRevision !== 'number' || + !Number.isSafeInteger(value.baseHeadRevision) || + value.baseHeadRevision < 1 || + typeof value.baseCommitOid !== 'string' || + typeof value.baseTreeOid !== 'string' || + value.pathPolicyVersion !== 3 || + value.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST || + !isCanonicalManagedMutationPathV1(value.expectedPath) + ) { + return false; + } + const oidPattern = /^[0-9a-f]{40}$/u; + if (!oidPattern.test(value.baseCommitOid) || !oidPattern.test(value.baseTreeOid)) return false; + return true; +} + +/** Platform-independent canonical Git path syntax used by durable mutation facts. */ +export function isCanonicalManagedMutationPathV1(path: unknown): path is string { + if ( + typeof path !== 'string' || + path.length === 0 || + path.length > 4096 || + path.includes('\\') || + path.includes('\0') || + path.includes(':') || + path.startsWith('/') || + path.endsWith('/') + ) { + return false; + } + const segments = path.split('/'); + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) { + return false; + } + const firstSegment = segments[0]!.toLowerCase(); + return firstSegment !== '.git' && firstSegment !== 'node_modules'; +} + function isRuntimeProtocolMarker(value: unknown): value is RuntimeEventProtocolMarker { return ( isRecord(value) && diff --git a/packages/core/src/tool-ledger-scanner.ts b/packages/core/src/tool-ledger-scanner.ts index 820f332d6b..4821779f23 100644 --- a/packages/core/src/tool-ledger-scanner.ts +++ b/packages/core/src/tool-ledger-scanner.ts @@ -182,6 +182,9 @@ export function validateGenericToolLedgerAppend( ): GenericToolLedgerAppendValidation { const lane = validateToolLedgerEventLane(event); if (!lane.ok) return lane; + if (event.actions?.managedMutationTerminal !== undefined) { + return { ok: false, code: 'reserved_tool_boundary_fact', eventId: event.id }; + } if (lane.lane === 'reconcile_result' || lane.lane === 'recovery_decision') { return { ok: false, code: 'reserved_recovery_fact', eventId: event.id }; } @@ -614,14 +617,19 @@ function matchesFunctionCallActions(actions: RuntimeEvent['actions']): boolean { function matchesFunctionResponseActions(actions: RuntimeEvent['actions']): boolean { if (!actions) return true; - if (!hasOnlyKeys(actions, ['stateDelta'])) return false; + const keys = Object.keys(actions); + if ( + keys.length === 0 || + keys.some((key) => key !== 'stateDelta' && key !== 'managedMutationTerminal') + ) { + return false; + } const stateDelta = actions.stateDelta; - return ( - stateDelta !== undefined && - hasOnlyKeys(stateDelta, ['durationMs']) && - typeof stateDelta.durationMs === 'number' && - Number.isFinite(stateDelta.durationMs) - ); + return stateDelta === undefined + ? actions.managedMutationTerminal !== undefined + : hasOnlyKeys(stateDelta, ['durationMs']) && + typeof stateDelta.durationMs === 'number' && + Number.isFinite(stateDelta.durationMs); } function toolCallIdentity(invocationId: string, toolCallId: string): string { diff --git a/packages/core/src/workspace-version-authority.ts b/packages/core/src/workspace-version-authority.ts index 2949a2df2d..386f0dff09 100644 --- a/packages/core/src/workspace-version-authority.ts +++ b/packages/core/src/workspace-version-authority.ts @@ -17,10 +17,11 @@ * under the License. */ -import type { RuntimeEvent } from './runtime-event.js'; +import { isCanonicalManagedMutationPathV1, type RuntimeEvent } from './runtime-event.js'; export const WORKSPACE_EPOCH_OPENED_FACT_KIND = 'maka.workspace.epoch_opened' as const; export const WORKSPACE_BASELINE_ACCEPTED_FACT_KIND = 'maka.workspace.baseline_accepted' as const; +export const WORKSPACE_VERSION_ACCEPTED_FACT_KIND = 'maka.workspace.version_accepted' as const; export const WORKSPACE_FACT_VERSION = 1 as const; export const WORKSPACE_VERSION_AUTHORITY_CAPABILITY_V1 = 'runtime_workspace_version_authority_v1' as const; @@ -72,6 +73,54 @@ export interface WorkspaceBaselineAcceptedV1 extends WorkspaceBaselineDescriptor policyHash: `sha256:${string}`; } +export interface WorkspaceSuccessorDescriptorV1 { + repositoryId: string; + workspaceId: string; + workspaceEpochId: string; + workspaceVersionId: string; + objectFormat: WorkspaceGitObjectFormat; + parentWorkspaceVersionId: string; + baseAcceptedEventId: string; + baseHeadRevision: number; + commitOid: string; + treeOid: string; + policyHash: `sha256:${string}`; + treeDeltaDigest: `sha256:${string}`; + changedPaths: readonly string[]; + changedFileCount: number; + deletedFileCount: number; + executionProfileDigest: `sha256:${string}`; +} + +export interface WorkspaceMutationOriginV1 { + operationId: string; + dispatchEventId: string; + outcomeEventId: string; +} + +export interface WorkspaceVersionAcceptedV1 { + protocol: 'workspace_version_accepted_v1'; + repositoryId: string; + workspaceId: string; + workspaceEpochId: string; + workspaceVersionId: string; + objectFormat: WorkspaceGitObjectFormat; + parents: readonly [string]; + origin: { kind: 'tool_mutation' } & WorkspaceMutationOriginV1; + baseAcceptedEventId: string; + baseHeadRevision: number; + commitOid: string; + treeOid: string; + policyHash: `sha256:${string}`; + treeDeltaDigest: `sha256:${string}`; + changedPaths: readonly string[]; + changedFileCount: number; + deletedFileCount: number; + executionProfileDigest: `sha256:${string}`; +} + +export type WorkspaceAcceptedVersionV1 = WorkspaceBaselineAcceptedV1 | WorkspaceVersionAcceptedV1; + export type RuntimeEventWorkspaceFactEnvelope = | { kind: typeof WORKSPACE_EPOCH_OPENED_FACT_KIND; @@ -82,6 +131,11 @@ export type RuntimeEventWorkspaceFactEnvelope = kind: typeof WORKSPACE_BASELINE_ACCEPTED_FACT_KIND; version: typeof WORKSPACE_FACT_VERSION; payload: WorkspaceBaselineAcceptedV1; + } + | { + kind: typeof WORKSPACE_VERSION_ACCEPTED_FACT_KIND; + version: typeof WORKSPACE_FACT_VERSION; + payload: WorkspaceVersionAcceptedV1; }; export interface WorkspaceBaselineAuthorityInput { @@ -92,6 +146,13 @@ export interface WorkspaceBaselineAuthorityInput { baseline: WorkspaceBaselineDescriptorV1; } +export interface WorkspaceSuccessorAuthorityInput { + acceptedEventId: string; + committedAt: number; + successor: WorkspaceSuccessorDescriptorV1; + origin: WorkspaceMutationOriginV1; +} + export interface WorkspaceAuthorityIdentity { sessionId: typeof WORKSPACE_AUTHORITY_SESSION_ID; invocationId: string; @@ -119,16 +180,24 @@ export interface ScannedWorkspaceBaselineAuthority { authority: WorkspaceAuthorityIdentity; } +export interface ScannedWorkspaceSuccessorAuthority { + successor: WorkspaceVersionAcceptedV1; + acceptedEventId: string; + acceptedAt: number; + eventSeq: number; + authority: WorkspaceAuthorityIdentity; +} + export interface WorkspaceEpochRecordV1 extends WorkspaceEpochOpenedV1 { epochOpenedEventId: string; authority: WorkspaceAuthorityIdentity; committedAt: number; } -export interface WorkspaceVersionRecordV1 extends WorkspaceBaselineAcceptedV1 { - baselineAcceptedEventId: string; +export type WorkspaceVersionRecordV1 = WorkspaceAcceptedVersionV1 & { + acceptedEventId: string; committedAt: number; -} +}; export interface WorkspaceHeadRecordV1 { readonly repositoryId: string; @@ -162,7 +231,9 @@ export type WorkspaceAuthorityIssueCode = | 'missing_baseline_version' | 'orphan_baseline_version' | 'event_order_conflict' - | 'baseline_contract_conflict'; + | 'baseline_contract_conflict' + | 'successor_contract_conflict' + | 'workspace_head_conflict'; export interface WorkspaceAuthorityIssue { code: WorkspaceAuthorityIssueCode; @@ -172,6 +243,8 @@ export interface WorkspaceAuthorityIssue { export interface WorkspaceBaselineAuthorityScanResult { baselines: ScannedWorkspaceBaselineAuthority[]; + successors: ScannedWorkspaceSuccessorAuthority[]; + heads: WorkspaceHeadRecordV1[]; issues: WorkspaceAuthorityIssue[]; hasCorruption: boolean; } @@ -268,6 +341,48 @@ export function buildWorkspaceBaselineAuthorityEvents( return { epochOpenedEvent, baselineAcceptedEvent }; } +export function buildWorkspaceSuccessorAuthorityEvent( + input: WorkspaceSuccessorAuthorityInput, +): RuntimeEvent { + assertWorkspaceSuccessorAuthorityInput(input); + const identity = workspaceAuthorityIdentity(input.successor.workspaceEpochId); + const payload: WorkspaceVersionAcceptedV1 = { + protocol: 'workspace_version_accepted_v1', + repositoryId: input.successor.repositoryId, + workspaceId: input.successor.workspaceId, + workspaceEpochId: input.successor.workspaceEpochId, + workspaceVersionId: input.successor.workspaceVersionId, + objectFormat: input.successor.objectFormat, + parents: [input.successor.parentWorkspaceVersionId], + origin: { kind: 'tool_mutation', ...input.origin }, + baseAcceptedEventId: input.successor.baseAcceptedEventId, + baseHeadRevision: input.successor.baseHeadRevision, + commitOid: input.successor.commitOid, + treeOid: input.successor.treeOid, + policyHash: input.successor.policyHash, + treeDeltaDigest: input.successor.treeDeltaDigest, + changedPaths: input.successor.changedPaths, + changedFileCount: input.successor.changedFileCount, + deletedFileCount: input.successor.deletedFileCount, + executionProfileDigest: input.successor.executionProfileDigest, + }; + return { + id: input.acceptedEventId, + ...identity, + ts: input.committedAt, + partial: false, + role: 'system', + author: 'system', + actions: { + workspaceFact: { + kind: WORKSPACE_VERSION_ACCEPTED_FACT_KIND, + version: WORKSPACE_FACT_VERSION, + payload, + }, + }, + }; +} + export function isRuntimeEventWorkspaceFactEnvelope( value: unknown, ): value is RuntimeEventWorkspaceFactEnvelope { @@ -278,6 +393,9 @@ export function isRuntimeEventWorkspaceFactEnvelope( if (value.kind === WORKSPACE_BASELINE_ACCEPTED_FACT_KIND) { return isWorkspaceBaselineAcceptedV1(value.payload); } + if (value.kind === WORKSPACE_VERSION_ACCEPTED_FACT_KIND) { + return isWorkspaceVersionAcceptedV1(value.payload); + } return false; } @@ -319,7 +437,8 @@ export function scanWorkspaceBaselineAuthority( const issues: WorkspaceAuthorityIssue[] = []; const seenEventIds = new Set(); const epochRows = new Map(); - const versionRows = new Map(); + const baselineRows = new Map(); + const successorRows = new Map(); const versionIds = new Map(); for (const row of rows) { @@ -349,11 +468,13 @@ export function scanWorkspaceBaselineAuthority( epochRows.set(epochId, matches); continue; } - const matches = versionRows.get(epochId) ?? []; + const target = + fact.kind === WORKSPACE_BASELINE_ACCEPTED_FACT_KIND ? baselineRows : successorRows; + const matches = target.get(epochId) ?? []; matches.push(row); - versionRows.set(epochId, matches); + target.set(epochId, matches); const priorEpoch = versionIds.get(fact.payload.workspaceVersionId); - if (priorEpoch !== undefined && priorEpoch !== epochId) { + if (priorEpoch !== undefined) { issues.push({ code: 'duplicate_workspace_version', eventId: event.id, @@ -365,14 +486,20 @@ export function scanWorkspaceBaselineAuthority( } const baselines: ScannedWorkspaceBaselineAuthority[] = []; - const epochIds = new Set([...epochRows.keys(), ...versionRows.keys()]); + const successors: ScannedWorkspaceSuccessorAuthority[] = []; + const heads: WorkspaceHeadRecordV1[] = []; + const epochIds = new Set([...epochRows.keys(), ...baselineRows.keys(), ...successorRows.keys()]); for (const epochId of [...epochIds].sort()) { const opened = epochRows.get(epochId) ?? []; - const accepted = versionRows.get(epochId) ?? []; + const accepted = baselineRows.get(epochId) ?? []; + const pendingSuccessors = [...(successorRows.get(epochId) ?? [])].sort( + (left, right) => + left.eventSeq - right.eventSeq || left.event.id.localeCompare(right.event.id), + ); if (opened.length === 0) { issues.push({ code: 'orphan_baseline_version', - eventId: accepted[0]!.event.id, + eventId: (accepted[0] ?? pendingSuccessors[0])!.event.id, workspaceEpochId: epochId, }); continue; @@ -401,15 +528,15 @@ export function scanWorkspaceBaselineAuthority( }); continue; } + let baseline: ScannedWorkspaceBaselineAuthority; try { - baselines.push( - assertWorkspaceBaselineAuthorityPair({ - epochOpenedEvent: opened[0]!.event, - baselineAcceptedEvent: accepted[0]!.event, - epochEventSeq: opened[0]!.eventSeq, - baselineEventSeq: accepted[0]!.eventSeq, - }), - ); + baseline = assertWorkspaceBaselineAuthorityPair({ + epochOpenedEvent: opened[0]!.event, + baselineAcceptedEvent: accepted[0]!.event, + epochEventSeq: opened[0]!.eventSeq, + baselineEventSeq: accepted[0]!.eventSeq, + }); + baselines.push(baseline); } catch (error) { const code = error instanceof WorkspaceAuthorityContractError @@ -420,11 +547,57 @@ export function scanWorkspaceBaselineAuthority( eventId: accepted[0]!.event.id, workspaceEpochId: epochId, }); + continue; + } + + let head: WorkspaceHeadRecordV1 = { + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceVersionId: baseline.baseline.workspaceVersionId, + acceptedEventId: baseline.baselineAcceptedEventId, + commitOid: baseline.baseline.commitOid, + treeOid: baseline.baseline.treeOid, + revision: 1, + }; + for (const row of pendingSuccessors) { + try { + const scanned = assertWorkspaceSuccessorAuthority({ + baseline, + currentHead: head, + event: row.event, + eventSeq: row.eventSeq, + }); + successors.push(scanned); + head = { + repositoryId: scanned.successor.repositoryId, + workspaceId: scanned.successor.workspaceId, + workspaceEpochId: scanned.successor.workspaceEpochId, + workspaceVersionId: scanned.successor.workspaceVersionId, + acceptedEventId: scanned.acceptedEventId, + commitOid: scanned.successor.commitOid, + treeOid: scanned.successor.treeOid, + revision: head.revision + 1, + }; + } catch (error) { + issues.push({ + code: + error instanceof WorkspaceAuthorityContractError + ? error.code + : 'successor_contract_conflict', + eventId: row.event.id, + workspaceEpochId: epochId, + }); + break; + } } + heads.push(head); } return { baselines: issues.length === 0 ? baselines : [], + successors: issues.length === 0 ? successors : [], + heads: issues.length === 0 ? heads : [], issues, hasCorruption: issues.length > 0, }; @@ -434,7 +607,10 @@ class WorkspaceAuthorityContractError extends Error { constructor( readonly code: Extract< WorkspaceAuthorityIssueCode, - 'event_order_conflict' | 'baseline_contract_conflict' + | 'event_order_conflict' + | 'baseline_contract_conflict' + | 'successor_contract_conflict' + | 'workspace_head_conflict' >, message: string, ) { @@ -503,6 +679,52 @@ function assertWorkspaceBaselineAuthorityPair(input: { }; } +function assertWorkspaceSuccessorAuthority(input: { + baseline: ScannedWorkspaceBaselineAuthority; + currentHead: WorkspaceHeadRecordV1; + event: RuntimeEvent; + eventSeq: number; +}): ScannedWorkspaceSuccessorAuthority { + const lane = validateWorkspaceFactEventLane(input.event); + const fact = input.event.actions?.workspaceFact; + if (!lane.ok || fact?.kind !== WORKSPACE_VERSION_ACCEPTED_FACT_KIND) { + throw new WorkspaceAuthorityContractError( + 'successor_contract_conflict', + 'Invalid workspace successor authority event lane', + ); + } + const successor = fact.payload; + const expectedSeq = input.currentHead.revision + 2; + if (input.eventSeq !== expectedSeq) { + throw new WorkspaceAuthorityContractError( + 'event_order_conflict', + 'Workspace successor facts must form one contiguous authority sequence', + ); + } + if ( + successor.repositoryId !== input.baseline.epoch.repositoryId || + successor.workspaceId !== input.baseline.epoch.workspaceId || + successor.workspaceEpochId !== input.baseline.epoch.workspaceEpochId || + successor.objectFormat !== input.baseline.epoch.objectFormat || + successor.policyHash !== input.baseline.epoch.policyHash || + successor.parents[0] !== input.currentHead.workspaceVersionId || + successor.baseAcceptedEventId !== input.currentHead.acceptedEventId || + successor.baseHeadRevision !== input.currentHead.revision + ) { + throw new WorkspaceAuthorityContractError( + 'workspace_head_conflict', + 'Workspace successor does not advance the current canonical head', + ); + } + return { + successor, + acceptedEventId: input.event.id, + acceptedAt: input.event.ts, + eventSeq: input.eventSeq, + authority: workspaceAuthorityIdentity(successor.workspaceEpochId), + }; +} + function assertWorkspaceBaselineAuthorityInput(input: WorkspaceBaselineAuthorityInput): void { if ( !EVENT_ID_PATTERN.test(input.epochOpenedEventId) || @@ -518,6 +740,18 @@ function assertWorkspaceBaselineAuthorityInput(input: WorkspaceBaselineAuthority } } +function assertWorkspaceSuccessorAuthorityInput(input: WorkspaceSuccessorAuthorityInput): void { + if ( + !EVENT_ID_PATTERN.test(input.acceptedEventId) || + !Number.isSafeInteger(input.committedAt) || + input.committedAt < 0 || + !isWorkspaceSuccessorDescriptor(input.successor) || + !isWorkspaceMutationOrigin(input.origin) + ) { + throw new Error('Invalid workspace successor authority input'); + } +} + function isWorkspaceEpochOpenedV1(value: unknown): value is WorkspaceEpochOpenedV1 { return ( hasExactKeys(value, [ @@ -594,6 +828,85 @@ function isWorkspaceBaselineAcceptedV1(value: unknown): value is WorkspaceBaseli return isWorkspaceBaselineDescriptor(value, value.objectFormat); } +function isWorkspaceVersionAcceptedV1(value: unknown): value is WorkspaceVersionAcceptedV1 { + if ( + !hasExactKeys(value, [ + 'protocol', + 'repositoryId', + 'workspaceId', + 'workspaceEpochId', + 'workspaceVersionId', + 'objectFormat', + 'parents', + 'origin', + 'baseAcceptedEventId', + 'baseHeadRevision', + 'commitOid', + 'treeOid', + 'policyHash', + 'treeDeltaDigest', + 'changedPaths', + 'changedFileCount', + 'deletedFileCount', + 'executionProfileDigest', + ]) || + value.protocol !== 'workspace_version_accepted_v1' || + !isWorkspaceSuccessorDescriptor({ + ...value, + parentWorkspaceVersionId: Array.isArray(value.parents) ? value.parents[0] : undefined, + }) || + !Array.isArray(value.parents) || + value.parents.length !== 1 || + !hasExactKeys(value.origin, ['kind', 'operationId', 'dispatchEventId', 'outcomeEventId']) || + value.origin.kind !== 'tool_mutation' || + !isWorkspaceMutationOrigin(value.origin) + ) { + return false; + } + return true; +} + +function isWorkspaceSuccessorDescriptor(value: unknown): value is WorkspaceSuccessorDescriptorV1 { + if (!isRecord(value)) return false; + return ( + isIdentifier(value.repositoryId, 'repositoryId') && + isIdentifier(value.workspaceId, 'workspaceId') && + isIdentifier(value.workspaceEpochId, 'workspaceEpochId') && + isIdentifier(value.workspaceVersionId, 'workspaceVersionId') && + isIdentifier(value.parentWorkspaceVersionId, 'workspaceVersionId') && + EVENT_ID_PATTERN.test(String(value.baseAcceptedEventId)) && + value.workspaceVersionId !== value.parentWorkspaceVersionId && + isObjectFormat(value.objectFormat) && + Number.isSafeInteger(value.baseHeadRevision) && + Number(value.baseHeadRevision) > 0 && + isGitOid(value.commitOid, value.objectFormat) && + isGitOid(value.treeOid, value.objectFormat) && + isSha256Digest(value.policyHash) && + isSha256Digest(value.treeDeltaDigest) && + isCanonicalManagedMutationPathSet(value.changedPaths) && + isNonNegativeSafeInteger(value.changedFileCount) && + value.changedFileCount === value.changedPaths.length && + isNonNegativeSafeInteger(value.deletedFileCount) && + isSha256Digest(value.executionProfileDigest) + ); +} + +function isCanonicalManagedMutationPathSet(value: unknown): value is readonly string[] { + if (!Array.isArray(value) || value.length === 0 || value.length > 32) return false; + if (!value.every(isCanonicalManagedMutationPathV1)) return false; + return value.every((path, index) => index === 0 || value[index - 1]! < path); +} + +function isWorkspaceMutationOrigin(value: unknown): value is WorkspaceMutationOriginV1 { + if (!isRecord(value)) return false; + return ( + EVENT_ID_PATTERN.test(String(value.operationId)) && + EVENT_ID_PATTERN.test(String(value.dispatchEventId)) && + EVENT_ID_PATTERN.test(String(value.outcomeEventId)) && + value.dispatchEventId !== value.outcomeEventId + ); +} + function isWorkspaceBaselineDescriptor( value: unknown, objectFormat: WorkspaceGitObjectFormat, diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index b0dd3d815d..9859af3a07 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1693,6 +1693,15 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { // entry covers the field, not its contents. A new key inside a state delta is // out of reach of any contract keyed on the action surface. stateDelta: { action: { continuationStart: true } }, + managedMutationTerminal: { + action: { + protocol: 'managed_mutation_terminal_v1', + operationId: 'coverage-operation', + dispatchEventId: 'coverage-dispatch', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + terminalKind: 'no_workspace_change', + }, + }, continuationStart: { action: { protocol: 'continuation_start_v2', 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 c6c216dd81..e6a56f7c66 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -29,7 +29,12 @@ import type { ToolOutcomeCommit, ToolPreparedCommit, } from '../runtime-commit-sink.js'; -import { ToolRuntime, type MakaTool } from '../tool-runtime.js'; +import { + ToolRuntime, + type MakaTool, + type RuntimeManagedMutationAdmission, + type ToolRuntimeInput, +} from '../tool-runtime.js'; describe('ToolRuntime durable boundary', () => { it('does not invoke the tool or publish a result when T1 fails', async () => { @@ -197,6 +202,1002 @@ describe('ToolRuntime durable boundary', () => { assert.equal(outcomes[0]?.runtimeEvent.refs?.operationId, prepared[0]?.operationId); }); + it('adopts an owner-committed managed successor without invoking generic T2', async () => { + const order: string[] = []; + const prepared: ToolPreparedCommit[] = []; + let operationId = ''; + const harness = makeHarness( + { + commitToolPrepared: async (input) => { + prepared.push(input); + order.push('t1'); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + order, + 'run-1', + { + admitManagedMutation: async (input) => { + operationId = input.operationId; + order.push('admit'); + return managedAdmission(async (operation) => { + order.push('lease-enter'); + const proof = await operation(); + order.push('successor-bundle'); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }, order); + }, + }, + ); + const managedTool = tool(() => { + throw new Error('ordinary mutable implementation must not run'); + }); + managedTool.managedMutationTransform = () => { + order.push('transform'); + return { ok: true }; + }; + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { ok: true }); + assert.deepEqual(order, [ + 'admit', + 't1', + 'lease-enter', + 'transform', + 'successor-bundle', + 'published-result', + 'dispose', + ]); + assert.deepEqual( + prepared[0]?.dispatchRuntimeEvent.actions?.toolDispatch?.managedMutation, + managedMutationDispatch(), + ); + }); + + it('does not replace a committed managed result when admission cleanup fails', async () => { + let operationId = ''; + 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(), + execute: async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }, + dispose: async () => { + throw new Error('cleanup failed after commit'); + }, + }; + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { ok: true }); + }); + + it('leaves a managed T1 unsettled without publishing or writing generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('candidate state is unknown') }; + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /candidate state is unknown/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + assert.equal( + harness.messages.some((message) => message.type === 'tool_result'), + false, + ); + }); + + it('fail-stops a thrown managed settlement instead of falling back to generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + throw new Error('owner settlement channel failed'); + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /owner settlement channel failed/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('fail-stops a managed success with no durable outcome instead of writing generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { + kind: 'workspace_successor_committed', + } as never; + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /durable outcome/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('does not let a managed owner replace the Runtime-owned success result', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + 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) => { + const proof = await operation(); + const forgedContent = { kind: 'json' as const, value: { source: 'durable-B' } }; + return { + kind: 'workspace_successor_committed', + // Simulate an untyped/older Host attempting to reintroduce the + // removed result channel. Runtime must ignore this value and + // compare the durable event with its own captured operation. + value: { + result: { source: 'live-A' }, + outcome: { + content: forgedContent, + isError: false, + durationMs: proof.durationMs, + }, + }, + durableOutcome: managedOutcomeEvent(operationId, forgedContent, false, { + durationMs: proof.durationMs, + }), + } as never; + }); + }, + }, + ); + const managedTool = tool(() => ({ source: 'runtime-original' })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); + assert.equal(genericOutcomeCalls, 0); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('publishes one immutable snapshot when the tool mutates its returned object later', async () => { + let operationId = ''; + const mutableResult = { state: 'A' }; + const appendedMessages: StoredMessage[] = []; + 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', + { + appendMessage: async (message) => { + if (message.type === 'tool_result') mutableResult.state = 'B'; + appendedMessages.push(structuredClone(message)); + }, + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => mutableResult); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = await harness.execute(managedTool); + const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); + const liveEvent = harness.events.find((event) => event.type === 'tool_result'); + + assert.equal(mutableResult.state, 'B'); + assert.deepEqual(result, { state: 'A' }); + assert.equal(Object.isFrozen(result), true); + assert.deepEqual(storedResult?.type === 'tool_result' ? storedResult.content : undefined, { + kind: 'json', + value: { state: 'A' }, + }); + assert.deepEqual(liveEvent?.type === 'tool_result' ? liveEvent.content : undefined, { + kind: 'json', + value: { state: 'A' }, + }); + }); + + it('preserves JSON __proto__ keys as immutable data properties', async () => { + let operationId = ''; + const resultWithProtoKey = Object.create(null) as Record; + Object.defineProperty(resultWithProtoKey, '__proto__', { + enumerable: true, + value: { safe: true }, + }); + 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 managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => resultWithProtoKey); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = (await harness.execute(managedTool)) as Record; + + assert.equal(Object.hasOwn(result, '__proto__'), true); + assert.deepEqual(result.__proto__, { safe: true }); + assert.equal(Object.isFrozen(result.__proto__), true); + assert.equal(JSON.stringify(result), '{"__proto__":{"safe":true}}'); + }); + + it('adopts an owner-committed safe discard without invoking generic T2', async () => { + let genericOutcomeCalls = 0; + let operationId = ''; + 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(); + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'operation_failed_no_effect_committed', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'candidate was safely discarded', + }); + assert.equal(genericOutcomeCalls, 0); + const published = harness.events.at(-1); + assert.equal(published?.type, 'tool_result'); + assert.equal(published?.type === 'tool_result' && published.isError, true); + }); + + it('publishes an owner-committed no-change success without invoking generic T2', async () => { + let operationId = ''; + 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 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, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ignored: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { ok: true, changed: false }); + 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 () => { + let operationId = ''; + const ownerResult = { error: 'discarded-A' }; + const appendedMessages: StoredMessage[] = []; + 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', + { + appendMessage: async (message) => { + if (message.type === 'tool_result') ownerResult.error = 'mutated-B'; + appendedMessages.push(structuredClone(message)); + }, + admitManagedMutation: async (input) => { + operationId = input.operationId; + return managedAdmission(async (operation) => { + await operation(); + return { + kind: 'operation_failed_no_effect_committed', + providerResult: ownerResult, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: { error: 'discarded-A' } }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const result = await harness.execute(managedTool); + const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); + + assert.equal(ownerResult.error, 'mutated-B'); + assert.deepEqual(result, { error: 'discarded-A' }); + assert.equal(Object.isFrozen(result), true); + assert.deepEqual(storedResult?.type === 'tool_result' ? storedResult.content : undefined, { + kind: 'json', + value: { error: 'discarded-A' }, + }); + }); + + it('revokes a retained managed operation after terminal settlement', async () => { + let operationId = ''; + let implementationCalls = 0; + let retainedOperation: Parameters[0] | undefined; + 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 managedAdmission(async (operation) => { + retainedOperation = operation; + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'operation_failed_no_effect_committed', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => { + implementationCalls += 1; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'candidate was safely discarded', + }); + assert.ok(retainedOperation); + await assert.rejects(retainedOperation(), /operation capability is closed/i); + assert.equal(implementationCalls, 0); + }); + + it('does not accept terminal settlement while a detached operation is running', async () => { + let operationId = ''; + let releaseOperation!: () => void; + const operationBlocked = new Promise((resolve) => { + releaseOperation = resolve; + }); + 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 managedAdmission(async (operation) => { + void operation().catch(() => undefined); + const result = { error: 'candidate was safely discarded' }; + return { + kind: 'operation_failed_no_effect_committed', + providerResult: result, + durableOutcome: managedOutcomeEvent( + operationId, + { kind: 'json', value: result }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(async () => { + await operationBlocked; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + const execution = harness.execute(managedTool); + const settledBeforeRelease = await Promise.race([ + execution.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]); + releaseOperation(); + + assert.equal(settledBeforeRelease, false); + await assert.rejects(execution, /owner settled before the operation completed/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('rejects a safe discard whose live error differs from its durable result', async () => { + let operationId = ''; + 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 managedAdmission(async (operation) => { + 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' } }, + true, + ), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + 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; + const oversizedResult = { payload: 'x'.repeat(128) } as Record; + Object.defineProperty(oversizedResult, 'mustNotBeRead', { + enumerable: true, + get: () => { + lateGetterReads += 1; + throw new Error('snapshot walked past its byte budget'); + }, + }); + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('unreachable') }; + }), + }, + ); + const managedTool = tool(() => oversizedResult); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects( + harness.executeNested(managedTool, 32), + /tool result byte limit exceeded/i, + ); + assert.equal(lateGetterReads, 0); + assert.equal(genericOutcomeCalls, 0); + }); + + it('enforces the fixed managed result budget when the caller omits one', 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 () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('unreachable') }; + }), + }, + ); + const managedTool = tool(() => ({ payload: 'x'.repeat(1024 * 1024 + 1) })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /tool result byte limit exceeded/i); + }); + + it('rejects a managed result deeper than the fixed profile permits', 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 () => + managedAdmission(async (operation) => { + await operation(); + return { kind: 'unsettled', error: new Error('unreachable') }; + }), + }, + ); + let result: Record = {}; + for (let depth = 0; depth < 66; depth += 1) result = { child: result }; + const managedTool = tool(() => result); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /tool result byte limit exceeded/i); + }); + + it('rejects undefined fields instead of creating a non-canonical durable result', async () => { + let operationId = ''; + 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 managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => ({ ok: true, missing: undefined })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), /strict JSON.*undefined/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + for (const [description, makeResult, expectedError] of [ + ['non-finite numbers', () => ({ n: Number.NaN }), /strict JSON.*not finite/i], + ['undefined array entries', () => ({ list: [undefined] }), /strict JSON.*undefined/i], + ['sparse arrays', () => ({ list: new Array(1) }), /strict JSON.*sparse array/i], + ] as const) { + it(`rejects ${description} before accepting a managed durable result`, async () => { + let operationId = ''; + 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 managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + }), + }; + }); + }, + }, + ); + const managedTool = tool(() => makeResult()); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await assert.rejects(harness.execute(managedTool), expectedError); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + } + + it('rejects a durable managed response with a different code-mode envelope', async () => { + let operationId = ''; + 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 managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { + durationMs: proof.durationMs, + origin: 'code_mode', + // The live nested call is hidden. A visible durable replay is + // a different provider contract and must never be adopted. + modelVisibility: 'visible', + 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), /mismatched durable outcome/i); + assert.equal( + harness.events.some((event) => event.type === 'tool_result'), + false, + ); + }); + + it('refuses a managed mutation before T1 when host admission is unavailable', async () => { + let preparedCalls = 0; + let implementationCalls = 0; + const harness = makeHarness({ + commitToolPrepared: async () => { + preparedCalls += 1; + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }); + const managedTool = tool(() => { + implementationCalls += 1; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'Managed workspace mutation admission is unavailable before T1', + }); + assert.equal(preparedCalls, 0); + assert.equal(implementationCalls, 0); + }); + it('rejects an oversized nested result before durable publication', async () => { const outcomes: ToolOutcomeCommit[] = []; const harness = makeHarness({ @@ -538,7 +1539,12 @@ describe('ToolRuntime durable boundary', () => { }); // `null` means the turn carries no run id at all; `undefined` keeps the default. -function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | null = 'run-1') { +function makeHarness( + sink: RuntimeCommitSink, + order?: string[], + runId: string | null = 'run-1', + overrides: Partial = {}, +) { const messages: StoredMessage[] = []; const events: SessionEvent[] = []; const runtime = createTestToolRuntime({ @@ -554,6 +1560,7 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | getPermissionPauseTarget: () => null, ...(runId ? { runId } : {}), runtimeCommitSink: sink, + ...overrides, }); return { messages, @@ -564,7 +1571,7 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | tool: target, turnId: 'turn-1', toolCallId: 'provider-call-1', - input: {}, + input: target.durableExecutionProfile ? { path: 'notes.txt' } : {}, abortSignal, eventSink: { push: (event) => { @@ -584,7 +1591,7 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | tool: target, turnId: 'turn-1', toolCallId: 'nested-call-1', - input: {}, + input: target.durableExecutionProfile ? { path: 'notes.txt' } : {}, abortSignal: new AbortController().signal, eventSink: { push: (event) => events.push(event), @@ -601,6 +1608,82 @@ function makeHarness(sink: RuntimeCommitSink, order?: string[], runId: string | }; } +function managedAdmission( + execute: RuntimeManagedMutationAdmission['execute'], + order?: string[], +): RuntimeManagedMutationAdmission { + return { + durableDispatch: managedMutationDispatch(), + execute, + dispose: async () => { + order?.push('dispose'); + }, + }; +} + +function managedOutcomeEvent( + operationId: string, + result: unknown, + isError: boolean, + options: { + durationMs?: number; + origin?: 'provider' | 'code_mode'; + modelVisibility?: 'visible' | 'hidden'; + toolCallId?: string; + parentToolCallId?: string; + parentOperationId?: string; + } = {}, +) { + const toolCallId = options.toolCallId ?? 'provider-call-1'; + return { + id: `${operationId}_response`, + invocationId: 'run-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 100, + partial: false, + role: 'tool' as const, + author: 'tool' as const, + origin: options.origin ?? ('provider' as const), + modelVisibility: options.modelVisibility ?? ('visible' as const), + content: { + kind: 'function_response' as const, + id: toolCallId, + name: 'Write', + result, + ...(isError ? { isError: true } : {}), + }, + refs: { + operationId, + toolCallId, + ...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}), + ...(options.parentOperationId ? { parentOperationId: options.parentOperationId } : {}), + }, + actions: { stateDelta: { durationMs: options.durationMs ?? 0 } }, + }; +} + +function managedMutationDispatch() { + return { + protocol: 'managed_mutation_v2' as const, + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: 'version_55555555555555555555555555555555', + baseAcceptedEventId: 'baseline-event-1', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'notes.txt', + pathPolicyVersion: 3 as const, + executionProfileDigest: + 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + }; +} + function tool(impl: MakaTool['impl']): MakaTool { return { name: 'Read', @@ -608,6 +1691,7 @@ function tool(impl: MakaTool['impl']): MakaTool { parameters: {}, recoveryMode: 'replay_safe', impl, + managedMutationTransform: (args) => impl(args, undefined as never), }; } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index efa00c2c8c..1df0853dea 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -323,6 +323,13 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (event.actions?.managedMutationTerminal) { + // The matching function_response owns the provider-visible row. This + // action only proves that the managed reservation reached a no-effect + // terminal through its dedicated atomic writer. + projected = true; + } + if (event.actions?.artifactDelta) { // Artifact counters are storage bookkeeping. The tool result that owns the // artifact owns its row; this delta has none of its own. diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index ff407b4408..01e57c55d5 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -64,7 +64,15 @@ import { computerUseModelCallArgs } from '@maka/core/computer-use'; import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { redactSecrets } from '@maka/core/redaction'; -import { TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent } from '@maka/core/runtime-event'; +import { + decodeRuntimeEvent, + MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC, + TOOL_BOUNDARY_PROTOCOL_V1, + type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutationV2, +} from '@maka/core/runtime-event'; +import { isDeepStrictEqual } from 'node:util'; import { recordToolArtifactsSafely, type ToolArtifactRecorder } from './tool-artifacts.js'; import { computerActionFields, describeComputerUseArgsViolation } from './computer-use-codec.js'; @@ -164,6 +172,13 @@ export interface MakaTool

{ }; /** Crash-recovery contract used by the durable tool boundary. */ recoveryMode?: ToolRecoveryMode; + /** Durable execution profile selected by the Host before T1. */ + durableExecutionProfile?: 'managed_mutation_v1'; + /** + * Pure Write/Edit transform for managed mutation mode. It receives only the + * frozen arguments and must not read or mutate the live workspace. + */ + managedMutationTransform?: (args: P) => Promise | R; /** Step-level admission contract. Exclusive tools cannot share an assistant step. */ executionSemantics?: 'parallel' | 'exclusive_step'; /** Nested CodeMode admission. Ordinary tools are nestable by default. */ @@ -374,6 +389,55 @@ export interface ToolRuntimeInput { recordToolArtifacts?: ToolArtifactRecorder; /** Optional Phase 2 T1/T2 commit boundary for hosts that persist RuntimeEvents. */ runtimeCommitSink?: RuntimeCommitSink; + /** Host-owned managed mutation admission. It may never fall back after returning a profile. */ + admitManagedMutation?: (input: { + readonly operationId: string; + readonly toolName: string; + readonly persistedArgs: unknown; + readonly abortSignal: AbortSignal; + }) => Promise; +} + +interface RuntimeManagedMutationOperationValue { + readonly result: T; + readonly outcome: { + readonly content: ToolResultContent; + readonly isError: boolean; + readonly durationMs: number; + }; +} + +/** + * The only operation evidence exposed to the workspace owner. Runtime keeps + * the provider-facing value private so the owner cannot replace the live + * result while committing a different durable response. + */ +export interface RuntimeManagedMutationOperationProof { + readonly content: ToolResultContent; + readonly isError: boolean; + readonly durationMs: number; +} + +export type RuntimeManagedMutationSettlement = + | { + readonly kind: 'workspace_successor_committed'; + readonly durableOutcome: RuntimeEvent; + } + | { + 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; + execute( + operation: () => Promise, + ): Promise; + /** Idempotent for an unused, failed-T1, or already executed admission. */ + dispose(): Promise; } interface DurableToolAttempt { @@ -384,6 +448,12 @@ interface DurableToolAttempt { isError: boolean, durationMs?: number, ): Promise<{ id: string; operationId: string; ts: number }>; + adoptCommittedOutcome( + event: RuntimeEvent, + result: ToolResultContent, + isError: boolean, + durationMs: number, + ): { id: string; operationId: string; ts: number }; } class RuntimeCommitBoundaryError extends Error { @@ -397,6 +467,16 @@ class RuntimeCommitBoundaryError extends Error { } } +class RuntimeManagedMutationUnsettledError extends Error { + constructor(cause: unknown) { + super( + `Managed workspace mutation remains unsettled: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + this.name = 'RuntimeManagedMutationUnsettledError'; + } +} + class ToolResultLimitError extends Error { constructor() { super('Tool result byte limit exceeded'); @@ -1280,8 +1360,39 @@ export class ToolRuntime { } } + let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; + if (tool.durableExecutionProfile === 'managed_mutation_v1') { + if ( + (tool.name !== 'Write' && tool.name !== 'Edit') || + tool.recoveryMode !== 'reconcile' || + !tool.managedMutationTransform || + !dispatchOperationId || + !this.input.runtimeCommitSink || + !this.input.admitManagedMutation + ) { + const reason = 'Managed workspace mutation admission is unavailable before T1'; + await refuseBeforeDispatch(reason); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } + try { + managedMutationAdmission = await this.input.admitManagedMutation({ + operationId: dispatchOperationId, + toolName: tool.name, + persistedArgs: structuredClone(persistedArgs), + abortSignal: ctx.abortSignal, + }); + } catch (error) { + const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; + await refuseBeforeDispatch(reason); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(reason); + } + } + const reservedSubagentSlot = this.reserveSubagentSlot(tool); if (!reservedSubagentSlot) { + await disposeManagedMutationAdmission(managedMutationAdmission); trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', { toolUseId, toolName: tool.name, @@ -1301,11 +1412,15 @@ export class ToolRuntime { persistedArgs, modelFacingArgs, abortSignal: ctx.abortSignal, + ...(managedMutationAdmission + ? { managedMutation: managedMutationAdmission.durableDispatch } + : {}), ...(invocationId ? { invocationId } : {}), ...(runId ? { runId } : {}), }); } catch (error) { if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + await disposeManagedMutationAdmission(managedMutationAdmission); throw error; } if (durableAttempt) { @@ -1342,97 +1457,242 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); - const result = await tool.impl(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, - turnId, - ...(runId ? { runId } : {}), - ...(this.input.orchestrationMode - ? { orchestrationMode: this.input.orchestrationMode } - : {}), - cwd: this.input.header.cwd, - executionBoundary, - permissionMode: this.input.header.permissionMode, - toolCallId: toolUseId, - // The id the call event actually carries, not the candidate: by here - // `prepareDurableToolAttempt` has pushed it on the dispatch lane. - ...(pushedCallEvent?.operationId ? { operationId: pushedCallEvent.operationId } : {}), - abortSignal: ctx.abortSignal, - emitOutput: output.emit, - emitProgress: (current, total) => { - const chunk = encodeToolStepProgress({ current, total }); - if (!chunk) return; - queue.push({ - type: 'tool_progress', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - chunk, - ...activityIdentity, - }); - }, - ...(trace - ? { - emitRunTrace: ( - type: - | 'tool_started' - | 'tool_searched' - | 'tool_completed' - | 'tool_failed' - | 'skill_searched' - | 'skill_loaded' - | 'skill_load_failed', - message: string, - data?: Record, - ) => - trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { - toolUseId, - toolName: tool.name, - ...(data ?? {}), - }), - } - : {}), - ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), - ...(this.input.readChildAgentOutput - ? { readChildAgentOutput: this.input.readChildAgentOutput } - : {}), - ...this.buildChildAgentContext({ + const invokeTool = () => + tool.impl(structuredClone(executionArgs) as never, { + sessionId: this.input.sessionId, turnId, + ...(runId ? { runId } : {}), + ...(this.input.orchestrationMode + ? { orchestrationMode: this.input.orchestrationMode } + : {}), + cwd: this.input.header.cwd, + executionBoundary, + permissionMode: this.input.header.permissionMode, + toolCallId: toolUseId, + // The id the call event actually carries, not the candidate: by here + // `prepareDurableToolAttempt` has pushed it on the dispatch lane. + ...(pushedCallEvent?.operationId ? { operationId: pushedCallEvent.operationId } : {}), abortSignal: ctx.abortSignal, - trace, - toolUseId, - toolName: tool.name, - queue, - activityIdentity, - }), - askUserQuestion: (questions) => - this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), - requestSandboxBoundary: (expansion, justification) => - this.requestSandboxBoundary( + emitOutput: output.emit, + emitProgress: (current, total) => { + const chunk = encodeToolStepProgress({ current, total }); + if (!chunk) return; + queue.push({ + type: 'tool_progress', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + chunk, + ...activityIdentity, + }); + }, + ...(trace + ? { + emitRunTrace: ( + type: + | 'tool_started' + | 'tool_searched' + | 'tool_completed' + | 'tool_failed' + | 'skill_searched' + | 'skill_loaded' + | 'skill_load_failed', + message: string, + data?: Record, + ) => + trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, { + toolUseId, + toolName: tool.name, + ...(data ?? {}), + }), + } + : {}), + ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), + ...(this.input.readChildAgentOutput + ? { readChildAgentOutput: this.input.readChildAgentOutput } + : {}), + ...this.buildChildAgentContext({ turnId, + abortSignal: ctx.abortSignal, + trace, toolUseId, - expansion, - justification, - ctx.abortSignal, + toolName: tool.name, queue, - ), - }); - if ( - ctx.maxResultBytes !== undefined && - serializedByteLength(result, ctx.maxResultBytes) > ctx.maxResultBytes - ) { - throw new ToolResultLimitError(); + activityIdentity, + }), + askUserQuestion: (questions) => + this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestSandboxBoundary: (expansion, justification) => + this.requestSandboxBoundary( + turnId, + toolUseId, + expansion, + justification, + ctx.abortSignal, + queue, + ), + }); + const invokeManagedTransform = () => + tool.managedMutationTransform!(structuredClone(executionArgs) as never); + const prepareOperationValue = async ( + immutableSnapshot = false, + ): Promise> => { + const rawResult = await (immutableSnapshot ? invokeManagedTransform() : invokeTool()); + const result = immutableSnapshot + ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) + : rawResult; + if ( + !immutableSnapshot && + ctx.maxResultBytes !== undefined && + serializedByteLength(result, ctx.maxResultBytes) > ctx.maxResultBytes + ) { + throw new ToolResultLimitError(); + } + const content = immutableSnapshot + ? Object.freeze(coerceResultContent(result)) + : coerceResultContent(result); + const outcome = { + content, + isError: deriveToolResultStatus(content, result) !== 'success', + durationMs: this.input.now() - startedAt, + }; + const value = { + result, + outcome: immutableSnapshot ? Object.freeze(outcome) : outcome, + }; + return immutableSnapshot ? Object.freeze(value) : value; + }; + let settledExecution: + | { + kind: 'managed'; + value: RuntimeManagedMutationOperationValue; + durableOutcome: RuntimeEvent; + } + | { kind: 'generic'; value: RuntimeManagedMutationOperationValue }; + if (managedMutationAdmission) { + const operationLifecycle: { + state: 'open' | 'running' | 'settled' | 'closed'; + } = { state: 'open' }; + let operationPromise: Promise | undefined; + let runtimeOwnedValue: RuntimeManagedMutationOperationValue | undefined; + const executeManagedOperation = (): Promise => { + if (operationLifecycle.state !== 'open') { + if (operationLifecycle.state === 'closed') { + return Promise.reject(new Error('Managed mutation operation capability is closed')); + } + return Promise.reject( + new Error('Managed mutation owner invoked the operation more than once'), + ); + } + operationLifecycle.state = 'running'; + operationPromise = (async () => { + try { + const value = await prepareOperationValue(true); + runtimeOwnedValue = value; + 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, + }; + } finally { + if (operationLifecycle.state === 'running') { + operationLifecycle.state = 'settled'; + } + } + })(); + // Runtime also observes the promise so a careless owner cannot + // turn an un-awaited operation rejection into an unhandled one. + void operationPromise.catch(() => undefined); + return operationPromise; + }; + let settlement: unknown; + let ownerFailed = false; + let ownerError: unknown; + try { + settlement = await managedMutationAdmission.execute(executeManagedOperation); + } catch (error) { + ownerFailed = true; + ownerError = error; + } + const ownerSettledWhileOperationRunning = operationLifecycle.state === 'running'; + if (ownerSettledWhileOperationRunning) { + try { + await operationPromise; + } catch { + // The terminal state is invalid regardless of how the detached + // operation settles. Join it so no side effect can occur later. + } finally { + operationLifecycle.state = 'closed'; + } + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation owner settled before the operation completed', { + ...(ownerFailed ? { cause: ownerError } : {}), + }), + ); + } + operationLifecycle.state = 'closed'; + if (ownerFailed) { + // Once managed T1 is durable, an admission/owner failure may never + // fall through to the generic synthetic T2 path. Only the owner can + // prove that a successor was accepted or the candidate was safely + // discarded; every other failure remains unsettled for recovery. + throw new RuntimeManagedMutationUnsettledError(ownerError); + } + const normalized = normalizeManagedMutationSettlement(settlement, ctx.maxResultBytes); + if (normalized.kind === 'workspace_successor_committed') { + if (!runtimeOwnedValue) { + throw new RuntimeManagedMutationUnsettledError( + new Error( + 'Managed mutation owner committed success without executing the operation', + ), + ); + } + settledExecution = { + kind: 'managed', + value: runtimeOwnedValue, + durableOutcome: normalized.durableOutcome, + }; + } else { + settledExecution = { + kind: 'managed', + value: normalized.value, + durableOutcome: normalized.durableOutcome, + }; + } + } else { + settledExecution = { kind: 'generic', value: await prepareOperationValue() }; } + const { result, outcome } = settledExecution.value; output.flush(); - const durationMs = this.input.now() - startedAt; - - const content = coerceResultContent(result); + const { content, durationMs } = outcome; + // Keep the full provider-facing terminal classification. `isError` is + // sufficient for the durable response envelope, but it intentionally + // collapses `aborted` into an error bit and therefore cannot drive live + // tool status, telemetry, or subagent lifecycle projection. const toolResultStatus = deriveToolResultStatus(content, result); - const durableOutcome = await durableAttempt?.commitOutcome( - content, - toolResultStatus !== 'success', - durationMs, - ); + let durableOutcome: { id: string; operationId: string; ts: number } | undefined; + if (settledExecution.kind === 'managed') { + if (!durableAttempt) { + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation settlement has no durable T1 attempt'), + ); + } + durableOutcome = durableAttempt.adoptCommittedOutcome( + settledExecution.durableOutcome, + content, + outcome.isError, + durationMs, + ); + } else { + durableOutcome = await durableAttempt?.commitOutcome( + content, + outcome.isError, + durationMs, + ); + } if (hasSandboxDenial(content)) { const denialKey = sandboxDenialKey(tool.name, this.input.header.cwd, executionArgs); this.recentSandboxDenials.add(denialKey); @@ -1540,7 +1800,19 @@ export class ToolRuntime { pauseTarget?.resume(); } } catch (err) { - if (err instanceof RuntimeCommitBoundaryError) throw err; + if ( + err instanceof RuntimeCommitBoundaryError || + err instanceof RuntimeManagedMutationUnsettledError + ) { + throw err; + } + // Admission exists only after the owner has selected managed mode and + // Runtime has committed its T1 dispatch. From that point onward every + // normalization, size check, adoption, publication, and telemetry error + // is recovery-owned. It may never fall through to generic synthetic T2. + if (managedMutationAdmission) { + throw new RuntimeManagedMutationUnsettledError(err); + } if (isInteractionControlError(err)) throw err; output.flush(); const sandboxError = serializeSandboxError(err); @@ -1689,6 +1961,7 @@ export class ToolRuntime { attemptBoundaryDetails, ); if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + await disposeManagedMutationAdmission(managedMutationAdmission); } } @@ -1699,6 +1972,7 @@ export class ToolRuntime { /** The projection the model replays as its own call. */ modelFacingArgs: unknown; abortSignal: AbortSignal; + managedMutation?: Readonly; invocationId?: string; runId?: string; }): Promise { @@ -1783,6 +2057,7 @@ export class ToolRuntime { toolName: input.tool.name, canonicalArgsHash, recoveryMode, + ...(input.managedMutation ? { managedMutation: input.managedMutation } : {}), }, }, refs: { @@ -1797,6 +2072,25 @@ export class ToolRuntime { }, }; try { + if (input.managedMutation) { + decodeRuntimeEvent(dispatchEvent); + const persistedPath = + input.persistedArgs && + typeof input.persistedArgs === 'object' && + !Array.isArray(input.persistedArgs) + ? (input.persistedArgs as { path?: unknown }).path + : undefined; + if ( + (input.tool.name !== 'Write' && input.tool.name !== 'Edit') || + typeof persistedPath !== 'string' || + input.managedMutation.expectedPath !== persistedPath || + input.managedMutation.pathPolicyVersion !== 3 || + input.managedMutation.executionProfileDigest !== + MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST + ) { + throw new Error('Managed mutation admission does not match the durable tool call'); + } + } this.assertDurableDispatchNotAborted(input.tool.name, input.abortSignal); const prepared = await sink.commitToolPrepared({ operationId, @@ -1815,43 +2109,49 @@ export class ToolRuntime { } catch (error) { throw new RuntimeCommitBoundaryError('T1', error); } + const buildResponseEvent = ( + result: unknown, + isError: boolean, + durationMs: number | undefined, + ts: number, + ): RuntimeEvent => ({ + id: `${operationId}_response`, + invocationId, + runId, + sessionId: this.input.sessionId, + turnId: input.startEvent.turnId, + ts, + partial: false, + role: 'tool', + author: 'tool', + origin: input.startEvent.origin ?? 'provider', + modelVisibility: input.startEvent.modelVisibility ?? 'visible', + content: { + kind: 'function_response', + id: input.startEvent.toolUseId, + name: input.tool.name, + result, + ...(isError ? { isError: true } : {}), + }, + refs: { + operationId, + toolCallId: input.startEvent.toolUseId, + ...(input.startEvent.parentToolCallId + ? { parentToolCallId: input.startEvent.parentToolCallId } + : {}), + ...(input.startEvent.parentOperationId + ? { parentOperationId: input.startEvent.parentOperationId } + : {}), + }, + ...(durationMs !== undefined ? { actions: { stateDelta: { durationMs } } } : {}), + }); let committedOutcome: { id: string; operationId: string; ts: number } | undefined; return { operationId, responseEventId: `${operationId}_response`, commitOutcome: async (result, isError, durationMs) => { if (committedOutcome) return committedOutcome; - const responseEvent: RuntimeEvent = { - id: `${operationId}_response`, - invocationId, - runId, - sessionId: this.input.sessionId, - turnId: input.startEvent.turnId, - ts: this.input.now(), - partial: false, - role: 'tool', - author: 'tool', - origin: input.startEvent.origin ?? 'provider', - modelVisibility: input.startEvent.modelVisibility ?? 'visible', - content: { - kind: 'function_response', - id: input.startEvent.toolUseId, - name: input.tool.name, - result, - ...(isError ? { isError: true } : {}), - }, - refs: { - operationId, - toolCallId: input.startEvent.toolUseId, - ...(input.startEvent.parentToolCallId - ? { parentToolCallId: input.startEvent.parentToolCallId } - : {}), - ...(input.startEvent.parentOperationId - ? { parentOperationId: input.startEvent.parentOperationId } - : {}), - }, - ...(durationMs !== undefined ? { actions: { stateDelta: { durationMs } } } : {}), - }; + const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); try { await sink.commitToolOutcome({ operationId, @@ -1872,6 +2172,21 @@ export class ToolRuntime { ); return committedOutcome; }, + adoptCommittedOutcome: (event, result, isError, durationMs) => { + if (committedOutcome) return committedOutcome; + const expected = buildResponseEvent(result, isError, durationMs, event.ts); + if (!Number.isFinite(event.ts) || !isDeepStrictEqual(event, expected)) { + throw new RuntimeCommitBoundaryError( + 'T2', + new Error('Managed mutation settlement returned a mismatched durable outcome'), + ); + } + committedOutcome = { id: event.id, operationId, ts: event.ts }; + this.durableToolAttempts.delete( + durableAttemptKey(input.startEvent.turnId, input.startEvent.toolUseId), + ); + return committedOutcome; + }, }; } @@ -2882,6 +3197,84 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } +function normalizeManagedMutationSettlement( + settlement: unknown, + maxResultBytes: number | undefined, +): + | { + 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)) { + throw new Error('Managed mutation owner returned an invalid settlement'); + } + const record = settlement as unknown as Record; + const kind = record.kind; + if (kind === 'unsettled') { + throw new RuntimeManagedMutationUnsettledError(record.error); + } + if ( + kind !== 'workspace_successor_committed' && + kind !== 'no_workspace_change_committed' && + kind !== 'operation_failed_no_effect_committed' + ) { + throw new Error('Managed mutation owner returned an unknown settlement kind'); + } + const durableOutcomeValue = Object.hasOwn(record, 'durableOutcome') + ? record.durableOutcome + : undefined; + if ( + !durableOutcomeValue || + typeof durableOutcomeValue !== 'object' || + Array.isArray(durableOutcomeValue) + ) { + throw new Error('Managed mutation settlement has no durable outcome'); + } + const durableOutcome = durableOutcomeValue as RuntimeEvent; + + if (kind === 'workspace_successor_committed') { + return { + kind, + durableOutcome, + }; + } + + 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 ( + response?.kind !== 'function_response' || + (expectedError ? response.isError !== true : response.isError === true) + ) { + 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, + }; +} + function coerceResultContent(raw: unknown): ToolResultContent { if (typeof raw === 'string') return { kind: 'text', text: raw }; if (raw && typeof raw === 'object') { @@ -2899,6 +3292,190 @@ function coerceResultContent(raw: unknown): ToolResultContent { return { kind: 'text', text: String(raw ?? '') }; } +/** + * Builds the one durable JSON representation while enforcing its byte budget. + * The walk stops at the first over-budget token and never retains a mutable + * tool/owner-owned object alias. + */ +const MANAGED_RESULT_MAX_BYTES = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxBytes; +const MANAGED_RESULT_MAX_DEPTH = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxDepth; +const MANAGED_RESULT_MAX_NODES = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxNodes; +const MANAGED_RESULT_MAX_PROPERTIES = + MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxProperties; +const MANAGED_RESULT_MAX_ARRAY_LENGTH = + MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxArrayLength; + +function snapshotManagedToolResult(value: unknown, maxBytes: number | undefined): unknown { + const budget: ManagedResultSnapshotBudget = { + bytes: 0, + limit: + maxBytes === undefined + ? MANAGED_RESULT_MAX_BYTES + : Number.isFinite(maxBytes) && maxBytes >= 0 + ? Math.min(Math.floor(maxBytes), MANAGED_RESULT_MAX_BYTES) + : 0, + nodes: 0, + properties: 0, + active: new WeakSet(), + }; + return snapshotStrictJsonValue(value, budget, '$', 0); +} + +interface ManagedResultSnapshotBudget { + bytes: number; + limit: number; + nodes: number; + properties: number; + active: WeakSet; +} + +function snapshotStrictJsonValue( + value: unknown, + budget: ManagedResultSnapshotBudget, + path: string, + depth: number, +): unknown { + budget.nodes += 1; + if (budget.nodes > MANAGED_RESULT_MAX_NODES || depth > MANAGED_RESULT_MAX_DEPTH) { + throw new ToolResultLimitError(); + } + if (value === null) { + consumeManagedResultBytes(budget, 4); + return null; + } + if (typeof value === 'string') { + consumeManagedJsonStringBytes(budget, value); + return value; + } + if (typeof value === 'boolean') { + consumeManagedResultBytes(budget, value ? 4 : 5); + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Managed tool result must be strict JSON: ${path} is not finite`); + } + const canonical = Object.is(value, -0) ? 0 : value; + consumeManagedResultBytes(budget, JSON.stringify(canonical).length); + return canonical; + } + if (value === undefined) { + throw new Error(`Managed tool result must be strict JSON: ${path} is undefined`); + } + if (typeof value !== 'object') { + throw new Error(`Managed tool result must be strict JSON: ${path} has an unsupported type`); + } + if (budget.active.has(value)) { + throw new Error(`Managed tool result must be strict JSON: ${path} is cyclic`); + } + if (!Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`Managed tool result must be strict JSON: ${path} is not a plain object`); + } + } + if ('toJSON' in value) { + throw new Error(`Managed tool result must be strict JSON: ${path} defines toJSON`); + } + + budget.active.add(value); + try { + if (Array.isArray(value)) { + if (value.length > MANAGED_RESULT_MAX_ARRAY_LENGTH) throw new ToolResultLimitError(); + consumeManagedResultBytes(budget, 1); + const output: unknown[] = []; + for (let index = 0; index < value.length; index += 1) { + if (index > 0) consumeManagedResultBytes(budget, 1); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor) { + throw new Error(`Managed tool result must be strict JSON: ${path} is a sparse array`); + } + if (!('value' in descriptor)) { + throw new Error( + `Managed tool result must be strict JSON: ${path}[${index}] is an accessor`, + ); + } + output.push( + snapshotStrictJsonValue(descriptor.value, budget, `${path}[${index}]`, depth + 1), + ); + } + consumeManagedResultBytes(budget, 1); + return Object.freeze(output); + } + + consumeManagedResultBytes(budget, 1); + const output: Record = {}; + let emitted = 0; + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + budget.properties += 1; + if (budget.properties > MANAGED_RESULT_MAX_PROPERTIES) throw new ToolResultLimitError(); + if (emitted > 0) consumeManagedResultBytes(budget, 1); + consumeManagedJsonStringBytes(budget, key); + consumeManagedResultBytes(budget, 1); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) { + throw new Error(`Managed tool result must be strict JSON: ${path}.${key} is an accessor`); + } + Object.defineProperty(output, key, { + configurable: true, + enumerable: true, + value: snapshotStrictJsonValue(descriptor.value, budget, `${path}.${key}`, depth + 1), + writable: true, + }); + emitted += 1; + } + consumeManagedResultBytes(budget, 1); + return Object.freeze(output); + } finally { + budget.active.delete(value); + } +} + +function consumeManagedJsonStringBytes(budget: ManagedResultSnapshotBudget, value: string): void { + consumeManagedResultBytes(budget, 1); + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + let bytes: number; + if ( + code === 0x22 || + code === 0x5c || + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ) { + bytes = 2; + } else if (code <= 0x1f) { + bytes = 6; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes = 4; + index += 1; + } else { + bytes = 6; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes = 6; + } else if (code <= 0x7f) { + bytes = 1; + } else if (code <= 0x7ff) { + bytes = 2; + } else { + bytes = 3; + } + consumeManagedResultBytes(budget, bytes); + } + consumeManagedResultBytes(budget, 1); +} + +function consumeManagedResultBytes(budget: ManagedResultSnapshotBudget, bytes: number): void { + if (budget.bytes > budget.limit - bytes) throw new ToolResultLimitError(); + budget.bytes += bytes; +} + function coerceTerminalFailure( tool: MakaTool, cwd: string, @@ -3120,6 +3697,17 @@ function durableAttemptKey(turnId: string, toolUseId: string): string { return JSON.stringify([turnId, toolUseId]); } +async function disposeManagedMutationAdmission( + admission: RuntimeManagedMutationAdmission | undefined, +): Promise { + try { + await admission?.dispose(); + } catch { + // Cleanup is best-effort. It must never rewrite a durable terminal outcome, + // nor obscure the primary pre-T1 or execution failure. + } +} + function providerToolErrorMessage(output: unknown): string | undefined { if (!output || typeof output !== 'object' || Array.isArray(output)) return undefined; const record = output as Record; diff --git a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts index 0d67890b2f..8370226c09 100644 --- a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts +++ b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts @@ -20,6 +20,7 @@ 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'; @@ -67,6 +68,10 @@ try { 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), @@ -315,3 +320,71 @@ function workspaceBaselineInput(variant: 'a' | 'b'): WorkspaceBaselineAuthorityI }, }; } + +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/src/__tests__/recovery-persistence-authority.test.ts b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts index 987e656073..fefea71ba0 100644 --- a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts +++ b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts @@ -108,7 +108,7 @@ describe('SQLite recovery persistence authority', () => { dispatch.ts, ); db.exec( - 'DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; 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;', + '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(); @@ -204,7 +204,7 @@ describe('SQLite recovery persistence authority', () => { 2, ); db.exec( - 'DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; 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;', + '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(); diff --git a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts index 53879a7920..0f13e96866 100644 --- a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts +++ b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts @@ -27,6 +27,7 @@ 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 { @@ -37,7 +38,11 @@ import { acquireOperationalStateDatabase, inspectOperationalStateSchema, } from '../operational-state-store.js'; -import { bindWorkspaceBaselineAuthorityStoreRootInternal } from '../workspace-version-authority-internal.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; @@ -288,6 +293,43 @@ describe('SQLite recovery authority multi-process races', () => { }); }); + 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'); @@ -297,6 +339,7 @@ describe('SQLite recovery authority multi-process races', () => { const db = new DatabaseSync(dbPath); try { db.exec(` + DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_session_event_ordinals; PRAGMA user_version = 10; UPDATE operational_schema_migrations SET version = 10 WHERE scope = 'runtime'; @@ -614,6 +657,36 @@ function preparedCommit() { }; } +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, diff --git a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts index 3ececd2303..cc1cb637db 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -26,7 +26,10 @@ 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 } from '@maka/core/workspace-version-authority'; +import { + type WorkspaceBaselineAuthorityInput, + type WorkspaceSuccessorAuthorityInput, +} from '@maka/core/workspace-version-authority'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteRuntimeStore, @@ -34,12 +37,40 @@ import { } 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) { @@ -154,6 +185,90 @@ if (childMode) { ); }); }); + + 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, + ); + }); + }); }); } @@ -184,6 +299,7 @@ async function withKilledChild( child.once('error', reject); }); const store = createSqliteRuntimeStore(dbPath); + registerCrashCandidateVerifier(store); try { await inspect(store, markerPath); } finally { @@ -222,6 +338,7 @@ async function runCrashChild(mode: string): Promise { 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(); @@ -235,12 +352,41 @@ async function runCrashChild(mode: string): Promise { 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 }); - if (mode === 'inside_workspace_baseline' || mode === 'after_workspace_baseline_commit') { + 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()); @@ -313,6 +459,197 @@ function workspaceBaselineInput(): WorkspaceBaselineAuthorityInput { }; } +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', 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 82ceabf2a7..26fecc1d58 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -28,6 +28,7 @@ import { 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'; @@ -38,10 +39,44 @@ import { } from '../sqlite-runtime-store.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', () => { @@ -155,6 +190,498 @@ describe('workspace version persistence authority', () => { }); }); + 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(), 14); + 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()); @@ -300,6 +827,41 @@ describe('workspace version persistence authority', () => { }); }); + 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()); @@ -487,6 +1049,8 @@ async function withDatabase( }, }); bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID); + registerWorkspaceSuccessorCandidateVerifierInternal(store, verifyTestCandidate); + registerManagedMutationNoEffectVerifierInternal(store, verifyTestNoEffect); try { await run({ root, @@ -502,6 +1066,291 @@ async function withDatabase( } } +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; + PRAGMA user_version = 12; + COMMIT; + PRAGMA foreign_keys = ON; + `); +} + function baselineInput( overrides: Partial = {}, ): WorkspaceBaselineAuthorityInput { diff --git a/packages/storage/src/runtime-event-authority.ts b/packages/storage/src/runtime-event-authority.ts index bcfb82a477..e323386010 100644 --- a/packages/storage/src/runtime-event-authority.ts +++ b/packages/storage/src/runtime-event-authority.ts @@ -30,6 +30,9 @@ export function assertNoReservedWorkspaceAuthorityAppend(event: RuntimeEvent): v 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/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index f48093f604..47add708ee 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 12; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 14; 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'; @@ -320,6 +320,129 @@ const MIGRATIONS: ReadonlyMap = new Map([ 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) + ); + `, + ], ]); export function configureSqliteRuntimeDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 91633b4197..c060f26833 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -25,16 +25,20 @@ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; import { isDeepStrictEqual } from 'node:util'; 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 { @@ -43,6 +47,7 @@ import { isTerminalRuntimeEvent, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutationV2, type ToolRecoveryMode, } from '@maka/core/runtime-event'; import { @@ -92,7 +97,14 @@ import { RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION, SQLITE_RUNTIME_SCHEMA_VERSION, } from './sqlite-runtime-schema.js'; -import { registerWorkspaceBaselineAuthorityWriterInternal } from './workspace-version-authority-internal.js'; +import { + registerWorkspaceBaselineAuthorityWriterInternal, + type ManagedMutationNoEffectClaimV1, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, + type WorkspaceSuccessorCommitInput, + type WorkspaceSuccessorCommitResult, +} from './workspace-version-authority-internal.js'; import type { ConversationCopyRuntimeEventBatch, ImmutableSteeringMessageProof, @@ -163,6 +175,9 @@ export type SqliteRuntimeStoreFailpoint = | '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 { @@ -1168,14 +1183,15 @@ export class SqliteRuntimeStore const events = buildWorkspaceBaselineAuthorityEvents(input); return this.transaction(() => { this.#assertWorkspaceStorageRootBinding(rootId); - const existingBaselines = this.readCanonicalWorkspaceBaselinesSync(); + 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(existingBaselines); + this.assertWorkspaceProjectionsMatchSync(existingAuthority); if ( !isDeepStrictEqual( [ @@ -1187,11 +1203,15 @@ export class SqliteRuntimeStore ) { throw new Error('Workspace baseline authority conflict'); } - return { created: false, head: workspaceHeadRecord(existing) }; + 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(existingBaselines); + this.assertWorkspaceProjectionsMatchSync(existingAuthority); } this.assertWorkspaceAuthorityStreamIsEmpty(events.epochOpenedEvent); this.assertInvocationIdentity([events.epochOpenedEvent, events.baselineAcceptedEvent]); @@ -1214,19 +1234,314 @@ export class SqliteRuntimeStore } this.options.failpoint?.('after_workspace_version_event_insert'); - const scanned = this.readCanonicalWorkspaceBaselinesSync(); - const accepted = scanned.find( + 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.insertWorkspaceVersionProjection(accepted, input.committedAt); + this.insertWorkspaceBaselineVersionProjection(accepted, input.committedAt); this.options.failpoint?.('after_workspace_version_projection_insert'); - this.insertWorkspaceHeadProjection(accepted); + 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); - return { created: true, head: workspaceHeadRecord(accepted) }; + 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 }; }); } @@ -1235,11 +1550,54 @@ export class SqliteRuntimeStore registerWorkspaceBaselineAuthorityWriterInternal( this, (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), + (input, rootId) => this.#commitWorkspaceSuccessor(input, rootId), + (input, rootId) => this.#commitManagedMutationTerminal(input, rootId), (rootId) => this.#bindWorkspaceStorageRoot(rootId), readWorkspaceHead, + (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(); @@ -1307,9 +1665,9 @@ export class SqliteRuntimeStore workspaceEpochId: string, ): Promise { return this.readTransaction(() => { - const baselines = this.readCanonicalWorkspaceBaselinesSync(); - this.assertWorkspaceProjectionsMatchSync(baselines); - const baseline = baselines.find( + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const baseline = authority.baselines.find( (candidate) => candidate.epoch.workspaceId === workspaceId && candidate.epoch.workspaceEpochId === workspaceEpochId, @@ -1322,12 +1680,16 @@ export class SqliteRuntimeStore workspaceVersionId: string, ): Promise { return this.readTransaction(() => { - const baselines = this.readCanonicalWorkspaceBaselinesSync(); - this.assertWorkspaceProjectionsMatchSync(baselines); - const baseline = baselines.find( + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const baseline = authority.baselines.find( (candidate) => candidate.baseline.workspaceVersionId === workspaceVersionId, ); - return baseline ? workspaceVersionRecord(baseline) : undefined; + if (baseline) return workspaceBaselineVersionRecord(baseline); + const successor = authority.successors.find( + (candidate) => candidate.successor.workspaceVersionId === workspaceVersionId, + ); + return successor ? workspaceSuccessorVersionRecord(successor) : undefined; }); } @@ -1336,42 +1698,50 @@ export class SqliteRuntimeStore workspaceEpochId: string, ): Promise { return this.readTransaction(() => { - const baselines = this.readCanonicalWorkspaceBaselinesSync(); - this.assertWorkspaceProjectionsMatchSync(baselines); - const baseline = baselines.find( + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + return authority.heads.find( (candidate) => - candidate.epoch.workspaceId === workspaceId && - candidate.epoch.workspaceEpochId === workspaceEpochId, + candidate.workspaceId === workspaceId && candidate.workspaceEpochId === workspaceEpochId, ); - return baseline ? workspaceHeadRecord(baseline) : undefined; }); } async rebuildWorkspaceVersionProjections(): Promise { return this.transaction(() => { - const baselines = this.readCanonicalWorkspaceBaselinesSync(); + 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 baselines) { + for (const baseline of authority.baselines) { const committedAt = Math.max( this.runtimeEventCommittedAt(baseline.epochOpenedEventId), this.runtimeEventCommittedAt(baseline.baselineAcceptedEventId), ); this.insertWorkspaceEpochProjection(baseline, committedAt); - this.insertWorkspaceVersionProjection(baseline, committedAt); - this.insertWorkspaceHeadProjection(baseline); + this.insertWorkspaceBaselineVersionProjection(baseline, committedAt); + } + for (const successor of authority.successors) { + this.insertWorkspaceSuccessorVersionProjection( + successor, + this.runtimeEventCommittedAt(successor.acceptedEventId), + ); } - this.assertWorkspaceProjectionsMatchSync(baselines); + for (const head of authority.heads) this.insertWorkspaceHeadProjection(head); + for (const reservation of authority.activeManagedMutations) { + this.insertManagedMutationReservationProjectionSync(reservation); + } + this.assertWorkspaceProjectionsMatchSync(authority); return { - epochs: baselines.length, - versions: baselines.length, - heads: baselines.length, + epochs: authority.baselines.length, + versions: authority.baselines.length + authority.successors.length, + heads: authority.heads.length, }; }); } - private readCanonicalWorkspaceBaselinesSync() { + private readCanonicalWorkspaceAuthoritySync(): CanonicalWorkspaceAuthority { const partial = this.db .prepare(` SELECT stream_key FROM runtime_partial_snapshots @@ -1391,8 +1761,9 @@ export class SqliteRuntimeStore ORDER BY invocation_id ASC, event_seq ASC, event_id ASC `) .all() as unknown as RuntimeEventPrefixStorageRow[]; - const authorityRows: WorkspaceAuthorityLedgerRow[] = rows.map((row) => ({ - event: decodeRuntimeEventStorageRow(row), + const events = rows.map(decodeRuntimeEventStorageRow); + const authorityRows: WorkspaceAuthorityLedgerRow[] = rows.map((row, index) => ({ + event: events[index]!, eventSeq: row.event_seq, })); const scan = scanWorkspaceBaselineAuthority(authorityRows); @@ -1402,8 +1773,147 @@ export class SqliteRuntimeStore `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.baselines; + 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 { @@ -1472,7 +1982,7 @@ export class SqliteRuntimeStore ); } - private insertWorkspaceVersionProjection( + private insertWorkspaceBaselineVersionProjection( accepted: ReturnType['baselines'][number], committedAt: number, ): void { @@ -1488,16 +1998,23 @@ export class SqliteRuntimeStore 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', ?, '[]', ?, ?, ?, ?, ?, ?, ?, 1, ?) + ) VALUES (?, ?, ?, ?, ?, 'baseline', ?, '[]', NULL, NULL, NULL, NULL, NULL, + ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) `) .run( baseline.workspaceVersionId, @@ -1510,6 +2027,7 @@ export class SqliteRuntimeStore baseline.treeOid, baseline.policyHash, baseline.treeDeltaDigest, + '[]', baseline.changedFileCount, baseline.deletedFileCount, accepted.baselineAcceptedEventId, @@ -1517,10 +2035,65 @@ export class SqliteRuntimeStore ); } - private insertWorkspaceHeadProjection( - accepted: ReturnType['baselines'][number], + private insertWorkspaceSuccessorVersionProjection( + accepted: ScannedWorkspaceSuccessorAuthority, + committedAt: number, ): void { - const head = workspaceHeadRecord(accepted); + 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 ( @@ -1546,16 +2119,17 @@ export class SqliteRuntimeStore ); } - private assertWorkspaceProjectionsMatchSync( - baselines: ReturnType['baselines'], - ): void { - const expectedEpochs = baselines + private assertWorkspaceProjectionsMatchSync(authority: CanonicalWorkspaceAuthority): void { + const expectedEpochs = authority.baselines .map(workspaceEpochProjectionRow) .sort(compareWorkspaceEpochRow); - const expectedVersions = baselines - .map(workspaceVersionProjectionRow) - .sort(compareWorkspaceVersionRow); - const expectedHeads = baselines.map(workspaceHeadProjectionRow).sort(compareWorkspaceHeadRow); + 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(` @@ -1598,10 +2172,16 @@ export class SqliteRuntimeStore 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, @@ -1633,6 +2213,19 @@ export class SqliteRuntimeStore ) .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) || @@ -1640,6 +2233,9 @@ export class SqliteRuntimeStore ) { 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 { @@ -1648,7 +2244,8 @@ export class SqliteRuntimeStore SELECT (SELECT COUNT(*) FROM runtime_workspace_epochs) + (SELECT COUNT(*) FROM runtime_workspace_versions) + - (SELECT COUNT(*) FROM runtime_workspace_heads) AS count + (SELECT COUNT(*) FROM runtime_workspace_heads) + + (SELECT COUNT(*) FROM runtime_managed_mutation_reservations) AS count `) .get() as { count: number }; return row.count; @@ -1687,11 +2284,16 @@ export class SqliteRuntimeStore 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, @@ -1738,10 +2340,125 @@ export class SqliteRuntimeStore 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, @@ -2057,7 +2774,10 @@ export class SqliteRuntimeStore return { operations: projected.length, journalEvents }; } - private commitToolOutcomeSync(input: CommitToolOutcomeInput): ToolCommitResult { + 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); @@ -2072,6 +2792,27 @@ export class SqliteRuntimeStore ); 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?.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( @@ -3319,6 +4060,28 @@ interface RuntimeEventStorageRow { 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 = ?') @@ -3361,10 +4124,16 @@ interface WorkspaceVersionProjectionRow { 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; @@ -3394,29 +4163,81 @@ function workspaceEpochRecord( }; } -function workspaceVersionRecord( - authority: ScannedWorkspaceBaselineAuthority, -): WorkspaceVersionRecordV1 { +function workspaceBaselineVersionRecord(authority: ScannedWorkspaceBaselineAuthority) { return { ...authority.baseline, - baselineAcceptedEventId: authority.baselineAcceptedEventId, + acceptedEventId: authority.baselineAcceptedEventId, committedAt: authority.baselineAcceptedAt, }; } -function workspaceHeadRecord(authority: ScannedWorkspaceBaselineAuthority): WorkspaceHeadRecordV1 { +function workspaceSuccessorVersionRecord(authority: ScannedWorkspaceSuccessorAuthority) { return { - repositoryId: authority.epoch.repositoryId, - workspaceId: authority.epoch.workspaceId, - workspaceEpochId: authority.epoch.workspaceEpochId, - workspaceVersionId: authority.baseline.workspaceVersionId, - acceptedEventId: authority.baselineAcceptedEventId, - commitOid: authority.baseline.commitOid, - treeOid: authority.baseline.treeOid, - revision: 1, + ...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 { @@ -3444,10 +4265,10 @@ function workspaceEpochProjectionRow( }; } -function workspaceVersionProjectionRow( +function workspaceBaselineVersionProjectionRow( authority: ScannedWorkspaceBaselineAuthority, ): WorkspaceVersionProjectionRow { - const record = workspaceVersionRecord(authority); + const record = workspaceBaselineVersionRecord(authority); return { workspace_version_id: record.workspaceVersionId, repository_id: record.repositoryId, @@ -3457,22 +4278,56 @@ function workspaceVersionProjectionRow( 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.baselineAcceptedEventId, + accepted_event_id: record.acceptedEventId, protocol_version: 1, committed_at: record.committedAt, }; } -function workspaceHeadProjectionRow( - authority: ScannedWorkspaceBaselineAuthority, -): WorkspaceHeadProjectionRow { - const record = workspaceHeadRecord(authority); +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, diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 9346d8c1ce..2ecf8985e8 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -21,21 +21,98 @@ 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; type WorkspaceHeadReader = ( workspaceId: string, workspaceEpochId: string, ) => Promise; +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 readHead: WorkspaceHeadReader; + readonly readActiveManagedMutation: ManagedMutationReservationReader; readonly bindStorageRoot: WorkspaceStorageRootBinder; boundRootId?: string; } @@ -48,19 +125,34 @@ const workspaceBaselineAuthorityWriters = new WeakMap< export function registerWorkspaceBaselineAuthorityWriterInternal( store: object, writer: WorkspaceBaselineAuthorityWriter, + successorWriter: WorkspaceSuccessorAuthorityWriter, + terminalWriter: ManagedMutationTerminalAuthorityWriter, bindStorageRoot: WorkspaceStorageRootBinder, readHead: WorkspaceHeadReader, + readActiveManagedMutation: ManagedMutationReservationReader, ): void { if (workspaceBaselineAuthorityWriters.has(store)) { throw new Error('Workspace baseline authority writer is already registered'); } workspaceBaselineAuthorityWriters.set(store, { writer, + successorWriter, + terminalWriter, readHead, + 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); +} + export function readWorkspaceHeadInternal( store: object, workspaceId: string, @@ -90,6 +182,71 @@ export function commitWorkspaceBaselineInternal( 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,