diff --git a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md index 990166573b4..fdb2739efba 100644 --- a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md @@ -45,17 +45,33 @@ |---|---| | `managed-dependency-environment`、bundled npm、worker bridge | M1/M1.3 独立前置;不从旧 M2 分支重复携带 | | Git candidate ref/commit、delta/path policy、orphan GC | M2.2 | -| T1 mutation profile/base freeze、owner-bound mutating admission | M2.3 | +| T1 mutation profile/base freeze、durable workspace reservation、accepted path truth | M2.3a | +| owner-bound mutating admission、reopen active-reservation gate | M2.3b | | 真实 Write/Edit composition、Host crash/reopen | M2.4 | | continuation boundary 绑定 workspace version | M3 | | restore、rebaseline、publish、undo、replication | M4 | +## M2.3a 增量归属 + +| 文件 | 主要不变量 | +|---|---| +| `packages/core/src/runtime-event.ts` | T1-frozen managed mutation identity 与平台无关 path grammar | +| `packages/core/src/workspace-version-authority.ts` | successor exact changed paths 进入 immutable accepted truth | +| `packages/storage/src/sqlite-runtime-schema.ts` | schema 14 active reservation 与 changed-path projection | +| `packages/storage/src/sqlite-runtime-store.ts` | T1 reservation、generic T2 fence、successor consume、canonical rebuild | +| `packages/storage/src/workspace-version-authority-internal.ts` | 非 public active reservation reader capability | +| storage/core focused tests | schema、rollback、rebuild、真实 crash、双进程 exclusivity | +| `runtime-managed-workspace-mutation-reservation-v1.zh-CN.md` | owner、原子边界、失败状态与平台矩阵 | + +M2.3a 不迁入 Runtime hook、ManagedWorkspaceOwner lease、candidate capture/discard 或真实 Write/Edit;前两项属于 +M2.3b,后两项属于 M2.4。 ## Commit 映射 | 当前重建内容 | 来源 | 说明 | |---|---|---| | core RED tests + causal successor facts | `14327f5f0` 的 core 最终状态 | 只保留 subpath contract | | storage RED tests + atomic successor writer | `14327f5f0` 的 storage 最终状态 | 在 current-main schema 12 上新增 migration 13 | +| reservation RED tests + T1 owner projection | `36e1e3ec4` | 从 M2.2 stack 中只提取 M2.3a 自有差异,跳过旧 Git CLI candidate owner | | 文档与 extraction ledger | 现行工程实践 | 记录最新 main、Gitoxide 换轨和 M2.2–M2.4 等待关系 | ## Diff 审核门槛 diff --git a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md new file mode 100644 index 00000000000..b9195b6337a --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md @@ -0,0 +1,142 @@ + + +# Managed Workspace Mutation Reservation v1 + +- 阶段:M2.3a +- 状态:实现切片;保持 Draft,等待 M2.3b admission 与 M2.4 Write/Edit 生产消费者 +- owner:SQLite workspace mutation authority + +## 1. 主要不变量 + +同一个 `workspaceInstanceId` 从 managed mutation 的 T1 durable 起,到该 operation 被可信 successor +原子接受之前,只能存在一个 durable mutation owner。进程内 lease 只负责 capability 隔离,不再承担跨进程、 +跨重启的排他真相。 + +T1 同时冻结 repository/workspace/epoch/instance identity、canonical base head、平台无关的 exact expected +paths、execution profile digest,以及 operation/dispatch identity。 + +任何 managed T1 都不得由通用 T2 writer 结算。成功只能通过 workspace successor authority 在同一个 +SQLite transaction 中提交 `T2 + version_accepted + projection + head CAS`,并消费 reservation。 + +## 2. 权威与投影 + +唯一事实源仍是 immutable RuntimeEvents: + +```text +function_call + + toolDispatch.managedMutation + + optional workspace version_accepted +``` + +`runtime_managed_mutation_reservations` 只是可重建投影。表以 `workspace_instance_id` 为主键,并对 +`operation_id`、`dispatch_event_id` 建唯一约束。在线写入、canonical rebuild 和内部 reader 都先扫描 +RuntimeEvents,再要求投影完全相等;删除或篡改投影不能改变事实。 + +## 3. 原子性边界 + +### 3.1 T1 reservation + +一个 `BEGIN IMMEDIATE` transaction 内完成: + +1. 重验 canonical workspace head; +2. 拒绝已有 active reservation; +3. 写 function call 与 dispatch RuntimeEvents; +4. 写 tool journal / operation projection; +5. 写 managed reservation projection; +6. COMMIT。 + +因此不存在“有 managed T1、没有 durable reservation”的正常可见状态。 + +### 3.2 successor acceptance + +一个 transaction 内完成: + +1. 读取 T1 与 reservation; +2. 比较 base、profile、operation、dispatch 与 exact changed paths; +3. 写成功 T2; +4. 写 immutable `workspace.version_accepted`; +5. 写 version projection; +6. CAS 推进 canonical head; +7. CAS 删除 reservation; +8. COMMIT。 + +失败、进程退出或 SQLite 异常使整组写入回滚。COMMIT 后响应丢失时,exact retry 返回原来的 immutable +successor,不重复推进 head。 + +## 4. 失败状态 + +| 状态 | 行为 | +|---|---| +| base/head 已变化 | T1 前拒绝,不创建 operation | +| 同 workspace 已有 reservation | T1 前拒绝,报告占用 operation | +| managed T1 走 generic T2 | 拒绝,operation 保持 prepared | +| successor paths 与 T1 不同 | 整个 successor transaction 回滚 | +| reservation 投影缺失或被篡改 | authority read/retry fail closed;允许从 RuntimeEvents rebuild | +| T1 后进程崩溃 | reservation 跨进程保留;新的 mutation 不能取得所有权 | +| operation park | reservation 保留,禁止另一个 mutation 越过未知副作用 | + +`safely_discarded` 的 canonical release fact 不属于本切片;M2.4 在拥有真实 candidate 与 Write/Edit 结果后 +定义。M2.3a 不提供手工删除 reservation 的公共 API。 + +## 5. 路径合同 + +Durable fact 使用平台无关的 Git path 语法:`/` 分隔、非绝对、无空段、无 `.`/`..`、无 NUL、反斜杠 +或冒号;首段 `.git` 和 `node_modules` 一律按 ASCII case-insensitive 拒绝。路径集合必须排序、去重, +数量为 1–32。 + +这只是 canonical syntax。symlink、reparse point、volume case sensitivity 与真实路径 containment 仍由 +M2.3b/M2.4 的平台 owner 在副作用前验证。Core 不读取 `process.platform`。 + +## 6. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +|---|---|---|---| +| SQLite T1/accept transaction | 承诺 | 承诺 | 承诺 | +| 多进程同 workspace 唯一 reservation | 承诺 | 承诺 | 承诺 | +| process-crash 后 reservation 重建 | 承诺 | 承诺 | 承诺 | +| path fact 跨平台同值同义 | 承诺 | 承诺 | 承诺 | +| filesystem mutation/candidate correctness | M2.4 | M2.4 | M2.4 | + +本切片不承诺断电后的 Git/filesystem 收敛,也不接 Desktop/CLI。 + +## 7. 验证 + +- strict Core decode:未知字段、非 canonical path、未排序 path set 均拒绝; +- T1 + reservation 原子持久化与 exact retry; +- generic T2 抢占被拒绝; +- changed paths 同时绑定 T1、successor fact、online writer 和 rebuild; +- projection 删除后 read/retry fail closed,rebuild 恢复; +- 真实子进程在 T1 COMMIT 后被杀,reopen 仍保留唯一 owner; +- 真实双进程同时提交不同 operation,恰好一个获得 reservation; +- successor transaction kill/reopen 继续沿用 M2.1 crash matrix。 + +## 8. 后续切片 + +### M2.3b — Runtime admission composition + +在 T1 前取得 owner-bound capability,重验平台路径、execution profile 和 canonical head;启动时读取 active +reservation,禁止新 admission 越过 prepared operation。它不得 capture/discard Git candidate,也不得自行写 +successor。 + +### M2.4 — Write/Edit production settlement + +真实 Write/Edit 执行、candidate capture/verification、owner-controlled settlement 与 reservation terminal +transition 在此闭环。Runtime 必须能区分:successor 已接受、candidate 可安全 discard、effect unknown/park; +不能在 managed callback 后自动写 generic T2。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md index cd671a544d4..1abeec72017 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-version-authority-v1.zh-CN.md @@ -65,7 +65,7 @@ M2.2 的 Git candidate owner;M2.1 只建立其唯一持久化出口。 | stale writer | base head 任一字段不一致即拒绝,且对应 tool operation 保持 `prepared` | | corruption | malformed fact、断链、重复 version identity、tool evidence 不匹配全部 fail closed | | 运行时回滚 | transaction 未提交时 T2/fact/projection/head 全部回滚 | -| 数据升级 | schema 12 baseline rows 原样升级到 schema 13;schema 13 不支持向旧 binary 降级 | +| 数据升级 | schema 12 baseline rows 原样升级;M2.1 使用 schema 13,M2.3a reservation/path projection 顺延为 schema 14;不支持向旧 binary 降级 | “唯一 writer”不是注释:普通 RuntimeEvent writer 仍拒绝 workspace fact;successor writer 仅能通过 `workspace-version-authority-internal.ts` 中与具体 store 实例绑定的 capability 调用。 @@ -191,17 +191,29 @@ SQLite transaction 提供三平台一致的数据库原子性;本切片不声 该 PR 在 M2.4 消费者存在前保持 Draft。 -### M2.3 — Mutation execution admission +### M2.3a — Durable mutation reservation + +主要不变量:managed T1 与 workspace-instance-exclusive reservation 原子持久化;直到可信 successor 接受前, +另一个进程或重启后的 owner 都不能为同一 workspace 创建第二个 mutation T1。 + +- owner:SQLite workspace mutation authority; +- 原子边界:call + dispatch + operation projection + active reservation; +- accepted truth:successor fact 携带 exact changed paths,online/rebuild 均与 T1 比较; +- generic T2 被存储权威拒绝; +- 详细合同见 + [Managed Workspace Mutation Reservation v1](./runtime-managed-workspace-mutation-reservation-v1.zh-CN.md)。 + +### M2.3b — Mutation execution admission 主要不变量:T1 前冻结 base workspace head、execution profile、operation identity 和 candidate lease;T1 后禁止切换 attached/managed mode 或退回旧直接写路径。 - owner:Runtime Host managed mutation admission; -- 原子边界:T1 durable dispatch 选择 `managed_mutation_v1`; +- 原子边界:消费 M2.3a,在 T1 durable dispatch 前签发 owner-bound capability; - 失败状态:能力缺失、scope 失效、base/head/profile 不一致均在工具副作用前拒绝; - 回滚:T1 前失败走标准 tool error;T1 后不确定状态保持 unsettled,交给 M2.4 收敛。 -该 PR 不新增第二套 workspace writer,只能消费 M2.1/M2.2 的 opaque capabilities。 +该 PR 不新增第二套 workspace writer,不 capture/discard candidate,只消费 M2.1/M2.3a 的 internal capability。 ### M2.4 — Write/Edit production composition @@ -223,7 +235,8 @@ M2.4 不接 workspace-bound continuation;那属于 M3。 - stale successor 不结算对应 prepared operation; - 真实 child process 在 successor transaction 内被杀后,reopen 证明 T2/fact/projection/head 全回滚; - 真实 child process 在 COMMIT 后被杀,reopen exact retry 收敛到同一 successor; -- populated schema 12 → 13 数据升级; +- populated schema 12 → 14 数据升级; +- managed T1 reservation 的真实 kill/reopen 与双进程唯一性; - canonical origin 被篡改后 reader fail closed; - schema、SQLite multi-process 与既有 recovery 定向 suites 保持通过。 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 61efe8caeb2..993900b604e 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 @@ -611,19 +611,21 @@ M1.1 合同见 这一步取代旧的“先做通用 checkpoint contract,再接 observe-only Git carrier”。不得同时维护两套 managed workspace version writer。 -M2 按 owner 与原子边界拆成四个 stacked slices: +M2 按 owner 与原子边界拆成五个 stacked slices: 1. **M2.1 mutation version persistence authority(当前切片)**:定义 successor fact/scanner,并在一个 SQLite transaction 中原子提交 T2、`version_accepted`、version projection 和 head CAS;schema 13 从 populated schema 12 保留 baseline/head;不接真实 Write/Edit; -2. **M2.2 Git mutation candidate owner**:唯一拥有 operation-bound candidate ref/commit、delta/path - policy、artifact receipt 与 orphan GC;不写 T2; -3. **M2.3 mutation execution admission**:T1 前冻结 base head、execution profile、operation identity 与 - candidate lease;T1 后禁止 silent fallback; -4. **M2.4 Write/Edit production composition**:把 owner-bound worker、candidate capture 与 M2.1 bundle +2. **M2.2 Gitoxide mutation candidate owner(等待 Gitoxide 数据面)**:唯一拥有 operation-bound + candidate commit/ref、exact-base CAS 与 artifact receipt;不写 T2,不恢复旧 Git CLI adapter; +3. **M2.3a durable mutation reservation(当前重建切片)**:schema 14 将 T1 与 workspace-instance-exclusive reservation + 原子绑定,successor fact 固化 exact changed paths,generic T2 无权结算 managed mutation; +4. **M2.3b mutation execution admission**:T1 前冻结 base head、execution profile、operation identity, + 消费 durable reservation gate;T1 后禁止 silent fallback; +5. **M2.4 Write/Edit production composition**:把 owner-bound worker、candidate capture 与 M2.1 bundle 串成唯一生产路径,并用真实 Host kill/reopen crash test 完成前三片的生产消费者。 -M2.2/M2.3 在 M2.4 消费者存在前保持 Draft。M2.4 不绑定 continuation boundary;该能力仍属于 M3。 +M2.2/M2.3a/M2.3b 在 M2.4 消费者存在前保持 Draft。M2.4 不绑定 continuation boundary;该能力仍属于 M3。 ### M3 — Workspace-bound continuation / resume @@ -662,8 +664,9 @@ M0.1 Git artifact owner (merged) └─> M1.3 explicit environment provisioning └─> M2.1 mutation version persistence authority └─> M2.2 Git mutation candidate owner - └─> M2.3 mutation execution admission - └─> M2.4 Write/Edit production composition + └─> M2.3a durable mutation reservation + └─> M2.3b mutation execution admission + └─> M2.4 Write/Edit production composition └─> M3 workspace-bound continuation └─> M4 restore / rebaseline / publish / replication diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 120d2eac3ae..0397b3e2ec4 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -457,6 +457,60 @@ describe('RuntimeEvent content variants', () => { }); describe('RuntimeEvent actions', () => { + test('decodes only a platform-independent T1-frozen managed mutation identity', () => { + const managedMutation = { + protocol: 'managed_mutation_v1', + 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), + expectedPaths: ['src/a.ts'], + executionProfileDigest: `sha256:${'a'.repeat(64)}`, + } 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, expectedPaths: ['src/../secrets.txt'] }, + { ...managedMutation, expectedPaths: ['NoDe_MoDuLeS/pkg/index.js'] }, + { ...managedMutation, expectedPaths: ['.GiT/config'] }, + { ...managedMutation, expectedPaths: ['z.txt', 'a.txt'] }, + { ...managedMutation, expectedPaths: ['x'.repeat(4097)] }, + { ...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/runtime-event.ts b/packages/core/src/runtime-event.ts index 9a80ea99b06..fa082005a05 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -254,6 +254,24 @@ export interface RuntimeEventToolDispatch { toolName: string; canonicalArgsHash: string; recoveryMode: ToolRecoveryMode; + /** T1-frozen managed workspace mutation identity. */ + managedMutation?: RuntimeEventManagedWorkspaceMutationV1; +} + +export interface RuntimeEventManagedWorkspaceMutationV1 { + protocol: 'managed_mutation_v1'; + repositoryId: string; + workspaceId: string; + workspaceEpochId: string; + workspaceInstanceId: string; + objectFormat: 'sha1' | 'sha256'; + baseWorkspaceVersionId: string; + baseAcceptedEventId: string; + baseHeadRevision: number; + baseCommitOid: string; + baseTreeOid: string; + expectedPaths: readonly string[]; + executionProfileDigest: `sha256:${string}`; } export interface RuntimeEventProtocolMarker { @@ -540,8 +558,27 @@ 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', + 'expectedPaths', + 'executionProfileDigest', + ], + [], + ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( ['toolBoundary'], [], @@ -805,10 +842,76 @@ 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 RuntimeEventManagedWorkspaceMutationV1 { + if ( + !isRecord(value) || + !hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE) || + value.protocol !== 'managed_mutation_v1' || + 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' && value.objectFormat !== 'sha256') || + 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' || + !isSha256Digest(value.executionProfileDigest) || + !Array.isArray(value.expectedPaths) || + value.expectedPaths.length === 0 || + value.expectedPaths.length > 32 + ) { + return false; + } + const expectedPaths = value.expectedPaths; + const oidPattern = value.objectFormat === 'sha1' ? /^[0-9a-f]{40}$/u : /^[0-9a-f]{64}$/u; + if (!oidPattern.test(value.baseCommitOid) || !oidPattern.test(value.baseTreeOid)) return false; + return ( + new Set(expectedPaths).size === expectedPaths.length && + expectedPaths.every(isCanonicalManagedMutationPathV1) && + expectedPaths.every((path, index) => index === 0 || expectedPaths[index - 1]! < path) + ); +} + +/** 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/workspace-version-authority.ts b/packages/core/src/workspace-version-authority.ts index 8f3d8e61b4b..386f0dff096 100644 --- a/packages/core/src/workspace-version-authority.ts +++ b/packages/core/src/workspace-version-authority.ts @@ -17,7 +17,7 @@ * 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; @@ -897,27 +897,6 @@ function isCanonicalManagedMutationPathSet(value: unknown): value is readonly st return value.every((path, index) => index === 0 || value[index - 1]! < path); } -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 isWorkspaceMutationOrigin(value: unknown): value is WorkspaceMutationOriginV1 { if (!isRecord(value)) return false; return ( 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 0d67890b2fe..ab5a0cdb93e 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,69 @@ 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_v1' 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), + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'a'.repeat(64)}` 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__/sqlite-recovery-concurrency.test.ts b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts index 53879a79208..0f13e968661 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 6c367d3ef7a..6f918078a5c 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -36,6 +36,7 @@ import { bindWorkspaceBaselineAuthorityStoreRootInternal, commitWorkspaceBaselineInternal, commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, type WorkspaceSuccessorCommitInput, } from '../workspace-version-authority-internal.js'; @@ -157,6 +158,25 @@ 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 () => { @@ -280,12 +300,17 @@ async function runCrashChild(mode: string): Promise { if ( mode === 'inside_workspace_baseline' || mode === 'after_workspace_baseline_commit' || + mode === 'after_workspace_mutation_t1' || mode === 'inside_workspace_successor' || mode === 'after_workspace_successor_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()); @@ -363,14 +388,20 @@ function workspaceBaselineInput(): WorkspaceBaselineAuthorityInput { }; } -function workspaceSuccessorPreparedCommit() { +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: 'workspace-successor-operation', - journalEventId: 'workspace-successor-operation_prepared', + operationId, + journalEventId: `${operationId}_prepared`, runtimeEvent: { - id: 'workspace-successor-call', + id: callEventId, invocationId: 'workspace-successor-invocation', runId: 'workspace-successor-run', sessionId: 'workspace-successor-session', @@ -381,17 +412,17 @@ function workspaceSuccessorPreparedCommit() { author: 'agent' as const, content: { kind: 'function_call' as const, - id: 'workspace-successor-call-id', + id: toolCallId, name: 'Write', args, }, refs: { - operationId: 'workspace-successor-operation', - toolCallId: 'workspace-successor-call-id', + operationId, + toolCallId, }, }, dispatchRuntimeEvent: { - id: 'workspace-successor-dispatch', + id: dispatchEventId, invocationId: 'workspace-successor-invocation', runId: 'workspace-successor-run', sessionId: 'workspace-successor-session', @@ -403,19 +434,34 @@ function workspaceSuccessorPreparedCommit() { actions: { toolDispatch: { protocol: 't1_after_preflight_v1' as const, - operationId: 'workspace-successor-operation', - providerToolCallId: 'workspace-successor-call-id', + operationId, + providerToolCallId: toolCallId, toolName: 'Write', canonicalArgsHash, recoveryMode: 'reconcile' as const, + managedMutation: { + protocol: 'managed_mutation_v1' 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), + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, + }, }, }, refs: { - operationId: 'workspace-successor-operation', - toolCallId: 'workspace-successor-call-id', + operationId, + toolCallId, }, }, - providerToolCallId: 'workspace-successor-call-id', + providerToolCallId: toolCallId, toolName: 'Write', canonicalArgsHash, recoveryMode: 'reconcile' as const, 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 8d4cb067b87..aae35699b06 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -40,6 +40,7 @@ import { bindWorkspaceBaselineAuthorityStoreRootInternal, commitWorkspaceBaselineInternal, commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, type WorkspaceSuccessorCommitInput, } from '../workspace-version-authority-internal.js'; @@ -157,6 +158,68 @@ 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('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 the workspace successor writer/i, + ); + assert.equal((await store.readToolOperation(prepared.operationId))?.currentState, 'prepared'); + }); + }); + it('atomically commits one tool outcome with its successor workspace head', async () => { await withDatabase(async ({ dbPath, store }) => { const { baseline, input } = await prepareSuccessorCommit(store); @@ -183,6 +246,17 @@ describe('workspace version persistence authority', () => { 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.head.workspaceVersionId) as { changed_paths_json: string } + ).changed_paths_json, + '["notes.txt"]', + ); assert.equal( countWhere(raw, 'runtime_events', 'session_id = ?', WORKSPACE_AUTHORITY_SESSION_ID), 3, @@ -215,6 +289,31 @@ describe('workspace version persistence authority', () => { }); }); + it('rejects a successor whose immutable changed paths exceed its T1 authorization', async () => { + await withDatabase(async ({ store }) => { + const { input } = await prepareSuccessorCommit(store); + const mismatched: WorkspaceSuccessorCommitInput = { + ...input, + successor: { + ...input.successor, + successor: { + ...input.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 } = await prepareSuccessorCommit(store); @@ -273,20 +372,27 @@ describe('workspace version persistence authority', () => { }); }); - it('rejects a stale successor without settling its prepared tool operation', async () => { + it('rejects a stale managed mutation before it can acquire T1 ownership', async () => { await withDatabase(async ({ store }) => { const first = await prepareSuccessorCommit(store, 1); - const stale = await prepareSuccessorCommit(store, 2); - 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( - commitWorkspaceSuccessorInternal(store, stale.input), - /compare-and-set base head conflict/i, - ); - assert.equal( - (await store.readToolOperation(stale.input.toolOutcome.operationId))?.currentState, - 'prepared', + 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); }); }); @@ -360,7 +466,7 @@ describe('workspace version persistence authority', () => { const upgraded = createSqliteRuntimeStore(dbPath); bindWorkspaceBaselineAuthorityStoreRootInternal(upgraded, TEST_STORAGE_ROOT_ID); try { - assert.equal(upgraded.schemaVersion(), 13); + assert.equal(upgraded.schemaVersion(), 14); assert.equal( ( await upgraded.readWorkspaceHead( @@ -527,6 +633,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()); @@ -778,6 +919,21 @@ async function prepareSuccessorCommit( toolName: 'Write', canonicalArgsHash: argsHash, recoveryMode: 'reconcile', + managedMutation: { + protocol: 'managed_mutation_v1', + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + objectFormat: baseline.epoch.objectFormat, + baseWorkspaceVersionId: opened.head.workspaceVersionId, + baseAcceptedEventId: opened.head.acceptedEventId, + baseHeadRevision: opened.head.revision, + baseCommitOid: opened.head.commitOid, + baseTreeOid: opened.head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'a'.repeat(64)}`, + }, }, }, refs: { operationId, toolCallId }, @@ -846,6 +1002,75 @@ async function prepareSuccessorCommit( }; } +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_v1' as const, + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + objectFormat: baseline.epoch.objectFormat, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPaths: ['notes.txt'], + executionProfileDigest: `sha256:${'a'.repeat(64)}` 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; @@ -915,6 +1140,7 @@ function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { 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; diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 7277a446e34..47add708ee1 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 = 13; +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'; @@ -416,6 +416,33 @@ const MIGRATIONS: ReadonlyMap = new Map([ 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 e2f6ad3cddf..56d35beedc6 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -37,6 +37,7 @@ import { type WorkspaceEpochRecordV1, type WorkspaceHeadRecordV1, type WorkspaceProjectionRebuildResult, + type WorkspaceVersionAcceptedV1, type WorkspaceVersionRecordV1, } from '@maka/core/workspace-version-authority'; import { @@ -45,6 +46,7 @@ import { isTerminalRuntimeEvent, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutationV1, type ToolRecoveryMode, } from '@maka/core/runtime-event'; import { @@ -1341,8 +1343,62 @@ export class SqliteRuntimeStore ) { 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.expectedPaths) || + !isDeepStrictEqual(successor.changedPaths, mutation.expectedPaths) + ) { + throw new Error('Managed mutation path authorization conflict'); + } - const outcomeResult = this.commitToolOutcomeSync(toolOutcome); + const outcomeResult = this.commitToolOutcomeSync(toolOutcome, 'workspace_successor'); const successorSeq = this.insertRuntimeEvent( successorEvent, input.successor.committedAt, @@ -1391,6 +1447,15 @@ export class SqliteRuntimeStore 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, @@ -1409,9 +1474,46 @@ export class SqliteRuntimeStore (input, rootId) => this.#commitWorkspaceSuccessor(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.some((path) => typeof path !== '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, + expectedPaths, + executionProfileDigest: reservation.execution_profile_digest, + reservedAt: reservation.reserved_at, + }; + }); + } + #bindWorkspaceStorageRoot(rootId: string): void { this.transaction(() => { const existing = this.#readWorkspaceStorageRootBinding(); @@ -1524,6 +1626,7 @@ export class SqliteRuntimeStore async rebuildWorkspaceVersionProjections(): Promise { return this.transaction(() => { const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.db.prepare('DELETE FROM runtime_managed_mutation_reservations').run(); this.db.prepare('DELETE FROM runtime_workspace_heads').run(); this.db.prepare('DELETE FROM runtime_workspace_versions').run(); this.db.prepare('DELETE FROM runtime_workspace_epochs').run(); @@ -1542,6 +1645,9 @@ export class SqliteRuntimeStore ); } for (const head of authority.heads) this.insertWorkspaceHeadProjection(head); + for (const reservation of authority.activeManagedMutations) { + this.insertManagedMutationReservationProjectionSync(reservation); + } this.assertWorkspaceProjectionsMatchSync(authority); return { epochs: authority.baselines.length, @@ -1551,7 +1657,7 @@ export class SqliteRuntimeStore }); } - private readCanonicalWorkspaceAuthoritySync() { + private readCanonicalWorkspaceAuthoritySync(): CanonicalWorkspaceAuthority { const partial = this.db .prepare(` SELECT stream_key FROM runtime_partial_snapshots @@ -1591,6 +1697,12 @@ export class SqliteRuntimeStore ); 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 || @@ -1599,6 +1711,14 @@ export class SqliteRuntimeStore 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' || @@ -1609,8 +1729,94 @@ export class SqliteRuntimeStore ); } } + const activeManagedMutations = this.scanCanonicalManagedMutationReservationsSync( + toolScan, + scan, + ); this.options.failpoint?.('after_workspace_canonical_scan'); - return scan; + return { ...scan, activeManagedMutations }; + } + + private scanCanonicalManagedMutationReservationsSync( + toolScan: ReturnType, + authority: ReturnType, + ): ManagedMutationReservationProjectionRow[] { + const acceptedOperations = new Set( + authority.successors.map((candidate) => candidate.successor.origin.operationId), + ); + const reservations: ManagedMutationReservationProjectionRow[] = []; + const occupied = new Set(); + for (const operation of toolScan.operations) { + const dispatchEvent = operation.dispatchEvent; + const dispatch = dispatchEvent?.actions?.toolDispatch; + const mutation = dispatch?.managedMutation; + if (!mutation) continue; + if ( + operation.issues.length > 0 || + !dispatchEvent || + dispatch.operationId !== operation.operationId || + dispatch.recoveryMode !== 'reconcile' || + (dispatch.toolName !== 'Write' && dispatch.toolName !== 'Edit') + ) { + throw new Error( + `Corrupt managed mutation reservation: identity_conflict at ${dispatchEvent?.id ?? operation.operationId}`, + ); + } + if (acceptedOperations.has(operation.operationId)) continue; + if (operation.responseEvent) { + throw new Error( + `Corrupt managed mutation reservation: generic_outcome at ${operation.responseEvent.id}`, + ); + } + 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.expectedPaths), + 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 { @@ -1704,13 +1910,14 @@ export class SqliteRuntimeStore tree_oid, policy_hash, tree_delta_digest, + changed_paths_json, changed_file_count, deleted_file_count, accepted_event_id, protocol_version, committed_at ) VALUES (?, ?, ?, ?, ?, 'baseline', ?, '[]', NULL, NULL, NULL, NULL, NULL, - ?, ?, ?, ?, ?, ?, ?, 1, ?) + ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) `) .run( baseline.workspaceVersionId, @@ -1723,6 +1930,7 @@ export class SqliteRuntimeStore baseline.treeOid, baseline.policyHash, baseline.treeDeltaDigest, + '[]', baseline.changedFileCount, baseline.deletedFileCount, accepted.baselineAcceptedEventId, @@ -1755,12 +1963,13 @@ export class SqliteRuntimeStore 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, ?) + ) VALUES (?, ?, ?, ?, ?, 'tool_mutation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) `) .run( successor.workspaceVersionId, @@ -1779,6 +1988,7 @@ export class SqliteRuntimeStore successor.treeOid, successor.policyHash, successor.treeDeltaDigest, + JSON.stringify(successor.changedPaths), successor.changedFileCount, successor.deletedFileCount, accepted.acceptedEventId, @@ -1812,9 +2022,7 @@ export class SqliteRuntimeStore ); } - private assertWorkspaceProjectionsMatchSync( - authority: ReturnType, - ): void { + private assertWorkspaceProjectionsMatchSync(authority: CanonicalWorkspaceAuthority): void { const expectedEpochs = authority.baselines .map(workspaceEpochProjectionRow) .sort(compareWorkspaceEpochRow); @@ -1876,6 +2084,7 @@ export class SqliteRuntimeStore tree_oid, policy_hash, tree_delta_digest, + changed_paths_json, changed_file_count, deleted_file_count, accepted_event_id, @@ -1907,6 +2116,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) || @@ -1914,6 +2136,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 { @@ -1922,7 +2147,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; @@ -1961,11 +2187,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, @@ -2012,10 +2243,116 @@ 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; + 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 (!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.expectedPaths), + 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, @@ -2331,7 +2668,10 @@ export class SqliteRuntimeStore return { operations: projected.length, journalEvents }; } - private commitToolOutcomeSync(input: CommitToolOutcomeInput): ToolCommitResult { + private commitToolOutcomeSync( + input: CommitToolOutcomeInput, + settlementOwner: 'generic' | 'workspace_successor' = 'generic', + ): ToolCommitResult { const operation = this.readToolOperationSync(input.operationId); if (!operation) throw new Error(`Unknown tool operation ${input.operationId}`); assertOutcomeIdentity(operation, input.runtimeEvent); @@ -2346,6 +2686,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 !== 'workspace_successor') { + throw new Error('Managed mutation outcome requires the workspace successor writer'); + } + } const runtimeEventSeq = this.insertRuntimeEvent(input.runtimeEvent, input.committedAt, false); this.options.failpoint?.('after_runtime_event_insert'); this.insertToolJournalEvent( @@ -3593,6 +3954,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 = ?') @@ -3644,6 +4027,7 @@ interface WorkspaceVersionProjectionRow { 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; @@ -3689,6 +4073,65 @@ function workspaceSuccessorVersionRecord(authority: ScannedWorkspaceSuccessorAut }; } +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: RuntimeEventManagedWorkspaceMutationV1 | undefined, + successor: WorkspaceVersionAcceptedV1, + baseHead: WorkspaceHeadRecordV1, + workspaceInstanceId: string, +): boolean { + return ( + mutation?.protocol === 'managed_mutation_v1' && + 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.expectedPaths, successor.changedPaths) + ); +} + function workspaceEpochProjectionRow( authority: ScannedWorkspaceBaselineAuthority, ): WorkspaceEpochProjectionRow { @@ -3738,6 +4181,7 @@ function workspaceBaselineVersionProjectionRow( tree_oid: record.treeOid, policy_hash: record.policyHash, tree_delta_digest: record.treeDeltaDigest, + changed_paths_json: '[]', changed_file_count: record.changedFileCount, deleted_file_count: record.deletedFileCount, accepted_event_id: record.acceptedEventId, @@ -3768,6 +4212,7 @@ function workspaceSuccessorVersionProjectionRow( 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, diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 8562fb9c368..5774e154d62 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -56,11 +56,31 @@ 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 expectedPaths: readonly string[]; + readonly executionProfileDigest: string; + readonly reservedAt: number; +} +type ManagedMutationReservationReader = ( + workspaceInstanceId: string, +) => Promise; interface WorkspaceBaselineAuthorityRegistration { readonly writer: WorkspaceBaselineAuthorityWriter; readonly successorWriter: WorkspaceSuccessorAuthorityWriter; readonly readHead: WorkspaceHeadReader; + readonly readActiveManagedMutation: ManagedMutationReservationReader; readonly bindStorageRoot: WorkspaceStorageRootBinder; readonly databasePath: string; readonly databaseFileIdentity?: string; @@ -79,6 +99,7 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( successorWriter: WorkspaceSuccessorAuthorityWriter, bindStorageRoot: WorkspaceStorageRootBinder, readHead: WorkspaceHeadReader, + readActiveManagedMutation: ManagedMutationReservationReader, ): void { if (workspaceBaselineAuthorityWriters.has(store)) { throw new Error('Workspace baseline authority writer is already registered'); @@ -88,12 +109,22 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( writer, successorWriter, readHead, + readActiveManagedMutation, bindStorageRoot, databasePath: resolvedDatabasePath, databaseFileIdentity: captureRegularFileIdentity(resolvedDatabasePath), }); } +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,