diff --git a/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md new file mode 100644 index 0000000000..6269c4ecd8 --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-baseline-open-v1.zh-CN.md @@ -0,0 +1,243 @@ +# Managed Workspace Baseline Open v1:M0 最终接受门 + +- 状态:M0 实现与审查收口中;前置 Git Workspace Service、Workspace Version Authority 与 + Managed Workspace Owner 合并后仍需从最新 `main` 平铺重建 +- 更新日期:2026-08-02 +- 主要不变量:只有经 Git artifact owner 持久化并重新验证的 exact baseline receipt,才能由同一 + storage-root lifecycle owner 提交给 SQLite workspace authority;Git artifact 单独成功不构成 + canonical workspace version +- Git artifact owner:`GitWorkspaceService` +- composition/lifecycle owner:`ManagedWorkspaceOwner` +- canonical history owner:SQLite 内 immutable workspace RuntimeEvents + +## 1. 本切片交付什么 + +前三个 M0 切片已经分别证明: + +1. Maka 可以用 pinned Git 创建、验证、repair 和 quarantine 自有 Git repository/worktree artifacts; +2. SQLite 可以把 epoch-opened、baseline-accepted 与三个 projection 原子提交,并从 RuntimeEvents 重建; +3. 一个 authenticated interactive storage root 只发布一个 managed workspace lifecycle owner。 + +它们之间仍缺一个权限与因果门。如果调用者可以把任意 OID 直接交给 SQLite,那么 Git 验证只是注释; +如果 Git 创建成功就被当作 canonical,那么 SQLite 失败后会出现第二套 workspace truth。本切片只关闭这条 +seam,不接 Desktop、CLI、runtime host 或工具执行。 + +公开入口是: + +```ts +owner.openManagedWorkspaceBaseline(runtimeStore, { + repositoryId, + workspaceId, + workspaceEpochId, + workspaceInstanceId, + sourceRoot, +}); +``` + +调用者只提供业务身份与 source root,不能提供 policy hash、commit/tree OID、version ID、event ID、 +tree delta 或“verified”布尔值。M0 policy 是实现实际执行并内部 canonicalize/hash 的固定协议,不是 +调用者提供的标签。 + +## 2. Owner、原子边界、失败状态与回滚 + +| 项目 | 决策 | +|---|---| +| Git artifact owner | `GitWorkspaceService` 证明 Git artifact,并在未导出的 receipt authority capability 中实现持久化与复验 | +| lifecycle owner | `ManagedWorkspaceOwner` 是 receipt capability 的唯一持有者,独占 Git receipt → SQLite authority 的组合调用 | +| canonical truth | SQLite authority stream 的 immutable RuntimeEvents;receipt 是 admission evidence,不是 canonical head | +| Git durability boundary | `baseline-receipt.json` 以同目录临时文件、file fsync、rename、POSIX parent fsync 持久化 | +| SQLite atomic boundary | epoch fact、baseline fact、epoch/version/head projection 位于同一事务 | +| root binding | authority store 必须是 authenticated storage root 下精确的普通非 symlink `runtime.sqlite`,并保持注册时的文件 identity;该文件必须只有一个 hard link,lease 的真实 DB path 是唯一依据;数据库 singleton rootId 必须等于 authenticated owner rootId;SQLite COMMIT 后、返回前再次验证 pathname、identity 与 durable rootId | +| policy identity | policy version/hash 从固定且实际执行的 M0 policy object 内部派生;覆盖 source cleanliness、tracked/untracked/ignored、UTF-8 lossless path、NFC/case-fold collision、symlink/submodule/attributes/special mode 与 materialization;public API 不接受裸 hash | +| exact retry | version/event IDs 由 binding + artifact + policy 确定性派生;durable receipt 冻结可重新证明的 Git evidence 与 delta | +| pre-accept orphan | receipt 已落盘而 SQLite 未接受时,它是可重试 orphan;不是 canonical workspace version | +| post-accept loss | SQLite 已有 head 但 receipt 缺失、漂移或 Git artifact 不可验证时,报 corruption/unavailable,不生成替代 receipt | +| 回滚 | 停止调用本入口即可;既有 RuntimeEvents 仍可读。不能删除已接受 receipt 来“回滚” canonical history | + +本切片没有尝试跨 Git filesystem 与 SQLite 建立一个不真实的分布式事务。它使用可重放协议收敛:Git +receipt 先 durable,SQLite 后原子接受;唯一允许的半完成状态是“未被接受的 Git orphan”。反方向的 +“SQLite 已接受但 Git receipt 不存在”不是可修复半状态,而是 fail-closed corruption。 + +## 3. Durable receipt 合同 + +receipt 位于 managed instance root,和 `binding.json` 同级: + +```text +managed-workspaces/ + w//e//i// + binding.json + baseline-receipt.json + worktree/ +``` + +v1 receipt 严格包含: + +```ts +{ + schemaVersion: 1, + protocol: 'maka_managed_workspace_baseline_receipt_v1', + binding, + workspaceVersionId, + policyVersion: 1, + policyHash, + epochOpenedEventId, + baselineAcceptedEventId, + treeDeltaDigest, + changedFileCount, + deletedFileCount: 0, +} +``` + +所有字段采用 exact-key strict decoder。receipt 必须重新证明: + +- durable binding 与调用 identity 完全相同; +- repository record、epoch artifact、baseline ref、head ref、commit/tree 与 pinned Git capability 一致; +- worktree registration 仍被 Maka ownership lock 锁定; +- HEAD/tree 等于 baseline,worktree clean; +- policy version/hash 等于实现实际执行的 canonical M0 policy; +- workspaceVersionId、epochOpenedEventId 与 baselineAcceptedEventId 可从 binding、baseline artifact 与 + policy 确定性重新派生;合法 shape 的磁盘篡改也必须被拒绝; +- tree delta 摘要与 baseline tree 重新计算结果一致。 + +`GitWorkspaceService` 的 public interface 不暴露 receipt 的 issue/require/verify,package root 也不导出 +`git-workspace-service` factory。artifact-only create/open 只作为 storage 内部前置实现存在;public +`ManagedWorkspaceOwner` 只暴露 `openManagedWorkspaceBaseline(...)` 与 lifecycle close。对应 receipt capability +位于未被 package root 导出的 internal module,并通过实例绑定的 `WeakMap` 只交给该 owner。普通 package +consumer 既不能预占一个 epoch 的 admission identity,也不能在 canonical acceptance 前取得 executable +worktree path。未来 ToolRuntime 接入时只允许消费由该入口成功返回的 accepted handle;在真实 consumer 出现前, +M0 不预设一个无消费者的 opaque-handle 抽象。 + +receipt 不保存 wall-clock `committedAt`:artifact owner 无法从 Git evidence 重新证明该时间,允许它进入 receipt +会让 orphan receipt 篡改污染 canonical event 时间。M0 的 baseline authority 使用协议固定逻辑时间 `0`; +canonical 顺序由 SQLite authority spine 的 `event_seq` 决定。真实 wall-clock acceptance time 若未来用于观测, +必须由 store 自己生成并作为 operational metadata 保存,不进入 Git evidence 或恢复身份。 + +## 4. Tree delta 摘要 + +M0 baseline 的逻辑 parent 是 empty tree,因此: + +- `changedFileCount` 等于 baseline tree 的受支持 blob entry 数; +- `deletedFileCount` 固定为 `0`; +- `treeDeltaDigest` 是 domain-separated canonical manifest 的 SHA-256,而不是 caller 自报值。 + +manifest 以 Buffer 读取 Git `ls-tree -r -z`,按 Git canonical 顺序记录每个 entry 的 `mode`、 +`objectType`、`oid` 和 `pathBytesBase64`。v1 为了保证 Git path 与 Node/host 路径语义一致,会严格 +拒绝不能无损解码并 round-trip 的非 UTF-8 path;不会把 replacement character 写进摘要。M0 只接受 +普通 blob mode `100644` / `100755`;symlink、submodule、special mode、 +`.gitattributes` 与 case collision 继续由 Git Workspace Service 在 receipt 生成前拒绝。 + +该摘要证明的是 Git object delta,不是 checkout 文件字节扫描,也不把 ignored/untracked 内容纳入 +canonical baseline。 + +## 5. 正常时序 + +```mermaid +sequenceDiagram + participant C as "Storage composition caller" + participant O as "ManagedWorkspaceOwner" + participant G as "GitWorkspaceService" + participant R as "baseline-receipt.json" + participant S as "SqliteRuntimeStore" + participant E as "Workspace RuntimeEvents" + + C->>O: openManagedWorkspaceBaseline(store, identity) + O->>O: authenticate root lease; assert exact runtime.sqlite identity + O->>S: bind/verify singleton durable storage-root ID + O->>G: create/adopt and verify managed workspace + G->>G: verify binding/repository/epoch/ref/worktree + G->>G: derive canonical policy + IDs + empty-tree → baseline delta + alt receipt already exists + G->>R: strict read + exact revalidation + else no canonical head and receipt absent + G->>R: atomic durable write of frozen receipt + end + G-->>O: verified durable receipt + O->>S: internal commitWorkspaceBaseline(receipt-derived input) + S->>S: BEGIN IMMEDIATE + S->>E: epoch-opened + baseline-accepted + S->>S: epoch/version/head projections + S->>S: COMMIT + S-->>O: created or exact-existing head + O->>O: post-commit revalidate exact runtime.sqlite identity + O->>S: revalidate exact durable storage-root ID + O->>G: post-commit reverify exact receipt and Git artifacts + G-->>O: verified + O->>O: re-authenticate root owner and durable root marker + O-->>C: usable canonical baseline +``` + +只有最后一步成功返回以后,调用者才得到可用的 managed baseline。SQLite commit 后的 reverify 失败 +不会撤销 canonical history;它阻止当前 instance 被交给后续工具,并要求 repair/quarantine 流程处理。 +最终 root-owner 复验与 Git receipt 复验是两个独立闸门:receipt 证明 managed artifact,root-owner +复验证明返回瞬间的 marker、lease、database binding 仍属于同一个 authenticated storage root。 + +## 6. Crash 与并发矩阵 + +| 崩溃/并发点 | durable Git 状态 | canonical SQLite 状态 | 重启结果 | +|---|---|---|---| +| receipt durable 前退出 | 无 receipt 或原子旧值 | absent | 重新验证 Git 后重新生成 receipt | +| receipt durable 后、SQLite 前退出 | exact orphan receipt | absent | exact reuse receipt,再提交 authority | +| epoch/version/projection 事务中退出 | exact receipt | 全回滚 | exact retry;不生成新 IDs | +| SQLite COMMIT 后、post-verify 前退出 | exact receipt | accepted | 重读 head + require existing receipt;exact existing | +| accepted 后 receipt 缺失 | missing | accepted | fail closed;禁止创建 replacement receipt | +| accepted 后 receipt 篡改 | invalid/drifted | accepted | fail closed;canonical history 不变 | +| accepted 后 Git ref/object/worktree 漂移 | unverifiable | accepted | fail closed,后续 quarantine/repair;不返回 cwd | +| 同一 root owner 内两个并发 open | artifact lock/SQLite writer 串行 | 一个 transaction 创建 | 一个 created,一个 exact existing | +| 两个进程竞争同一 root owner | loser 不能越过 root-owner admission | winner 独占 composition | winner 关闭后 loser 重试并得到 exact existing | +| owner 传入另一 storage root 的 DB | 不写 receipt | 不写错误 DB | admission 前拒绝 | +| 两个 storage root 以 hard link 共享 `runtime.sqlite` inode | 不写 receipt | 不接受共享 DB;拒绝 `nlink != 1` | fail closed,避免 WAL/SHM 跨目录分裂 | +| 单独复制或移动已绑定的 `runtime.sqlite` 到另一 root | 不写/不读取错误 root 的 receipt | DB rootId 与 owner rootId 冲突 | fail closed;必须使用 whole-root import/adopt 协议 | +| 正式复制整个 storage root 后 adopt | storage-root marker 与 DB 随 root 保留 | DB rootId 与 marker rootId 保持一致 | `adoptStorageRootOnImport` 只恢复 storage-root identity;不证明旧路径下已 materialize 的 linked worktree 可迁移 | +| imported root 含既有 managed workspace instance | binding 与 Git admin metadata 可能仍引用旧绝对路径 | canonical history 保留 | fail closed;首版必须显式 relocation/adoption 或重新 materialize 新 instance,不能直接返回旧 cwd | +| unbound DB 已含 Session、RuntimeEvent、claim 或 workspace fact 等逻辑数据 | 不生成新 receipt | 禁止静默认领 | 要求独立、显式、可备份和可审计的 legacy root-binding adoption;M0 baseline open 不承担迁移 | +| 初次 DB identity 检查后 canonical pathname 被替换 | receipt 可能成为 orphan | 已打开连接的提交视为 detached,不返回 usable baseline | post-commit DB identity 复验拒绝;新 canonical DB 不获得错误 head | +| post-commit artifact 复验后 root marker 被替换 | exact receipt 与 accepted head 保留 | canonical history 不回滚 | 最终 root-owner identity 复验拒绝;不返回 usable baseline | +| owned quarantine/instance parent 被替换为 symlink | physical layout revalidation 拒绝 | canonical history 不变 | fail closed,不读取外部 control record、不移动到外部目录 | + +真实进程 crash harness 分别覆盖 receipt durable 后、SQLite authority 前,以及 SQLite COMMIT 后、 +post-verify 前的 `SIGKILL`;两者重启后都使用同一 receipt/identity 收敛。SQLite 事务内部的 +process-kill/rollback 证明继续由 Workspace Version Authority 测试拥有;M0 只增加跨 Git、root owner、 +SQLite 与 post-verify 的 composition crash proof。 + +## 7. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +|---|---|---|---| +| pinned Git artifact revalidation | 支持 | 支持 | 支持 | +| receipt file fsync + atomic rename | 支持 | 支持 | 进程崩溃支持;不承诺断电级 parent fsync | +| SQLite atomic baseline acceptance | 支持 | 支持 | 支持 | +| raw Git path | 非 UTF-8 fail closed | 非 UTF-8 fail closed | Node/Git UTF-8 path policy | +| real-process crash test | 发布门槛 | 发布门槛 | 有限支持;依赖 Node/Git 的 SIGKILL 等价行为 | +| power-loss durability | 不承诺 | 不承诺 | 不承诺 | + +Windows 上不虚构 POSIX directory fsync 承诺。M0 保证进程崩溃后的可重试状态收敛;机器断电、磁盘缓存 +或文件系统损坏不在 v1 发布证明内。 + +## 8. 明确延期 + +- Desktop、CLI、runtime-host 与设置入口; +- 工具 cwd 切换、ignored dependency/scratch provisioning; +- mutation candidate ref、T1/T2 + workspace version 原子接受、conditional redo; +- continuation 绑定 workspace version 与自动 resume; +- orphan receipt/ref/object GC; +- whole-root import 后既有 managed worktree 的 relocation/adoption 或重新 materialize; +- 非空 legacy DB 的显式备份、授权、root-binding adoption 与完整重扫维护入口; +- replication、跨端同步、publish、undo 与 multi-agent merge。 + +这些能力必须分别由后续 PR 证明自己的 owner、原子边界、失败状态与回滚方式。M0 完成只代表系统拥有 +一个可验证、可重试、canonical 的 managed baseline,不代表 agent 已经在该 worktree 中执行工具。 + +## 9. 验收门槛 + +- 首次 open 与 exact retry 返回同一 receipt/head; +- receipt-after-write crash 与 SQLite rollback 都能 exact retry; +- SQLite COMMIT 后、post-verify 前真实进程 crash 返回 exact existing; +- accepted 后 receipt missing/tampered fail closed; +- post-commit Git 复验后 root marker 变化时,保留 canonical head 但拒绝返回 usable workspace; +- lease 伪 path、同 root 第二 DB、memory DB、DB symlink 与文件 identity 变化均在 admission 前拒绝; +- public Git service 无 receipt issuer,canonical policy/hash 与 deterministic IDs 有 literal/篡改测试; +- raw path digest 使用字节编码,非 UTF-8 source path fail closed; +- 同 owner 并发 open 收敛为一个 created、一个 existing;跨进程首先由 root-owner lock 仲裁; +- Git service、owner、workspace authority 的 focused real-Git/crash suites 全绿; +- `@maka/core` / `@maka/storage` build 与 `git diff --check` 通过; +- 不包含 Desktop/CLI/runtime-host 生产接线。 diff --git a/docs/architecture/runtime-managed-workspace-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-owner-v1.zh-CN.md new file mode 100644 index 0000000000..51d5f7df8b --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-owner-v1.zh-CN.md @@ -0,0 +1,111 @@ +# Managed Workspace Owner v1:M0 生命周期门 + +- 状态:实现完成;Git Workspace Service 与 Workspace Version Authority 合并后平铺重建 +- 更新日期:2026-08-02 +- 主要不变量:一个 authenticated interactive storage-root owner 在其生命周期内至多发布一个 + managed workspace owner;已经 admission 的 workspace 操作必须在关闭前 drain +- artifact owner:`GitWorkspaceService` +- lifecycle owner:`ManagedWorkspaceOwner` +- canonical workspace history:仍由 Workspace Version Authority 拥有;M0 composition 只允许 owner 的 + `openManagedWorkspaceBaseline(...)` 通过 storage-internal writer 写入 baseline RuntimeEvents + +## 1. 为什么需要独立 owner + +`GitWorkspaceService` 能创建、验证、repair 与 quarantine Maka-owned Git artifacts,但它是一个 +operation-scoped service;仅有它还不能回答: + +- 哪一个 host 有权在当前 storage root 上驱动这些操作; +- 初始化进行中或失败时,是否可能发布半个可用 owner; +- shutdown 与进行中的 Git 操作谁先完成; +- Desktop、CLI 与 runtime-host 是否可能各自绕过同一生命周期门。 + +本切片把现有 authenticated `InteractiveRootOwner` 作为上层 lease authority。它不增加第二个 OS +owner lock,也不通过路径自行证明 ownership。 + +## 2. Owner、边界、失败状态与回滚 + +| 项目 | 决策 | +|---|---| +| 唯一 owner | 一个真实 `InteractiveRootOwner` 对象只能组合一个 `ManagedWorkspaceOwner` | +| 初始化边界 | pinned Git digest 验证与 storage-root authority probe 全部运行在 root write lease 内 | +| operation admission | 仅 `ready` 可 admission;每项操作同时持有 managed-owner residency 与 root lease operation | +| shutdown | `ready -> closing -> closed`;`closing` 拒绝新操作并等待已 admission 操作 drain | +| 初始化失败 | 返回 `managed_workspace_owner_unavailable`,释放未发布 claim,允许同一 root owner 修正后重试 | +| 重复组合 | 返回 `managed_workspace_owner_conflict` | +| drift | 不返回 drifted cwd;receipt/artifact 复验发现 drift 时 fail closed;复验后 reopen 竞态发现 drift 时 durable quarantine | +| 回滚 | 不接 Desktop/CLI/runtime-host,不改变 attached mode;可删除本 owner 而不改变 Git artifacts 或 RuntimeEvents | + +owner 不关闭外层 `InteractiveRootOwner`。Runtime Host 仍拥有 root owner 的最终关闭顺序;managed owner +必须先关闭。反过来,如果 root owner 已开始关闭,lease revalidation 会阻止新的 managed operation。 + +## 3. 公开状态机 + +```mermaid +stateDiagram-v2 + [*] --> opening + opening --> ready: pinned Git + root lease probe 成功 + opening --> failed: 初始化失败,释放 unpublished claim + ready --> closing: close() + closing --> closing: 拒绝新 operation,等待 residency drain + closing --> closed: active operations = 0 + closed --> closed: repeated close() +``` + +`opening` 与 `failed` 不作为已发布 owner 的可见状态。factory 只有在初始化完成并再次确认 root owner +仍然有效后才返回 `ready` owner。 + +## 4. Workspace gate + +owner 的 public surface 只开放一个 workspace admission 操作: + +1. `openManagedWorkspaceBaseline(store, identity)` 从 eligible clean source 创建/exact-adopt artifact, + 持久化并复验 receipt,再由 storage-internal writer 接受 canonical baseline。 + +artifact-only create/open 和 `GitWorkspaceService` factory 不从 package root 导出。调用者不能在 SQLite +acceptance 前取得 `worktreePath` 或裸 `ManagedWorkspaceBinding`。入口返回前必须验证 worktree、index、 +HEAD、tree、ownership lock、canonical `runtime.sqlite` pathname/inode、durable receipt,以及最终时刻的 +`InteractiveRootOwner`/root marker identity;任何失败都不能把 cwd 交给工具。SQLite 已提交而最终 owner +复验失败时,canonical history 保留,但本次调用不得发布 usable workspace。 + +本切片不扫描目录来猜测 workspace identity。Baseline Open Bundle 通过 Git artifact owner 的 durable +receipt 与 canonical workspace authority 绑定 exact identity;未接受 Git artifact 属于 orphan GC 范畴。 + +## 5. Crash 与并发证明 + +| 场景 | 必须结果 | +|---|---| +| 同一 root owner 两次 open | 一个 ready;另一个 owner conflict | +| pinned Git 初始化失败 | 不发布 owner;修正 digest 后可重试 | +| operation admission 后 close | close 等待 operation;新 operation 被拒绝 | +| root owner 同时 close | root close 与 managed close 都等待同一 lease-bound operation | +| external drift 后 reopen | receipt/artifact 复验 fail closed;若发生在复验与 reopen 之间则 durable quarantine | +| post-commit artifact 复验后 root marker 被替换 | admission 时捕获的 lease identity guard 最终复验并拒绝;保留 canonical head,不发布 cwd;owner closing 只阻止新 admission,不误杀正在 drain 的操作 | +| repeated close | exact no-op,不重复释放外层 root owner | + +Git artifact create/quarantine 的进程崩溃矩阵继续由 `GitWorkspaceService` 负责;本 owner 不复制第二套 +repair 状态机。Baseline Open Bundle 将补充“startup 时先验证 canonical receipt,再按 exact binding +reopen/repair,最后才允许 baseline authority read”的组合顺序。 + +## 6. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +|---|---|---|---| +| owner uniqueness / lifecycle | 支持 | 支持 | 支持 | +| root lease-bound operation drain | 支持 | 支持 | 支持 | +| pinned Git initialization | 支持 | 支持 | 支持 | +| external drift quarantine | 支持 | 支持 | 有限支持,沿用 Git service 的 Windows 承诺 | +| power-loss durability | 不承诺 | 不承诺 | 不承诺 | + +## 7. 明确延期 + +- Desktop、CLI、runtime-host 接线与 managed-mode 设置; +- filesystem worker、mutation coordinator 与工具 cwd 切换; +- candidate refs、mutation repair、GC、replication outbox; +- ignored dependencies、build/test environment provisioning; +- Durable Write、workspace-bound continuation 与自动 resume。 +- whole-root import 后既有 linked worktree 的 relocation/adoption; +- 非空 legacy database 的显式备份与 root-binding migration 工具。 + +这些能力不能借 owner lifecycle PR 顺手接入。Baseline Open Bundle 已作为本 owner 的第一个 +canonical-fact consumer 完成组合,证明 Git baseline 与 RuntimeEvent baseline 不会只成功一半;后续 +M1 execution admission 仍必须从该 bundle 成功返回的结果进入,不能重新开放 artifact-only 旁路。 diff --git a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md index effac5e4fd..4a6896eec1 100644 --- a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md @@ -415,6 +415,9 @@ Continuation Authority 合并后,workspace plane 不再从 #1346 移植通用 > baseline-accepted fact、epoch/version/head projection 对外只能全可见或全不可见;并发只能接受一个 > baseline identity。若 projection 被外部删除,reader 必须 fail closed,不能把损坏态当作未创建。 +Schema 9 进一步要求该 authority stream 在首次写入前绑定 authenticated storage root 的 durable +`rootId`;只有 metadata-only 新库可自动绑定,已有逻辑数据必须显式 adoption,单独复制数据库不能改变归属。 + ### 10.1 文件归属 | 文件 | 本切片职责 | @@ -423,7 +426,7 @@ Continuation Authority 合并后,workspace plane 不再从 #1346 移植通用 | `core/runtime-event.ts` | typed `actions.workspaceFact` 与 control-plane stream 说明 | | `core/runtime-event-store.ts` | baseline authority capability 与专用 writer contract | | `storage/runtime-event-authority.ts` | workspace fact/authority stream generic-writer reservation | -| `storage/sqlite-runtime-schema.ts` | schema 7、三张 projection、capability marker | +| `storage/sqlite-runtime-schema.ts` | schema 7 facts/projections/capability;schema 8 headless task events;schema 9 singleton durable storage-root binding | | `storage/sqlite-runtime-store.ts` | atomic baseline bundle、read cross-check、rebuild、failpoints | | `storage/agent-run-store.ts` | JSONL 与 conversation copy fail closed | | `storage/conversation-operational-state.ts` | ordinary Session purge 不得删除 authority stream | @@ -446,7 +449,8 @@ Continuation Authority 合并后,workspace plane 不再从 #1346 移植通用 - projection delete/rebuild、canonical corruption fail closed; - SQLite/JSONL/tool/recovery/continuation/copy writer bypass; - 两进程 exact/conflicting baseline arbitration; -- 两进程 schema 6→7 migration; +- 两进程 schema 6/7→8 migration; +- DB rootId exact binding、跨 root 单文件复制拒绝、whole-root storage authority import/adopt 与 unbound operational data fail closed;既有 managed worktree relocation 和 legacy DB binding 仍是独立维护协议,不由 M0 baseline open 暗中完成; - Linux/macOS process-kill crash harness; - workspace fact 不进入 UI/provider message projection。 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 0a5ee54e40..b70f530cf0 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 @@ -1,13 +1,13 @@ # Runtime Resume Phase 3–4 实施路线 -> 路线更新(2026-08-01):workspace 主线已从“通用 checkpoint 抽象优先”切换为 +> 路线更新(2026-08-02):workspace 主线已从“通用 checkpoint 抽象优先”切换为 > **Git-native managed workspace**。本文保留 Recovery/Continuation 已落地协议与旧路线的历史论证; > 新实现不再以通用 per-file checkpoint、native manifest 或 CAS object store 为前置。 > 当前 M0 的 baseline-only 权威合同见 > [Workspace Version Authority v1](./runtime-workspace-version-authority-v1.zh-CN.md)。 - 状态:Implementation tracked -- 更新日期:2026-08-01 +- 更新日期:2026-08-02 - 事实权威:immutable RuntimeEvents - 主要平台:Linux、macOS;Windows 有限支持 - 拆分审计:`runtime-resume-extraction-ledger.zh-CN.md` @@ -28,7 +28,7 @@ RuntimeEvent 是语义事实的唯一权威,但不能替代执行所有权的 | recovery semantics | immutable RuntimeEvents | call、dispatch、outcome、observation、decision | | execution ownership | admission/claim CAS | 一个 source boundary 只能有一个执行者 | | projection | SQLite tables | 可删除、可从事件重建 | -| workspace artifact | checkpoint provider | 保存/验证 workspace 状态,不能单独授权 continuation | +| workspace artifact | managed Git artifact owner(强模式) | 保存/验证 workspace 状态,不能单独授权 continuation | ## 2. Phase 3A:工具恢复 @@ -507,138 +507,115 @@ generic repair 必须识别 claim-owned target 并 defer,不能用普通 `app_ identity。当前 PR B 交付 authority-capable SessionManager 与 SQLite 协议;在 PR D 把该 authority 接入 runtime-host 生产组合并完成 owner/ordering 测试前,不得宣称 hosted auto-resume 已启用。 -## 3. Native 与 Git 的能力边界 +## 3. Native、attached 与 managed 的能力边界 -### 无 Git CLI / 非 Git workspace +### Native / attached checkout -Native 支持止于单次 operation: +Attached 模式继续服务现有用户目录与兼容场景,但不冒充强 workspace continuity: -- Write/Edit 的 before/expected-after evidence; -- 崩溃后 after-state finalize; -- 不能证明时 park; -- 不建设 native workspace manifest 或 CAS object store; -- 不提供 workspace-wide drift、isolated restore 或 durable rebaseline。 +- RuntimeEvent history、continuation authority 与已落地的工具恢复事实继续有效; +- 不能证明 workspace 内容对应某个 Runtime boundary 时 park; +- 不建设第二套 native workspace manifest 或 CAS object store; +- 不在用户 checkout 上自动 reset、redo 或覆盖 drift; +- 旧的 generic checkpoint provider、per-file carrier 与 observe-only user-repository Git 路线只保留为 + attached/legacy research,不再是 `managed_worktree` 的实现前置。 -### 有 Git CLI 且 workspace 是 eligible repository +### Git-native managed workspace -Git 是 workspace continuity carrier,而不是单文件因果证明的前置条件: +强模式以 Maka-owned repository/worktree 为 artifact carrier。Git 证明文件世界,SQLite immutable +RuntimeEvents 决定 Maka 是否接受某个文件版本;Git commit、ref、receipt 与 projection 都不是第二套真相。 +内置并校验的 Git CLI 是该模式的运行能力,不要求用户自行安装 Git,也不修改用户 repository 的 index/ref。 -- workspace snapshot; -- RuntimeEvent boundary 与 Git tree/commit 绑定; -- workspace-wide drift detection; -- isolated worktree restore; -- durable rebaseline; -- object retention 与 GC。 +## 4. Git-native managed workspace 演进 -## 4. Phase 3B:Checkpoint 语义 +### M0 — Baseline admission(当前切片) -### PR E — Checkpoint contracts +M0 只证明一条接受链: -先定义纯语义和 fake provider,不接 Git: - -在第一个通用 Phase 3B 审计事实落盘前,先引入 generic invisible `runtimeFact` envelope。 -现有 `actions.toolRecovery` 保持不变,避免返工;workspace transition 与 checkpoint 使用通用 -envelope。旧 read-model 对未知 fact 可跳过展示,但 recovery consumer 必须 fail closed。这个前置 -不追溯阻塞已经存在的 PR A tool recovery facts。 - -```ts -interface WorkspaceBoundary { - workspaceIdentity: string; - workspaceEpoch: number; - immutableRuntimeHighWater: number; - immutableRuntimeDigest: string; - checkpointRef: string; - checkpointPolicyHash: string; -} +```text +authenticated storage-root owner +→ create/reopen Maka-owned managed Git workspace +→ durable exact receipt +→ SQLite atomic epoch + baseline acceptance +→ post-commit DB/Git/root-owner revalidation +→ publish usable baseline ``` -需要证明: - -- checkpoint 绑定的是 immutable boundary,不是 mutable UI snapshot; -- cwd 切换产生明确 workspace transition; -- checkpoint provider 只能 capture/verify/materialize,不能自行批准 resume; -- required/optional/legacy host policy 有稳定结果; -- plan 与 execution revalidation 使用同一 boundary。 +M0 不接 Desktop、CLI、runtime host 或工具执行。Whole-root import 当前只恢复 storage-root identity 与 +SQLite ownership;既有 linked worktree 的路径迁移需要独立 relocation/rematerialization 协议。非空且未绑定的 +legacy DB 继续 fail closed,未来由显式、备份后、可审计的 root-binding maintenance flow 处理,不能藏在首次 +baseline open 中。 -### PR F — Canonical checkpoint bundle +### M1 — Execution admission 与环境可用性 -把 checkpoint accepted fact 与 RuntimeEvent boundary 原子绑定: +只允许 host 从 M0 成功返回的 accepted handle 取得工具 cwd,并证明: -- fact 只有一个 canonical writer; -- projection 可重建; -- checkpoint ref、workspace identity、epoch、high-water、digest 全部交叉校验; -- artifact 已生成但 fact 未提交时允许 GC; -- fact 已提交但 artifact 缺失时 fail closed。 +- managed owner 与 runtime host 的启动、drain、关闭顺序; +- 每次执行前 exact instance/HEAD/tree/ownership revalidation; +- ignored dependencies、`.env` 与 build scratch 使用明确 provisioning/overlay policy,不污染 canonical tree; +- 外部修改 Maka-owned worktree 时检测 drift、fail closed 并 quarantine; +- attached 与 managed execution profile 在类型和配置上不可静默互相 fallback。 -## 5. Phase 4A:Git observe/capture +### M2 — Mutation version acceptance -### PR G — Observe-only Git carrier +每个 mutating tool 使用 candidate ref/version 协议: -先只读验证: +1. T1 前冻结 execution profile、base workspace version 与 mutation identity; +2. 工具只在 owned worktree 中执行; +3. Git artifact owner 捕获并验证 candidate commit/tree; +4. SQLite 在一个 authority transaction 中接受 tool outcome 与 successor workspace version; +5. artifact orphan 可回收;canonical fact 已接受而 artifact 缺失时 fail closed; +6. crash matrix 覆盖执行前后、candidate durable 前后、SQLite commit 前后与返回前复验。 -- repository/worktree identity; -- HEAD/tree/index/dirty state; -- ignored/untracked policy; -- submodule、LFS、sparse checkout、case sensitivity 能力探测; -- 不写 ref、不改用户 index、不改 working tree。 +这一步取代旧的“先做通用 checkpoint contract,再接 observe-only Git carrier”。不得同时维护两套 +managed workspace version writer。 -不合格仓库降级到 native operation recovery,不伪装具备 workspace continuity。 +### M3 — Workspace-bound continuation / resume -### PR H — Production capture + retention +Continuation boundary 同时绑定 immutable RuntimeEvent cursor 与 accepted workspace version/epoch: -- 使用 Maka 自有 ref namespace 或独立 object ownership; -- capture 不修改用户 branch/index; -- checkpoint fact 接受后才成为 durable root; -- quota、retention、orphan GC; -- p50/p95 capture 延迟和磁盘增量 telemetry; -- host lifecycle 完成后才默认启用。 +- planner 与 execution revalidation 读取同一 canonical boundary; +- startup reopen 必须重新证明 exact Git artifact、storage root 与 owner; +- operation effect 无法证明、artifact 漂移或 execution profile 不匹配时 park; +- 自动 resume 仍由独立用户设置控制,默认关闭;手动恢复与自动恢复不能共享一个含糊开关。 -## 6. Phase 4B/4C:恢复与 rebaseline +### M4 — Product workflows -### Isolated restore +在 M0–M3 的事实与 owner 边界稳定后,再分别交付: -workspace drift 时默认恢复到隔离 worktree/目录,不覆盖用户当前工作: +- isolated restore,不覆盖用户当前目录; +- explicit rebaseline,创建新 epoch 并要求模型重新读取; +- publish/merge/undo、retention 与 orphan GC; +- whole-root managed-workspace relocation; +- replication/cross-device 与 multi-agent merge。 -1. verify checkpoint; -2. materialize isolated workspace; -3. 写 workspace transition fact; -4. continuation 在新 identity/epoch 下开始; -5. 用户当前目录保持不变。 +每项都必须是独立 PR;不能借“恢复产品化”一次跨越 schema、runtime protocol、host lifecycle 与 platform I/O。 -### Durable rebaseline - -“以当前文件为准继续”不是忽略错误: - -1. capture 当前 workspace; -2. 持久化新 baseline fact; -3. increment workspace epoch; -4. 告知模型必须重新读取受影响文件; -5. continuation 只引用新 boundary。 - -## 7. 依赖顺序 +## 5. 依赖顺序 ```text -PR A persistence authority - ├─> PR B continuation correctness - └─> PR C file finalize-only recovery - -PR B - ├─> execution-profile binding - └─> PR D hosted authority + owner lifecycle - -PR B + PR C - └─> PR E checkpoint contracts - └─> PR F canonical checkpoint bundle - └─> PR G observe-only Git - └─> PR H capture + retention - ├─> isolated restore - └─> durable rebaseline - -execution-profile binding 与 PR D hosted authority/owner lifecycle 必须在 production -auto-resume 默认开启前完成;PR D 也必须在 production checkpoint capture 默认开启前完成。 +PR A recovery persistence authority (merged) +└─> PR B continuation correctness (merged) + +Git-native workspace: +M0.1 Git artifact owner (merged) + + M0.2 workspace version authority (merged) + + M0.3 managed owner lifecycle (merged) + └─> M0.4 baseline open bundle (current) + └─> M1 execution admission / provisioning + └─> M2 mutation version acceptance + └─> M3 workspace-bound continuation + └─> M4 restore / rebaseline / publish / replication + +Independent maintenance gates before broad production enablement: + - legacy non-empty DB root-binding adoption + - whole-root managed-workspace relocation/rematerialization ``` -## 8. 工程门槛 +旧 PR C–H 的论证可作为历史材料,但不再代表 `managed_worktree` 的施工依赖图;其中不具备生产消费者的 +generic checkpoint、observe-only carrier 与预设抽象不得迁入新主线。 + +## 6. 工程门槛 每个 PR 必须: @@ -657,16 +634,16 @@ auto-resume 默认开启前完成;PR D 也必须在 production checkpoint capt - continuation claim verification 的 p50/p95 与每次 append 扫描 claim 的数量;长期应增加 source/target/run 索引、局部验证或可失效的已验证缓存,避免退化为 `O(claims × immutable writes)`; -- checkpoint capture p50/p95; +- managed mutation candidate capture/accept p50/p95; - `workspace_drift`、`mode_mismatch`、`artifact_missing` park 比例; - 自动恢复成功率必须把长命令、大仓库、dirty workspace 纳入分母。 -## 9. 不做的承诺 +## 7. 不做的承诺 - PR A 不提供真实工具自动恢复; - PR B 不恢复 workspace; -- PR C 不自动 redo stale before-state; -- 无 Git 环境不提供 workspace snapshot; -- Git carrier 不覆盖用户当前工作区; +- M0 不把 managed worktree 交给生产工具; +- attached checkout 不提供 managed 级 workspace continuity; +- Maka-owned Git artifact 不覆盖用户当前 checkout; - 无法证明的 Bash/远程 API 副作用不自动重试; - process-crash transaction atomicity 不等于断电级 durability。 diff --git a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md index f6d52b468b..c9531457c6 100644 --- a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md @@ -1,7 +1,8 @@ # Workspace Version Authority v1:Baseline 事实权威 -- 状态:Draft foundation;在 durable verified baseline receipt 接入前不得标记 Ready -- 更新日期:2026-08-01 +- 状态:authority foundation 已由 M0 Baseline Open Bundle 接入;仍待前置 PR 合并后从最新 `main` + 平铺并完成最终 CI +- 更新日期:2026-08-02 - 主要不变量:经专用 writer 提交的一个 workspace epoch,其 baseline canonical facts 与三个 SQLite projection 对外只能全可见或全不可见 - 事实权威:immutable RuntimeEvents - artifact owner:后续 `GitWorkspaceService`;本切片不执行 Git 命令 @@ -31,29 +32,27 @@ projection,公开 reader 会 fail closed,直到显式 rebuild;这种损坏 本切片只接受 baseline,不接受 mutation。它不创建 internal repository、不创建 worktree、不调用工具, 也不改变 Desktop/CLI 行为。这种收缩是刻意的:一个 PR 只证明一个主要不变量。 -当前分支只证明事实合同、SQLite 原子写入与 projection 可重建性,**尚未证明 supplied Git object -真实存在**。因此 raw baseline writer 已从 `@maka/core` store contract 与 `@maka/storage` package API -移除,只保留为 storage 内部 symbol seam,供本切片的 persistence/crash tests 使用;本 PR 也不能独立 -合并为可用的“accepted workspace”能力。Ready 的硬门槛是后续 Baseline Open slice 提供一份由 -`GitWorkspaceService` 持久化、可重读、可重新验证的 receipt,并让唯一 composition owner 在验证 -receipt 后调用未来的 `commitVerifiedWorkspaceBaseline(receipt)`。裸 OID、TypeScript brand 或 caller -自报的 `verified: true` 都不能跨过该门槛。 +authority slice 本身只证明事实合同、SQLite 原子写入与 projection 可重建性;M0 Baseline Open Bundle +在其上增加了生产 composition seam:`ManagedWorkspaceOwner` 持有未导出的 Git receipt capability, +先持久化并复验 exact receipt,再调用 storage-internal raw writer,SQLite COMMIT 后还会再次复验 Git +artifact。裸 OID、TypeScript brand、caller 自报的 `verified: true` 或 caller 提供的 policy hash 都不 +构成证据。 ## 2. Owner、边界、失败状态与回滚 | 项目 | 决策 | |---|---| | 协议 owner | `@maka/core` 的 strict fact contract 与 pure scanner | -| 写入 owner | storage 内部 `WORKSPACE_BASELINE_AUTHORITY_COMMIT` symbol;不属于 package API | +| 写入 owner | storage-internal WeakMap writer;不属于 package API,只能由 `ManagedWorkspaceOwner` composition seam 到达 | | 原子性边界 | 单个 `BEGIN IMMEDIATE ... COMMIT` SQLite transaction | | canonical source | store-owned authority stream 中的两条 immutable RuntimeEvents | | disposable state | `runtime_workspace_epochs`、`runtime_workspace_versions`、`runtime_workspace_heads` | | 正常失败 | exact retry 返回 existing;payload/identity drift 返回 conflict | | 损坏失败 | malformed fact、orphan、projection mismatch、partial snapshot 污染全部 fail closed | | 运行时回滚 | 事务未提交时五部分全部回滚;已提交时五部分全部可读 | -| 版本回滚 | schema 7 数据库不能由只支持 schema 6 的旧 binary 打开;降级必须使用升级前备份 | +| 版本回滚 | schema 9 数据库不能由只支持 schema 8 的旧 binary 打开;降级必须使用升级前备份 | -逻辑回滚可以停止调用本 writer,但必须保留 schema 7 reader/migration;不能通过删除 capability marker +逻辑回滚可以停止调用本 writer,但必须保留 schema 9 reader/migration;不能通过删除 capability marker 伪装成旧格式。 ## 3. Authority stream @@ -174,28 +173,30 @@ Writer reservation 同时覆盖: storage 内部 writer 接受 typed baseline input,由 store 自己构造 RuntimeEvents。它不接受 caller 拼好的 event,因此 caller 没有机会夹带另一条 semantic lane。该 seam 所在模块不从 `@maka/storage` 导出; -公开 store 只暴露 capability、reader 与 projection rebuild。未来唯一的生产入口必须接收并重新验证 -durable Git receipt,而不是把这条 raw seam 重新公开。 +公开 store 只暴露 capability、reader 与 projection rebuild。当前唯一生产入口由 +`ManagedWorkspaceOwner` 重新验证 durable Git receipt 后调用 raw seam;receipt issuer 与 raw writer +都不进入 package root exports。 ## 6. Baseline Open Bundle 时序 ```mermaid sequenceDiagram - participant G as GitWorkspaceService(后续切片) + participant G as GitWorkspaceService + participant O as ManagedWorkspaceOwner participant S as SqliteRuntimeStore participant E as immutable RuntimeEvents participant P as workspace projections G->>G: 验证 source repository、commit/tree、fixed materialization profile G->>G: 导入并验证 internal baseline commit/tree - G->>S: commitVerifiedWorkspaceBaseline(durable receipt) - S->>S: re-read and verify receipt, derive frozen identity + G->>O: verified durable receipt (internal capability) + O->>S: commitWorkspaceBaselineInternal(receipt-derived input) S->>S: BEGIN IMMEDIATE S->>S: 扫描全部 canonical workspace facts alt exact bundle 已存在 - S-->>G: created=false + S-->>O: created=false else identity 或 payload 冲突 - S-->>G: fail closed / rollback + S-->>O: fail closed / rollback else authority 为空 S->>E: append epoch event (seq=1) S->>E: append baseline event (seq=2) @@ -203,19 +204,18 @@ sequenceDiagram S->>P: insert version S->>P: set head=baseline S->>S: COMMIT - S-->>G: created=true + S-->>O: created=true end ``` -Git 验证与 tree-delta 计算故意不放进 SQLite transaction。后续 Baseline Open Bundle 负责在调用 -writer 前读取并重新验证 durable receipt;SQLite writer 负责冻结已验证 identity,并保证 -facts/projections 原子提交。在该 composition 存在前,当前 typed input 只存在于 storage 内部 test seam, -不能成为 Desktop、CLI、tool 或自动恢复的生产入口。 +Git 验证与 tree-delta 计算故意不放进 SQLite transaction。Baseline Open Bundle 在调用 writer 前读取 +并重新验证 durable receipt,COMMIT 后再次复验;SQLite writer 负责冻结已验证 identity,并保证 +facts/projections 原子提交。该 composition 仍未接 Desktop、CLI、tool 或自动恢复入口。 `treeDeltaDigest` 必须是 canonical empty-tree → baseline-tree delta 的摘要,不能由 caller 随意填写。 -## 7. Schema 7 与 projection +## 7. Schema 7–8 与 projection -Schema 7 从已发布 schema 6 做纯增量升级,并写入 capability: +Schema 7 从 schema 6 增加 workspace authority facts/projections 并写入 capability: ```text runtime_workspace_version_authority @ 1 @@ -228,6 +228,15 @@ runtime_workspace_version_authority @ 1 | `runtime_workspace_epochs` | epoch identity、source 与 materialization policy | | `runtime_workspace_versions` | accepted baseline commit/tree 与因果引用 | | `runtime_workspace_heads` | 当前 epoch head;M0 必须等于 baseline | +| `runtime_storage_root_binding` | singleton durable rootId;阻止单独复制/移动 `runtime.sqlite` 后被另一 storage root 静默认领 | + +Schema 9 增加 `runtime_storage_root_binding(singleton=1, root_id, protocol_version=1)`;schema 8 保留 +main 已发布的 `headless_task_run_events`。M0 在任何 +workspace fact 写入前,通过 storage-internal binder 将它绑定到 authenticated root owner 的 durable +`rootId`;已绑定数据库只接受 exact rootId。没有 binding 但已经含任何 Session、RuntimeEvent、claim 或 +workspace fact 等逻辑数据的实验数据库必须显式 adopt/清理,不能自动认领;只有除 schema/capability metadata +外完全为空的新数据库可以首次绑定。正式 whole-root import 保留 marker 与数据库中的同一 rootId,并只通过 +`adoptStorageRootOnImport` 更新 host-local dev/ino,因此仍可打开;只复制 `runtime.sqlite` 会 fail closed。 公开的 `WorkspaceHeadRecordV1` 使用通用字段 `acceptedEventId`,因为后续 head 可能由 mutation acceptance 推进;只有 baseline version record 与 canonical scanner 使用更具体的 `baselineAcceptedEventId`。SQLite @@ -252,7 +261,7 @@ SQLite read transaction/snapshot;否则并发 writer 可能让读者拼接两 | 两进程提交相同 baseline | 一个 created、一个 existing | | 两进程提交冲突 baseline | 只接受一个;另一个 conflict | | reader 扫描期间另一进程提交 baseline | reader 在旧 snapshot 返回 absent;下一次读取看到完整 baseline | -| schema 6 两进程同时升级 | 在 migration lock 内重读版本,只执行一次 schema 7 migration | +| schema 6/7 两进程同时升级 | 在 migration lock 内重读版本,每个 pending migration 只执行一次 | | epoch event insert 后崩溃 | facts/projections 全无 | | version event insert 后崩溃 | facts/projections 全无 | | epoch projection insert 后崩溃 | facts/projections 全无 | @@ -273,7 +282,7 @@ SQLite read transaction/snapshot;否则并发 writer 可能让读者拼接两 | strict fact/lane | 支持 | 支持 | 支持 | | SQLite bundle 原子性 | 支持 | 支持 | 支持 | | 多进程 exact/conflict arbitration | 支持 | 支持 | 已有定向测试 | -| schema 6→7 并发升级 | 支持 | 支持 | 已有定向测试 | +| schema 6/7→8 并发升级 | 支持 | 支持 | 已有定向测试 | | 真实 SIGKILL crash harness | 发布门槛 | 发布门槛 | 当前不承诺 | | Git object/worktree 语义 | 后续切片 | 后续切片 | 后续能力矩阵 | @@ -298,13 +307,13 @@ SQLite read transaction/snapshot;否则并发 writer 可能让读者拼接两 ## 11. 后续 M0 交付顺序 -### Slice 2:Managed Workspace Owner +### Slice 2:Managed Workspace Owner(已完成) 只证明:Maka 能用 bundled Git 创建并独占一个 private internal repository/worktree lifecycle;外部 drift 被检测后 quarantine。需要先拍板 ignored dependencies/scratch、identity marker、fixed Git config、 symlink/LFS/submodule/case/filemode 平台政策。 -### Slice 3:Baseline Open Bundle +### Slice 3:Baseline Open Bundle(实现中) 只证明:从 eligible clean source HEAD 导入 Maka-owned objects、materialize baseline、验证 tree/cleanliness, 然后调用本切片的 atomic authority writer。Git artifact 在失败时可作为 orphan GC;只有 RuntimeEvent @@ -313,6 +322,9 @@ symlink/LFS/submodule/case/filemode 平台政策。 完成这两片以后,系统才拥有一个可供工具使用的 managed baseline。真正的 Durable Write 与 workspace-version/T2 原子接受仍是下一组独立 PR,不能在 host 接线中顺手补入。 +Baseline Open 的详细 receipt 合同、组合 owner、crash matrix 与平台承诺见 +[`runtime-managed-workspace-baseline-open-v1.zh-CN.md`](./runtime-managed-workspace-baseline-open-v1.zh-CN.md)。 + ## 12. 当前验收记录 本分支完成的短门禁: diff --git a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts index aec156ee32..d19e5909bf 100644 --- a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts +++ b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts @@ -1,27 +1,47 @@ import { writeFileSync, writeSync } from 'node:fs'; +import { join } from 'node:path'; import { createGitWorkspaceService } from '../../git-workspace-service.js'; +import { openManagedWorkspaceOwner } from '../../managed-workspace-owner.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../../root-authority.js'; +import { createSqliteRuntimeStore } from '../../sqlite-runtime-store.js'; -const service = createGitWorkspaceService({ - storageRoot: requiredEnv('MAKA_GIT_WORKSPACE_STORAGE'), - gitRuntime: { - executablePath: requiredEnv('MAKA_GIT_WORKSPACE_EXECUTABLE'), - expectedSha256: requiredEnv('MAKA_GIT_WORKSPACE_SHA256') as `sha256:${string}`, - }, - failpoint(point) { - if (point !== requiredEnv('MAKA_GIT_WORKSPACE_FAILPOINT')) return; - writeSync(1, 'READY\n'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); - }, -}); +const storageRoot = requiredEnv('MAKA_GIT_WORKSPACE_STORAGE'); +const gitRuntime = { + executablePath: requiredEnv('MAKA_GIT_WORKSPACE_EXECUTABLE'), + expectedSha256: requiredEnv('MAKA_GIT_WORKSPACE_SHA256') as `sha256:${string}`, +}; +const failpoint = (point: string) => { + if (point !== requiredEnv('MAKA_GIT_WORKSPACE_FAILPOINT')) return; + writeSync(1, 'READY\n'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); +}; -const binding = await service.createManagedWorkspaceFromSource({ +const request = { repositoryId: 'repository_11111111111111111111111111111111', workspaceId: 'workspace_22222222222222222222222222222222', workspaceEpochId: 'epoch_33333333333333333333333333333333', workspaceInstanceId: 'instance_44444444444444444444444444444444', sourceRoot: requiredEnv('MAKA_GIT_WORKSPACE_SOURCE'), +} as const; + +if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'baseline-receipt') { + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + if (!rootOwner) throw new Error('Unable to acquire crash-child root owner'); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const owner = await openManagedWorkspaceOwner({ rootOwner, gitRuntime, failpoint }); + await owner.openManagedWorkspaceBaseline(runtimeStore, request); + throw new Error('Managed baseline crash child missed its failpoint'); +} + +const service = createGitWorkspaceService({ + storageRoot, + gitRuntime, + failpoint, }); +const binding = await service.createManagedWorkspaceFromSource(request); + if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'quarantine') { writeFileSync( requiredEnv('MAKA_GIT_WORKSPACE_BINDING_OUTPUT'), 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 2f8378920e..ddf6b2097b 100644 --- a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts +++ b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts @@ -9,7 +9,10 @@ import { } from '@maka/core'; import { createSqliteRuntimeStore } from '../../sqlite-runtime-store.js'; import { acquireOperationalStateDatabase } from '../../operational-state-store.js'; -import { commitWorkspaceBaselineInternal } from '../../workspace-version-authority-internal.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, +} from '../../workspace-version-authority-internal.js'; const mode = requiredEnv('MAKA_SQLITE_RECOVERY_CONCURRENCY_MODE'); const dbPath = requiredEnv('MAKA_SQLITE_RECOVERY_CONCURRENCY_DB'); @@ -42,6 +45,7 @@ try { } else if (mode === 'rebuild') { await store!.rebuildToolProjectionsFromRuntimeEvents(); } else if (mode === 'workspace_baseline_a' || mode === 'workspace_baseline_b') { + bindWorkspaceBaselineAuthorityStoreRootInternal(store!, 'a'.repeat(64)); const result = await commitWorkspaceBaselineInternal( store!, workspaceBaselineInput(mode === 'workspace_baseline_b' ? 'b' : 'a'), diff --git a/packages/storage/src/__tests__/managed-workspace-baseline.test.ts b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts new file mode 100644 index 0000000000..d80e292596 --- /dev/null +++ b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts @@ -0,0 +1,1092 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + cp, + copyFile, + link, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterEach, before, test } from 'node:test'; +import { openManagedWorkspaceOwner } from '../managed-workspace-owner.js'; +import { createGitWorkspaceService } from '../git-workspace-service.js'; +import * as publicStorage from '../index.js'; +import { acquireOperationalStateDatabase } from '../operational-state-store.js'; +import { + adoptStorageRootOnImport, + resolveStorageRoot, + STORAGE_ROOT_MARKER_FILE, + tryAcquireInteractiveRootOwner, +} from '../root-authority.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; + +const execFileAsync = promisify(execFile); +const cleanup: string[] = []; +let gitExecutablePath: string; +let gitExecutableSha256: `sha256:${string}`; + +before(async () => { + gitExecutablePath = await findGitExecutable(); + gitExecutableSha256 = await sha256File(gitExecutablePath); +}); + +test('does not expose baseline receipt issuance on the public Git workspace service', () => { + const service = createGitWorkspaceService({ + storageRoot: join(tmpdir(), 'maka-public-receipt-authority-test'), + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + assert.equal('openManagedWorkspaceBaselineReceipt' in service, false); + assert.equal('requireManagedWorkspaceBaselineReceipt' in service, false); + assert.equal('verifyManagedWorkspaceBaselineReceipt' in service, false); +}); + +test('does not expose artifact-only workspace creation through the public storage API', async () => { + assert.equal('createGitWorkspaceService' in publicStorage, false); + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + assert.equal('createManagedWorkspaceFromSource' in owner, false); + assert.equal('openManagedWorkspaceFromBinding' in owner, false); + await owner.close(); + } finally { + await rootOwner.close(); + } +}); + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +test('opens one canonical baseline from a durable verified Git receipt', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const input = openRequest(sourceRoot); + + const first = await owner.openManagedWorkspaceBaseline(runtimeStore, input); + const retry = await owner.openManagedWorkspaceBaseline(runtimeStore, input); + + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.equal('committedAt' in first.receipt, false); + assert.deepEqual(retry.receipt, first.receipt); + assert.equal(first.head.commitOid, first.binding.baselineCommitOid); + assert.equal(first.head.treeOid, first.binding.baselineTreeOid); + assert.equal(first.receipt.changedFileCount, 2); + assert.equal(first.receipt.deletedFileCount, 0); + assert.equal(first.receipt.policyVersion, 1); + assert.equal( + first.receipt.policyHash, + 'sha256:48eb80c9c8dd6c4d1e7a635dcc187488e0e0b20e2296b81bbaae0be7c5467341', + ); + assert.match(first.receipt.treeDeltaDigest, /^sha256:[a-f0-9]{64}$/u); + assert.equal( + first.receipt.treeDeltaDigest, + 'sha256:84a37411a467c40c9fd763ab128a6b23e56ec74ab3da2bd54de8d61e375244e8', + ); + assert.deepEqual( + await runtimeStore.readWorkspaceHead(input.workspaceId, input.workspaceEpochId), + first.head, + ); + const unrelatedStore = createSqliteRuntimeStore(join(root, 'other-root', 'runtime.sqlite')); + try { + await assert.rejects( + owner.openManagedWorkspaceBaseline(unrelatedStore, input), + /belongs to a different storage root/u, + ); + } finally { + unrelatedStore.close(); + } + + const receiptPath = join(dirname(first.binding.worktreePath), 'baseline-receipt.json'); + await writeFile( + receiptPath, + `${JSON.stringify({ + ...first.receipt, + treeDeltaDigest: `sha256:${'f'.repeat(64)}`, + })}\n`, + 'utf8', + ); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /does not match its verified Git boundary/u, + ); + await writeFile(receiptPath, `${JSON.stringify(first.receipt)}\n`, 'utf8'); + + await writeFile( + receiptPath, + `${JSON.stringify({ + ...first.receipt, + workspaceVersionId: `version_${'a'.repeat(32)}`, + epochOpenedEventId: `workspace_epoch_opened_${'b'.repeat(64)}`, + baselineAcceptedEventId: `workspace_baseline_accepted_${'c'.repeat(64)}`, + policyHash: `sha256:${'d'.repeat(64)}`, + })}\n`, + 'utf8', + ); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /does not match its verified Git boundary/u, + ); + await writeFile(receiptPath, `${JSON.stringify(first.receipt)}\n`, 'utf8'); + + await writeFile( + receiptPath, + `${JSON.stringify({ ...first.receipt, committedAt: Date.now() })}\n`, + 'utf8', + ); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /Invalid managed workspace baseline receipt/u, + ); + await writeFile(receiptPath, `${JSON.stringify(first.receipt)}\n`, 'utf8'); + + const bindingPath = join(dirname(first.binding.worktreePath), 'binding.json'); + await rm(receiptPath); + await rm(bindingPath); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /baseline receipt is unavailable/u, + ); + await assert.rejects(readFile(bindingPath, 'utf8'), { code: 'ENOENT' }); + assert.deepEqual( + await runtimeStore.readWorkspaceHead(input.workspaceId, input.workspaceEpochId), + first.head, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('reuses an orphan receipt after interruption before SQLite acceptance', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + let interruptOnce = true; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_baseline_receipt' && interruptOnce) { + interruptOnce = false; + throw new Error('simulated interruption after durable baseline receipt'); + } + }, + }); + const input = openRequest(sourceRoot); + + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /simulated interruption/u, + ); + assert.equal( + await runtimeStore.readWorkspaceHead(input.workspaceId, input.workspaceEpochId), + undefined, + ); + + const recovered = await owner.openManagedWorkspaceBaseline(runtimeStore, input); + assert.equal(recovered.created, true); + assert.equal(recovered.receipt.baselineAcceptedEventId, recovered.head.acceptedEventId); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects a receipt read through a replaced instance-root symlink before parsing it', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const instanceRoot = dirname(accepted.binding.worktreePath); + const externalRoot = join(root, 'external-instance'); + await mkdir(externalRoot, { recursive: true }); + const movedInstance = join(externalRoot, 'instance'); + await rename(instanceRoot, movedInstance); + await symlink(movedInstance, instanceRoot, process.platform === 'win32' ? 'junction' : 'dir'); + await writeFile(join(movedInstance, 'baseline-receipt.json'), '{invalid-json', 'utf8'); + + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + /owned directory escaped or changed identity/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('serializes concurrent baseline opens under the same managed workspace owner', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const [left, right] = await Promise.all([ + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + ]); + assert.deepEqual([left.created, right.created].sort(), [false, true]); + assert.deepEqual(left.receipt, right.receipt); + assert.deepEqual(left.head, right.head); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects a leased authority database whose real root differs from its claimed path', async () => { + const root = await temporaryRoot(); + const actualRoot = join(root, 'actual-storage'); + const claimedRoot = join(root, 'claimed-storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: claimedRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const databaseLease = acquireOperationalStateDatabase(actualRoot); + const runtimeStore = createSqliteRuntimeStore(join(claimedRoot, 'runtime.sqlite'), { + databaseLease, + }); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(sourceRoot), + }), + /belongs to a different storage root/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects a non-canonical SQLite file in the authenticated storage root', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'other.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(join(root, 'unused-source')), + }), + /unavailable for this storage root/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects an in-memory SQLite authority store', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(':memory:'); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(join(root, 'unused-source')), + }), + /unavailable for this storage root/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects a cross-root hard link to the canonical SQLite authority database', async () => { + const root = await temporaryRoot(); + const sourceStorageRoot = join(root, 'source-storage'); + const claimedStorageRoot = join(root, 'claimed-storage'); + await mkdir(claimedStorageRoot, { recursive: true }); + const sourceDatabasePath = join(sourceStorageRoot, 'runtime.sqlite'); + const sourceStore = createSqliteRuntimeStore(sourceDatabasePath); + sourceStore.close(); + await link(sourceDatabasePath, join(claimedStorageRoot, 'runtime.sqlite')); + + const capability = await resolveStorageRoot({ path: claimedStorageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(claimedStorageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(join(root, 'unused-source')), + }), + /unavailable for this storage root/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects a canonical SQLite database copied from a different durable storage root', async () => { + const root = await temporaryRoot(); + const sourceStorageRoot = join(root, 'storage-a'); + const claimedStorageRoot = join(root, 'storage-b'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const request = openRequest(sourceRoot); + const sourceCapability = await resolveStorageRoot({ + path: sourceStorageRoot, + kind: 'interactive', + }); + const sourceRootOwner = await tryAcquireInteractiveRootOwner(sourceCapability); + assert.ok(sourceRootOwner); + const sourceStore = createSqliteRuntimeStore(join(sourceStorageRoot, 'runtime.sqlite')); + try { + const sourceOwner = await openManagedWorkspaceOwner({ + rootOwner: sourceRootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await sourceOwner.openManagedWorkspaceBaseline(sourceStore, request); + await sourceOwner.close(); + } finally { + sourceStore.close(); + await sourceRootOwner.close(); + } + + const claimedCapability = await resolveStorageRoot({ + path: claimedStorageRoot, + kind: 'interactive', + }); + await copyFile( + join(sourceStorageRoot, 'runtime.sqlite'), + join(claimedStorageRoot, 'runtime.sqlite'), + ); + const claimedRootOwner = await tryAcquireInteractiveRootOwner(claimedCapability); + assert.ok(claimedRootOwner); + const claimedStore = createSqliteRuntimeStore(join(claimedStorageRoot, 'runtime.sqlite')); + try { + const claimedOwner = await openManagedWorkspaceOwner({ + rootOwner: claimedRootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + claimedOwner.openManagedWorkspaceBaseline(claimedStore, request), + /different durable storage root/u, + ); + await claimedOwner.close(); + } finally { + claimedStore.close(); + await claimedRootOwner.close(); + } +}); + +test('preserves the durable database root binding across formal whole-root import', { + skip: process.platform === 'win32', +}, async () => { + const root = await temporaryRoot(); + const sourceStorageRoot = join(root, 'storage-a'); + const importedStorageRoot = join(root, 'storage-imported'); + const sourceCapability = await resolveStorageRoot({ + path: sourceStorageRoot, + kind: 'interactive', + }); + const sourceRootOwner = await tryAcquireInteractiveRootOwner(sourceCapability); + assert.ok(sourceRootOwner); + const sourceStore = createSqliteRuntimeStore(join(sourceStorageRoot, 'runtime.sqlite')); + try { + const sourceOwner = await openManagedWorkspaceOwner({ + rootOwner: sourceRootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + sourceOwner.openManagedWorkspaceBaseline( + sourceStore, + openRequest(join(root, 'missing-source')), + ), + /Directory is unavailable/u, + ); + await sourceOwner.close(); + } finally { + sourceStore.close(); + await sourceRootOwner.close(); + } + + await cp(sourceStorageRoot, importedStorageRoot, { recursive: true }); + await rm(sourceStorageRoot, { recursive: true, force: true }); + const importedCapability = await adoptStorageRootOnImport({ + path: importedStorageRoot, + kind: 'interactive', + expectedRootId: sourceCapability.rootId, + }); + const importedRootOwner = await tryAcquireInteractiveRootOwner(importedCapability); + assert.ok(importedRootOwner); + const importedStore = createSqliteRuntimeStore(join(importedStorageRoot, 'runtime.sqlite')); + try { + const importedOwner = await openManagedWorkspaceOwner({ + rootOwner: importedRootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + importedOwner.openManagedWorkspaceBaseline( + importedStore, + openRequest(join(root, 'missing-source')), + ), + /Directory is unavailable/u, + ); + await importedOwner.close(); + } finally { + importedStore.close(); + await importedRootOwner.close(); + } +}); + +test('rejects an authenticated owner after its durable root marker id is replaced', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const markerPath = join(storageRoot, STORAGE_ROOT_MARKER_FILE); + const originalMarker = await readFile(markerPath, 'utf8'); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const marker = JSON.parse(originalMarker) as { rootId: string }; + marker.rootId = `${marker.rootId.startsWith('0') ? '1' : '0'}${marker.rootId.slice(1)}`; + await writeFile(markerPath, `${JSON.stringify(marker)}\n`, 'utf8'); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(join(root, 'missing-source'))), + /storage root|root identity|marker/u, + ); + await writeFile(markerPath, originalMarker, 'utf8'); + await owner.close(); + } finally { + await writeFile(markerPath, originalMarker, 'utf8'); + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects final admission when the root marker changes after post-commit artifact verification', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const request = openRequest(sourceRoot); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const markerPath = join(storageRoot, STORAGE_ROOT_MARKER_FILE); + const originalMarker = await readFile(markerPath, 'utf8'); + let replaceOnce = true; + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + async failpoint(point) { + if ((point as string) !== 'after_post_commit_artifact_verification' || !replaceOnce) return; + replaceOnce = false; + const marker = JSON.parse(originalMarker) as { rootId: string }; + marker.rootId = `${marker.rootId.startsWith('0') ? '1' : '0'}${marker.rootId.slice(1)}`; + await writeFile(markerPath, `${JSON.stringify(marker)}\n`, 'utf8'); + }, + }); + try { + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, request), + /storage root|root identity|marker/u, + ); + assert.ok(await runtimeStore.readWorkspaceHead(request.workspaceId, request.workspaceEpochId)); + } finally { + await writeFile(markerPath, originalMarker, 'utf8'); + await owner.close(); + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects an authority database whose file identity changes after registration', { + skip: process.platform === 'win32', +}, async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const databasePath = join(storageRoot, 'runtime.sqlite'); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(databasePath); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await rename(databasePath, `${databasePath}.replaced`); + await writeFile(databasePath, 'replacement database identity\n', 'utf8'); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(join(root, 'unused-source'))), + /database file identity changed/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('does not return an accepted baseline when runtime.sqlite is replaced after the initial root check', { + skip: process.platform === 'win32', +}, async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const databasePath = join(storageRoot, 'runtime.sqlite'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const request = openRequest(sourceRoot); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(databasePath); + let replaceOnce = true; + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + async failpoint(point) { + if ((point as string) !== 'after_initial_store_root_validation' || !replaceOnce) return; + replaceOnce = false; + await rename(databasePath, `${databasePath}.detached`); + await writeFile(databasePath, 'replacement database identity\n', 'utf8'); + }, + }); + try { + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, request), + /database file identity changed/u, + ); + } finally { + await owner.close(); + runtimeStore.close(); + await rootOwner.close(); + } + + await Promise.all([ + rm(databasePath, { force: true }), + rm(`${databasePath}-wal`, { force: true }), + rm(`${databasePath}-shm`, { force: true }), + ]); + // SQLite may keep the accepted transaction in a WAL that remains associated + // with the original pathname, so the detached main file is not a portable + // authority read after this adversarial rename. The safety invariant is that + // the replacement canonical database never receives that head and no usable + // baseline was returned above. + const replacementStore = createSqliteRuntimeStore(databasePath); + try { + assert.equal( + await replacementStore.readWorkspaceHead(request.workspaceId, request.workspaceEpochId), + undefined, + ); + } finally { + replacementStore.close(); + } +}); + +test('rejects runtime.sqlite when the canonical database path is a symlink', async (context) => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const externalRoot = join(root, 'external'); + await mkdir(storageRoot, { recursive: true }); + const externalPath = join(externalRoot, 'runtime.sqlite'); + createSqliteRuntimeStore(externalPath).close(); + try { + await symlink(externalPath, join(storageRoot, 'runtime.sqlite'), 'file'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) { + context.skip('File symlinks require Windows developer mode or elevated privileges'); + return; + } + throw error; + } + + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(join(root, 'unused-source')), + }), + /unavailable for this storage root/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('keeps an orphan receipt across SQLite rollback and accepts it on retry', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + let interruptOnce = true; + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite'), { + failpoint(point) { + if (point === 'after_workspace_version_event_insert' && interruptOnce) { + interruptOnce = false; + throw new Error('simulated SQLite baseline rollback'); + } + }, + }); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const input = openRequest(sourceRoot); + + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, input), + /simulated SQLite baseline rollback/u, + ); + assert.equal( + await runtimeStore.readWorkspaceHead(input.workspaceId, input.workspaceEpochId), + undefined, + ); + + const recovered = await owner.openManagedWorkspaceBaseline(runtimeStore, input); + assert.equal(recovered.created, true); + assert.equal(recovered.receipt.workspaceVersionId, recovered.head.workspaceVersionId); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('accepts the same durable receipt after a real process crash before SQLite authority', { + timeout: 60_000, +}, async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./fixtures/git-workspace-service-crash-child.js', import.meta.url))], + { + env: { + ...process.env, + MAKA_GIT_WORKSPACE_STORAGE: storageRoot, + MAKA_GIT_WORKSPACE_SOURCE: sourceRoot, + MAKA_GIT_WORKSPACE_EXECUTABLE: gitExecutablePath, + MAKA_GIT_WORKSPACE_SHA256: gitExecutableSha256, + MAKA_GIT_WORKSPACE_FAILPOINT: 'after_baseline_receipt', + MAKA_GIT_WORKSPACE_ACTION: 'baseline-receipt', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + try { + await waitForReady(child, 30_000); + const durableBeforeCrash = JSON.parse( + await readFile(baselineReceiptPath(storageRoot, openRequest(sourceRoot)), 'utf8'), + ); + child.kill('SIGKILL'); + await waitForExit(child); + + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline(runtimeStore, { + ...openRequest(sourceRoot), + }); + assert.equal(accepted.created, true); + assert.equal(accepted.receipt.baselineAcceptedEventId, accepted.head.acceptedEventId); + assert.deepEqual(accepted.receipt, durableBeforeCrash); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +test('reopens the exact accepted baseline after a real crash before post-commit verification', { + timeout: 60_000, +}, async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./fixtures/git-workspace-service-crash-child.js', import.meta.url))], + { + env: { + ...process.env, + MAKA_GIT_WORKSPACE_STORAGE: storageRoot, + MAKA_GIT_WORKSPACE_SOURCE: sourceRoot, + MAKA_GIT_WORKSPACE_EXECUTABLE: gitExecutablePath, + MAKA_GIT_WORKSPACE_SHA256: gitExecutableSha256, + MAKA_GIT_WORKSPACE_FAILPOINT: 'after_baseline_authority_commit', + MAKA_GIT_WORKSPACE_ACTION: 'baseline-receipt', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + try { + await waitForReady(child, 30_000); + const durableBeforeCrash = JSON.parse( + await readFile(baselineReceiptPath(storageRoot, openRequest(sourceRoot)), 'utf8'), + ); + child.kill('SIGKILL'); + await waitForExit(child); + + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const reopened = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + assert.equal(reopened.created, false); + assert.deepEqual(reopened.receipt, durableBeforeCrash); + assert.equal(reopened.head.acceptedEventId, reopened.receipt.baselineAcceptedEventId); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +test('rejects a source tree containing a non-UTF-8 Git path', { + skip: process.platform === 'win32', +}, async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const invalidPath = Buffer.concat([Buffer.from(`${sourceRoot}/`, 'utf8'), Buffer.from([0xff])]); + await writeFile(invalidPath, 'invalid path bytes\n'); + await git(sourceRoot, 'add', '-A'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'add invalid path', + ); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + /requires Git paths to be valid UTF-8/u, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-workspace-baseline-')); + cleanup.push(root); + return root; +} + +async function findGitExecutable(): Promise { + const command = process.platform === 'win32' ? 'where.exe' : 'which'; + const { stdout } = await execFileAsync(command, ['git'], { encoding: 'utf8' }); + const first = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(Boolean); + if (!first) throw new Error('Git executable is unavailable for integration tests'); + return realpath(first); +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} + +async function createEligibleSource(sourceRoot: string): Promise { + await mkdir(sourceRoot, { recursive: true }); + await git(sourceRoot, 'init', '--quiet'); + await writeFile(join(sourceRoot, 'tracked.txt'), 'tracked\n', 'utf8'); + await writeFile(join(sourceRoot, '.gitignore'), '.maka-workspace.json\n', 'utf8'); + await writeFile(join(sourceRoot, '.maka-workspace.json'), '{"host":"metadata"}\n', 'utf8'); + await git(sourceRoot, 'add', 'tracked.txt', '.gitignore'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'source baseline', + ); + return realpath(sourceRoot); +} + +function openRequest(sourceRoot: string) { + return { + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + sourceRoot, + } as const; +} + +function baselineReceiptPath( + storageRoot: string, + identity: ReturnType, +): string { + const compact = (value: string) => createHash('sha256').update(value).digest('hex').slice(0, 20); + return join( + storageRoot, + 'managed-workspaces', + 'w', + compact(identity.workspaceId), + 'e', + compact(identity.workspaceEpochId), + 'i', + compact(identity.workspaceInstanceId), + 'baseline-receipt.json', + ); +} + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + }); + return stdout.trim(); +} + +function waitForReady(child: ReturnType, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Managed baseline crash child did not become ready: ${stderr}`)); + }, timeoutMs); + const onStdout = (chunk: unknown) => { + stdout += String(chunk); + if (stdout.includes('READY\n')) { + cleanup(); + resolve(); + } + }; + const onStderr = (chunk: unknown) => { + stderr += String(chunk); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`Managed baseline crash child exited early: ${code}/${signal} ${stderr}`)); + }; + const cleanup = () => { + clearTimeout(timeout); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + }; + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.once('exit', onExit); + }); +} + +function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve, reject) => { + child.once('exit', () => resolve()); + child.once('error', reject); + }); +} diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts new file mode 100644 index 0000000000..dd7bad25da --- /dev/null +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream, existsSync } from 'node:fs'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { promisify } from 'node:util'; +import { afterEach, before, test } from 'node:test'; +import { + ManagedWorkspaceOwnerError, + openManagedWorkspaceOwner, +} from '../managed-workspace-owner.js'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; + +const execFileAsync = promisify(execFile); +const cleanup: string[] = []; +let gitExecutablePath: string; +let gitExecutableSha256: `sha256:${string}`; + +before(async () => { + gitExecutablePath = await findGitExecutable(); + gitExecutableSha256 = await sha256File(gitExecutablePath); +}); + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +test('admits exactly one managed workspace owner for an interactive root owner', async () => { + const storageRoot = await temporaryRoot(); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + try { + const managedOwner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + + await assert.rejects( + openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }), + isOwnerError('managed_workspace_owner_conflict'), + ); + await managedOwner.close(); + await managedOwner.close(); + } finally { + await rootOwner.close(); + } +}); + +test('releases a partial owner claim when pinned Git initialization fails', async () => { + const storageRoot = await temporaryRoot(); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + try { + await assert.rejects( + openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: `sha256:${'0'.repeat(64)}`, + }, + }), + isOwnerError('managed_workspace_owner_unavailable'), + ); + + const retry = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + assert.equal(retry.state, 'ready'); + await retry.close(); + } finally { + await rootOwner.close(); + } +}); + +test('creates an accepted managed baseline only through the active owner', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + + const { binding } = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + + assert.equal(binding.sourceTreeOid, binding.baselineTreeOid); + assert.equal(existsSync(join(binding.worktreePath, '.maka-workspace.json')), false); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('drains an admitted workspace operation before closing and rejects new work', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + let releaseOperation!: () => void; + const operationMayFinish = new Promise((resolve) => { + releaseOperation = resolve; + }); + let operationAdmitted!: () => void; + const operationReachedFailpoint = new Promise((resolve) => { + operationAdmitted = resolve; + }); + let creating: + | ReturnType< + Awaited>['openManagedWorkspaceBaseline'] + > + | undefined; + let closing: Promise | undefined; + let rootClosing: Promise | undefined; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint: async (point) => { + if (point !== 'after_worktree_materialized') return; + operationAdmitted(); + await operationMayFinish; + }, + }); + creating = owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)); + await operationReachedFailpoint; + + let closeSettled = false; + closing = owner.close().then(() => { + closeSettled = true; + }); + let rootCloseSettled = false; + rootClosing = rootOwner.close().then(() => { + rootCloseSettled = true; + }); + const rootCloseDisposition = await Promise.race([ + rootClosing.then(() => 'closed' as const), + delay(250, 'pending' as const), + ]); + + assert.equal(owner.state, 'closing'); + assert.equal(closeSettled, false); + assert.equal(rootCloseSettled, false); + assert.equal(rootCloseDisposition, 'pending'); + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + isOwnerError('managed_workspace_owner_closing'), + ); + + releaseOperation(); + await creating; + await closing; + await rootClosing; + assert.equal(owner.state, 'closed'); + } finally { + releaseOperation(); + await Promise.allSettled( + [creating, closing, rootClosing].filter((value) => value !== undefined), + ); + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('rejects external drift instead of reopening a non-ready workspace', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + }); + const { binding } = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external drift\n', 'utf8'); + + await assert.rejects( + owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), + (error: unknown) => + error instanceof Error && + error.name === 'GitWorkspaceServiceError' && + 'code' in error && + error.code === 'managed_workspace_drifted', + ); + + assert.equal(existsSync(binding.worktreePath), true); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +function isOwnerError(code: string): (error: unknown) => boolean { + return (error) => error instanceof ManagedWorkspaceOwnerError && error.code === code; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-workspace-owner-')); + cleanup.push(root); + return root; +} + +async function findGitExecutable(): Promise { + const command = process.platform === 'win32' ? 'where.exe' : 'which'; + const { stdout } = await execFileAsync(command, ['git'], { encoding: 'utf8' }); + const first = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(Boolean); + if (!first) throw new Error('Git executable is unavailable for integration tests'); + return realpath(first); +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} + +async function createEligibleSource(sourceRoot: string): Promise { + await mkdir(sourceRoot, { recursive: true }); + await git(sourceRoot, 'init', '--quiet'); + await writeFile(join(sourceRoot, 'tracked.txt'), 'tracked\n', 'utf8'); + await writeFile(join(sourceRoot, '.gitignore'), '.maka-workspace.json\n', 'utf8'); + await writeFile(join(sourceRoot, '.maka-workspace.json'), '{"host":"metadata"}\n', 'utf8'); + await git(sourceRoot, 'add', 'tracked.txt', '.gitignore'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'source baseline', + ); + return realpath(sourceRoot); +} + +function openRequest(sourceRoot: string) { + return { + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + sourceRoot, + } as const; +} + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + }); + return stdout.trim(); +} diff --git a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts index 1f86dcf940..ae6fc159bc 100644 --- a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts +++ b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts @@ -91,7 +91,7 @@ describe('SQLite recovery persistence authority', () => { dispatch.ts, ); db.exec( - 'DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); @@ -187,7 +187,7 @@ describe('SQLite recovery persistence authority', () => { 2, ); db.exec( - 'DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; 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 596d9f470d..dbbe9ad91a 100644 --- a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts +++ b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts @@ -12,6 +12,7 @@ import { SQLITE_RUNTIME_SCHEMA_VERSION, createSqliteRuntimeStore, } from '../sqlite-runtime-store.js'; +import { bindWorkspaceBaselineAuthorityStoreRootInternal } from '../workspace-version-authority-internal.js'; const WORKER_READY_TIMEOUT_MS = 15_000; const WORKER_EXECUTION_TIMEOUT_MS = 30_000; @@ -254,7 +255,7 @@ describe('SQLite recovery authority multi-process races', () => { const db = new DatabaseSync(dbPath); try { db.exec( - "DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; DELETE FROM runtime_capabilities WHERE capability = 'runtime_workspace_version_authority'; PRAGMA user_version = 6;", + "DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE headless_task_run_events; DELETE FROM runtime_capabilities WHERE capability = 'runtime_workspace_version_authority'; PRAGMA user_version = 6;", ); } finally { db.close(); @@ -295,6 +296,7 @@ async function withPreparedDatabase( const startPath = join(root, 'start'); const store = createSqliteRuntimeStore(dbPath); try { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); await store.commitToolPrepared(preparedCommit()); await store.appendRuntimeEvent('session-1', 'continuation-source-run', { id: 'continuation-source-user', diff --git a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts index 252e3e3ed0..542b98e0d0 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -15,7 +15,10 @@ import { createSqliteRuntimeStore, type SqliteRuntimeStoreFailpoint, } from '../sqlite-runtime-store.js'; -import { commitWorkspaceBaselineInternal } from '../workspace-version-authority-internal.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, +} from '../workspace-version-authority-internal.js'; const CRASH_READ_ARGS_HASH = canonicalToolArgsHash('Read', { path: '/workspace/README.md', @@ -220,6 +223,7 @@ async function runCrashChild(mode: string): Promise { }; const store = createSqliteRuntimeStore(dbPath, { failpoint }); if (mode === 'inside_workspace_baseline' || mode === 'after_workspace_baseline_commit') { + bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'a'.repeat(64)); await commitWorkspaceBaselineInternal(store, workspaceBaselineInput()); if (mode === 'after_workspace_baseline_commit') blockUntilKilled(); throw new Error(`Workspace baseline crash mode ${mode} missed its failpoint`); diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index 11079d2740..e998b1bff3 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -13,7 +13,7 @@ describe('SQLite runtime schema migration', () => { return { get() { versionReads += 1; - return { user_version: versionReads === 1 ? 4 : 8 }; + return { user_version: versionReads === 1 ? 4 : 9 }; }, }; }, diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index f4880a21ad..1a908bc286 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -48,6 +48,7 @@ describe('SqliteRuntimeStore', () => { const legacy = new DatabaseSync(dbPath); legacy.exec(` + DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; @@ -80,6 +81,57 @@ describe('SqliteRuntimeStore', () => { }); }); + it('upgrades a populated mainline schema 8 database without losing headless task events', async () => { + await withStore(async (store, dbPath) => { + store.close(); + + const mainline = new DatabaseSync(dbPath); + mainline + .prepare(` + INSERT INTO headless_task_run_events(task_run_id, sequence, event_id, record_json) + VALUES (?, ?, ?, ?) + `) + .run('task-run-1', 0, 'headless-event-1', '{"kind":"started"}'); + mainline.close(); + + const upgraded = createSqliteRuntimeStore(dbPath); + try { + assert.equal(upgraded.schemaVersion(), 9); + const inspect = new DatabaseSync(dbPath); + try { + assert.deepEqual( + inspect + .prepare(` + SELECT task_run_id, sequence, event_id, record_json + FROM headless_task_run_events + `) + .all() + .map((row) => ({ ...row })), + [ + { + task_run_id: 'task-run-1', + sequence: 0, + event_id: 'headless-event-1', + record_json: '{"kind":"started"}', + }, + ], + ); + assert.deepEqual( + inspect + .prepare('PRAGMA table_info(runtime_storage_root_binding)') + .all() + .map((row) => (row as { name: string }).name), + ['singleton', 'root_id', 'protocol_version'], + ); + } finally { + inspect.close(); + } + } finally { + upgraded.close(); + } + }); + }); + it('makes a raw canonical-equivalent terminal durability retry idempotent', async () => { await withStore(async (store) => { const terminal: RuntimeEvent = { 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 6aaef2424e..ad3d32cc22 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -17,7 +17,12 @@ import { createSqliteRuntimeStore, type SqliteRuntimeStoreFailpoint, } from '../sqlite-runtime-store.js'; -import { commitWorkspaceBaselineInternal } from '../workspace-version-authority-internal.js'; +import { + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, +} from '../workspace-version-authority-internal.js'; + +const TEST_STORAGE_ROOT_ID = 'a'.repeat(64); describe('workspace version persistence authority', () => { it('does not expose the unverified baseline writer on the public SQLite store', () => { @@ -49,6 +54,7 @@ describe('workspace version persistence authority', () => { const dbPath = join(root, 'runtime.sqlite'); const input = baselineInput(); const writer = createSqliteRuntimeStore(dbPath); + bindWorkspaceBaselineAuthorityStoreRootInternal(writer, TEST_STORAGE_ROOT_ID); let writePromise: ReturnType | undefined; let injected = false; const reader = createSqliteRuntimeStore(dbPath, { @@ -130,6 +136,53 @@ describe('workspace version persistence authority', () => { }); }); + it('refuses to silently claim unbound workspace authority facts for another root', async () => { + await withDatabase(async ({ dbPath, store }) => { + await commitWorkspaceBaselineInternal(store, baselineInput()); + const raw = new DatabaseSync(dbPath); + try { + raw.exec('DELETE FROM runtime_storage_root_binding'); + } finally { + raw.close(); + } + await assert.rejects( + commitWorkspaceBaselineInternal(store, baselineInput()), + /durable storage-root binding changed/u, + ); + assert.throws( + () => bindWorkspaceBaselineAuthorityStoreRootInternal(store, 'b'.repeat(64)), + /require explicit storage-root adoption/u, + ); + }); + }); + + it('refuses to silently claim an unbound database with ordinary runtime state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-unbound-operational-state-')); + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + const event: RuntimeEvent = { + id: 'ordinary-existing-runtime-event', + sessionId: 'session-existing', + invocationId: 'invocation-existing', + runId: 'run-existing', + turnId: 'turn-existing', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'existing root-owned state' }, + }; + await store.appendRuntimeEvent(event.sessionId, event.runId, event); + assert.throws( + () => bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID), + /unbound operational data require explicit storage-root adoption/iu, + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + for (const failpoint of [ 'after_workspace_epoch_event_insert', 'after_workspace_version_event_insert', @@ -414,6 +467,7 @@ async function withDatabase( if (point === activeFailpoint) throw new Error(`failpoint:${point}`); }, }); + bindWorkspaceBaselineAuthorityStoreRootInternal(store, TEST_STORAGE_ROOT_ID); try { await run({ root, diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 469884d4fa..6fc61c169a 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -14,6 +14,7 @@ import { import { dirname, isAbsolute, join, normalize, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; import { withArtifactWriterLock } from './artifact-writer-lock.js'; +import { registerManagedBaselineReceiptAuthorityInternal } from './managed-baseline-receipt-authority-internal.js'; const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 2 * 60 * 1_000; @@ -22,6 +23,7 @@ const BINDING_SCHEMA_VERSION = 1; const REPOSITORY_SCHEMA_VERSION = 1; const EPOCH_ARTIFACT_SCHEMA_VERSION = 1; const QUARANTINE_INTENT_SCHEMA_VERSION = 1; +const BASELINE_RECEIPT_SCHEMA_VERSION = 1; const IDENTIFIER_PATTERN = /^(repository|workspace|epoch|instance)_[a-f0-9]{32}$/u; const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u; const OID_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u; @@ -84,7 +86,36 @@ const QUARANTINE_INTENT_KEYS = [ 'binding', ] as const; const QUARANTINE_RECORD_KEYS = ['protocol', 'reason', 'binding'] as const; +const BASELINE_RECEIPT_KEYS = [ + 'schemaVersion', + 'protocol', + 'binding', + 'workspaceVersionId', + 'policyVersion', + 'policyHash', + 'epochOpenedEventId', + 'baselineAcceptedEventId', + 'treeDeltaDigest', + 'changedFileCount', + 'deletedFileCount', +] as const; const MATERIALIZATION_SEMANTICS = 'git_tree_materialized_with_fixed_config_v1'; +const MANAGED_BASELINE_POLICY_V1 = { + protocol: 'maka_managed_workspace_baseline_policy_v1', + source: 'source_head_tree', + sourceWorkingTree: 'clean_tracked_and_untracked_except_ignored', + trackedFiles: 'include', + untrackedFiles: 'exclude', + ignoredFiles: 'exclude', + pathEncoding: 'utf8_lossless_roundtrip_required', + caseCollisions: 'reject_nfc_casefold_v1', + symlinks: 'reject', + submodules: 'reject', + attributes: 'reject', + specialModes: 'reject', + materialization: MATERIALIZATION_SEMANTICS, +} as const; +const MANAGED_BASELINE_POLICY_HASH_V1 = hashCanonicalJson(MANAGED_BASELINE_POLICY_V1); const BASELINE_MESSAGE = 'maka managed workspace baseline v1\n'; const BASELINE_DATE = '2000-01-01T00:00:00Z'; @@ -130,7 +161,8 @@ export type GitWorkspaceServiceFailpoint = | 'after_quarantine_unlock' | 'after_quarantine_move' | 'after_quarantine_binding_removed' - | 'after_quarantine_pruned'; + | 'after_quarantine_pruned' + | 'after_baseline_receipt'; export interface ManagedWorkspaceIdentity { /** @@ -166,7 +198,7 @@ export interface ManagedWorkspaceBinding { readonly baselineTreeOid: string; readonly headRef: string; readonly gitRuntimeSha256: `sha256:${string}`; - readonly objectFormat: string; + readonly objectFormat: 'sha1' | 'sha256'; readonly materializationProfileDigest: `sha256:${string}`; readonly materializationSemantics: typeof MATERIALIZATION_SEMANTICS; } @@ -189,7 +221,22 @@ export interface ManagedWorkspaceQuarantine { readonly reason: string; } +export interface ManagedWorkspaceBaselineReceiptV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_managed_workspace_baseline_receipt_v1'; + readonly binding: ManagedWorkspaceBinding; + readonly workspaceVersionId: string; + readonly policyVersion: 1; + readonly policyHash: `sha256:${string}`; + readonly epochOpenedEventId: string; + readonly baselineAcceptedEventId: string; + readonly treeDeltaDigest: `sha256:${string}`; + readonly changedFileCount: number; + readonly deletedFileCount: 0; +} + export interface GitWorkspaceService { + assertAvailable(): Promise; createManagedWorkspaceFromSource( input: CreateManagedWorkspaceFromSourceInput, ): Promise; @@ -214,12 +261,20 @@ interface SourceRepositoryInspection { readonly gitCommonDir: string; readonly headCommitOid: string; readonly treeOid: string; - readonly objectFormat: string; + readonly objectFormat: 'sha1' | 'sha256'; } interface GitTreeEntry { readonly mode: string; + readonly objectType: string; + readonly oid: string; readonly path: string; + readonly pathBytesBase64: string; +} + +interface BaselineTreeSummary { + readonly treeDeltaDigest: `sha256:${string}`; + readonly changedFileCount: number; } interface ManagedRepositoryRecord { @@ -229,7 +284,7 @@ interface ManagedRepositoryRecord { readonly repositoryPath: string; readonly hooksPath: string; readonly gitRuntimeSha256: `sha256:${string}`; - readonly objectFormat: string; + readonly objectFormat: 'sha1' | 'sha256'; readonly repositoryCapabilityDigest: `sha256:${string}`; } @@ -248,7 +303,7 @@ interface ManagedWorkspaceEpochArtifact { readonly baselineRef: string; readonly headRef: string; readonly gitRuntimeSha256: `sha256:${string}`; - readonly objectFormat: string; + readonly objectFormat: 'sha1' | 'sha256'; readonly materializationProfileDigest: `sha256:${string}`; readonly materializationSemantics: typeof MATERIALIZATION_SEMANTICS; } @@ -278,6 +333,7 @@ interface WorkspaceLayout { readonly epochArtifactPath: string; readonly instanceRoot: string; readonly bindingPath: string; + readonly baselineReceiptPath: string; readonly worktreePath: string; readonly quarantineRoot: string; readonly quarantineIntentRoot: string; @@ -295,6 +351,16 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } this.runtime = new VerifiedGitRuntime(input.gitRuntime); + registerManagedBaselineReceiptAuthorityInternal(this, { + issue: (binding) => this.#createOrReuseManagedWorkspaceBaselineReceipt(binding), + require: (request) => this.#requireManagedWorkspaceBaselineReceipt(request), + verify: (receipt) => this.#verifyManagedWorkspaceBaselineReceipt(receipt), + }); + } + + async assertAvailable(): Promise { + await this.runtime.verify(); + await withArtifactWriterLock(this.input.storageRoot, async () => undefined); } async createManagedWorkspaceFromSource( @@ -487,6 +553,98 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + async #requireManagedWorkspaceBaselineReceipt( + input: CreateManagedWorkspaceFromSourceInput, + ): Promise { + const runtime = await this.runtime.verify(); + assertOpenIdentity(input); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, input); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const receipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!receipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + assertBindingMatches(receipt.binding, input, layout, runtime.digest); + const summary = await this.requireVerifiedBaselineContext( + receipt.binding, + layout, + runtime.digest, + ); + assertBaselineReceiptMatches(receipt, receipt.binding, summary); + return receipt; + }); + } + + async #createOrReuseManagedWorkspaceBaselineReceipt( + binding: ManagedWorkspaceBinding, + ): Promise { + const runtime = await this.runtime.verify(); + assertBindingShape(binding); + assertOpenIdentity(binding); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + const summary = await this.requireVerifiedBaselineContext(binding, layout, runtime.digest); + const existing = await readBaselineReceipt(layout.baselineReceiptPath); + if (existing) { + assertBaselineReceiptMatches(existing, binding, summary); + return existing; + } + const identities = deriveBaselineReceiptIdentities(binding); + const receipt: ManagedWorkspaceBaselineReceiptV1 = { + schemaVersion: BASELINE_RECEIPT_SCHEMA_VERSION, + protocol: 'maka_managed_workspace_baseline_receipt_v1', + binding, + workspaceVersionId: identities.workspaceVersionId, + policyVersion: 1, + policyHash: MANAGED_BASELINE_POLICY_HASH_V1, + epochOpenedEventId: identities.epochOpenedEventId, + baselineAcceptedEventId: identities.baselineAcceptedEventId, + treeDeltaDigest: summary.treeDeltaDigest, + changedFileCount: summary.changedFileCount, + deletedFileCount: 0, + }; + await atomicWriteJson(layout.baselineReceiptPath, receipt); + await this.input.failpoint?.('after_baseline_receipt'); + const durable = await readBaselineReceipt(layout.baselineReceiptPath); + if (!durable) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Managed workspace baseline receipt was not durable', + ); + } + assertBaselineReceiptMatches(durable, binding, summary); + return durable; + }); + } + + async #verifyManagedWorkspaceBaselineReceipt( + receipt: ManagedWorkspaceBaselineReceiptV1, + ): Promise { + const runtime = await this.runtime.verify(); + assertBaselineReceiptShape(receipt); + assertOpenIdentity(receipt.binding); + await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, receipt.binding); + const summary = await this.requireVerifiedBaselineContext( + receipt.binding, + layout, + runtime.digest, + ); + const durable = await readBaselineReceipt(layout.baselineReceiptPath); + if (!durable || !sameBaselineReceipt(durable, receipt)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed workspace baseline receipt does not match its durable artifact', + ); + } + assertBaselineReceiptMatches(durable, receipt.binding, summary); + }); + } + async quarantineManagedWorkspace( binding: ManagedWorkspaceBinding, reason: string, @@ -532,6 +690,54 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + private async requireVerifiedBaselineContext( + binding: ManagedWorkspaceBinding, + layout: WorkspaceLayout, + runtimeDigest: `sha256:${string}`, + ): Promise { + assertBindingPaths(binding, layout); + const quarantined = await this.resumePendingQuarantine(binding, layout, runtimeDigest); + if (quarantined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace instance was quarantined: ${binding.workspaceInstanceId}`, + ); + } + const stored = await readBinding(layout.bindingPath); + if (!stored || !sameBinding(stored, binding)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed workspace binding is unavailable: ${binding.workspaceInstanceId}`, + ); + } + const repository = await this.requireRepository(binding, layout); + assertBindingRepository(binding, repository); + const epoch = await this.requireEpochArtifact(binding, repository, layout); + assertBindingEpoch(binding, epoch); + const inspection = await this.inspectBinding(binding, layout); + if (inspection.state !== 'ready') { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + `Managed workspace contains unaccepted changes: ${binding.worktreePath}`, + ); + } + return this.readBaselineTreeSummary(binding, layout); + } + + private async readBaselineTreeSummary( + binding: ManagedWorkspaceBinding, + layout: WorkspaceLayout, + ): Promise { + const entries = parseTreeEntries( + await this.runtime.runBuffer( + ['--git-dir', binding.repositoryPath, 'ls-tree', '-r', '-z', binding.baselineCommitOid], + layout.homePath, + ), + ); + assertSupportedTree(entries); + return baselineTreeSummary(entries); + } + private async inspectSourceRepository(sourceRoot: string): Promise { try { const topLevelRaw = await this.runtime.run([ @@ -572,7 +778,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } const entries = parseTreeEntries( - await this.runtime.run(['-C', sourceRoot, 'ls-tree', '-r', '-z', 'HEAD']), + await this.runtime.runBuffer(['-C', sourceRoot, 'ls-tree', '-r', '-z', 'HEAD']), ); assertSupportedTree(entries); @@ -586,12 +792,19 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { this.runtime.run(['-C', sourceRoot, 'rev-parse', '--verify', 'HEAD^{tree}']), this.runtime.run(['-C', sourceRoot, 'rev-parse', '--show-object-format']), ]); + const normalizedObjectFormat = objectFormat.trim(); + if (normalizedObjectFormat !== 'sha1' && normalizedObjectFormat !== 'sha256') { + throw new GitWorkspaceServiceError( + 'repository_ineligible', + `Unsupported Git object format: ${normalizedObjectFormat}`, + ); + } return { sourceRoot: normalize(sourceRoot), gitCommonDir: normalize(gitCommonDir), headCommitOid: headCommitOid.trim(), treeOid: treeOid.trim(), - objectFormat: objectFormat.trim(), + objectFormat: normalizedObjectFormat, }; } catch (error) { if (error instanceof GitWorkspaceServiceError) throw error; @@ -1426,6 +1639,36 @@ class VerifiedGitRuntime { } } + async runBuffer( + args: readonly string[], + homePath?: string, + extraEnv?: NodeJS.ProcessEnv, + ): Promise { + const runtime = await this.verify(); + const hooksPath = homePath ? join(homePath, 'empty-hooks') : dirname(runtime.executablePath); + if (homePath) { + await mkdir(homePath, { recursive: true }); + await mkdir(hooksPath, { recursive: true }); + } + const env = isolatedGitEnvironment( + runtime.executablePath, + homePath ?? dirname(runtime.executablePath), + ); + const { stdout } = await execFileAsync( + runtime.executablePath, + [...fixedGitArguments(hooksPath), ...args], + { + cwd: homePath ?? dirname(runtime.executablePath), + env: { ...env, ...extraEnv }, + encoding: 'buffer', + maxBuffer: GIT_MAX_BUFFER_BYTES, + timeout: GIT_TIMEOUT_MS, + windowsHide: true, + }, + ); + return stdout; + } + async runOptional( args: readonly string[], acceptedMissingExitCode: number, @@ -1571,6 +1814,7 @@ function workspaceLayout( epochArtifactPath: join(epochRoot, 'epoch.json'), instanceRoot, bindingPath: join(instanceRoot, 'binding.json'), + baselineReceiptPath: join(instanceRoot, 'baseline-receipt.json'), worktreePath: join(instanceRoot, 'worktree'), quarantineRoot: join(managedRoot, 'quarantine'), quarantineIntentRoot, @@ -1732,6 +1976,15 @@ async function readBinding(path: string): Promise { + const value = await readJson(path); + if (value === undefined) return undefined; + assertBaselineReceiptShape(value); + return value; +} + async function readRepositoryRecord(path: string): Promise { const value = await readJson(path); if (value === undefined) return undefined; @@ -1891,6 +2144,103 @@ function isBinding(value: unknown): value is ManagedWorkspaceBinding { ); } +function assertBaselineReceiptShape( + value: unknown, +): asserts value is ManagedWorkspaceBaselineReceiptV1 { + if (!isBaselineReceipt(value)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Invalid managed workspace baseline receipt', + ); + } +} + +function isBaselineReceipt(value: unknown): value is ManagedWorkspaceBaselineReceiptV1 { + if (!isRecord(value)) return false; + return ( + hasExactKeys(value, BASELINE_RECEIPT_KEYS) && + value.schemaVersion === BASELINE_RECEIPT_SCHEMA_VERSION && + value.protocol === 'maka_managed_workspace_baseline_receipt_v1' && + isBinding(value.binding) && + typeof value.workspaceVersionId === 'string' && + /^version_[a-f0-9]{32}$/u.test(value.workspaceVersionId) && + value.policyVersion === 1 && + typeof value.policyHash === 'string' && + SHA256_PATTERN.test(value.policyHash) && + typeof value.epochOpenedEventId === 'string' && + /^[A-Za-z0-9_-]{1,128}$/u.test(value.epochOpenedEventId) && + typeof value.baselineAcceptedEventId === 'string' && + /^[A-Za-z0-9_-]{1,128}$/u.test(value.baselineAcceptedEventId) && + value.epochOpenedEventId !== value.baselineAcceptedEventId && + typeof value.treeDeltaDigest === 'string' && + SHA256_PATTERN.test(value.treeDeltaDigest) && + typeof value.changedFileCount === 'number' && + Number.isSafeInteger(value.changedFileCount) && + value.changedFileCount >= 0 && + value.deletedFileCount === 0 + ); +} + +function assertBaselineReceiptMatches( + receipt: ManagedWorkspaceBaselineReceiptV1, + binding: ManagedWorkspaceBinding, + summary: BaselineTreeSummary, +): void { + const identities = deriveBaselineReceiptIdentities(binding); + if ( + !sameBinding(receipt.binding, binding) || + receipt.workspaceVersionId !== identities.workspaceVersionId || + receipt.epochOpenedEventId !== identities.epochOpenedEventId || + receipt.baselineAcceptedEventId !== identities.baselineAcceptedEventId || + receipt.policyVersion !== 1 || + receipt.policyHash !== MANAGED_BASELINE_POLICY_HASH_V1 || + receipt.treeDeltaDigest !== summary.treeDeltaDigest || + receipt.changedFileCount !== summary.changedFileCount || + receipt.deletedFileCount !== 0 + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed workspace baseline receipt does not match its verified Git boundary', + ); + } +} + +function deriveBaselineReceiptIdentities(binding: ManagedWorkspaceBinding): { + readonly workspaceVersionId: string; + readonly epochOpenedEventId: string; + readonly baselineAcceptedEventId: string; +} { + const identity = { + repositoryId: binding.repositoryId, + workspaceId: binding.workspaceId, + workspaceEpochId: binding.workspaceEpochId, + workspaceInstanceId: binding.workspaceInstanceId, + baselineCommitOid: binding.baselineCommitOid, + baselineTreeOid: binding.baselineTreeOid, + policyVersion: 1, + policyHash: MANAGED_BASELINE_POLICY_HASH_V1, + } as const; + const derive = (purpose: string): string => + createHash('sha256') + .update( + JSON.stringify({ + protocol: 'maka_managed_workspace_baseline_identity_v1', + purpose, + identity, + }), + ) + .digest('hex'); + return { + workspaceVersionId: `version_${derive('workspace_version').slice(0, 32)}`, + epochOpenedEventId: `workspace_epoch_opened_${derive('epoch_opened').slice(0, 64)}`, + baselineAcceptedEventId: `workspace_baseline_accepted_${derive('baseline_accepted').slice(0, 64)}`, + }; +} + +function hashCanonicalJson(value: unknown): `sha256:${string}` { + return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; +} + function isRepositoryRecord(value: unknown): value is ManagedRepositoryRecord { if (!isRecord(value)) return false; return ( @@ -1987,6 +2337,13 @@ function sameBinding(left: ManagedWorkspaceBinding, right: ManagedWorkspaceBindi return JSON.stringify(left) === JSON.stringify(right); } +function sameBaselineReceipt( + left: ManagedWorkspaceBaselineReceiptV1, + right: ManagedWorkspaceBaselineReceiptV1, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function sameQuarantineRecord( left: ManagedWorkspaceQuarantineRecord, right: ManagedWorkspaceQuarantineRecord, @@ -2313,29 +2670,70 @@ function materializationProfileDigest( return `sha256:${createHash('sha256').update(profile).digest('hex')}`; } -function parseTreeEntries(output: string): GitTreeEntry[] { - return output - .split('\0') - .filter(Boolean) - .map((record) => { - const tab = record.indexOf('\t'); - const metadata = tab >= 0 ? record.slice(0, tab) : ''; - const path = tab >= 0 ? record.slice(tab + 1) : ''; - const mode = metadata.split(' ', 1)[0] ?? ''; - if (!mode || !path) { - throw new GitWorkspaceServiceError( - 'repository_ineligible', - 'Git source tree contains an unreadable entry', - ); - } - return { mode, path }; - }); +function parseTreeEntries(output: Buffer): GitTreeEntry[] { + const entries: GitTreeEntry[] = []; + let start = 0; + while (start < output.length) { + const end = output.indexOf(0, start); + if (end < 0) { + throw new GitWorkspaceServiceError( + 'repository_ineligible', + 'Git source tree contains an unterminated entry', + ); + } + const record = output.subarray(start, end); + start = end + 1; + if (record.length === 0) continue; + const tab = record.indexOf(0x09); + const metadata = tab >= 0 ? record.subarray(0, tab).toString('ascii') : ''; + const pathBytes = tab >= 0 ? record.subarray(tab + 1) : Buffer.alloc(0); + const [mode = '', objectType = '', oid = ''] = metadata.split(' '); + let path = ''; + try { + path = new TextDecoder('utf-8', { fatal: true }).decode(pathBytes); + } catch { + throw new GitWorkspaceServiceError( + 'repository_ineligible', + 'Managed workspace v1 requires Git paths to be valid UTF-8', + ); + } + if ( + !mode || + !objectType || + !OID_PATTERN.test(oid) || + pathBytes.length === 0 || + !Buffer.from(path, 'utf8').equals(pathBytes) + ) { + throw new GitWorkspaceServiceError( + 'repository_ineligible', + 'Git source tree contains an unreadable entry', + ); + } + entries.push({ mode, objectType, oid, path, pathBytesBase64: pathBytes.toString('base64') }); + } + return entries; +} + +function baselineTreeSummary(entries: readonly GitTreeEntry[]): BaselineTreeSummary { + const manifest = JSON.stringify({ + protocol: 'maka_git_empty_tree_delta_v1', + entries: entries.map(({ mode, objectType, oid, pathBytesBase64 }) => ({ + mode, + objectType, + oid, + pathBytesBase64, + })), + }); + return { + treeDeltaDigest: `sha256:${createHash('sha256').update(manifest).digest('hex')}`, + changedFileCount: entries.length, + }; } function assertSupportedTree(entries: readonly GitTreeEntry[]): void { const caseFolded = new Set(); for (const entry of entries) { - if (entry.mode !== '100644' && entry.mode !== '100755') { + if ((entry.mode !== '100644' && entry.mode !== '100755') || entry.objectType !== 'blob') { throw new GitWorkspaceServiceError( 'repository_ineligible', 'Symlinks, submodules, and special Git modes are not supported by managed workspace v1', diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 9f5840a024..3f81d85ed5 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -124,7 +124,7 @@ export * from './long-term-memory-store.js'; export * from './project-catalog.js'; export * from './project-session-backfill.js'; export * from './git-worktree-child-executor.js'; -export * from './git-workspace-service.js'; +export * from './managed-workspace-owner.js'; export * from './session-bundle-policy.js'; export * from './session-bundle-contract.js'; export * from './session-bundle-manifest.js'; diff --git a/packages/storage/src/managed-baseline-receipt-authority-internal.ts b/packages/storage/src/managed-baseline-receipt-authority-internal.ts new file mode 100644 index 0000000000..fb2f37e5f8 --- /dev/null +++ b/packages/storage/src/managed-baseline-receipt-authority-internal.ts @@ -0,0 +1,37 @@ +import type { + CreateManagedWorkspaceFromSourceInput, + GitWorkspaceService, + ManagedWorkspaceBaselineReceiptV1, + ManagedWorkspaceBinding, +} from './git-workspace-service.js'; + +export interface ManagedBaselineReceiptAuthorityInternal { + issue(binding: ManagedWorkspaceBinding): Promise; + require(input: CreateManagedWorkspaceFromSourceInput): Promise; + verify(receipt: ManagedWorkspaceBaselineReceiptV1): Promise; +} + +const receiptAuthorities = new WeakMap< + GitWorkspaceService, + ManagedBaselineReceiptAuthorityInternal +>(); + +export function registerManagedBaselineReceiptAuthorityInternal( + service: GitWorkspaceService, + authority: ManagedBaselineReceiptAuthorityInternal, +): void { + if (receiptAuthorities.has(service)) { + throw new Error('Managed baseline receipt authority is already registered'); + } + receiptAuthorities.set(service, authority); +} + +export function requireManagedBaselineReceiptAuthorityInternal( + service: GitWorkspaceService, +): ManagedBaselineReceiptAuthorityInternal { + const authority = receiptAuthorities.get(service); + if (!authority) { + throw new Error('Managed baseline receipt authority is unavailable'); + } + return authority; +} diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts new file mode 100644 index 0000000000..60741d3978 --- /dev/null +++ b/packages/storage/src/managed-workspace-owner.ts @@ -0,0 +1,304 @@ +import { + createGitWorkspaceService, + type CreateManagedWorkspaceFromSourceInput, + GitWorkspaceServiceError, + type GitWorkspaceService, + type GitWorkspaceServiceFailpoint, + type ManagedWorkspaceBaselineReceiptV1, + type ManagedWorkspaceBinding, + type VerifiedGitRuntimeInput, +} from './git-workspace-service.js'; +import { + requireManagedBaselineReceiptAuthorityInternal, + type ManagedBaselineReceiptAuthorityInternal, +} from './managed-baseline-receipt-authority-internal.js'; +import type { RuntimeWorkspaceVersionAuthorityStore, WorkspaceHeadRecordV1 } from '@maka/core'; +import { + assertInteractiveRootOwner, + authenticateInteractiveRootOwner, + createStorageRootLeaseIdentityGuard, + runWithStorageRootLease, + type InteractiveRootOwner, +} from './root-authority.js'; +import { + assertWorkspaceBaselineAuthorityStoreRootInternal, + bindWorkspaceBaselineAuthorityStoreRootInternal, + commitWorkspaceBaselineInternal, +} from './workspace-version-authority-internal.js'; + +// RuntimeEvent order is assigned by the SQLite authority spine. M0 therefore +// uses a protocol-fixed logical timestamp and keeps unauthenticated wall-clock +// time out of the durable Git receipt. +const MANAGED_BASELINE_LOGICAL_TIMESTAMP_V1 = 0; + +export type ManagedWorkspaceOwnerErrorCode = + | 'managed_workspace_owner_conflict' + | 'managed_workspace_owner_unavailable' + | 'managed_workspace_owner_closing' + | 'managed_workspace_quarantined'; + +export class ManagedWorkspaceOwnerError extends Error { + constructor( + readonly code: ManagedWorkspaceOwnerErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'ManagedWorkspaceOwnerError'; + } +} + +export interface OpenManagedWorkspaceOwnerInput { + readonly rootOwner: InteractiveRootOwner; + readonly gitRuntime: VerifiedGitRuntimeInput; + readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise; +} + +export type ManagedWorkspaceOwnerFailpoint = + | GitWorkspaceServiceFailpoint + | 'after_initial_store_root_validation' + | 'after_baseline_authority_commit' + | 'after_post_commit_artifact_verification'; + +export type OpenManagedWorkspaceBaselineInput = CreateManagedWorkspaceFromSourceInput; + +export interface OpenManagedWorkspaceBaselineResult { + readonly created: boolean; + readonly binding: ManagedWorkspaceBinding; + readonly receipt: ManagedWorkspaceBaselineReceiptV1; + readonly head: WorkspaceHeadRecordV1; +} + +export interface ManagedWorkspaceOwner { + readonly state: 'ready' | 'closing' | 'closed'; + openManagedWorkspaceBaseline( + store: RuntimeWorkspaceVersionAuthorityStore, + input: OpenManagedWorkspaceBaselineInput, + ): Promise; + close(): Promise; +} + +const owners = new WeakMap(); + +export async function openManagedWorkspaceOwner( + input: OpenManagedWorkspaceOwnerInput, +): Promise { + const rootOwner = authenticateInteractiveRootOwner(input.rootOwner); + await assertInteractiveRootOwner(rootOwner); + if (owners.has(rootOwner)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_conflict', + 'This storage root owner already has a managed workspace owner', + ); + } + + const claim = {}; + owners.set(rootOwner, claim); + try { + const service = createGitWorkspaceService({ + storageRoot: rootOwner.capability.canonicalPath, + gitRuntime: input.gitRuntime, + ...(input.failpoint ? { failpoint: input.failpoint } : {}), + }); + await runWithStorageRootLease(rootOwner.lease, 'interactive', 'write', () => + service.assertAvailable(), + ); + // The root owner may begin closing while capability initialization is in + // flight. Revalidate after the lease-bound operation so a stale lifecycle + // owner is never published as ready. + await assertInteractiveRootOwner(rootOwner); + const owner = new ManagedWorkspaceOwnerImpl( + rootOwner, + service, + requireManagedBaselineReceiptAuthorityInternal(service), + input.failpoint, + ); + owners.set(rootOwner, owner); + return owner; + } catch (error) { + if (owners.get(rootOwner) === claim) owners.delete(rootOwner); + if (error instanceof ManagedWorkspaceOwnerError) throw error; + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Unable to initialize the managed workspace owner', + { cause: error }, + ); + } +} + +class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { + #state: 'ready' | 'closing' | 'closed' = 'ready'; + #activeOperations = 0; + readonly #drainWaiters = new Set<() => void>(); + readonly #assertCurrentRootIdentity: () => Promise; + #closeTask: Promise | undefined; + + constructor( + private readonly rootOwner: InteractiveRootOwner, + private readonly service: GitWorkspaceService, + private readonly receiptAuthority: ManagedBaselineReceiptAuthorityInternal, + private readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise, + ) { + // Capture the identity guard while the lease is active. Unlike a fresh + // admission check, this guard remains valid for an already-admitted + // operation while owner.close() waits for that operation to drain. + this.#assertCurrentRootIdentity = createStorageRootLeaseIdentityGuard( + rootOwner.lease, + 'interactive', + 'write', + ); + } + + get state(): 'ready' | 'closing' | 'closed' { + return this.#state; + } + + async openManagedWorkspaceBaseline( + store: RuntimeWorkspaceVersionAuthorityStore, + input: OpenManagedWorkspaceBaselineInput, + ): Promise { + return this.#run(async () => { + await assertWorkspaceBaselineAuthorityStoreRootInternal( + store, + this.rootOwner.capability.canonicalPath, + ); + bindWorkspaceBaselineAuthorityStoreRootInternal(store, this.rootOwner.capability.rootId); + await this.failpoint?.('after_initial_store_root_validation'); + const existingHead = await store.readWorkspaceHead(input.workspaceId, input.workspaceEpochId); + const receipt = existingHead ? await this.receiptAuthority.require(input) : undefined; + const binding = receipt + ? await this.#openReadyBinding(receipt.binding) + : await this.#requireReady(await this.service.createManagedWorkspaceFromSource(input)); + const durableReceipt = receipt ?? (await this.receiptAuthority.issue(binding)); + if ( + existingHead && + (existingHead.workspaceVersionId !== durableReceipt.workspaceVersionId || + existingHead.acceptedEventId !== durableReceipt.baselineAcceptedEventId || + existingHead.commitOid !== binding.baselineCommitOid || + existingHead.treeOid !== binding.baselineTreeOid) + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Canonical workspace head does not match its durable Git baseline receipt', + ); + } + const committed = await commitWorkspaceBaselineInternal(store, { + epochOpenedEventId: durableReceipt.epochOpenedEventId, + baselineAcceptedEventId: durableReceipt.baselineAcceptedEventId, + committedAt: MANAGED_BASELINE_LOGICAL_TIMESTAMP_V1, + epoch: { + repositoryId: binding.repositoryId, + workspaceId: binding.workspaceId, + workspaceEpochId: binding.workspaceEpochId, + workspaceInstanceId: binding.workspaceInstanceId, + mode: 'managed_worktree', + objectFormat: binding.objectFormat, + sourceCommitOid: binding.sourceHeadCommitOid, + sourceTreeOid: binding.sourceTreeOid, + materializationProfileDigest: binding.materializationProfileDigest, + materializationSemantics: binding.materializationSemantics, + policyHash: durableReceipt.policyHash, + }, + baseline: { + workspaceVersionId: durableReceipt.workspaceVersionId, + commitOid: binding.baselineCommitOid, + treeOid: binding.baselineTreeOid, + treeDeltaDigest: durableReceipt.treeDeltaDigest, + changedFileCount: durableReceipt.changedFileCount, + deletedFileCount: durableReceipt.deletedFileCount, + }, + }); + await this.failpoint?.('after_baseline_authority_commit'); + // The transaction writes through the already-open SQLite handle. Rebind + // that handle to the canonical pathname after COMMIT so an external + // rename/replacement cannot make an orphan database look accepted. + await assertWorkspaceBaselineAuthorityStoreRootInternal( + store, + this.rootOwner.capability.canonicalPath, + ); + bindWorkspaceBaselineAuthorityStoreRootInternal(store, this.rootOwner.capability.rootId); + // Canonical acceptance never makes a missing Git artifact acceptable. + // Reverify after the SQLite transaction so post-accept artifact loss is + // reported fail-closed instead of returning a usable workspace head. + await this.receiptAuthority.verify(durableReceipt); + await this.failpoint?.('after_post_commit_artifact_verification'); + // The root marker is mutable host state and is not covered by receipt + // verification. Revalidate its identity at the final return gate without + // rejecting an operation that owner.close() is legitimately draining. + await this.#assertCurrentRootIdentity(); + return { ...committed, binding, receipt: durableReceipt }; + }); + } + + close(): Promise { + this.#closeTask ??= (async () => { + this.#state = 'closing'; + await this.#waitForDrain(); + this.#state = 'closed'; + })(); + return this.#closeTask; + } + + #assertReady(): void { + if (this.#state !== 'ready') { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_closing', + 'Managed workspace owner is closing or closed', + ); + } + } + + async #run(operation: () => Promise): Promise { + this.#assertReady(); + this.#activeOperations += 1; + try { + return await runWithStorageRootLease(this.rootOwner.lease, 'interactive', 'write', operation); + } finally { + this.#activeOperations -= 1; + if (this.#activeOperations === 0) { + for (const resolve of this.#drainWaiters) resolve(); + this.#drainWaiters.clear(); + } + } + } + + #waitForDrain(): Promise { + if (this.#activeOperations === 0) return Promise.resolve(); + return new Promise((resolve) => this.#drainWaiters.add(resolve)); + } + + async #requireReady(binding: ManagedWorkspaceBinding): Promise { + const inspection = await this.service.inspectManagedWorkspace(binding); + if (inspection.state === 'ready') return binding; + const quarantine = await this.service.quarantineManagedWorkspace( + binding, + 'external_workspace_drift', + ); + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_quarantined', + `Managed workspace drift was quarantined at ${quarantine.quarantinePath}`, + ); + } + + async #openReadyBinding(binding: ManagedWorkspaceBinding): Promise { + try { + return await this.#requireReady(await this.service.openManagedWorkspaceFromBinding(binding)); + } catch (error) { + if ( + !(error instanceof GitWorkspaceServiceError) || + error.code !== 'managed_workspace_drifted' + ) { + throw error; + } + const quarantine = await this.service.quarantineManagedWorkspace( + binding, + 'external_workspace_drift', + ); + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_quarantined', + `Managed workspace drift was quarantined at ${quarantine.quarantinePath}`, + { cause: error }, + ); + } + } +} diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 803aaa0c63..01bd94f3cd 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 8; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 9; 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'; @@ -252,6 +252,18 @@ const MIGRATIONS: ReadonlyMap = new Map([ ); `, ], + [ + 9, + ` + CREATE TABLE runtime_storage_root_binding ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + root_id TEXT NOT NULL CHECK ( + length(root_id) = 64 AND root_id NOT GLOB '*[^0-9a-f]*' + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1) + ); + `, + ], ]); 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 702cbaff8a..57eab98c6b 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -221,7 +221,9 @@ export class SqliteRuntimeStore assertRecoveryAuthorityCapability(this.db); assertContinuationAuthorityCapability(this.db); assertWorkspaceVersionAuthorityCapability(this.db); - if (!options.readOnly) this.registerWorkspaceBaselineAuthorityWriter(); + if (!options.readOnly) { + this.registerWorkspaceBaselineAuthorityWriter(options.databaseLease.databasePath); + } return; } const DatabaseSync = loadDatabaseSync(); @@ -244,7 +246,7 @@ export class SqliteRuntimeStore assertRecoveryAuthorityCapability(this.db); assertContinuationAuthorityCapability(this.db); assertWorkspaceVersionAuthorityCapability(this.db); - if (!options.readOnly) this.registerWorkspaceBaselineAuthorityWriter(); + if (!options.readOnly) this.registerWorkspaceBaselineAuthorityWriter(path); } catch (error) { this.db.close(); this.closed = true; @@ -864,9 +866,11 @@ export class SqliteRuntimeStore async #commitWorkspaceBaseline( input: WorkspaceBaselineAuthorityInput, + rootId: string, ): Promise { const events = buildWorkspaceBaselineAuthorityEvents(input); return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); const existingBaselines = this.readCanonicalWorkspaceBaselinesSync(); const existing = existingBaselines.find( (candidate) => @@ -929,12 +933,77 @@ export class SqliteRuntimeStore }); } - private registerWorkspaceBaselineAuthorityWriter(): void { - registerWorkspaceBaselineAuthorityWriterInternal(this, (input) => - this.#commitWorkspaceBaseline(input), + private registerWorkspaceBaselineAuthorityWriter(databasePath: string): void { + registerWorkspaceBaselineAuthorityWriterInternal( + this, + databasePath, + (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), + (rootId) => this.#bindWorkspaceStorageRoot(rootId), ); } + #bindWorkspaceStorageRoot(rootId: string): void { + this.transaction(() => { + const existing = this.#readWorkspaceStorageRootBinding(); + if (existing) { + if (existing.root_id !== rootId || existing.protocol_version !== 1) { + throw new Error( + 'Workspace authority database belongs to a different durable storage root', + ); + } + return; + } + if (this.#databaseHasLogicalStateBeforeRootBinding()) { + throw new Error('Unbound operational data require explicit storage-root adoption'); + } + this.db + .prepare(` + INSERT INTO runtime_storage_root_binding(singleton, root_id, protocol_version) + VALUES (1, ?, 1) + `) + .run(rootId); + }); + } + + #assertWorkspaceStorageRootBinding(rootId: string): void { + const existing = this.#readWorkspaceStorageRootBinding(); + if (!existing || existing.root_id !== rootId || existing.protocol_version !== 1) { + throw new Error('Workspace authority database durable storage-root binding changed'); + } + } + + #readWorkspaceStorageRootBinding(): { root_id: string; protocol_version: number } | undefined { + return this.db + .prepare(` + SELECT root_id, protocol_version + FROM runtime_storage_root_binding + WHERE singleton = 1 + `) + .get() as { root_id: string; protocol_version: number } | undefined; + } + + #databaseHasLogicalStateBeforeRootBinding(): boolean { + const metadataTables = new Set([ + 'operational_schema_migrations', + 'runtime_capabilities', + 'runtime_storage_root_binding', + ]); + const tables = this.db + .prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `) + .all() as Array<{ name: string }>; + for (const { name } of tables) { + if (metadataTables.has(name)) continue; + const quotedName = `"${name.replaceAll('"', '""')}"`; + if (this.db.prepare(`SELECT 1 FROM ${quotedName} LIMIT 1`).get()) return true; + } + return false; + } + async readWorkspaceEpoch( workspaceId: string, workspaceEpochId: string, diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 65ecdedfcb..908e382c3a 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -1,31 +1,135 @@ import type { WorkspaceBaselineAuthorityInput, WorkspaceBaselineCommitResult } from '@maka/core'; +import { lstatSync } from 'node:fs'; +import { realpath } from 'node:fs/promises'; +import { basename, dirname, join, normalize, resolve } from 'node:path'; +import { OPERATIONAL_STATE_DATABASE_NAME } from './operational-state-store.js'; type WorkspaceBaselineAuthorityWriter = ( input: WorkspaceBaselineAuthorityInput, + rootId: string, ) => Promise; +type WorkspaceStorageRootBinder = (rootId: string) => void; -const workspaceBaselineAuthorityWriters = new WeakMap(); +interface WorkspaceBaselineAuthorityRegistration { + readonly writer: WorkspaceBaselineAuthorityWriter; + readonly bindStorageRoot: WorkspaceStorageRootBinder; + readonly databasePath: string; + readonly databaseFileIdentity?: string; + boundRootId?: string; +} + +const workspaceBaselineAuthorityWriters = new WeakMap< + object, + WorkspaceBaselineAuthorityRegistration +>(); export function registerWorkspaceBaselineAuthorityWriterInternal( store: object, + databasePath: string, writer: WorkspaceBaselineAuthorityWriter, + bindStorageRoot: WorkspaceStorageRootBinder, ): void { if (workspaceBaselineAuthorityWriters.has(store)) { throw new Error('Workspace baseline authority writer is already registered'); } - workspaceBaselineAuthorityWriters.set(store, writer); + const resolvedDatabasePath = resolve(databasePath); + workspaceBaselineAuthorityWriters.set(store, { + writer, + bindStorageRoot, + databasePath: resolvedDatabasePath, + databaseFileIdentity: captureRegularFileIdentity(resolvedDatabasePath), + }); } /** - * Storage-internal seam for persistence/crash tests. This module is deliberately - * absent from @maka/storage package exports. Production composition must use a - * future verified-receipt API instead of accepting caller-supplied Git OIDs. + * Storage-internal authority seam. This module is deliberately absent from the + * @maka/storage package exports. Production composition reaches it only through + * ManagedWorkspaceOwner after durable Git receipt verification; focused + * persistence/crash tests use it directly to prove the SQLite transaction. */ export function commitWorkspaceBaselineInternal( store: object, input: WorkspaceBaselineAuthorityInput, ): Promise { - const writer = workspaceBaselineAuthorityWriters.get(store); - if (!writer) throw new Error('Workspace baseline authority writer is unavailable'); - return writer(input); + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace baseline authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace baseline authority store has no durable storage-root binding'); + } + return registration.writer(input, registration.boundRootId); +} + +export function bindWorkspaceBaselineAuthorityStoreRootInternal( + store: object, + rootId: string, +): void { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Workspace baseline authority writer is unavailable'); + if (!/^[a-f0-9]{64}$/u.test(rootId)) { + throw new Error('Invalid durable storage-root identity'); + } + registration.bindStorageRoot(rootId); + registration.boundRootId = rootId; +} + +export async function assertWorkspaceBaselineAuthorityStoreRootInternal( + store: object, + storageRoot: string, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if ( + !registration || + !registration.databaseFileIdentity || + basename(registration.databasePath) !== OPERATIONAL_STATE_DATABASE_NAME + ) { + throw new Error('Workspace baseline authority store is unavailable for this storage root'); + } + const expectedDatabasePath = join(storageRoot, OPERATIONAL_STATE_DATABASE_NAME); + const currentIdentity = captureRegularFileIdentity(registration.databasePath); + if (currentIdentity !== registration.databaseFileIdentity) { + throw new Error('Workspace baseline authority database file identity changed'); + } + let databasePath: string; + let expectedPath: string; + let expectedRoot: string; + try { + [databasePath, expectedPath, expectedRoot] = await Promise.all([ + realpath(registration.databasePath), + realpath(expectedDatabasePath), + realpath(storageRoot), + ]); + } catch (error) { + throw new Error('Workspace baseline authority store belongs to a different storage root', { + cause: error, + }); + } + const canonicalDatabasePath = normalize(databasePath); + const canonicalExpectedPath = normalize(expectedPath); + const canonicalExpectedRoot = normalize(expectedRoot); + if ( + !sameFilesystemPath(canonicalDatabasePath, canonicalExpectedPath) || + !sameFilesystemPath(dirname(canonicalDatabasePath), canonicalExpectedRoot) + ) { + throw new Error('Workspace baseline authority store belongs to a different storage root'); + } +} + +function captureRegularFileIdentity(path: string): string | undefined { + try { + const info = lstatSync(path, { bigint: true }); + // SQLite sidecars are pathname-scoped. Opening the same main database + // inode through a second hard-linked storage root can split its WAL/SHM + // coordination across two directories, so a canonical authority database + // must have exactly one directory entry. + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) return undefined; + return `${info.dev}:${info.ino}:${info.nlink}`; + } catch { + return undefined; + } +} + +function sameFilesystemPath(left: string, right: string): boolean { + return process.platform === 'win32' + ? left.toLocaleLowerCase('en-US') === right.toLocaleLowerCase('en-US') + : left === right; }