From b95ef2e9d93ae9cdfe2622f1e4f89b2b7ee0d07a Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Wed, 19 Aug 2026 16:10:35 +0800 Subject: [PATCH] feat(workspace): add transactional checkpoints and rewind --- README.en.md | 10 +- README.md | 13 +- docs/DESKTOP_TEST_MATRIX.md | 1 + docs/PROJECT_MEMORY.md | 34 +- docs/PROJECT_STATUS.md | 39 +- docs/README.md | 2 + ...118-transactional-workspace-checkpoints.md | 216 + docs/architecture.md | 41 + docs/http-api.md | 33 +- docs/openapi.json | 4437 +++++++++++------ docs/usage.md | 34 + docs/workspace-checkpoints.md | 155 + internal/app/api_command.go | 14 +- internal/app/app.go | 4 +- internal/app/model_diff_wake_command_test.go | 13 + internal/app/workspace_checkpoint_command.go | 289 ++ .../app/workspace_checkpoint_command_test.go | 95 + internal/application/agent_code_tools.go | 12 +- internal/application/command_runtime.go | 183 +- internal/application/command_runtime_test.go | 6 +- internal/application/context_continuity.go | 89 +- internal/application/file_edit_apply.go | 64 +- internal/application/git_mutation_service.go | 57 +- internal/application/workspace_checkpoints.go | 2120 ++++++++ .../application/workspace_checkpoints_test.go | 734 +++ internal/desktop/bridge.go | 3 + internal/desktop/bridge_test.go | 2 + internal/desktop/control_plane.go | 16 +- internal/events/event.go | 4 + internal/httpapi/openapi.go | 44 + internal/httpapi/openapi_test.go | 23 + internal/httpapi/runtime_capabilities.go | 40 +- internal/httpapi/server.go | 42 +- internal/httpapi/workspace_checkpoints.go | 412 ++ .../httpapi/workspace_checkpoints_test.go | 197 + internal/repository/mutation.go | 5 +- internal/repository/worktree.go | 347 ++ internal/runner/command_runtime_job.go | 14 +- internal/store/migration_v117.go | 413 ++ internal/store/migrations.go | 2 +- internal/store/session_continuity.go | 49 +- internal/store/skill_catalog_test.go | 4 +- internal/store/sqlite.go | 1 + ...supervisor_tool_registry_migration_test.go | 4 +- internal/store/workspace_checkpoints.go | 836 ++++ internal/store/workspace_checkpoints_test.go | 371 ++ internal/toolgateway/command_runtime.go | 27 +- internal/toolgateway/command_runtime_test.go | 8 +- internal/toolgateway/model.go | 6 +- internal/workspace/agent_code.go | 6 +- internal/workspacecheckpoint/capture.go | 766 +++ internal/workspacecheckpoint/capture_test.go | 239 + internal/workspacecheckpoint/model.go | 631 +++ internal/workspacecheckpoint/restore.go | 815 +++ internal/workspacecheckpoint/restore_test.go | 231 + internal/workspaceidentity/fingerprint.go | 26 + .../identity_other.go} | 4 +- .../identity_unix.go} | 4 +- .../identity_windows.go} | 4 +- web/src/api/client.test.ts | 3 + web/src/api/client.ts | 7 +- web/src/api/schema.d.ts | 611 +++ web/src/api/types.ts | 6 + web/src/components/connection-gate.test.tsx | 2 + web/src/components/connection-gate.tsx | 1 + web/src/components/run-workspace.tsx | 8 +- .../workspace-checkpoint-panel.test.tsx | 187 + .../components/workspace-checkpoint-panel.tsx | 342 ++ web/src/lib/desktop-bridge.test.ts | 1 + web/src/lib/desktop-bridge.ts | 9 +- web/src/styles.css | 167 + 71 files changed, 14033 insertions(+), 1602 deletions(-) create mode 100644 docs/adr/0118-transactional-workspace-checkpoints.md create mode 100644 docs/workspace-checkpoints.md create mode 100644 internal/app/workspace_checkpoint_command.go create mode 100644 internal/app/workspace_checkpoint_command_test.go create mode 100644 internal/application/workspace_checkpoints.go create mode 100644 internal/application/workspace_checkpoints_test.go create mode 100644 internal/httpapi/workspace_checkpoints.go create mode 100644 internal/httpapi/workspace_checkpoints_test.go create mode 100644 internal/repository/worktree.go create mode 100644 internal/store/migration_v117.go create mode 100644 internal/store/workspace_checkpoints.go create mode 100644 internal/store/workspace_checkpoints_test.go create mode 100644 internal/workspacecheckpoint/capture.go create mode 100644 internal/workspacecheckpoint/capture_test.go create mode 100644 internal/workspacecheckpoint/model.go create mode 100644 internal/workspacecheckpoint/restore.go create mode 100644 internal/workspacecheckpoint/restore_test.go create mode 100644 internal/workspaceidentity/fingerprint.go rename internal/{workspace/agent_code_root_identity_other.go => workspaceidentity/identity_other.go} (64%) rename internal/{workspace/agent_code_root_identity_unix.go => workspaceidentity/identity_unix.go} (75%) rename internal/{workspace/agent_code_root_identity_windows.go => workspaceidentity/identity_windows.go} (89%) create mode 100644 web/src/components/workspace-checkpoint-panel.test.tsx create mode 100644 web/src/components/workspace-checkpoint-panel.tsx diff --git a/README.en.md b/README.en.md index e913585a..746c4e2e 100644 --- a/README.en.md +++ b/README.en.md @@ -61,7 +61,7 @@ The allowed direction is always `TypeScript -> Go -> LLM/Rust/Docker`. TypeScrip | Models and context | Mock, Anthropic-compatible, OpenAI-compatible, and loopback-only Ollama providers, routing, qualification, capability probing, streaming, compaction, hierarchical project instructions, explicit user/project memory, and Session continuity trees | | Planning and collaboration | Plan/Delivery, work items, notes, up to two core children, 1/2/4/6 read-only fan-out tiers, shared budgets and cancellation | | Tools and permissions | Tool Gateway, JSON Schema validation, Policy, Scope, human approval, four host-permission tiers, fixed commands, an ordinary-mode Run-owned command runtime, per-command PowerShell/Git Bash approval, and time-bound Debug terminal input | -| Code workflows | Native folder selection and Workspace import, workspace browsing, repository state/history, diff review, file-edit proposals, verification plans, Code Journey, and Handoff | +| Code workflows | Native folder selection and Workspace import, workspace browsing, repository state/history, diff review, file-edit proposals, transactional Workspace Checkpoints, Undo/Redo/Rewind, independent Forks, verification plans, Code Journey, and Handoff | | Observability | Append-only Run events, Live Activity, public model commentary, Harness facts, Artifacts, Findings/Evidence/Reports, and SARIF | | Extension seams | Mode-aware inert Skill packages, human-reviewed generated candidates, Provider and Tool interfaces, Go/Rust JSON protocol, embedded WASI Analyzer, Sandbox contracts, and a network-none Docker product execution that is disabled by default | | Clients | `cyberagent` CLI, Bubble Tea TUI, authenticated HTTP/OpenAPI, React/Vite, and Windows/macOS Desktop portable preview | @@ -79,6 +79,12 @@ Schema v115 introduces `agent-code-tools.v1`, allowing the root Supervisor to co Read results are deterministically ordered, paginated, and bounded. Root escape, casing aliases, hidden entries outside the Go allowlist (`.github` is the sole code-evidence exception), ignored entries, links or reparse points, binary/non-UTF-8 data, and oversized files fail closed. `workspace_change` creates replace/create/move proposals only; `workspace_delete` is a separate exactly confirmed deletion proposal; `workspace_apply` can apply only an approved exact revision and rechecks source and destination hashes to detect review-time drift. Calls, results or refusals, authority snapshots, budget charges, and bounded Artifacts enter the resumable Supervisor ledger. `cyberagent run show `, the Run Detail API, and the Desktop Run page expose the current generation and per-tool availability. This protocol grants no Shell, Git, network, or Sandbox authority. See the [Usage Guide](docs/usage.md) and [ADR 0116](docs/adr/0116-model-callable-workspace-tools.md). +### Transactional Workspace Checkpoints + +Schema v117 `workspace-checkpoint.v1` records immutable checkpoints before and after file tools, Run-owned command batches/background Jobs, typed Git writes, and the agent-merge boundary. Each checkpoint binds the base commit, branch, raw Git index, a deterministic tracked/untracked manifest, content hashes, trigger receipt, attempt/capability generation, and recovery grade. Ordinary file and index bytes are deduplicated by SHA-256; ignored, generated, large, sensitive-looking, linked, and external state is represented explicitly instead of being silently advertised as recoverable. Because no portable filesystem watcher is installed, Shell boundaries are explicitly `partial` and cover only observed Workspace/Git state, not effects outside the root. + +The Desktop **Workspace Checkpoints** tab, `cyberagent workspace checkpoint ...`, and authenticated OpenAPI routes call the same Application service. Rewind, Undo, and Redo first perform a live/current/target three-way preview, then require the exact cursor CAS and current authority. A restore is a new append-only write: it does not rewrite history, invoke `git reset --hard`, or blanket-delete untracked files. Fork creates a distinct Git worktree, Workspace, Mission/Run/Session, and does not inherit approval, credential, capability, lease, process, or network authority. HTTP/Desktop neither accept nor return an absolute worktree path; Go derives a deterministic sibling from the trusted source Workspace. See [Workspace Checkpoints](docs/workspace-checkpoints.md) and [ADR 0118](docs/adr/0118-transactional-workspace-checkpoints.md). + ### Real Git, PowerShell, and Bash Prayu invokes real Git and operating-system shells; it is not a command emulator. It deliberately does not give the model a permanent, unreviewed raw terminal. The Code workflow separates execution by risk: @@ -98,7 +104,7 @@ Every `debug_terminal` write still passes Shell Policy; commands that require se ### Security boundaries - Provider-private thinking, raw prompts, raw deltas, tool arguments, raw tool output, and API keys are never exposed as public activity. -- Project instructions, long-term memory, and checkpoints are always untrusted, non-authorizing context. Fork/Resume never restores approvals, capabilities, credentials, network access, processes, terminal leases, or execution profiles. See the [bilingual context, threat-model, and deletion guide](docs/context-continuity.md) and [ADR 0115](docs/adr/0115-non-authorizing-durable-context-continuity.md). +- Project instructions, long-term memory, and conversation checkpoints are always untrusted, non-authorizing context; Workspace Checkpoints retain bounded file/index state only. Neither kind of Fork/Resume restores approvals, capabilities, credentials, network access, processes, terminal leases, or execution profiles. See the [bilingual context, threat-model, and deletion guide](docs/context-continuity.md), [Workspace Checkpoints](docs/workspace-checkpoints.md), [ADR 0115](docs/adr/0115-non-authorizing-durable-context-continuity.md), and [ADR 0118](docs/adr/0118-transactional-workspace-checkpoints.md). - File edits, host commands, browser CDP, terminal input, and Sandbox execution are independent authorization surfaces. - The ordinary command runtime accepts only `network=disabled` and `credentials=none`, clears credential-helper/profile/high-risk environment paths, and rejects explicit network intent. It is not a provable OS network sandbox: `full_access` remains host execution, and a command that needs network access must use a separate per-call reviewed path. - Conservative commands use Go-owned fixed templates. PowerShell/Bash is available only through one of three independent paths: the Code/Deliver/root + `full_access` Run-owned runtime, per-command approval, or a revocable Debug lease. General host execution and Debug authority cannot be enabled by a model, Skill, or repository document. diff --git a/README.md b/README.md index b492d582..f6614e73 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ CLI / TUI / React / Windows Desktop / CI | 模型与上下文 | Mock、Anthropic-compatible、OpenAI-compatible 与 loopback-only Ollama Provider、模型路由、资格校验、能力探测、流式响应、上下文压缩、层级项目指令、显式 user/project 长期记忆与 Session 恢复树 | | 计划与协作 | Plan/Delivery、工作项、备注、最多两个核心 child、1/2/4/6 档只读 Fan-out、共享预算与取消扇出 | | 工具与权限 | Tool Gateway、JSON Schema 校验、Policy、Scope、人工审批、四档宿主权限、受控固定命令、普通模式 Run-owned 命令运行时、逐条审批 PowerShell/Git Bash,以及限时 Debug 终端输入 | -| 代码工作流 | 系统目录选择与 Workspace 导入、工作区浏览、仓库状态、提交历史、Diff 审阅、文件编辑提案、验证计划、Code Journey 与 Handoff | +| 代码工作流 | 系统目录选择与 Workspace 导入、工作区浏览、仓库状态、提交历史、Diff 审阅、文件编辑提案、事务化 Workspace Checkpoint、Undo/Redo/Rewind、独立 Fork、验证计划、Code Journey 与 Handoff | | 可观测性 | 追加式 Run 事件、Live Activity、公开模型进度、Harness 事实、Artifact、Finding/Evidence/Report、SARIF | | 扩展 | 模式感知的惰性 Skill 包、生成候选人工审查、Provider/Tool 接口、Go/Rust JSON 协议、内嵌 WASI Analyzer、Sandbox 合同与默认关闭的 network-none Docker 产品执行 | | 客户端 | `cyberagent` CLI、Bubble Tea TUI、认证 HTTP/OpenAPI、React/Vite、Windows/macOS Desktop 便携预览 | @@ -79,6 +79,12 @@ Schema v115 引入 `agent-code-tools.v1`,让 root Supervisor 能在真实 Work 只读结果稳定排序、分页且有界,并拒绝根目录逃逸、大小写别名、未列入 Go allowlist 的隐藏项(仅 `.github` 作为代码证据开放)、忽略项、链接或重解析点、二进制、非 UTF-8 与超限文件。`workspace_change` 只创建 replace/create/move 提案;`workspace_delete` 是独立、需精确确认的删除提案;`workspace_apply` 只能应用已经批准的精确版本,并重新检查原文件与目标文件哈希,避免审阅后内容漂移。每次调用、结果/拒绝、authority 快照、预算消耗与有界 Artifact 都进入可恢复 Supervisor 账本。`cyberagent run show `、Run Detail API 和 Desktop Run 页面可查看当前 generation、逐工具可用性与拒绝原因。该协议不授予 Shell、Git、网络或 Sandbox 权限;完整设计见[使用手册](docs/usage.md)和 [ADR 0116](docs/adr/0116-model-callable-workspace-tools.md)。 +### 事务化 Workspace Checkpoint + +Schema v117 的 `workspace-checkpoint.v1` 在文件工具、Run-owned 命令批次/后台 Job、typed Git 写入与 agent merge 边界前后记录不可变检查点。检查点固定 base commit、branch、原始 Git index、稳定排序的 tracked/untracked manifest、内容哈希、触发收据、attempt/capability generation 与恢复等级;普通文件和 index 以 SHA-256 内容寻址去重,ignored/generated/large/sensitive/link/external 状态会显式标记而不是静默承诺可恢复。Shell 没有可移植 watcher,因此其边界明确降级为 `partial`,只承诺观测到的 Workspace/Git 状态,不宣称回滚根目录外副作用。 + +Desktop 的 **工作区检查点 / Checkpoints**、CLI 的 `cyberagent workspace checkpoint ...` 和认证 OpenAPI 共用同一个 Application 服务。Rewind、Undo、Redo 先做 live/current/target 三方预览,再以精确 cursor CAS 和当前权限确认写入;恢复本身是一次新的追加式写操作,不改写旧历史、不调用 `git reset --hard`、不批量清理 untracked 文件。Fork 从历史检查点建立独立 Git worktree、Workspace、Mission/Run/Session,且不继承审批、凭据、capability、lease、进程或网络授权;HTTP/Desktop 不接受或返回绝对 worktree 路径,由 Go 从受信源 Workspace 确定性生成同级目标。完整操作说明见 [Workspace Checkpoints](docs/workspace-checkpoints.md),设计与失败语义见 [ADR 0118](docs/adr/0118-transactional-workspace-checkpoints.md)。 + ### 真实 Git、PowerShell 与 Bash Prayu 调用真实的 Git 和操作系统 Shell,不是命令模拟器;但它也不会给模型一个永久、无审阅的裸终端。当前 Code 工作流按风险拆成以下入口: @@ -98,7 +104,7 @@ Prayu 调用真实的 Git 和操作系统 Shell,不是命令模拟器;但它 ### 安全边界 - 不公开 Provider 私有 thinking、原始 Prompt、raw delta、工具参数、工具原始输出或 API key。 -- 项目指令、长期记忆和 Checkpoint 始终是不可信、非授权上下文;Fork/Resume 不恢复审批、capability、凭据、网络、进程、终端租约或执行档位。详见[双语上下文/威胁模型与删除说明](docs/context-continuity.md)和 [ADR 0115](docs/adr/0115-non-authorizing-durable-context-continuity.md)。 +- 项目指令、长期记忆和对话 Checkpoint 始终是不可信、非授权上下文;Workspace Checkpoint 只保存有界文件/index 状态。两类 Fork/Resume 都不恢复审批、capability、凭据、网络、进程、终端租约或执行档位。详见[双语上下文/威胁模型与删除说明](docs/context-continuity.md)、[Workspace Checkpoints](docs/workspace-checkpoints.md)、[ADR 0115](docs/adr/0115-non-authorizing-durable-context-continuity.md)和 [ADR 0118](docs/adr/0118-transactional-workspace-checkpoints.md)。 - 文件编辑、宿主命令、浏览器 CDP、终端输入和 Sandbox 是彼此独立的授权面。 - 普通命令运行时只接受 `network=disabled` 与 `credentials=none`,清空 credential helper/Profile/高风险环境入口并拒绝显式网络意图;它不是可证明的 OS 网络沙箱,`full_access` 仍是宿主执行能力,需要联网的命令必须改走独立逐次审阅路径。 - 受控命令默认使用 Go 固定模板;PowerShell/Bash 只通过 Code/Deliver/root + `full_access` 的 Run-owned runtime、逐条审批,或可撤销 Debug 租约三条独立路径开放。通用宿主执行与 Debug 能力不会因模型、Skill 或仓库文档而自动开启。 @@ -322,7 +328,7 @@ Get-AuthenticodeSignature .\PrayuDesktop.msix | Format-List Status, StatusMessag 完整逐切片原始记录保留在 [`PROGRESS_BOOK.md`](docs/PROGRESS_BOOK.md),当前检查点与验收证据保留在 [`PROJECT_STATUS.md`](docs/PROJECT_STATUS.md),恢复上下文见 [`PROJECT_MEMORY.md`](docs/PROJECT_MEMORY.md)。这些账本是历史记录,不应被当作待重新执行的任务列表。
-SQLite Schema v1-v116 迁移审计表 / Migration ledger +SQLite Schema v1-v117 迁移审计表 / Migration ledger 此表是 Store 防漏迁移测试使用的审计合同。新增 schema 时必须按顺序追加,不得改写或删除既有行。 @@ -444,6 +450,7 @@ Get-AuthenticodeSignature .\PrayuDesktop.msix | Format-List Status, StatusMessag | v114 | 层级项目指令快照、显式长期记忆与非授权会话连续性树 | hierarchical project-instruction snapshots, explicit long-term memory, and non-authorizing session continuity trees | | v115 | 模型可调用的工作区工具与哈希保护文件变更 | model-callable workspace tools and hash-guarded file mutations | | v116 | 增加 Run-owned command-runtime.v2 Job 与 Supervisor 调用账本 | add Run-owned command-runtime.v2 jobs and Supervisor call ledger support | +| v117 | 增加事务化 workspace-checkpoint.v1、恢复/Fork 账本与内容寻址 blob | add transactional workspace-checkpoint.v1, restore/Fork ledger, and content-addressed blobs |
diff --git a/docs/DESKTOP_TEST_MATRIX.md b/docs/DESKTOP_TEST_MATRIX.md index 31e2a93d..4e9c9b57 100644 --- a/docs/DESKTOP_TEST_MATRIX.md +++ b/docs/DESKTOP_TEST_MATRIX.md @@ -52,6 +52,7 @@ - [ ] 长对话滚动 - [ ] Markdown 渲染 - [ ] Live Activity / 事件流 +- [ ] 工作区检查点:时间线来源/恢复等级、Rewind 三方预览、冲突禁用、显式确认、Fork 后切换独立 Run - [ ] 审批队列 - [ ] 设置页(含"高度敏感权限"Full CDP 标签) diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index 14209c0a..6ba80722 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -2,9 +2,37 @@ > Scope checkpoint (2026-08-13): continue only the general-purpose Agent Harness and Code workflow. CTF-specific solving/offensive automation is an optional add-on with no active slices; retain generic extension seams only. Historical Cyber percentages are not current planning metrics. See [PRODUCT_SCOPE.md](PRODUCT_SCOPE.md). -Last updated: 2026-08-15 - -## Current Single-Slice Checkpoint: Structured Dependency Waiting / Issue #50 +Last updated: 2026-08-19 + +## Current Single-Slice Checkpoint: Transactional Workspace Checkpoints / Issue #101 + +2026-08-19 的 issue #101 落地 `workspace-checkpoint.v1`(ADR 0118,schema v117)。每个 +检查点不可变绑定 Run/Mission/Session/Workspace、attempt/capability generation、触发收据、 +parent/cursor、规范根指纹、base commit/branch、原始 Git index 与稳定 tracked/untracked +manifest;普通文件/index 以 SHA-256 内容寻址去重。固定上限为 20,000 entries、4 MiB/ +file、32 MiB/index、64 MiB/checkpoint、2,000 preview changes、256 conflicts、2 GiB +全局 blob store。ignored/generated/large/sensitive/link/reparse/special/unreadable/external +都显式标记;没有静态加密声明,疑似敏感文件不会写入 blob。SQLite sealing/refcount/ +immutability/GC/quota trigger 与 schema v1-v116 downgrade chain 均已覆盖测试。 + +FileEdit 与 `agent-code-tools.v1` 写入、Run-owned command foreground/background、typed Git +写入以及未来 agent merge writer 共用 operation-keyed before/after boundary。Shell 没有 +portable watcher,因此即便 in-root bytes 可存储也明确为 partial;根外、registry/service/ +network side effects 不在恢复承诺中。Preview 比较 reviewed cursor、target 与 freshly +captured live state;external edit、dirty index、root/branch/commit/case/link drift 都冲突。 +Undo/Redo/Rewind 是 append-only 新写,要求 paused Code/Deliver、active Session、无 live +execution lease、当前权限/进程 capability、显式 operator 与 exact CAS cursor,不使用 +`git reset --hard` 或 blanket untracked deletion。 + +Fork 创建并验证独立 Git worktree/branch,再原子注册 Workspace/Mission/Run/Session/events/ +continuity node;只复制目标 bytes/index,不继承 permission/profile authority、approval、 +credential、capability、lease、terminal/process/network grant,source cursor 不变。启动时 +reconciliation 关闭普通 interrupted boundary、只在 identity/authority/cursor 仍匹配时恢复 +prepared restore,并完成已注册但 checkpoint 尚未落盘的 Fork 而不复制 worktree。CLI、 +OpenAPI 与 Desktop Checkpoints tab 共用 Application 服务。不要把 conversation continuity +checkpoint 当成 Workspace bytes,也不要把 Workspace restore 当成历史 authority 恢复。 + +## Previous Single-Slice Checkpoint: Structured Dependency Waiting / Issue #50 2026-08-15 的 issue #50 落地结构化依赖等待(ADR 0102,schema v101)。`agent_dependency_edges` 持久化版本化 wait edge(source/target/reason/deadline/generation/failure_policy, diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index d624dae5..625a5675 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -6,7 +6,44 @@ Last updated: 2026-08-19 ## Resume Context -当前检查点是 issue #100 / schema v116 的普通模式 Run-owned 命令闭环。root +当前检查点是 issue #101 / schema v117 的事务化 Workspace Checkpoint、恢复与独立 +Fork。`workspace-checkpoint.v1` 固定 Run/Workspace/root、base commit、branch、原始 Git +index、稳定 manifest、内容哈希、触发收据、attempt/capability generation 和恢复等级; +普通内容以 SHA-256 去重,SQLite seal/refcount/quota trigger 保证不可变引用和 2 GiB 全局 +blob 硬上限,并将 checkpoint/manifest entry/transaction 元数据分别限制为 +10,000/2,000,000/20,000 条。FileEdit、模型工作区 apply/delete、Run-owned command batch/background Job、typed +Git 与 agent merge 合同都使用同一 before/after 事务边界。Shell 因没有可移植 watcher +明确为 partial,不宣称根目录外副作用可回滚。 + +Timeline、manual capture、三方 preview、Undo/Redo/Rewind 与 Fork 共用 Application 服务, +由 CLI、认证 OpenAPI 和 Desktop Checkpoints tab 投影。恢复是新的追加写,要求 paused +Code/Deliver、active Session、无 live execution lease、当前非 conservative 权限、进程 +capability、显式操作者、精确 cursor CAS 与 operation key;root/branch/commit/index/path +大小写/link/external drift 均失败关闭。实现只做 root-confined 原子文件/index 替换,绝不 +`git reset --hard` 或 blanket-delete untracked;Git index 经标准 `index.lock` 做 presence+ +digest CAS,能精确恢复“原本没有 index”的状态。Fork 建立并验证独立 Git worktree、 +Workspace/Mission/Run/Session/continuity node,不继承审批、凭据、capability、lease、进程、 +终端或网络 authority;启动 reconciliation 收敛 prepared boundary/restore/已注册 Fork, +并补齐 boundary prepare 前后及 terminal transaction/cursor commit 之间的两个 CAS 窗口 +(Fork 永不推进 source cursor), +并在 Run 尚未注册时凭 SQLite 私有、非 JSON 的目标路径/分支记录校验完整 commit 后清理 +孤立 worktree;清理前重捕获完整 manifest/index,崩溃后的用户编辑会导致失败关闭并保留。 +手工 capture 在同 Run 有 open mutation boundary 时冲突,不会移动写者游标。 +完整边界见 ADR 0118 与 `docs/workspace-checkpoints.md`。 + +Issue #101 的本地发布门已通过 `go test -count=1 -timeout 20m ./...`(完整 Store +迁移链 854.6 秒,Application 414.7 秒,HTTP API 180.5 秒)、`go vet ./...`、 +`go mod verify`、`go mod tidy -diff`,以及 +workspacecheckpoint/schema-v117/Application 新路径的 `-race` 回归。OpenAPI golden 与 +TypeScript schema 已确定性重建;受影响 Go 包的 `SA*`/`S1*`/`QF*` staticcheck、strict +TypeScript、61 个前端文件/249 项测试、production +build 和 `npm audit --audit-level=high` 均通过。额外 govulncheck 如实报告本机 Go 1.26.5 +标准库的 5 项已知问题(上游修复版本 1.26.6);它们不是仓库模块或本次变更,不能把本机 +扫描写成 zero finding,GitHub CI 的 latest Go 1.25 patch 扫描仍是合并门。生成后的 +OpenAPI 为 117 paths / 130 operations / 293 schemas;Fork DTO 不含 Workspace root、 +内部 Run config 或 continuity 正文。 + +前一检查点是 issue #100 / schema v116 的普通模式 Run-owned 命令闭环。root Supervisor 仅在 Code/Local/Deliver、durable `full_access`、当前 execution lease 与 进程内 danger-full-access capability 同时成立时看到 `command_runtime`。固定 PowerShell/Bash 与绝对路径原生进程支持有序批次、单调 stdout/stderr cursor、受限 diff --git a/docs/README.md b/docs/README.md index f964f98e..16753e19 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ This directory separates user-facing documentation, current engineering state, a | [README 中文](../README.md) / [README English](../README.en.md) | 产品定位、核心能力、快速开始和历史阶段索引 | | [产品范围 / Product Scope](PRODUCT_SCOPE.md) | 当前核心范围、可选附加能力和扩展接口 | | [使用手册 / Usage](usage.md) | CLI、Provider、Workspace、Run、审批和操作者工作流 | +| [Workspace Checkpoints](workspace-checkpoints.md) | 检查点时间线、预览、Undo/Redo/Rewind、独立 Fork 与故障处理 | | [Windows Desktop 计划](DESKTOP_PLAN.md) | 桌面架构、发布门和仍未开放的能力 | | [Skill 包计划](SKILL_PACKAGE_PLAN.md) | 惰性 Skill 导入、校验和未来分发边界 | @@ -24,6 +25,7 @@ This directory separates user-facing documentation, current engineering state, a | [HTTP API](http-api.md) | 认证 API 行为与 DTO 边界 | | [OpenAPI](openapi.json) | 由 Go 生成并受测试保护的机器可读合同 | | [错误模型 / Errors](errors.md) | 稳定错误类别、CLI 退出码和 HTTP 映射 | +| [ADR 0118 / Workspace Checkpoints](adr/0118-transactional-workspace-checkpoints.md) | 事务边界、内容寻址、三方恢复、崩溃收敛与权限模型 | | [ADR 索引](adr/) | 权限、持久化、执行、浏览器、Desktop 等架构决策 | ## 当前工程上下文 / Current Engineering Context diff --git a/docs/adr/0118-transactional-workspace-checkpoints.md b/docs/adr/0118-transactional-workspace-checkpoints.md new file mode 100644 index 00000000..7228e368 --- /dev/null +++ b/docs/adr/0118-transactional-workspace-checkpoints.md @@ -0,0 +1,216 @@ +# ADR 0118: 事务化 Workspace Checkpoint、Rewind 与 Fork / Transactional Workspace Checkpoints, Rewind, and Fork + +Date: 2026-08-19 + +## Status / 状态 + +Accepted for schema v117 and `workspace-checkpoint.v1`. + +已接受,用于 schema v117 与 `workspace-checkpoint.v1`。 + +## Context / 背景 + +FileEdit receipts protect one reviewed file operation, while Git records only +committed or explicitly staged state. A shell batch, build tool, model-callable +Workspace tool, typed Git mutation, or future multi-agent merge can change many +tracked and untracked files and the Git index at once. Run checkpoints and context +compaction do not capture those bytes. Prompt conventions therefore cannot provide +a truthful undo boundary or detect an operator editing the same file concurrently. + +FileEdit 收据只保护一次已审阅文件操作,Git 也只记录已提交或显式暂存状态。Shell +批次、构建工具、模型工作区工具、typed Git mutation 或未来的多代理合并可以同时改动多个 +tracked/untracked 文件与 index;Run checkpoint 和上下文压缩不保存这些字节。因此仅靠 +Prompt 约定既不能形成可信 Undo 边界,也无法发现操作者并发修改。 + +Recovery is a new write, not a time-travel authority grant. A historical state +must never revive a lease, approval, credential, process, network grant, execution +profile, or capability that was valid when the checkpoint was created. + +恢复是一次新的写操作,不是历史权限复活。历史状态不得恢复当时有效的 lease、审批、 +凭据、进程、网络授权、execution profile 或进程 capability。 + +## Decision / 决策 + +### 1. Immutable protocol and bounded content store / 不可变协议与有界内容仓库 + +`workspace-checkpoint.v1` seals the exact Run, Mission, Session, Workspace, +attempt, capability generation, trigger receipt, parent checkpoint, canonical +root fingerprint/path hash, base commit, branch, raw Git-index hash/blob, +deterministically sorted worktree manifest, recovery grade, reasons, byte counts, +operator/title attribution, and creation time. + +Manifest entries distinguish file/directory/link/special state, tracked/staged, +mode, size, worktree hash, index OID/mode, binary and newline classification, +storage policy, recoverability, and a bounded reason. Content is addressed by +SHA-256 and stored once across checkpoints. SQLite reference counts are updated +only by sealing triggers; referenced content is immutable and cannot be collected. +GC removes only zero-reference blobs. + +The fixed ceilings are 20,000 entries per checkpoint, 4 MiB per ordinary file, +32 MiB for the raw index, 64 MiB of referenced blobs per checkpoint, 2,000 preview +changes, 256 conflicts, 2 GiB for the global blob store, 10,000 checkpoints, +2,000,000 manifest entries, and 20,000 transactions. SQLite insert triggers enforce +both content and metadata quotas so concurrent writers cannot bypass them. Ignored, generated, +large, sensitive-looking, linked/reparse, special, unreadable, and out-of-root +content is represented explicitly and is never silently advertised as restorable. +There is no at-rest encryption claim: secret-like files are excluded rather than +written to the blob database. + +`workspace-checkpoint.v1` 固定 Run/Mission/Session/Workspace、attempt、capability +generation、触发收据、父节点、规范根目录指纹/路径摘要、base commit、branch、原始 Git +index hash/blob、稳定排序 manifest、可恢复等级、原因、字节计数以及操作者/标题。内容以 +SHA-256 寻址并跨检查点去重;SQLite sealing trigger 维护引用计数,GC 只删除零引用 blob。 +单文件、index、单检查点和全局 blob 仓库分别受 4 MiB、32 MiB、64 MiB、2 GiB 硬上限; +全局 checkpoint、manifest entry 和 transaction 另受 10,000、2,000,000 和 20,000 条上限。 +ignored/generated/large/sensitive/link/reparse/special/unreadable/external 内容均显式标记。 +本协议不宣称静态加密;疑似敏感文件不会进入 blob 数据库。 + +### 2. Transaction boundaries and attribution / 事务边界与归因 + +Every integrated mutation receives one deterministic operation identity and one +pre/post transaction, not one checkpoint per low-level syscall: + +| Source | Boundary | Attribution | +|---|---|---| +| FileEdit apply and `agent-code-tools.v1` apply/delete | before the durable apply; after success, denial, failure, or replay | exact edit/tool receipt, invocation, attempt, capability generation, execution lease | +| `command-runtime.v2` foreground batch | once around the ordered batch | command operation and Supervisor binding | +| background command Job | at Start; completed by terminal read/wait/stdin/cancel/kill/reconciliation | Job operation and current owner/lease binding | +| typed Git mutation | around stage/unstage/commit/branch mutation | Git operation receipt | +| agent merge contract | public `BeginBoundary`/`CompleteBoundary` around the future merge writer | merge receipt | + +Only a current running Run, active Session, and exact unexpired execution lease can +open an automatic mutation boundary. One Run cannot have two open Workspace +transactions. A terminal transaction binds its post-checkpoint to the CAS cursor; +historical rows remain immutable. + +Shell attribution uses before/after manifests plus Git/index state. No portable +filesystem watcher is currently installed, so command boundaries always carry the +explicit reason `filesystem watcher attribution unavailable`; their grade is +partial even if all observed Workspace bytes are stored. Effects outside the root, +registry/database/service changes, escaped descendants, and network effects are +outside the recovery claim. + +### 3. Three-way preview and fail-closed restore / 三方预览与失败关闭恢复 + +Preview captures a fresh, non-persisted observed manifest and compares: + +1. the cursor checkpoint reviewed by the operator; +2. the historical target; +3. the live observed Workspace and raw Git index. + +Text, binary, create, modify, rename, delete, untracked files, modes, newlines, and +the index are compared by exact hashes. If live state no longer equals the reviewed +current side for any path the target would touch, the result is a bounded conflict, +not an overwrite. Root identity, root path case, base commit, branch, links/reparse +points, unsupported content, and index drift also stop the operation. + +Confirmed Undo, Redo, and Rewind require the exact preview cursor, a paused Code/ +Deliver Run, active Session, no live execution lease, a non-conservative current +permission, a matching process-start capability, and an explicit operator. The +operation first writes a preflight checkpoint and prepared transaction. Restore +uses root-confined atomic file replacement/removal and exact raw-index replacement +through Git's standard `index.lock`; the current index presence and digest are +rechecked before rename, including the exact missing-index state; +it never calls `git reset --hard`, overwrites the tree wholesale, or blanket-deletes +untracked files. A final capture must match the target manifest/index/base identity +before the new post-checkpoint and transaction become terminal. + +### 4. Undo/Redo, Rewind, and independent Fork / Undo、Redo、Rewind 与独立 Fork + +Undo targets the `before` side of the boundary whose `after` checkpoint is the +current cursor. Redo is valid only when the current cursor is the terminal result +of an Undo and targets the original mutation's `after` side. Rewind targets any +materializable checkpoint in the same Run. Each is appended as another immutable +transaction; none rewrites older history. + +Fork creates a new branch at the historical base commit through typed literal Git +argv, verifies the exact branch and full commit, restores the target into a new +worktree, then atomically registers a distinct Workspace, Mission, Run, Session, +initial events, and continuity node. The new Run begins in `created`; its permission, +profile authority, approvals, credentials, capabilities, leases, terminals, +processes, and network grants are not inherited. The source cursor does not move. +If registration succeeds but final checkpoint persistence is interrupted, the +prepared fork remains replayable and restart reconciliation finishes the exact +new Run instead of creating a second worktree. +If the process stops after worktree creation but before durable Run registration, +the prepared transaction retains private, non-JSON cleanup identity. Reconciliation +verifies the exact source, destination, branch, and commit before asking Git to +remove the orphan. It also re-captures the complete manifest and raw index, so +post-crash user edits are preserved; any drift fails closed and leaves the +transaction retryable. + +### 5. WAL, reconciliation, and events / WAL、重启收敛与事件 + +Blobs, entries, the seal, and the creation event commit in one SQLite transaction; +an unsealed manifest is never readable. Transaction preparation precedes file +application, and terminal transaction/event persistence is atomic. Operation-key +digests plus request fingerprints make exact retries converge and reject changed +intent. Run-state advancement is compare-and-swap. + +At startup, reconciliation enumerates prepared/applying transactions. An unfinished +ordinary boundary whose cursor never reached its `before` checkpoint first advances +that exact expected cursor by CAS, then captures the observed partial result with an +explicit restart reason and closes as `interrupted`; an unexpired execution lease +blocks reconciliation instead of letting a second process close a live writer's +boundary. If a non-Fork transaction became terminal but the final cursor update did +not commit, reconciliation CAS-advances the cursor from the exact `before` checkpoint +to the terminal `after` checkpoint. Fork is excluded because its source cursor must +never move. An identical explicit restore retry can resume only while the cursor, +Workspace identity, current authority, and manifests still agree. A registered Fork with +no final cursor re-captures/verifies the existing independent worktree; missing or +drifted identities fail closed. No persisted PID, lease, or capability is restored. +An interrupted pre-registration Fork is closed only after its exact verified +worktree/branch has been removed. Manual capture also conflicts while any mutation +boundary for the same Run remains open, so it cannot move the cursor underneath an +in-flight writer. + +### 6. Equivalent product surfaces / 等价产品入口 + +- Desktop has a bilingual Checkpoints tab with immutable timeline, provenance, + recovery grade/reasons, affected paths, conflict explanation, explicit preview + and confirmation, Undo/Redo/Rewind, manual capture, and Fork name/branch fields. + React neither submits nor receives an absolute worktree path; Go derives a + deterministic absent sibling from the trusted source root and operation digest. +- CLI exposes `cyberagent workspace checkpoint timeline|capture|preview|rewind|undo|redo|fork`. +- OpenAPI exposes GET/POST timeline/capture plus `/preview`, `/rewind`, `/undo`, + `/redo`, and `/fork`. Mutations require the control bearer and explicit + confirmation; timeline uses the read bearer. + +All three call the same Application service. UI/CLI/API confirmation does not +bypass server-side authority or CAS checks. + +## Alternatives considered / 备选方案 + +- `git reset --hard` plus `git clean`: rejected because it loses dirty-index and + untracked user work and cannot represent non-Git Workspaces. +- Copy the whole Workspace per prompt: rejected as unbounded, duplicate-heavy, + link-unsafe, and unable to explain exclusions. +- Trust only Git status: rejected because status does not preserve untracked or + binary content, exact index bytes, modes, or concurrent file identity. +- Treat context checkpoint/Fork as Workspace recovery: rejected because bounded + conversation state neither contains Workspace bytes nor grants write authority. +- Resume silently on restart: rejected because runtime authorization and external + state may have changed. + +## Consequences / 后果 + +- Common in-root file mutations become inspectable and reversible without silently + modifying Git history. +- Checkpoints can be partial or unavailable by design; the product explains why. +- Repeated unchanged checkpoints are cheap through content-addressed deduplication, + but immutable retained history still consumes manifest rows and referenced blobs. +- Host shell execution remains unsandboxed. The checkpoint is evidence and bounded + file recovery, not a rollback guarantee for all process side effects. + +## Verification / 验证 + +Tests cover text/binary, create/modify/rename/delete, untracked content, dirty raw +index, CRLF, Windows exact casing, links, sensitive and oversized exclusions, +external edits, cursor races, idempotent replay, Undo/Redo/Rewind, independent Git +worktree Fork, pre-registration orphan cleanup, injected post-registration Fork +persistence failure and restart reconciliation, immutable SQLite rows, +refcounts/GC/migrations, strict HTTP auth and +JSON, CLI round trips, Desktop timeline/preview confirmation, generated OpenAPI, +and command/file/Git boundary integrations. Platform-specific Windows casing tests +skip explicitly on non-Windows hosts; POSIX and Windows command-runtime suites cover +their respective process attribution paths. diff --git a/docs/architecture.md b/docs/architecture.md index c008170a..aa149bc6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1174,3 +1174,44 @@ explicit network commands are denied, and any Policy result requiring approval i routed away from this automatic tool. Because `full_access` is still unsandboxed host execution, network or credential use requires a separate exact review, while Docker `network none` remains the path for containment evidence. + +## Transactional Workspace Checkpoints + +ADR 0118 and schema v117 add `workspace-checkpoint.v1` as a storage and Application +boundary below all product surfaces. A checkpoint binds one Run/Workspace/root +fingerprint to its base commit, branch, exact raw Git index, deterministically ordered +tracked/untracked manifest, source receipt, attempt/capability generation, and recovery +grade. File and index content is SHA-256 addressed and deduplicated; SQLite sealing +triggers make referenced blobs immutable and maintain references atomically. Per-entry, +per-index, per-checkpoint, preview/conflict, and global-store ceilings are fixed in Go, +with the 2-GiB store bound also enforced by SQLite so concurrent writers cannot bypass +it. Ignored, generated, large, sensitive-looking, linked/reparse, special, unreadable, +and external content is explicit evidence, not an implicit recovery promise. + +FileEdit/model Workspace writes, Run-owned foreground batches/background Jobs, and +typed Git writes open and close one operation-keyed transaction around the logical +mutation. The same public boundary is available to the agent-merge writer. Manifest, +Git-status/index, receipt, invocation, attempt, capability-generation, and lease facts +provide attribution. Since no portable filesystem watcher is installed, Shell-derived +checkpoints deliberately remain `partial`; effects outside the canonical root are never +claimed as reversible. + +Restore is an append-only transaction under current authority. Preview compares the +reviewed cursor, historical target, and a fresh live capture. Path/index/root/branch/ +commit/case/link drift conflicts before any write. Confirmed Undo/Redo/Rewind requires +a paused Code/Deliver Run, active Session, no current execution lease, exact process +capability, non-conservative permission, explicit operator, expected cursor, and stable +operation key. Application uses root-confined atomic file writes plus exact raw-index +replacement, never `git reset --hard` or blanket untracked cleanup. Terminal capture +must exactly verify the target before the CAS cursor advances. + +Fork creates and verifies a distinct Git worktree and branch, then atomically registers +a new Workspace/Mission/Run/Session/continuity node. It copies file/index state but no +approval, credential, capability, execution lease, process, terminal, or network grant; +the source history and cursor stay immutable. CLI may provide an explicit operator path; +HTTP/Desktop never accept one and instead use a Go-derived sibling keyed by the operation +digest, with no root path in the response. Prepared operations reconcile on startup: +ordinary interrupted boundaries close with explicit partial evidence, restores resume +only under the same live identity, and a registered Fork finishes its existing Run +instead of creating a duplicate. Desktop, CLI, and OpenAPI are thin projections over +this single service. Operational details are in [Workspace Checkpoints](workspace-checkpoints.md). diff --git a/docs/http-api.md b/docs/http-api.md index 1a60cb1c..451de1a8 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -1,8 +1,8 @@ # 本地 HTTP API / Local HTTP API -CyberAgent Workbench 提供由 Go 控制的本地 `api.v1`,用于检查 SQLite 持久状态并投影可恢复 Run events。独立 capability 允许受控 Run/Session/Plan/审批、固定命令提案审阅、Provider 诊断/路由/系统凭证、FileEdit 提案/只读恢复/审阅/apply、wake 意图/前台消费、不可变操作者验证、metadata-only 快照回执及其不授权复核、惰性 Skill 安装、schema v116 的 Run-owned 普通命令运行时,以及 schema v99 默认关闭的精确 Docker Sandbox 产品执行。只读面还提供 capability/worker health、exact-root Repository 状态与脱敏 Diff、非原子的多文件 FileEdit 汇总、逐验证项确定性快照下载/回执历史和带有界复核元数据的可重建 Code Handoff。API 不直接接受 Shell/argv/stdin 或 Job mutation endpoint;命令只能由认证 Run execution 内的 root Supervisor 通过同一 Tool Gateway/Application 服务发起,仍受 Code/Local/Deliver/full-access、当前租约、Policy、无网络/无凭证与进程启动 capability 约束。 +CyberAgent Workbench 提供由 Go 控制的本地 `api.v1`,用于检查 SQLite 持久状态并投影可恢复 Run events。独立 capability 允许受控 Run/Session/Plan/审批、固定命令提案审阅、Provider 诊断/路由/系统凭证、FileEdit 提案/只读恢复/审阅/apply、wake 意图/前台消费、不可变操作者验证、metadata-only 快照回执及其不授权复核、惰性 Skill 安装、schema v116 的 Run-owned 普通命令运行时、schema v117 的 Workspace Checkpoint 时间线/恢复/Fork,以及 schema v99 默认关闭的精确 Docker Sandbox 产品执行。只读面还提供 capability/worker health、exact-root Repository 状态与脱敏 Diff、非原子的多文件 FileEdit 汇总、逐验证项确定性快照下载/回执历史和带有界复核元数据的可重建 Code Handoff。API 不直接接受 Shell/argv/stdin 或 Job mutation endpoint;命令只能由认证 Run execution 内的 root Supervisor 通过同一 Tool Gateway/Application 服务发起,仍受 Code/Local/Deliver/full-access、当前租约、Policy、无网络/无凭证与进程启动 capability 约束。 -CyberAgent Workbench exposes a Go-controlled local `api.v1` for durable SQLite state and resumable Run-event projections. Independent capabilities permit controlled Run/Session/Plan/approval operations, fixed-command proposal review, Provider diagnostics/routes/system credentials, operator price-snapshot import and listing, FileEdit propose/read-only recovery/review/apply, wake intent/foreground consumption, immutable operator verification, metadata-only snapshot receipts and their non-authorizing review, inert Skill installation, the schema-v116 Run-owned ordinary command runtime, and schema-v99 exact Docker Sandbox execution that is disabled by default. Read-only surfaces also expose capabilities/worker health, exact-root Repository state and redacted Diffs, non-atomic multi-file FileEdit summaries, deterministic per-check verification snapshot downloads/receipt history, and a regenerable Code handoff with bounded review metadata. There is no direct HTTP Shell/argv/stdin or Job-mutation endpoint: only an authenticated Run execution may let its root Supervisor call the same Tool Gateway/Application service under Code/Local/Deliver/full-access, current-lease, Policy, no-network/no-credential, and process-startup gates. +CyberAgent Workbench exposes a Go-controlled local `api.v1` for durable SQLite state and resumable Run-event projections. Independent capabilities permit controlled Run/Session/Plan/approval operations, fixed-command proposal review, Provider diagnostics/routes/system credentials, operator price-snapshot import and listing, FileEdit propose/read-only recovery/review/apply, wake intent/foreground consumption, immutable operator verification, metadata-only snapshot receipts and their non-authorizing review, inert Skill installation, the schema-v116 Run-owned ordinary command runtime, schema-v117 Workspace Checkpoint timeline/restore/Fork operations, and schema-v99 exact Docker Sandbox execution that is disabled by default. Read-only surfaces also expose capabilities/worker health, exact-root Repository state and redacted Diffs, non-atomic multi-file FileEdit summaries, deterministic per-check verification snapshot downloads/receipt history, and a regenerable Code handoff with bounded review metadata. There is no direct HTTP Shell/argv/stdin or Job-mutation endpoint: only an authenticated Run execution may let its root Supervisor call the same Tool Gateway/Application service under Code/Local/Deliver/full-access, current-lease, Policy, no-network/no-credential, and process-startup gates. ## 启动 / Start @@ -337,6 +337,26 @@ allowlist/scoped egress 需要尚未实现的 Go-owned host/port/protocol guard `managed_egress_unavailable`;无 Docker 时不会退回宿主执行。见 [ADR 0099](adr/0099-docker-sandbox-product-admission-and-recovery.md)。 +## Workspace Checkpoint API + +Schema v117 的 checkpoint timeline 使用 read bearer;manual capture、Rewind、Undo、 +Redo 与 Fork 使用不同的 control bearer,并要求进程已开启 +`workspace_checkpoint_control_enabled`。所有 POST 都严格拒绝未知/重复字段与超限 body。 +Preview 是只读的三方比较,但仍使用 control bearer,因为它会采集 live Workspace 状态; +Rewind/Undo/Redo/Fork 还必须提交 `confirm: true`、稳定 operation key 和精确 +`expected_current_checkpoint_id`。服务端随后重新检查 paused Code/Deliver Run、active +Session、当前权限、进程 capability、无 execution lease、root/Git identity 与 CAS cursor; +客户端确认不能绕过这些门禁。 + +Timeline 返回检查点来源、attempt/capability generation、Git/index/manifest 摘要、恢复 +等级和不完整原因。Preview 返回有界 path/index change 与冲突;它不写文件。恢复追加新的 +transaction/checkpoint/event,不改写历史,不接受任意路径或 Git argv,也不恢复历史授权。 +Fork 的 branch 由 Go 规范化、验证和创建;目标路径不进入 HTTP body,而是根据受信 source +Workspace 和 operation key 确定性生成同级 `prayu-fork-` worktree。成功响应 +只返回安全的 Workspace/Run ID 与状态,不返回 `RootPath`、内部 Run config 或 continuity +正文。精确请求/响应 schema 见 `docs/openapi.json`,操作流程见 +[Workspace Checkpoints](workspace-checkpoints.md)。 + ## Endpoints | Method | Path | Result / Filters | @@ -380,6 +400,13 @@ allowlist/scoped egress 需要尚未实现的 Go-owned host/port/protocol guard | `GET` | `/api/v1/runs/{run_id}/project-instructions` | Pinned/live hierarchical sources, hashes, why-effective/conflict details, history, and non-mutating drift diff | | `POST` | `/api/v1/runs/{run_id}/project-instructions/refresh` | Pinned-and-reviewed-live dual-fingerprint confirmation that appends a new immutable instruction revision | | `POST` | `/api/v1/runs/{run_id}/continuity-checkpoints` | Capture one bounded, redacted, provenance-bearing, all-false-authority context checkpoint | +| `GET` | `/api/v1/runs/{run_id}/workspace-checkpoints` | Bounded immutable Workspace checkpoint timeline, cursor, transaction provenance, recovery grade, and reasons | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints` | Control-bearer, operation-keyed manual capture; no file mutation or authority grant | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/preview` | Fresh live/current/target three-way diff and bounded conflicts; no file mutation | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/rewind` | Explicitly confirmed, exact-cursor append-only restore to one checkpoint | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/undo` | Explicitly confirmed undo of the current terminal mutation boundary | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/redo` | Explicitly confirmed redo available only after the matching Undo terminal state | +| `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/fork` | Explicitly confirmed independent Git worktree/Workspace/Mission/Run/Session Fork; no authority inheritance | | `GET` | `/api/v1/runs/{run_id}/external-skills` | Bounded external-Skill provenance and root/Specialist delivery counts; no content, paths, digests, or private identities | | `GET` | `/api/v1/runs/{run_id}/activity` | Chronological public model updates plus allowlisted Harness facts; redacted/bounded, no private reasoning, raw payload, Prompt, Tool arguments, or Tool output | | `GET` | `/api/v1/runs/{run_id}/events` | Ordered Run events; pagination | @@ -561,7 +588,7 @@ cyberagent api openapi --output docs/openapi.json 运行时的 `/api/v1/openapi.json` 返回同一份原始文档,仍要求 loopback 与 read Bearer 认证,不接受 query 或 body。它使用 `application/vnd.oai.openapi+json`,不套普通 `api.v1` envelope。当前契约有 111 个 path、123 个 operation 和 274 个 schema。测试逐条命中公开 handler,并确认普通 DTO 不包含 Workspace root、Artifact/Skill/Session 正文、模型输出、工具参数、私有 lifecycle、operation/fencing/lease owner、API key、Provider Base URL 或环境变量名。CDP 权限响应只投影固定能力布尔值、当前进程闸门和四项 false authority,不包含 endpoint、browser path、Profile、Cookie、请求正文或 CDP payload。 -The runtime `/api/v1/openapi.json` returns the same raw document under the loopback and read-bearer boundary and accepts neither a query nor a body. It uses `application/vnd.oai.openapi+json` rather than the ordinary `api.v1` envelope. The contract contains 111 paths, 123 operations, and 274 schemas. Tests exercise every handler and verify that ordinary DTOs omit Workspace roots, Artifact/Skill/Session bodies, model output, Tool arguments, private lifecycle, operation/fencing/lease-owner identities, API keys, Provider base URLs, and environment-variable names. CDP permission responses expose only closed capability booleans, current process gates, and four false authority fields; they contain no endpoint, browser path, Profile, Cookie, request body, or CDP payload. +The runtime `/api/v1/openapi.json` returns the same raw document under the loopback and read-bearer boundary and accepts neither a query nor a body. It uses `application/vnd.oai.openapi+json` rather than the ordinary `api.v1` envelope. The contract contains 117 paths, 130 operations, and 293 schemas. Tests exercise every handler and verify that ordinary DTOs omit Workspace roots, Artifact/Skill/Session bodies, model output, Tool arguments, private lifecycle, operation/fencing/lease-owner identities, API keys, Provider base URLs, and environment-variable names. CDP permission responses expose only closed capability booleans, current process gates, and four false authority fields; they contain no endpoint, browser path, Profile, Cookie, request body, or CDP payload. ## 主动取消 / Active-Call Cancellation diff --git a/docs/openapi.json b/docs/openapi.json index 63e9d1af..3feb6689 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -9862,38 +9862,49 @@ "x-cyberagent-read-only": true } }, - "/api/v1/sandbox/docker/admissions": { - "post": { - "operationId": "admitDockerSandbox", - "summary": "Admit an exact Docker Sandbox request", - "description": "Recomputes every current Go-owned gate and records an idempotent process-bound admission. Idempotency-Key is required.", + "/api/v1/runs/{run_id}/workspace-checkpoints": { + "get": { + "operationId": "listWorkspaceCheckpoints", + "summary": "Browse the Workspace checkpoint timeline", + "description": "Returns immutable pre/post Workspace manifests, mutation transactions, the CAS cursor, recovery completeness, and content-store usage without exposing blob content.", "tags": [ - "Sandbox" + "Runs" ], "parameters": [ { - "name": "Idempotency-Key", - "in": "header", - "description": "Opaque retry key; only a domain-separated digest is persisted", + "name": "run_id", + "in": "path", + "description": "Run identity", "required": true, "schema": { "maxLength": 256, - "minLength": 16, - "pattern": "^\\S+$", + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum checkpoints and transactions", + "schema": { + "default": 100, + "maximum": 2000, + "minimum": 1, + "type": "integer" + } } ], "responses": { "200": { - "description": "Control request accepted or idempotently replayed", + "description": "Successful read", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/DockerSandboxAdmissionView" + "$ref": "#/components/schemas/WorkspaceCheckpointTimeline" }, "request_id": { "maxLength": 256, @@ -9924,21 +9935,12 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "412": { - "$ref": "#/components/responses/FailedPrecondition" - }, - "413": { - "$ref": "#/components/responses/RequestEntityTooLarge" + "404": { + "$ref": "#/components/responses/NotFound" }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -9946,56 +9948,39 @@ "$ref": "#/components/responses/InternalError" } }, - "security": [ - { - "ControlBearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DockerSandboxAdmissionRequestView" - } - } - } - }, - "x-cyberagent-read-only": false - } - }, - "/api/v1/sandbox/docker/cancellations": { + "x-cyberagent-read-only": true + }, "post": { - "operationId": "cancelDockerSandbox", - "summary": "Cancel a Docker Sandbox attempt", - "description": "Persists sticky cancellation and converges bounded cleanup without restoring start authority. Idempotency-Key is required.", + "operationId": "createWorkspaceCheckpoint", + "summary": "Create a manual Workspace checkpoint", + "description": "Captures a bounded content-addressed manifest and exact Git index under an idempotency key; excluded content and incomplete attribution remain explicit.", "tags": [ - "Sandbox" + "Control" ], "parameters": [ { - "name": "Idempotency-Key", - "in": "header", - "description": "Opaque retry key; only a domain-separated digest is persisted", + "name": "run_id", + "in": "path", + "description": "Run identity", "required": true, "schema": { "maxLength": 256, - "minLength": 16, - "pattern": "^\\S+$", + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } ], "responses": { - "200": { - "description": "Control request accepted or idempotently replayed", + "201": { + "description": "Resource created or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/DockerSandboxCancellationView" + "$ref": "#/components/schemas/Checkpoint" }, "request_id": { "maxLength": 256, @@ -10026,6 +10011,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "409": { "$ref": "#/components/responses/Conflict" }, @@ -10058,7 +10046,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DockerSandboxCancelRequestView" + "$ref": "#/components/schemas/workspaceCheckpointCaptureView" } } } @@ -10066,24 +10054,38 @@ "x-cyberagent-read-only": false } }, - "/api/v1/sandbox/docker/readiness": { + "/api/v1/runs/{run_id}/workspace-checkpoints/fork": { "post": { - "operationId": "evaluateDockerSandboxReadiness", - "summary": "Evaluate Docker Sandbox readiness", - "description": "Performs a non-mutating, no-cache readiness evaluation for one exact Go-validated plan and Manifest. It cannot accept a daemon endpoint, host path, image override, mount override, or Docker flag.", + "operationId": "forkWorkspaceCheckpoint", + "summary": "Fork a checkpoint into an independent Run", + "description": "Creates an exact branch-backed Git worktree, a distinct Workspace and a new Run. Context is bounded; approvals, credentials, capabilities, leases, processes, terminals, network authority, and execution profiles are reset.", "tags": [ - "Sandbox" + "Control" + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } ], "responses": { - "200": { - "description": "Successful read", + "201": { + "description": "Resource created or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/DockerSandboxReadinessView" + "$ref": "#/components/schemas/workspaceCheckpointForkResultView" }, "request_id": { "maxLength": 256, @@ -10114,9 +10116,24 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10124,37 +10141,42 @@ "$ref": "#/components/responses/InternalError" } }, + "security": [ + { + "ControlBearerAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DockerSandboxReadinessRequestView" + "$ref": "#/components/schemas/workspaceCheckpointForkView" } } } }, - "x-cyberagent-read-only": true + "x-cyberagent-read-only": false } }, - "/api/v1/sandbox/docker/starts": { + "/api/v1/runs/{run_id}/workspace-checkpoints/preview": { "post": { - "operationId": "startDockerSandbox", - "summary": "Start an admitted Docker Sandbox", - "description": "Synchronously executes the exact admitted plan through the Go-owned lifecycle; client disconnect cancels and cleans up. Idempotency-Key is required.", + "operationId": "previewWorkspaceRewind", + "summary": "Preview a Workspace rewind", + "description": "Computes a bounded three-way diff against live files and the Git index without writing. External drift is returned as explicit conflicts.", "tags": [ - "Sandbox" + "Control" ], "parameters": [ { - "name": "Idempotency-Key", - "in": "header", - "description": "Opaque retry key; only a domain-separated digest is persisted", + "name": "run_id", + "in": "path", + "description": "Run identity", "required": true, "schema": { "maxLength": 256, - "minLength": 16, - "pattern": "^\\S+$", + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } @@ -10168,7 +10190,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/DockerSandboxStatusView" + "$ref": "#/components/schemas/WorkspaceRestoreResult" }, "request_id": { "maxLength": 256, @@ -10199,6 +10221,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "409": { "$ref": "#/components/responses/Conflict" }, @@ -10231,7 +10256,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DockerSandboxStartRequestView" + "$ref": "#/components/schemas/workspaceCheckpointPreviewView" } } } @@ -10239,37 +10264,38 @@ "x-cyberagent-read-only": false } }, - "/api/v1/sandbox/docker/status": { - "get": { - "operationId": "getDockerSandboxStatus", - "summary": "Inspect Docker Sandbox status", - "description": "Returns the content-free admission, launch, terminal outcome, cleanup, and artifact-count projection.", + "/api/v1/runs/{run_id}/workspace-checkpoints/redo": { + "post": { + "operationId": "redoWorkspaceMutation", + "summary": "Redo the latest Workspace undo", + "description": "Restores the original mutation's after checkpoint under the same paused-Run, CAS, permission, and conflict checks as rewind.", "tags": [ - "Sandbox" + "Control" ], "parameters": [ { - "name": "admission_id", - "in": "query", - "description": "Opaque Docker Sandbox admission identity", + "name": "run_id", + "in": "path", + "description": "Run identity", "required": true, "schema": { "maxLength": 256, "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } ], "responses": { "200": { - "description": "Successful read", + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/DockerSandboxStatusView" + "$ref": "#/components/schemas/WorkspaceRestoreResult" }, "request_id": { "maxLength": 256, @@ -10303,9 +10329,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10313,56 +10351,56 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workspaceCheckpointCursorActionView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/sessions": { - "get": { - "operationId": "listSessions", - "summary": "List Sessions", - "description": "Returns a bounded creation-ordered keyset page of persisted Sessions. Updates and later Sessions cannot shift continuation pages.", + "/api/v1/runs/{run_id}/workspace-checkpoints/rewind": { + "post": { + "operationId": "rewindWorkspaceCheckpoint", + "summary": "Rewind to a Workspace checkpoint", + "description": "Requires confirm=true, a paused quiescent Run, current operator authorization, and an unchanged preview cursor. Restore is an auditable new write and never uses hard reset or blanket untracked deletion.", "tags": [ - "Sessions" + "Control" ], "parameters": [ { - "name": "limit", - "in": "query", - "description": "Page size from 1 to 100; defaults to 50", - "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "name": "cursor", - "in": "query", - "description": "Opaque cursor bound to this route and exact filter set", + "name": "run_id", + "in": "path", + "description": "Run identity", + "required": true, "schema": { - "maxLength": 512, + "maxLength": 256, "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } ], "responses": { "200": { - "description": "Successful read", + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "items": { - "$ref": "#/components/schemas/SessionView" - }, - "type": "array" - }, - "page": { - "$ref": "#/components/schemas/Page" + "$ref": "#/components/schemas/WorkspaceRestoreResult" }, "request_id": { "maxLength": 256, @@ -10377,8 +10415,7 @@ "required": [ "version", "request_id", - "data", - "page" + "data" ], "type": "object" } @@ -10394,9 +10431,24 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10404,22 +10456,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workspaceCheckpointRewindView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/sessions/{session_id}": { - "get": { - "operationId": "getSession", - "summary": "Inspect a Session", - "description": "Returns Session metadata and its optional bound Run.", + "/api/v1/runs/{run_id}/workspace-checkpoints/undo": { + "post": { + "operationId": "undoWorkspaceMutation", + "summary": "Undo the current Workspace mutation", + "description": "Restores the current mutation's before checkpoint with three-way conflict detection and appends a new immutable undo transaction.", "tags": [ - "Sessions" + "Control" ], "parameters": [ { - "name": "session_id", + "name": "run_id", "in": "path", - "description": "Session identity", + "description": "Run identity", "required": true, "schema": { "maxLength": 256, @@ -10431,14 +10498,14 @@ ], "responses": { "200": { - "description": "Successful read", + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SessionDetailView" + "$ref": "#/components/schemas/WorkspaceRestoreResult" }, "request_id": { "maxLength": 256, @@ -10472,9 +10539,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10482,33 +10561,48 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workspaceCheckpointCursorActionView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/sessions/{session_id}/archive": { + "/api/v1/sandbox/docker/admissions": { "post": { - "operationId": "archiveSession", - "summary": "Archive a Session", - "description": "Removes a Session from active conversation history while retaining its messages and audit records. Replays are idempotent.", + "operationId": "admitDockerSandbox", + "summary": "Admit an exact Docker Sandbox request", + "description": "Recomputes every current Go-owned gate and records an idempotent process-bound admission. Idempotency-Key is required.", "tags": [ - "Control" + "Sandbox" ], "parameters": [ { - "name": "session_id", - "in": "path", - "description": "Session identity", + "name": "Idempotency-Key", + "in": "header", + "description": "Opaque retry key; only a domain-separated digest is persisted", "required": true, "schema": { "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "minLength": 16, + "pattern": "^\\S+$", "type": "string" } } ], "responses": { - "202": { + "200": { "description": "Control request accepted or idempotently replayed", "content": { "application/json": { @@ -10516,7 +10610,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SessionArchiveControlView" + "$ref": "#/components/schemas/DockerSandboxAdmissionView" }, "request_id": { "maxLength": 256, @@ -10547,9 +10641,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "409": { "$ref": "#/components/responses/Conflict" }, @@ -10582,7 +10673,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionArchiveControlRequestView" + "$ref": "#/components/schemas/DockerSandboxAdmissionRequestView" } } } @@ -10590,73 +10681,38 @@ "x-cyberagent-read-only": false } }, - "/api/v1/sessions/{session_id}/messages": { - "get": { - "operationId": "listSessionMessages", - "summary": "List Session messages", - "description": "Returns persisted redacted messages.", + "/api/v1/sandbox/docker/cancellations": { + "post": { + "operationId": "cancelDockerSandbox", + "summary": "Cancel a Docker Sandbox attempt", + "description": "Persists sticky cancellation and converges bounded cleanup without restoring start authority. Idempotency-Key is required.", "tags": [ - "Sessions" + "Sandbox" ], "parameters": [ { - "name": "session_id", - "in": "path", - "description": "Session identity", + "name": "Idempotency-Key", + "in": "header", + "description": "Opaque retry key; only a domain-separated digest is persisted", "required": true, "schema": { "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Page size from 1 to 100; defaults to 50", - "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "name": "cursor", - "in": "query", - "description": "Opaque cursor bound to this route and exact filter set", - "schema": { - "maxLength": 512, - "minLength": 1, + "minLength": 16, + "pattern": "^\\S+$", "type": "string" } - }, - { - "name": "include_compacted", - "in": "query", - "description": "Include compacted historical messages", - "schema": { - "type": "boolean" - } } ], "responses": { "200": { - "description": "Successful read", + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "items": { - "$ref": "#/components/schemas/MessageView" - }, - "type": "array" - }, - "page": { - "$ref": "#/components/schemas/Page" + "$ref": "#/components/schemas/DockerSandboxCancellationView" }, "request_id": { "maxLength": 256, @@ -10671,8 +10727,7 @@ "required": [ "version", "request_id", - "data", - "page" + "data" ], "type": "object" } @@ -10688,12 +10743,21 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10701,51 +10765,42 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true - }, - "post": { - "operationId": "submitSessionMessage", - "summary": "Submit a Run-bound Session message", - "description": "Creates or replays one redacted durable operator-steering record for the exact Run-bound Session. It does not append Session history early, start or resume the Run, acquire a lease, call a model or tool, or grant a capability.", - "tags": [ - "Control" - ], - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Session identity", - "required": true, - "schema": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", - "type": "string" - } - }, + "security": [ { - "name": "Idempotency-Key", - "in": "header", - "description": "Opaque retry key; only a domain-separated digest is persisted", - "required": true, - "schema": { - "maxLength": 256, - "minLength": 16, - "pattern": "^\\S+$", - "type": "string" + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DockerSandboxCancelRequestView" + } } } + }, + "x-cyberagent-read-only": false + } + }, + "/api/v1/sandbox/docker/readiness": { + "post": { + "operationId": "evaluateDockerSandboxReadiness", + "summary": "Evaluate Docker Sandbox readiness", + "description": "Performs a non-mutating, no-cache readiness evaluation for one exact Go-validated plan and Manifest. It cannot accept a daemon endpoint, host path, image override, mount override, or Docker flag.", + "tags": [ + "Sandbox" ], "responses": { - "202": { - "description": "Control request accepted or idempotently replayed", + "200": { + "description": "Successful read", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SessionMessageControlView" + "$ref": "#/components/schemas/DockerSandboxReadinessView" }, "request_id": { "maxLength": 256, @@ -10776,24 +10831,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "412": { - "$ref": "#/components/responses/FailedPrecondition" - }, - "413": { - "$ref": "#/components/responses/RequestEntityTooLarge" - }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -10801,57 +10841,28 @@ "$ref": "#/components/responses/InternalError" } }, - "security": [ - { - "ControlBearerAuth": [] - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionMessageControlRequestView" + "$ref": "#/components/schemas/DockerSandboxReadinessRequestView" } } } }, - "x-cyberagent-read-only": false + "x-cyberagent-read-only": true } }, - "/api/v1/sessions/{session_id}/messages/{message_id}/cancel": { + "/api/v1/sandbox/docker/starts": { "post": { - "operationId": "cancelSessionSteering", - "summary": "Cancel queued Session steering", - "description": "Cancels one exact pending operator-steering message for the bound Session. Prepared, committed, and already consumed input is immutable; this operation does not stop a model call or start execution.", + "operationId": "startDockerSandbox", + "summary": "Start an admitted Docker Sandbox", + "description": "Synchronously executes the exact admitted plan through the Go-owned lifecycle; client disconnect cancels and cleans up. Idempotency-Key is required.", "tags": [ - "Control" + "Sandbox" ], "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Session identity", - "required": true, - "schema": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", - "type": "string" - } - }, - { - "name": "message_id", - "in": "path", - "description": "Operator steering message identity", - "required": true, - "schema": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", - "type": "string" - } - }, { "name": "Idempotency-Key", "in": "header", @@ -10866,7 +10877,7 @@ } ], "responses": { - "202": { + "200": { "description": "Control request accepted or idempotently replayed", "content": { "application/json": { @@ -10874,7 +10885,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SessionSteeringCancellationView" + "$ref": "#/components/schemas/DockerSandboxStatusView" }, "request_id": { "maxLength": 256, @@ -10905,9 +10916,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "409": { "$ref": "#/components/responses/Conflict" }, @@ -10940,7 +10948,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionSteeringCancellationRequestView" + "$ref": "#/components/schemas/DockerSandboxStartRequestView" } } } @@ -10948,24 +10956,23 @@ "x-cyberagent-read-only": false } }, - "/api/v1/sessions/{session_id}/tree": { + "/api/v1/sandbox/docker/status": { "get": { - "operationId": "getSessionContinuityTree", - "summary": "Browse Session checkpoints and branches", - "description": "Returns the connected checkpoint/Fork/Resume component plus compaction, decision, Artifact, Delivery, Git drift, and memory expiry projections.", + "operationId": "getDockerSandboxStatus", + "summary": "Inspect Docker Sandbox status", + "description": "Returns the content-free admission, launch, terminal outcome, cleanup, and artifact-count projection.", "tags": [ - "Sessions" + "Sandbox" ], "parameters": [ { - "name": "session_id", - "in": "path", - "description": "Session identity", + "name": "admission_id", + "in": "query", + "description": "Opaque Docker Sandbox admission identity", "required": true, "schema": { "maxLength": 256, "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } @@ -10979,7 +10986,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SessionTree" + "$ref": "#/components/schemas/DockerSandboxStatusView" }, "request_id": { "maxLength": 256, @@ -11026,38 +11033,53 @@ "x-cyberagent-read-only": true } }, - "/api/v1/skills/packages/install": { - "post": { - "operationId": "installSkillPackage", - "summary": "Install one inert Skill package", - "description": "Imports one explicitly confirmed, strictly validated, bounded archive into the content-addressed untrusted Skill Registry. Import executes no scripts, hooks, commands, tools, Provider calls, or network requests and grants no Run-selection or context-delivery authority.", + "/api/v1/sessions": { + "get": { + "operationId": "listSessions", + "summary": "List Sessions", + "description": "Returns a bounded creation-ordered keyset page of persisted Sessions. Updates and later Sessions cannot shift continuation pages.", "tags": [ - "Control" + "Sessions" ], "parameters": [ { - "name": "Idempotency-Key", - "in": "header", - "description": "Opaque retry key; only a domain-separated digest is persisted", - "required": true, + "name": "limit", + "in": "query", + "description": "Page size from 1 to 100; defaults to 50", "schema": { - "maxLength": 256, - "minLength": 16, - "pattern": "^\\S+$", + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque cursor bound to this route and exact filter set", + "schema": { + "maxLength": 512, + "minLength": 1, "type": "string" } } ], "responses": { - "202": { - "description": "Control request accepted or idempotently replayed", + "200": { + "description": "Successful read", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/SkillPackageInstallView" + "items": { + "$ref": "#/components/schemas/SessionView" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/Page" }, "request_id": { "maxLength": 256, @@ -11072,7 +11094,8 @@ "required": [ "version", "request_id", - "data" + "data", + "page" ], "type": "object" } @@ -11088,21 +11111,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "412": { - "$ref": "#/components/responses/FailedPrecondition" - }, - "413": { - "$ref": "#/components/responses/RequestEntityTooLarge" - }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -11110,37 +11121,22 @@ "$ref": "#/components/responses/InternalError" } }, - "security": [ - { - "ControlBearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SkillPackageInstallRequestView" - } - } - } - }, - "x-cyberagent-read-only": false + "x-cyberagent-read-only": true } }, - "/api/v1/work-items/{work_item_id}": { + "/api/v1/sessions/{session_id}": { "get": { - "operationId": "getWorkItem", - "summary": "Inspect a WorkItem", - "description": "Returns one structured WorkItem.", + "operationId": "getSession", + "summary": "Inspect a Session", + "description": "Returns Session metadata and its optional bound Run.", "tags": [ - "Memory" + "Sessions" ], "parameters": [ { - "name": "work_item_id", + "name": "session_id", "in": "path", - "description": "WorkItem identity", + "description": "Session identity", "required": true, "schema": { "maxLength": 256, @@ -11159,7 +11155,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkItemView" + "$ref": "#/components/schemas/SessionDetailView" }, "request_id": { "maxLength": 256, @@ -11206,53 +11202,38 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces": { - "get": { - "operationId": "listWorkspaces", - "summary": "List Workspaces", - "description": "Returns registered Workspace ids and names without local root paths.", + "/api/v1/sessions/{session_id}/archive": { + "post": { + "operationId": "archiveSession", + "summary": "Archive a Session", + "description": "Removes a Session from active conversation history while retaining its messages and audit records. Replays are idempotent.", "tags": [ - "Workspaces" + "Control" ], "parameters": [ { - "name": "limit", - "in": "query", - "description": "Page size from 1 to 100; defaults to 50", - "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "name": "cursor", - "in": "query", - "description": "Opaque cursor bound to this route and exact filter set", + "name": "session_id", + "in": "path", + "description": "Session identity", + "required": true, "schema": { - "maxLength": 512, + "maxLength": 256, "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } ], "responses": { - "200": { - "description": "Successful read", + "202": { + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "items": { - "$ref": "#/components/schemas/WorkspaceView" - }, - "type": "array" - }, - "page": { - "$ref": "#/components/schemas/Page" + "$ref": "#/components/schemas/SessionArchiveControlView" }, "request_id": { "maxLength": 256, @@ -11267,8 +11248,7 @@ "required": [ "version", "request_id", - "data", - "page" + "data" ], "type": "object" } @@ -11284,9 +11264,24 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -11294,22 +11289,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionArchiveControlRequestView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/workspaces/{workspace_id}/explore": { + "/api/v1/sessions/{session_id}/messages": { "get": { - "operationId": "exploreWorkspace", - "summary": "Inspect a bounded Workspace entry", - "description": "Lists one directory level or returns a bounded redacted UTF-8 file preview. Go resolves the registered Workspace root, rejects traversal and symbolic links, omits internal staging files, and marks all content as non-authorizing evidence. Local root paths are never returned.", + "operationId": "listSessionMessages", + "summary": "List Session messages", + "description": "Returns persisted redacted messages.", "tags": [ - "Workspaces" + "Sessions" ], "parameters": [ { - "name": "workspace_id", + "name": "session_id", "in": "path", - "description": "Workspace identity", + "description": "Session identity", "required": true, "schema": { "maxLength": 256, @@ -11319,16 +11329,35 @@ } }, { - "name": "path", + "name": "limit", "in": "query", - "description": "Canonical Workspace-relative path returned by a previous explorer response; defaults to the root", + "description": "Page size from 1 to 100; defaults to 50", "schema": { - "default": ".", - "maxLength": 512, - "type": "string" - } - } - ], + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque cursor bound to this route and exact filter set", + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + { + "name": "include_compacted", + "in": "query", + "description": "Include compacted historical messages", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { "description": "Successful read", @@ -11338,7 +11367,13 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkspaceExplorerView" + "items": { + "$ref": "#/components/schemas/MessageView" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/Page" }, "request_id": { "maxLength": 256, @@ -11353,7 +11388,8 @@ "required": [ "version", "request_id", - "data" + "data", + "page" ], "type": "object" } @@ -11383,21 +11419,19 @@ } }, "x-cyberagent-read-only": true - } - }, - "/api/v1/workspaces/{workspace_id}/repository-commit-comparison": { - "get": { - "operationId": "compareWorkspaceRepositoryCommits", - "summary": "Compare two exact commit trees", - "description": "Compares two exact local commit objects through bounded tree metadata. The commits need not have an ancestor relationship. The response contains no author, body, blob, patch, remote, host path, rename inference, mutation, process, hook, network, or authority.", + }, + "post": { + "operationId": "submitSessionMessage", + "summary": "Submit a Run-bound Session message", + "description": "Creates or replays one redacted durable operator-steering record for the exact Run-bound Session. It does not append Session history early, start or resume the Run, acquire a lease, call a model or tool, or grant a capability.", "tags": [ - "Workspaces" + "Control" ], "parameters": [ { - "name": "workspace_id", + "name": "session_id", "in": "path", - "description": "Workspace identity", + "description": "Session identity", "required": true, "schema": { "maxLength": 256, @@ -11407,40 +11441,28 @@ } }, { - "name": "base_object_id", - "in": "query", - "description": "Exact lowercase base commit object identity", - "required": true, - "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", - "type": "string" - } - }, - { - "name": "head_object_id", - "in": "query", - "description": "Exact lowercase head commit object identity", + "name": "Idempotency-Key", + "in": "header", + "description": "Opaque retry key; only a domain-separated digest is persisted", "required": true, "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", + "maxLength": 256, + "minLength": 16, + "pattern": "^\\S+$", "type": "string" } } ], "responses": { - "200": { - "description": "Successful read", + "202": { + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitComparisonView" + "$ref": "#/components/schemas/SessionMessageControlView" }, "request_id": { "maxLength": 256, @@ -11474,9 +11496,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -11484,22 +11518,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessageControlRequestView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}": { - "get": { - "operationId": "getWorkspaceRepositoryCommit", - "summary": "Inspect exact commit changed-file metadata", - "description": "Compares one exact local commit object with its first parent and returns only bounded path, change-kind, file-kind, and content/mode-change metadata. It returns no author, body, blob, patch, remote, or host path and performs no checkout, ref update, process, hook, or network operation.", + "/api/v1/sessions/{session_id}/messages/{message_id}/cancel": { + "post": { + "operationId": "cancelSessionSteering", + "summary": "Cancel queued Session steering", + "description": "Cancels one exact pending operator-steering message for the bound Session. Prepared, committed, and already consumed input is immutable; this operation does not stop a model call or start execution.", "tags": [ - "Workspaces" + "Control" ], "parameters": [ { - "name": "workspace_id", + "name": "session_id", "in": "path", - "description": "Workspace identity", + "description": "Session identity", "required": true, "schema": { "maxLength": 256, @@ -11509,28 +11558,40 @@ } }, { - "name": "object_id", + "name": "message_id", "in": "path", - "description": "Exact lowercase forty-character Git commit object identity", + "description": "Operator steering message identity", "required": true, "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "description": "Opaque retry key; only a domain-separated digest is persisted", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 16, + "pattern": "^\\S+$", "type": "string" } } ], "responses": { - "200": { - "description": "Successful read", + "202": { + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitDetailView" + "$ref": "#/components/schemas/SessionSteeringCancellationView" }, "request_id": { "maxLength": 256, @@ -11564,9 +11625,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -11574,22 +11647,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSteeringCancellationRequestView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}/file-preview": { + "/api/v1/sessions/{session_id}/tree": { "get": { - "operationId": "getWorkspaceRepositoryCommitFilePreview", - "summary": "Preview one redacted file from an exact commit", - "description": "Returns a bounded secret-redacted UTF-8 projection of one regular file from one exact local commit object. Links, binary data, oversized content, missing objects, raw blobs, host roots, remotes, checkout, ref mutation, processes, hooks, and network access are refused or excluded; returned text remains non-authorizing evidence.", + "operationId": "getSessionContinuityTree", + "summary": "Browse Session checkpoints and branches", + "description": "Returns the connected checkpoint/Fork/Resume component plus compaction, decision, Artifact, Delivery, Git drift, and memory expiry projections.", "tags": [ - "Workspaces" + "Sessions" ], "parameters": [ { - "name": "workspace_id", + "name": "session_id", "in": "path", - "description": "Workspace identity", + "description": "Session identity", "required": true, "schema": { "maxLength": 256, @@ -11597,29 +11685,6 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } - }, - { - "name": "object_id", - "in": "path", - "description": "Exact lowercase forty-character Git commit object identity", - "required": true, - "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", - "type": "string" - } - }, - { - "name": "path", - "in": "query", - "description": "Canonical repository-relative file path", - "required": true, - "schema": { - "maxLength": 512, - "minLength": 1, - "type": "string" - } } ], "responses": { @@ -11631,7 +11696,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitFilePreviewView" + "$ref": "#/components/schemas/SessionTree" }, "request_id": { "maxLength": 256, @@ -11678,38 +11743,38 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-diff": { - "get": { - "operationId": "getWorkspaceRepositoryDiff", - "summary": "Inspect bounded repository patches", - "description": "Returns secret-redacted bounded UTF-8 patches for a Git repository rooted exactly at the registered Workspace. Go follows no links, discovers no parent repository, starts no process, executes no hooks, uses no network, and grants no mutation authority.", + "/api/v1/skills/packages/install": { + "post": { + "operationId": "installSkillPackage", + "summary": "Install one inert Skill package", + "description": "Imports one explicitly confirmed, strictly validated, bounded archive into the content-addressed untrusted Skill Registry. Import executes no scripts, hooks, commands, tools, Provider calls, or network requests and grants no Run-selection or context-delivery authority.", "tags": [ - "Workspaces" + "Control" ], "parameters": [ { - "name": "workspace_id", - "in": "path", - "description": "Workspace identity", + "name": "Idempotency-Key", + "in": "header", + "description": "Opaque retry key; only a domain-separated digest is persisted", "required": true, "schema": { "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "minLength": 16, + "pattern": "^\\S+$", "type": "string" } } ], "responses": { - "200": { - "description": "Successful read", + "202": { + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryDiffView" + "$ref": "#/components/schemas/SkillPackageInstallView" }, "request_id": { "maxLength": 256, @@ -11740,12 +11805,21 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -11753,22 +11827,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillPackageInstallRequestView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/workspaces/{workspace_id}/repository-file-history": { + "/api/v1/work-items/{work_item_id}": { "get": { - "operationId": "getWorkspaceRepositoryFileHistory", - "summary": "Inspect bounded history for one exact file path", - "description": "Follows at most 512 first-parent commits and returns at most fifty metadata-only changes for one canonical repository-relative path. It does not infer renames or expose blob/patch content, identities, remotes, host roots, checkout, reference mutation, processes, hooks, network, or authority.", + "operationId": "getWorkItem", + "summary": "Inspect a WorkItem", + "description": "Returns one structured WorkItem.", "tags": [ - "Workspaces" + "Memory" ], "parameters": [ { - "name": "workspace_id", + "name": "work_item_id", "in": "path", - "description": "Workspace identity", + "description": "WorkItem identity", "required": true, "schema": { "maxLength": 256, @@ -11776,12 +11865,88 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/WorkItemView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces": { + "get": { + "operationId": "listWorkspaces", + "summary": "List Workspaces", + "description": "Returns registered Workspace ids and names without local root paths.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Page size from 1 to 100; defaults to 50", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } }, { - "name": "path", + "name": "cursor", "in": "query", - "description": "Canonical repository-relative file path", - "required": true, + "description": "Opaque cursor bound to this route and exact filter set", "schema": { "maxLength": 512, "minLength": 1, @@ -11798,7 +11963,13 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryFileHistoryView" + "items": { + "$ref": "#/components/schemas/WorkspaceView" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/Page" }, "request_id": { "maxLength": 256, @@ -11813,7 +11984,8 @@ "required": [ "version", "request_id", - "data" + "data", + "page" ], "type": "object" } @@ -11829,9 +12001,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, @@ -11845,11 +12014,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-history": { + "/api/v1/workspaces/{workspace_id}/explore": { "get": { - "operationId": "getWorkspaceRepositoryHistory", - "summary": "Inspect bounded local repository history", - "description": "Returns at most fifty first-parent commit subjects and sixty-four local branch heads for a Git repository rooted exactly at the registered Workspace. Subjects are secret-redacted; author identities, commit bodies, remotes, host paths, processes, hooks, and network access are excluded.", + "operationId": "exploreWorkspace", + "summary": "Inspect a bounded Workspace entry", + "description": "Lists one directory level or returns a bounded redacted UTF-8 file preview. Go resolves the registered Workspace root, rejects traversal and symbolic links, omits internal staging files, and marks all content as non-authorizing evidence. Local root paths are never returned.", "tags": [ "Workspaces" ], @@ -11865,6 +12034,16 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "Canonical Workspace-relative path returned by a previous explorer response; defaults to the root", + "schema": { + "default": ".", + "maxLength": 512, + "type": "string" + } } ], "responses": { @@ -11876,7 +12055,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryHistoryView" + "$ref": "#/components/schemas/WorkspaceExplorerView" }, "request_id": { "maxLength": 256, @@ -11923,11 +12102,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-state": { + "/api/v1/workspaces/{workspace_id}/repository-commit-comparison": { "get": { - "operationId": "getWorkspaceRepositoryState", - "summary": "Inspect read-only repository state", - "description": "Returns a bounded status-only projection for a Git repository rooted exactly at the registered Workspace. Go does not discover parent repositories, return file content, patches, remotes or local root paths, start a process, execute hooks, or use the network.", + "operationId": "compareWorkspaceRepositoryCommits", + "summary": "Compare two exact commit trees", + "description": "Compares two exact local commit objects through bounded tree metadata. The commits need not have an ancestor relationship. The response contains no author, body, blob, patch, remote, host path, rename inference, mutation, process, hook, network, or authority.", "tags": [ "Workspaces" ], @@ -11943,6 +12122,30 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } + }, + { + "name": "base_object_id", + "in": "query", + "description": "Exact lowercase base commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } + }, + { + "name": "head_object_id", + "in": "query", + "description": "Exact lowercase head commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } } ], "responses": { @@ -11954,7 +12157,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryStateView" + "$ref": "#/components/schemas/RepositoryCommitComparisonView" }, "request_id": { "maxLength": 256, @@ -12001,11 +12204,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/search": { + "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}": { "get": { - "operationId": "searchWorkspace", - "summary": "Search bounded Workspace evidence", - "description": "Performs one deterministic bounded scan over redacted UTF-8 Explorer projections. It follows no links, starts no indexer, returns canonical relative references and snippets only, and marks every result as non-authorizing evidence.", + "operationId": "getWorkspaceRepositoryCommit", + "summary": "Inspect exact commit changed-file metadata", + "description": "Compares one exact local commit object with its first parent and returns only bounded path, change-kind, file-kind, and content/mode-change metadata. It returns no author, body, blob, patch, remote, or host path and performs no checkout, ref update, process, hook, or network operation.", "tags": [ "Workspaces" ], @@ -12023,13 +12226,14 @@ } }, { - "name": "query", - "in": "query", - "description": "Normalized case-insensitive filename or redacted text query", + "name": "object_id", + "in": "path", + "description": "Exact lowercase forty-character Git commit object identity", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", "type": "string" } } @@ -12043,7 +12247,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkspaceSearchView" + "$ref": "#/components/schemas/RepositoryCommitDetailView" }, "request_id": { "maxLength": 256, @@ -12089,41 +12293,554 @@ }, "x-cyberagent-read-only": true } - } - }, - "components": { - "schemas": { - "APIError": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "code", - "message" + }, + "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}/file-preview": { + "get": { + "operationId": "getWorkspaceRepositoryCommitFilePreview", + "summary": "Preview one redacted file from an exact commit", + "description": "Returns a bounded secret-redacted UTF-8 projection of one regular file from one exact local commit object. Links, binary data, oversized content, missing objects, raw blobs, host roots, remotes, checkout, ref mutation, processes, hooks, and network access are refused or excluded; returned text remains non-authorizing evidence.", + "tags": [ + "Workspaces" ], - "type": "object" - }, - "ActiveCallInfo": { - "additionalProperties": false, - "properties": { - "attempt_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "cancel_requested": { - "type": "boolean" - }, - "max_attempts": { - "format": "int32", - "type": "integer" - }, + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "object_id", + "in": "path", + "description": "Exact lowercase forty-character Git commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "Canonical repository-relative file path", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryCommitFilePreviewView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/repository-diff": { + "get": { + "operationId": "getWorkspaceRepositoryDiff", + "summary": "Inspect bounded repository patches", + "description": "Returns secret-redacted bounded UTF-8 patches for a Git repository rooted exactly at the registered Workspace. Go follows no links, discovers no parent repository, starts no process, executes no hooks, uses no network, and grants no mutation authority.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryDiffView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/repository-file-history": { + "get": { + "operationId": "getWorkspaceRepositoryFileHistory", + "summary": "Inspect bounded history for one exact file path", + "description": "Follows at most 512 first-parent commits and returns at most fifty metadata-only changes for one canonical repository-relative path. It does not infer renames or expose blob/patch content, identities, remotes, host roots, checkout, reference mutation, processes, hooks, network, or authority.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "Canonical repository-relative file path", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryFileHistoryView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/repository-history": { + "get": { + "operationId": "getWorkspaceRepositoryHistory", + "summary": "Inspect bounded local repository history", + "description": "Returns at most fifty first-parent commit subjects and sixty-four local branch heads for a Git repository rooted exactly at the registered Workspace. Subjects are secret-redacted; author identities, commit bodies, remotes, host paths, processes, hooks, and network access are excluded.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryHistoryView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/repository-state": { + "get": { + "operationId": "getWorkspaceRepositoryState", + "summary": "Inspect read-only repository state", + "description": "Returns a bounded status-only projection for a Git repository rooted exactly at the registered Workspace. Go does not discover parent repositories, return file content, patches, remotes or local root paths, start a process, execute hooks, or use the network.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryStateView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/search": { + "get": { + "operationId": "searchWorkspace", + "summary": "Search bounded Workspace evidence", + "description": "Performs one deterministic bounded scan over redacted UTF-8 Explorer projections. It follows no links, starts no indexer, returns canonical relative references and snippets only, and marks every result as non-authorizing evidence.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "Normalized case-insensitive filename or redacted text query", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/WorkspaceSearchView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + } + }, + "components": { + "schemas": { + "APIError": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ActiveCallInfo": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "cancel_requested": { + "type": "boolean" + }, + "max_attempts": { + "format": "int32", + "type": "integer" + }, "model": { "type": "string" }, @@ -12979,27 +13696,193 @@ "minimum": 1, "type": "integer" }, - "timeout_seconds": { + "timeout_seconds": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "max_turns" + ], + "type": "object" + }, + "CancellationSpec": { + "additionalProperties": false, + "properties": { + "grace_period_millis": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "grace_period_millis" + ], + "type": "object" + }, + "Change": { + "additionalProperties": false, + "properties": { + "binary": { + "type": "boolean" + }, + "from_sha256": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "path": { + "type": "string" + }, + "previous_path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "recoverable": { + "type": "boolean" + }, + "to_sha256": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "from_sha256", + "to_sha256", + "binary", + "recoverable" + ], + "type": "object" + }, + "Checkpoint": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "base_commit": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "capability_generation": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "entry_count": { + "format": "int32", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "incomplete_reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "index_blob_sha256": { + "type": "string" + }, + "index_sha256": { + "type": "string" + }, + "manifest_sha256": { + "type": "string" + }, + "mission_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "parent_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "phase": { + "type": "string" + }, + "protocol_version": { + "type": "string" + }, + "recovery_level": { + "type": "string" + }, + "requested_by": { + "type": "string" + }, + "root_fingerprint": { + "type": "string" + }, + "root_path_sha256": { + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "stored_bytes": { "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "max_turns" - ], - "type": "object" - }, - "CancellationSpec": { - "additionalProperties": false, - "properties": { - "grace_period_millis": { - "format": "int32", "type": "integer" + }, + "title": { + "type": "string" + }, + "trigger": { + "type": "string" + }, + "trigger_receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" } }, "required": [ - "grace_period_millis" + "id", + "protocol_version", + "run_id", + "mission_id", + "session_id", + "workspace_id", + "trigger", + "phase", + "trigger_receipt_id", + "root_fingerprint", + "root_path_sha256", + "base_commit", + "branch", + "index_sha256", + "manifest_sha256", + "recovery_level", + "incomplete_reasons", + "entry_count", + "stored_bytes", + "created_at" ], "type": "object" }, @@ -14220,6 +15103,34 @@ ], "type": "object" }, + "Conflict": { + "additionalProperties": false, + "properties": { + "current_sha256": { + "type": "string" + }, + "expected_sha256": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "target_sha256": { + "type": "string" + } + }, + "required": [ + "kind", + "reason" + ], + "type": "object" + }, "ContextMemoryExport": { "additionalProperties": false, "properties": { @@ -19891,6 +20802,62 @@ ], "type": "object" }, + "Preview": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "$ref": "#/components/schemas/Change" + }, + "type": "array" + }, + "conflicts": { + "items": { + "$ref": "#/components/schemas/Conflict" + }, + "type": "array" + }, + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "index_changed": { + "type": "boolean" + }, + "observed_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "protocol_version": { + "type": "string" + }, + "recovery_level": { + "type": "string" + }, + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "protocol_version", + "expected_current_checkpoint_id", + "target_checkpoint_id", + "observed_checkpoint_id", + "recovery_level", + "index_changed", + "changes", + "conflicts", + "truncated" + ], + "type": "object" + }, "PriceEntryView": { "additionalProperties": false, "properties": { @@ -23223,6 +24190,42 @@ ], "type": "object" }, + "RunState": { + "additionalProperties": false, + "properties": { + "current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "last_transaction_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "run_id", + "workspace_id", + "current_checkpoint_id", + "updated_at" + ], + "type": "object" + }, "RunView": { "additionalProperties": false, "properties": { @@ -23786,6 +24789,9 @@ }, "wake_worker": { "$ref": "#/components/schemas/RunWakeWorkerHealthView" + }, + "workspace_checkpoint_control_enabled": { + "type": "boolean" } }, "required": [ @@ -23819,6 +24825,7 @@ "evidence_attachment_enabled", "verification_evidence_enabled", "embedded_analyzer_execution_enabled", + "workspace_checkpoint_control_enabled", "process_execution_enabled", "shell_execution_enabled", "docker_execution_enabled", @@ -24443,6 +25450,29 @@ ], "type": "object" }, + "StorageUsage": { + "additionalProperties": false, + "properties": { + "blob_bytes": { + "format": "int64", + "type": "integer" + }, + "blob_count": { + "format": "int32", + "type": "integer" + }, + "checkpoint_count": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "blob_count", + "blob_bytes", + "checkpoint_count" + ], + "type": "object" + }, "SupervisorCheckpointView": { "additionalProperties": false, "properties": { @@ -24604,59 +25634,156 @@ "minimum": 1, "type": "integer" }, - "round": { - "format": "int32", - "minimum": 1, - "type": "integer" + "round": { + "format": "int32", + "minimum": 1, + "type": "integer" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "turn": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "run_id", + "turn", + "attempt_id", + "round", + "model_attempt", + "calls", + "created_at" + ], + "type": "object" + }, + "ToolUsageView": { + "additionalProperties": false, + "properties": { + "consumed": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "exhausted_at": { + "format": "date-time", + "type": "string" + }, + "limit": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "remaining": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "consumed", + "limit", + "remaining" + ], + "type": "object" + }, + "Transaction": { + "additionalProperties": false, + "properties": { + "after_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "before_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "completed_at": { + "format": "date-time", + "type": "string" + }, + "conflict_json": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "error_code": { + "type": "string" + }, + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "kind": { + "type": "string" + }, + "operation_key_digest": { + "type": "string" + }, + "protocol_version": { + "type": "string" + }, + "recovery_level": { + "type": "string" + }, + "request_fingerprint": { + "type": "string" }, "run_id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "turn": { - "format": "int32", - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "run_id", - "turn", - "attempt_id", - "round", - "model_attempt", - "calls", - "created_at" - ], - "type": "object" - }, - "ToolUsageView": { - "additionalProperties": false, - "properties": { - "consumed": { - "format": "int64", - "minimum": 0, - "type": "integer" + "status": { + "type": "string" }, - "exhausted_at": { - "format": "date-time", + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "limit": { - "format": "int64", - "minimum": 0, - "type": "integer" + "trigger_receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "remaining": { - "format": "int64", - "type": "integer" + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" } }, "required": [ - "consumed", - "limit", - "remaining" + "id", + "protocol_version", + "operation_key_digest", + "request_fingerprint", + "run_id", + "workspace_id", + "kind", + "trigger_receipt_id", + "before_checkpoint_id", + "status", + "recovery_level", + "created_at", + "updated_at" ], "type": "object" }, @@ -25889,10 +27016,201 @@ "model_assertion": { "type": "boolean" }, - "mutation_supported": { + "mutation_supported": { + "type": "boolean" + }, + "operator_identity_included": { + "type": "boolean" + }, + "pass_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "plan_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "plan_item_ordinal": { + "format": "int32", + "maximum": 32, + "minimum": 1, + "type": "integer" + }, + "plan_item_sha256": { + "maxLength": 64, + "type": "string" + }, + "plan_sha256": { + "maxLength": 64, + "type": "string" + }, + "private_evidence_bodies_included": { + "type": "boolean" + }, + "private_plan_body_included": { + "type": "boolean" + }, + "protocol_version": { + "enum": [ + "operator_verification_plan_item_snapshot_export.v1" + ], + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "record_rewritten": { + "type": "boolean" + }, + "result_inferred": { + "type": "boolean" + }, + "returned_association_count": { + "format": "int32", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "snapshot_high_water_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "snapshot_protocol_version": { + "enum": [ + "operator_verification_plan_item_snapshot.v1" + ], + "type": "string" + }, + "unknown_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "protocol_version", + "snapshot_protocol_version", + "format", + "filename", + "mime_type", + "run_id", + "session_id", + "workspace_id", + "plan_id", + "plan_sha256", + "plan_item_ordinal", + "plan_item_sha256", + "snapshot_high_water_event_sequence", + "associated_evidence_count", + "pass_count", + "fail_count", + "unknown_count", + "returned_association_count", + "associations_truncated", + "content_sha256", + "content_bytes", + "content", + "metadata_only", + "read_only", + "download_only", + "private_plan_body_included", + "private_evidence_bodies_included", + "operator_identity_included", + "result_inferred", + "command_executed", + "model_assertion", + "record_rewritten", + "approval", + "authority_granted", + "mutation_supported", + "execution_started" + ], + "type": "object" + }, + "VerificationSnapshotReceiptControlView": { + "additionalProperties": false, + "properties": { + "approval": { + "type": "boolean" + }, + "associated_evidence_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "associations_truncated": { + "type": "boolean" + }, + "authority_granted": { + "type": "boolean" + }, + "content_bytes": { + "format": "int32", + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, + "content_included": { + "type": "boolean" + }, + "content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "execution_started": { + "type": "boolean" + }, + "fail_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "format": { + "enum": [ + "json", + "markdown" + ], + "type": "string" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "immutable": { + "type": "boolean" + }, + "metadata_only": { + "type": "boolean" + }, + "operator_identity_included": { "type": "boolean" }, - "operator_identity_included": { + "operator_recorded": { "type": "boolean" }, "pass_count": { @@ -25920,24 +27238,37 @@ "maxLength": 64, "type": "string" }, - "private_evidence_bodies_included": { - "type": "boolean" - }, - "private_plan_body_included": { + "private_bodies_included": { "type": "boolean" }, "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_export.v1" + "operator_verification_plan_item_snapshot_receipt.v1" ], "type": "string" }, "read_only": { "type": "boolean" }, + "receipt_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, "record_rewritten": { "type": "boolean" }, + "recorded_at": { + "format": "date-time", + "type": "string" + }, + "replayed": { + "type": "boolean" + }, + "result_accepted": { + "type": "boolean" + }, "result_inferred": { "type": "boolean" }, @@ -25957,18 +27288,15 @@ "minLength": 1, "type": "string" }, + "snapshot_accepted": { + "type": "boolean" + }, "snapshot_high_water_event_sequence": { "format": "int64", "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, - "snapshot_protocol_version": { - "enum": [ - "operator_verification_plan_item_snapshot.v1" - ], - "type": "string" - }, "unknown_count": { "format": "int32", "maximum": 1000000000, @@ -25983,10 +27311,7 @@ }, "required": [ "protocol_version", - "snapshot_protocol_version", - "format", - "filename", - "mime_type", + "id", "run_id", "session_id", "workspace_id", @@ -25994,6 +27319,7 @@ "plan_sha256", "plan_item_ordinal", "plan_item_sha256", + "format", "snapshot_high_water_event_sequence", "associated_evidence_count", "pass_count", @@ -26003,94 +27329,123 @@ "associations_truncated", "content_sha256", "content_bytes", - "content", + "receipt_event_sequence", + "recorded_at", + "immutable", + "operator_recorded", "metadata_only", "read_only", - "download_only", - "private_plan_body_included", - "private_evidence_bodies_included", + "content_included", + "private_bodies_included", "operator_identity_included", + "snapshot_accepted", + "result_accepted", "result_inferred", - "command_executed", - "model_assertion", "record_rewritten", "approval", "authority_granted", - "mutation_supported", - "execution_started" + "execution_started", + "replayed" ], "type": "object" }, - "VerificationSnapshotReceiptControlView": { + "VerificationSnapshotReceiptInventoryView": { "additionalProperties": false, "properties": { "approval": { "type": "boolean" }, - "associated_evidence_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" - }, - "associations_truncated": { + "authority_granted": { "type": "boolean" }, - "authority_granted": { + "execution_started": { "type": "boolean" }, - "content_bytes": { - "format": "int32", - "maximum": 262144, - "minimum": 1, - "type": "integer" + "items": { + "items": { + "$ref": "#/components/schemas/VerificationSnapshotReceiptView" + }, + "type": "array" }, - "content_included": { + "metadata_only": { "type": "boolean" }, - "content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", + "protocol_version": { + "enum": [ + "operator_verification_plan_item_snapshot_receipt_inventory.v1" + ], "type": "string" }, - "execution_started": { + "read_only": { "type": "boolean" }, - "fail_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" + "record_rewritten": { + "type": "boolean" }, - "format": { - "enum": [ - "json", - "markdown" - ], + "result_accepted": { + "type": "boolean" + }, + "result_inferred": { + "type": "boolean" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "id": { + "session_id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "immutable": { + "snapshot_accepted": { "type": "boolean" }, - "metadata_only": { + "truncated": { "type": "boolean" }, - "operator_identity_included": { + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "protocol_version", + "run_id", + "session_id", + "workspace_id", + "items", + "truncated", + "metadata_only", + "read_only", + "snapshot_accepted", + "result_accepted", + "result_inferred", + "record_rewritten", + "approval", + "authority_granted", + "execution_started" + ], + "type": "object" + }, + "VerificationSnapshotReceiptRequestView": { + "additionalProperties": false, + "properties": { + "confirm_metadata_snapshot": { "type": "boolean" }, - "operator_recorded": { - "type": "boolean" + "content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" }, - "pass_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" + "format": { + "enum": [ + "json", + "markdown" + ], + "type": "string" }, "plan_id": { "maxLength": 256, @@ -26103,39 +27458,100 @@ "minimum": 1, "type": "integer" }, - "plan_item_sha256": { - "maxLength": 64, + "snapshot_high_water_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "version": { + "enum": [ + "operator_verification_plan_item_snapshot_receipt.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "plan_id", + "plan_item_ordinal", + "format", + "snapshot_high_water_event_sequence", + "content_sha256", + "confirm_metadata_snapshot" + ], + "type": "object" + }, + "VerificationSnapshotReceiptReviewControlView": { + "additionalProperties": false, + "properties": { + "approval": { + "type": "boolean" + }, + "authority_granted": { + "type": "boolean" + }, + "content_included": { + "type": "boolean" + }, + "decision": { + "enum": [ + "metadata_confirmed", + "metadata_disputed" + ], "type": "string" }, - "plan_sha256": { - "maxLength": 64, + "execution_started": { + "type": "boolean" + }, + "id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, + "immutable": { + "type": "boolean" + }, + "metadata_only": { + "type": "boolean" + }, + "operator_identity_included": { + "type": "boolean" + }, + "operator_reviewed": { + "type": "boolean" + }, "private_bodies_included": { "type": "boolean" }, "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt.v1" + "operator_verification_plan_item_snapshot_receipt_review.v1" ], "type": "string" }, "read_only": { "type": "boolean" }, + "receipt_content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, "receipt_event_sequence": { "format": "int64", "maximum": 9007199254740991, "minimum": 1, "type": "integer" }, + "receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, "record_rewritten": { "type": "boolean" }, - "recorded_at": { - "format": "date-time", - "type": "string" - }, "replayed": { "type": "boolean" }, @@ -26145,12 +27561,19 @@ "result_inferred": { "type": "boolean" }, - "returned_association_count": { - "format": "int32", - "maximum": 100, - "minimum": 0, + "review_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 1, "type": "integer" }, + "review_non_authorizing": { + "type": "boolean" + }, + "reviewed_at": { + "format": "date-time", + "type": "string" + }, "run_id": { "maxLength": 256, "minLength": 1, @@ -26164,18 +27587,6 @@ "snapshot_accepted": { "type": "boolean" }, - "snapshot_high_water_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "unknown_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" - }, "workspace_id": { "maxLength": 256, "minLength": 1, @@ -26188,26 +27599,17 @@ "run_id", "session_id", "workspace_id", - "plan_id", - "plan_sha256", - "plan_item_ordinal", - "plan_item_sha256", - "format", - "snapshot_high_water_event_sequence", - "associated_evidence_count", - "pass_count", - "fail_count", - "unknown_count", - "returned_association_count", - "associations_truncated", - "content_sha256", - "content_bytes", + "receipt_id", + "receipt_content_sha256", "receipt_event_sequence", - "recorded_at", + "decision", + "review_event_sequence", + "reviewed_at", "immutable", - "operator_recorded", + "operator_reviewed", "metadata_only", "read_only", + "review_non_authorizing", "content_included", "private_bodies_included", "operator_identity_included", @@ -26222,7 +27624,7 @@ ], "type": "object" }, - "VerificationSnapshotReceiptInventoryView": { + "VerificationSnapshotReceiptReviewInventoryView": { "additionalProperties": false, "properties": { "approval": { @@ -26236,7 +27638,7 @@ }, "items": { "items": { - "$ref": "#/components/schemas/VerificationSnapshotReceiptView" + "$ref": "#/components/schemas/VerificationSnapshotReceiptReviewView" }, "type": "array" }, @@ -26245,7 +27647,7 @@ }, "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt_inventory.v1" + "operator_verification_plan_item_snapshot_receipt_review_inventory.v1" ], "type": "string" }, @@ -26261,6 +27663,9 @@ "result_inferred": { "type": "boolean" }, + "review_non_authorizing": { + "type": "boolean" + }, "run_id": { "maxLength": 256, "minLength": 1, @@ -26292,6 +27697,7 @@ "truncated", "metadata_only", "read_only", + "review_non_authorizing", "snapshot_accepted", "result_accepted", "result_inferred", @@ -26302,81 +27708,238 @@ ], "type": "object" }, - "VerificationSnapshotReceiptRequestView": { + "VerificationSnapshotReceiptReviewRequestView": { "additionalProperties": false, "properties": { - "confirm_metadata_snapshot": { + "confirm_non_authorizing_review": { "type": "boolean" }, - "content_sha256": { + "decision": { + "enum": [ + "metadata_confirmed", + "metadata_disputed" + ], + "type": "string" + }, + "receipt_content_sha256": { "maxLength": 64, "pattern": "^[a-f0-9]{64}$", "type": "string" }, - "format": { + "receipt_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { "enum": [ - "json", - "markdown" + "operator_verification_plan_item_snapshot_receipt_review.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "receipt_id", + "receipt_content_sha256", + "receipt_event_sequence", + "decision", + "confirm_non_authorizing_review" + ], + "type": "object" + }, + "VerificationSnapshotReceiptReviewView": { + "additionalProperties": false, + "properties": { + "approval": { + "type": "boolean" + }, + "authority_granted": { + "type": "boolean" + }, + "content_included": { + "type": "boolean" + }, + "decision": { + "enum": [ + "metadata_confirmed", + "metadata_disputed" ], "type": "string" }, - "plan_id": { + "execution_started": { + "type": "boolean" + }, + "id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "plan_item_ordinal": { - "format": "int32", - "maximum": 32, + "immutable": { + "type": "boolean" + }, + "metadata_only": { + "type": "boolean" + }, + "operator_identity_included": { + "type": "boolean" + }, + "operator_reviewed": { + "type": "boolean" + }, + "private_bodies_included": { + "type": "boolean" + }, + "protocol_version": { + "enum": [ + "operator_verification_plan_item_snapshot_receipt_review.v1" + ], + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "receipt_content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "receipt_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, "minimum": 1, "type": "integer" }, - "snapshot_high_water_event_sequence": { + "receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "record_rewritten": { + "type": "boolean" + }, + "result_accepted": { + "type": "boolean" + }, + "result_inferred": { + "type": "boolean" + }, + "review_event_sequence": { "format": "int64", "maximum": 9007199254740991, - "minimum": 0, + "minimum": 1, "type": "integer" }, - "version": { - "enum": [ - "operator_verification_plan_item_snapshot_receipt.v1" - ], + "review_non_authorizing": { + "type": "boolean" + }, + "reviewed_at": { + "format": "date-time", + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "snapshot_accepted": { + "type": "boolean" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, "required": [ - "version", - "plan_id", - "plan_item_ordinal", - "format", - "snapshot_high_water_event_sequence", - "content_sha256", - "confirm_metadata_snapshot" + "protocol_version", + "id", + "run_id", + "session_id", + "workspace_id", + "receipt_id", + "receipt_content_sha256", + "receipt_event_sequence", + "decision", + "review_event_sequence", + "reviewed_at", + "immutable", + "operator_reviewed", + "metadata_only", + "read_only", + "review_non_authorizing", + "content_included", + "private_bodies_included", + "operator_identity_included", + "snapshot_accepted", + "result_accepted", + "result_inferred", + "record_rewritten", + "approval", + "authority_granted", + "execution_started" ], "type": "object" }, - "VerificationSnapshotReceiptReviewControlView": { + "VerificationSnapshotReceiptView": { "additionalProperties": false, "properties": { "approval": { "type": "boolean" }, + "associated_evidence_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "associations_truncated": { + "type": "boolean" + }, "authority_granted": { "type": "boolean" }, + "content_bytes": { + "format": "int32", + "maximum": 262144, + "minimum": 1, + "type": "integer" + }, "content_included": { "type": "boolean" }, - "decision": { + "content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "execution_started": { + "type": "boolean" + }, + "fail_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "format": { "enum": [ - "metadata_confirmed", - "metadata_disputed" + "json", + "markdown" ], "type": "string" }, - "execution_started": { - "type": "boolean" - }, "id": { "maxLength": 256, "minLength": 1, @@ -26391,42 +27954,58 @@ "operator_identity_included": { "type": "boolean" }, - "operator_reviewed": { + "operator_recorded": { "type": "boolean" }, + "pass_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, + "plan_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "plan_item_ordinal": { + "format": "int32", + "maximum": 32, + "minimum": 1, + "type": "integer" + }, + "plan_item_sha256": { + "maxLength": 64, + "type": "string" + }, + "plan_sha256": { + "maxLength": 64, + "type": "string" + }, "private_bodies_included": { "type": "boolean" }, "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt_review.v1" + "operator_verification_plan_item_snapshot_receipt.v1" ], "type": "string" }, "read_only": { "type": "boolean" }, - "receipt_content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", - "type": "string" - }, "receipt_event_sequence": { "format": "int64", "maximum": 9007199254740991, "minimum": 1, "type": "integer" }, - "receipt_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, "record_rewritten": { "type": "boolean" }, - "replayed": { - "type": "boolean" + "recorded_at": { + "format": "date-time", + "type": "string" }, "result_accepted": { "type": "boolean" @@ -26434,19 +28013,12 @@ "result_inferred": { "type": "boolean" }, - "review_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, + "returned_association_count": { + "format": "int32", + "maximum": 100, + "minimum": 0, "type": "integer" }, - "review_non_authorizing": { - "type": "boolean" - }, - "reviewed_at": { - "format": "date-time", - "type": "string" - }, "run_id": { "maxLength": 256, "minLength": 1, @@ -26460,6 +28032,18 @@ "snapshot_accepted": { "type": "boolean" }, + "snapshot_high_water_event_sequence": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "unknown_count": { + "format": "int32", + "maximum": 1000000000, + "minimum": 0, + "type": "integer" + }, "workspace_id": { "maxLength": 256, "minLength": 1, @@ -26472,17 +28056,26 @@ "run_id", "session_id", "workspace_id", - "receipt_id", - "receipt_content_sha256", + "plan_id", + "plan_sha256", + "plan_item_ordinal", + "plan_item_sha256", + "format", + "snapshot_high_water_event_sequence", + "associated_evidence_count", + "pass_count", + "fail_count", + "unknown_count", + "returned_association_count", + "associations_truncated", + "content_sha256", + "content_bytes", "receipt_event_sequence", - "decision", - "review_event_sequence", - "reviewed_at", + "recorded_at", "immutable", - "operator_reviewed", + "operator_recorded", "metadata_only", "read_only", - "review_non_authorizing", "content_included", "private_bodies_included", "operator_identity_included", @@ -26492,68 +28085,131 @@ "record_rewritten", "approval", "authority_granted", - "execution_started", - "replayed" + "execution_started" ], "type": "object" }, - "VerificationSnapshotReceiptReviewInventoryView": { + "WorkItemView": { "additionalProperties": false, "properties": { - "approval": { - "type": "boolean" + "acceptance_criteria": { + "items": { + "type": "string" + }, + "type": "array" }, - "authority_granted": { - "type": "boolean" + "blocked_reason": { + "type": "string" }, - "execution_started": { - "type": "boolean" + "completed_at": { + "format": "date-time", + "type": "string" }, - "items": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "dependencies": { "items": { - "$ref": "#/components/schemas/VerificationSnapshotReceiptReviewView" + "type": "string" }, "type": "array" }, - "metadata_only": { - "type": "boolean" + "description": { + "type": "string" }, - "protocol_version": { + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_agent_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "priority": { "enum": [ - "operator_verification_plan_item_snapshot_receipt_review_inventory.v1" + "low", + "normal", + "high", + "critical" ], "type": "string" }, - "read_only": { - "type": "boolean" + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "record_rewritten": { - "type": "boolean" + "status": { + "enum": [ + "pending", + "in_progress", + "blocked", + "completed", + "cancelled" + ], + "type": "string" }, - "result_accepted": { - "type": "boolean" + "title": { + "type": "string" }, - "result_inferred": { - "type": "boolean" + "updated_at": { + "format": "date-time", + "type": "string" }, - "review_non_authorizing": { - "type": "boolean" + "version": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "id", + "run_id", + "title", + "status", + "priority", + "acceptance_criteria", + "dependencies", + "version", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceCheckpointTimeline": { + "additionalProperties": false, + "properties": { + "checkpoints": { + "items": { + "$ref": "#/components/schemas/Checkpoint" + }, + "type": "array" }, - "run_id": { - "maxLength": 256, - "minLength": 1, + "current": { + "$ref": "#/components/schemas/RunState" + }, + "protocol_version": { "type": "string" }, - "session_id": { + "run_id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "snapshot_accepted": { - "type": "boolean" + "storage_usage": { + "$ref": "#/components/schemas/StorageUsage" }, - "truncated": { - "type": "boolean" + "transactions": { + "items": { + "$ref": "#/components/schemas/Transaction" + }, + "type": "array" }, "workspace_id": { "maxLength": 256, @@ -26562,171 +28218,143 @@ } }, "required": [ - "protocol_version", - "run_id", - "session_id", - "workspace_id", - "items", - "truncated", - "metadata_only", - "read_only", - "review_non_authorizing", - "snapshot_accepted", - "result_accepted", - "result_inferred", - "record_rewritten", - "approval", - "authority_granted", - "execution_started" + "protocol_version", + "run_id", + "workspace_id", + "checkpoints", + "transactions", + "storage_usage" + ], + "type": "object" + }, + "WorkspaceExplorerEntryView": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "directory", + "file", + "blocked" + ], + "type": "string" + }, + "name": { + "maxLength": 255, + "type": "string" + }, + "path": { + "maxLength": 512, + "type": "string" + }, + "readable": { + "type": "boolean" + }, + "size_bytes": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "path", + "kind", + "size_bytes", + "readable" ], "type": "object" }, - "VerificationSnapshotReceiptReviewRequestView": { + "WorkspaceExplorerProvenanceView": { "additionalProperties": false, "properties": { - "confirm_non_authorizing_review": { + "content_sha256": { + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "instruction_authorized": { "type": "boolean" }, - "decision": { + "source_kind": { "enum": [ - "metadata_confirmed", - "metadata_disputed" + "workspace_file", + "workspace_listing" ], "type": "string" }, - "receipt_content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", - "type": "string" - }, - "receipt_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "receipt_id": { - "maxLength": 256, - "minLength": 1, + "source_ref": { + "maxLength": 512, "type": "string" }, "version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt_review.v1" + "context_provenance.v1" ], "type": "string" } }, "required": [ "version", - "receipt_id", - "receipt_content_sha256", - "receipt_event_sequence", - "decision", - "confirm_non_authorizing_review" + "source_kind", + "source_ref", + "content_sha256", + "instruction_authorized" ], "type": "object" }, - "VerificationSnapshotReceiptReviewView": { + "WorkspaceExplorerView": { "additionalProperties": false, "properties": { - "approval": { - "type": "boolean" - }, - "authority_granted": { - "type": "boolean" + "content": { + "type": "string" }, - "content_included": { - "type": "boolean" + "entries": { + "items": { + "$ref": "#/components/schemas/WorkspaceExplorerEntryView" + }, + "maxItems": 200, + "type": "array" }, - "decision": { + "kind": { "enum": [ - "metadata_confirmed", - "metadata_disputed" + "directory", + "file" ], "type": "string" }, - "execution_started": { - "type": "boolean" - }, - "id": { - "maxLength": 256, - "minLength": 1, + "path": { + "maxLength": 512, "type": "string" }, - "immutable": { - "type": "boolean" - }, - "metadata_only": { - "type": "boolean" - }, - "operator_identity_included": { - "type": "boolean" - }, - "operator_reviewed": { - "type": "boolean" - }, - "private_bodies_included": { - "type": "boolean" - }, "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt_review.v1" + "workspace_explorer.v1" ], "type": "string" }, - "read_only": { - "type": "boolean" - }, - "receipt_content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", - "type": "string" + "provenance": { + "$ref": "#/components/schemas/WorkspaceExplorerProvenanceView" }, - "receipt_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, + "redaction_count": { + "format": "int32", + "minimum": 0, "type": "integer" }, - "receipt_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "record_rewritten": { - "type": "boolean" - }, - "result_accepted": { - "type": "boolean" + "returned_bytes": { + "format": "int32", + "maximum": 131072, + "minimum": 0, + "type": "integer" }, - "result_inferred": { + "root_path_exposed": { "type": "boolean" }, - "review_event_sequence": { + "total_bytes": { "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, + "minimum": 0, "type": "integer" }, - "review_non_authorizing": { - "type": "boolean" - }, - "reviewed_at": { - "format": "date-time", - "type": "string" - }, - "run_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "session_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "snapshot_accepted": { + "truncated": { "type": "boolean" }, "workspace_id": { @@ -26737,186 +28365,135 @@ }, "required": [ "protocol_version", - "id", - "run_id", - "session_id", "workspace_id", - "receipt_id", - "receipt_content_sha256", - "receipt_event_sequence", - "decision", - "review_event_sequence", - "reviewed_at", - "immutable", - "operator_reviewed", - "metadata_only", - "read_only", - "review_non_authorizing", - "content_included", - "private_bodies_included", - "operator_identity_included", - "snapshot_accepted", - "result_accepted", - "result_inferred", - "record_rewritten", - "approval", - "authority_granted", - "execution_started" - ], - "type": "object" - }, - "VerificationSnapshotReceiptView": { - "additionalProperties": false, - "properties": { - "approval": { - "type": "boolean" - }, - "associated_evidence_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" - }, - "associations_truncated": { - "type": "boolean" - }, - "authority_granted": { - "type": "boolean" - }, - "content_bytes": { - "format": "int32", - "maximum": 262144, - "minimum": 1, - "type": "integer" - }, - "content_included": { - "type": "boolean" - }, - "content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", - "type": "string" - }, - "execution_started": { - "type": "boolean" - }, - "fail_count": { - "format": "int32", - "maximum": 1000000000, - "minimum": 0, - "type": "integer" - }, - "format": { - "enum": [ - "json", - "markdown" - ], - "type": "string" + "path", + "kind", + "entries", + "content", + "total_bytes", + "returned_bytes", + "truncated", + "redaction_count", + "root_path_exposed", + "provenance" + ], + "type": "object" + }, + "WorkspaceRestoreResult": { + "additionalProperties": false, + "properties": { + "after": { + "$ref": "#/components/schemas/Checkpoint" }, - "id": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "before": { + "$ref": "#/components/schemas/Checkpoint" }, - "immutable": { + "confirmed": { "type": "boolean" }, - "metadata_only": { - "type": "boolean" + "preview": { + "$ref": "#/components/schemas/Preview" }, - "operator_identity_included": { + "protocol_version": { + "type": "string" + }, + "replayed": { "type": "boolean" }, - "operator_recorded": { + "transaction": { + "$ref": "#/components/schemas/Transaction" + } + }, + "required": [ + "protocol_version", + "preview", + "before", + "confirmed", + "replayed" + ], + "type": "object" + }, + "WorkspaceSearchResultView": { + "additionalProperties": false, + "properties": { + "content_truncated": { "type": "boolean" }, - "pass_count": { + "line": { "format": "int32", - "maximum": 1000000000, "minimum": 0, "type": "integer" }, - "plan_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "plan_item_ordinal": { - "format": "int32", - "maximum": 32, - "minimum": 1, - "type": "integer" - }, - "plan_item_sha256": { - "maxLength": 64, + "match_kind": { + "enum": [ + "filename", + "content", + "filename_and_content" + ], "type": "string" }, - "plan_sha256": { - "maxLength": 64, + "path": { + "maxLength": 512, "type": "string" }, - "private_bodies_included": { - "type": "boolean" + "provenance": { + "$ref": "#/components/schemas/WorkspaceExplorerProvenanceView" }, + "snippet": { + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "path", + "match_kind", + "line", + "snippet", + "content_truncated", + "provenance" + ], + "type": "object" + }, + "WorkspaceSearchView": { + "additionalProperties": false, + "properties": { "protocol_version": { "enum": [ - "operator_verification_plan_item_snapshot_receipt.v1" + "workspace_search.v1" ], "type": "string" }, - "read_only": { - "type": "boolean" - }, - "receipt_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "record_rewritten": { - "type": "boolean" - }, - "recorded_at": { - "format": "date-time", - "type": "string" - }, - "result_accepted": { - "type": "boolean" + "results": { + "items": { + "$ref": "#/components/schemas/WorkspaceSearchResultView" + }, + "maxItems": 50, + "type": "array" }, - "result_inferred": { + "root_path_exposed": { "type": "boolean" }, - "returned_association_count": { - "format": "int32", - "maximum": 100, + "scanned_bytes": { + "format": "int64", + "maximum": 4194560, "minimum": 0, "type": "integer" }, - "run_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "session_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "snapshot_accepted": { - "type": "boolean" - }, - "snapshot_high_water_event_sequence": { - "format": "int64", - "maximum": 9007199254740991, + "scanned_entries": { + "format": "int32", + "maximum": 1000, "minimum": 0, "type": "integer" }, - "unknown_count": { + "scanned_files": { "format": "int32", - "maximum": 1000000000, + "maximum": 64, "minimum": 0, "type": "integer" }, + "truncated": { + "type": "boolean" + }, "workspace_id": { "maxLength": 256, "minLength": 1, @@ -26925,368 +28502,305 @@ }, "required": [ "protocol_version", - "id", - "run_id", - "session_id", "workspace_id", - "plan_id", - "plan_sha256", - "plan_item_ordinal", - "plan_item_sha256", - "format", - "snapshot_high_water_event_sequence", - "associated_evidence_count", - "pass_count", - "fail_count", - "unknown_count", - "returned_association_count", - "associations_truncated", - "content_sha256", - "content_bytes", - "receipt_event_sequence", - "recorded_at", - "immutable", - "operator_recorded", - "metadata_only", - "read_only", - "content_included", - "private_bodies_included", - "operator_identity_included", - "snapshot_accepted", - "result_accepted", - "result_inferred", - "record_rewritten", - "approval", - "authority_granted", - "execution_started" + "results", + "scanned_entries", + "scanned_files", + "scanned_bytes", + "truncated", + "root_path_exposed" ], "type": "object" }, - "WorkItemView": { + "WorkspaceView": { "additionalProperties": false, "properties": { - "acceptance_criteria": { - "items": { - "type": "string" - }, - "type": "array" - }, - "blocked_reason": { - "type": "string" - }, - "completed_at": { - "format": "date-time", - "type": "string" - }, "created_at": { "format": "date-time", "type": "string" }, - "dependencies": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": { - "type": "string" - }, "id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "owner": { + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "created_at" + ], + "type": "object" + }, + "contextMemoryCreateRequestView": { + "additionalProperties": false, + "properties": { + "content": { "type": "string" }, - "owner_agent_id": { - "maxLength": 256, - "minLength": 1, + "redact_sensitive": { + "type": "boolean" + }, + "references": { + "items": { + "type": "string" + }, + "type": "array" + }, + "retention_until": { + "format": "date-time", "type": "string" }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "critical" - ], + "scope": { "type": "string" }, - "run_id": { + "scope_id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "status": { - "enum": [ - "pending", - "in_progress", - "blocked", - "completed", - "cancelled" - ], + "source_ref": { "type": "string" }, "title": { "type": "string" - }, - "updated_at": { - "format": "date-time", - "type": "string" - }, - "version": { + } + }, + "required": [ + "scope", + "scope_id", + "title", + "content" + ], + "type": "object" + }, + "contextMemoryDeleteRequestView": { + "additionalProperties": false, + "properties": { + "expected_version": { "format": "int64", - "minimum": 1, "type": "integer" } }, "required": [ - "id", - "run_id", - "title", - "status", - "priority", - "acceptance_criteria", - "dependencies", - "version", - "created_at", - "updated_at" + "expected_version" ], "type": "object" }, - "WorkspaceExplorerEntryView": { + "contextMemoryDeleteView": { "additionalProperties": false, "properties": { - "kind": { - "enum": [ - "directory", - "file", - "blocked" - ], - "type": "string" - }, - "name": { - "maxLength": 255, - "type": "string" + "deleted": { + "type": "boolean" }, - "path": { - "maxLength": 512, + "id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "readable": { + "recoverable": { "type": "boolean" - }, - "size_bytes": { - "format": "int64", - "minimum": 0, - "type": "integer" } }, "required": [ - "name", - "path", - "kind", - "size_bytes", - "readable" + "id", + "deleted", + "recoverable" ], "type": "object" }, - "WorkspaceExplorerProvenanceView": { + "contextMemoryUpdateRequestView": { "additionalProperties": false, "properties": { - "content_sha256": { - "maxLength": 64, - "pattern": "^[a-f0-9]{64}$", + "content": { "type": "string" }, - "instruction_authorized": { + "expected_version": { + "format": "int64", + "type": "integer" + }, + "redact_sensitive": { "type": "boolean" }, - "source_kind": { - "enum": [ - "workspace_file", - "workspace_listing" - ], - "type": "string" + "references": { + "items": { + "type": "string" + }, + "type": "array" }, + "retention_until": {}, "source_ref": { - "maxLength": 512, "type": "string" }, - "version": { - "enum": [ - "context_provenance.v1" - ], + "status": { + "type": "string" + }, + "title": { "type": "string" } }, "required": [ - "version", - "source_kind", - "source_ref", - "content_sha256", - "instruction_authorized" + "expected_version" ], "type": "object" }, - "WorkspaceExplorerView": { + "continuityBranchRequestView": { "additionalProperties": false, "properties": { - "content": { + "goal": { "type": "string" + } + }, + "type": "object" + }, + "continuityBranchView": { + "additionalProperties": false, + "properties": { + "capability_grant": { + "type": "boolean" }, - "entries": { + "inherited": { "items": { - "$ref": "#/components/schemas/WorkspaceExplorerEntryView" + "type": "string" }, - "maxItems": 200, "type": "array" }, - "kind": { - "enum": [ - "directory", - "file" - ], - "type": "string" - }, - "path": { - "maxLength": 512, - "type": "string" - }, - "protocol_version": { - "enum": [ - "workspace_explorer.v1" - ], - "type": "string" + "mission": { + "$ref": "#/components/schemas/MissionView" }, - "provenance": { - "$ref": "#/components/schemas/WorkspaceExplorerProvenanceView" + "node": { + "$ref": "#/components/schemas/ContinuityNode" }, - "redaction_count": { - "format": "int32", - "minimum": 0, - "type": "integer" + "not_inherited": { + "items": { + "type": "string" + }, + "type": "array" }, - "returned_bytes": { - "format": "int32", - "maximum": 131072, - "minimum": 0, - "type": "integer" + "run": { + "$ref": "#/components/schemas/RunView" + } + }, + "required": [ + "mission", + "run", + "node", + "inherited", + "not_inherited", + "capability_grant" + ], + "type": "object" + }, + "continuityCheckpointRequestView": { + "additionalProperties": false, + "properties": { + "summary": { + "type": "string" }, - "root_path_exposed": { + "title": { + "type": "string" + } + }, + "type": "object" + }, + "projectInstructionRefreshRequestView": { + "additionalProperties": false, + "properties": { + "confirm": { "type": "boolean" }, - "total_bytes": { - "format": "int64", - "minimum": 0, - "type": "integer" + "expected_fingerprint": { + "type": "string" }, - "truncated": { - "type": "boolean" + "expected_live_fingerprint": { + "type": "string" }, - "workspace_id": { - "maxLength": 256, - "minLength": 1, + "target_path": { "type": "string" } }, "required": [ - "protocol_version", - "workspace_id", - "path", - "kind", - "entries", - "content", - "total_bytes", - "returned_bytes", - "truncated", - "redaction_count", - "root_path_exposed", - "provenance" + "expected_fingerprint", + "expected_live_fingerprint", + "confirm" ], "type": "object" }, - "WorkspaceSearchResultView": { + "workspaceCheckpointCaptureView": { "additionalProperties": false, "properties": { - "content_truncated": { - "type": "boolean" - }, - "line": { - "format": "int32", - "minimum": 0, - "type": "integer" - }, - "match_kind": { - "enum": [ - "filename", - "content", - "filename_and_content" - ], + "operation_key": { "type": "string" }, - "path": { - "maxLength": 512, + "title": { "type": "string" + } + }, + "required": [ + "operation_key" + ], + "type": "object" + }, + "workspaceCheckpointCursorActionView": { + "additionalProperties": false, + "properties": { + "confirm": { + "type": "boolean" }, - "provenance": { - "$ref": "#/components/schemas/WorkspaceExplorerProvenanceView" + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "snippet": { - "maxLength": 512, + "operation_key": { "type": "string" } }, "required": [ - "path", - "match_kind", - "line", - "snippet", - "content_truncated", - "provenance" + "expected_current_checkpoint_id", + "operation_key", + "confirm" ], "type": "object" }, - "WorkspaceSearchView": { + "workspaceCheckpointForkContinuityView": { "additionalProperties": false, "properties": { - "protocol_version": { - "enum": [ - "workspace_search.v1" - ], + "created_at": { + "format": "date-time", "type": "string" }, - "results": { - "items": { - "$ref": "#/components/schemas/WorkspaceSearchResultView" - }, - "maxItems": 50, - "type": "array" + "git_branch": { + "type": "string" }, - "root_path_exposed": { - "type": "boolean" + "git_head": { + "type": "string" }, - "scanned_bytes": { - "format": "int64", - "maximum": 4194560, - "minimum": 0, - "type": "integer" + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "scanned_entries": { - "format": "int32", - "maximum": 1000, - "minimum": 0, - "type": "integer" + "kind": { + "type": "string" }, - "scanned_files": { - "format": "int32", - "maximum": 64, - "minimum": 0, - "type": "integer" + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "truncated": { - "type": "boolean" + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_node_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, "workspace_id": { "maxLength": 256, @@ -27295,230 +28809,237 @@ } }, "required": [ - "protocol_version", + "id", + "kind", + "session_id", + "run_id", "workspace_id", - "results", - "scanned_entries", - "scanned_files", - "scanned_bytes", - "truncated", - "root_path_exposed" + "created_at" ], "type": "object" }, - "WorkspaceView": { + "workspaceCheckpointForkMissionView": { "additionalProperties": false, "properties": { - "created_at": { - "format": "date-time", - "type": "string" - }, "id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "name": { + "profile": { + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, "required": [ "id", - "name", - "created_at" + "workspace_id", + "profile" ], "type": "object" }, - "contextMemoryCreateRequestView": { + "workspaceCheckpointForkResultView": { "additionalProperties": false, "properties": { - "content": { - "type": "string" + "checkpoint": { + "$ref": "#/components/schemas/Checkpoint" }, - "redact_sensitive": { - "type": "boolean" + "continuity_node": { + "$ref": "#/components/schemas/workspaceCheckpointForkContinuityView" }, - "references": { + "mission": { + "$ref": "#/components/schemas/workspaceCheckpointForkMissionView" + }, + "not_inherited": { "items": { "type": "string" }, "type": "array" }, - "retention_until": { - "format": "date-time", + "protocol_version": { "type": "string" }, - "scope": { - "type": "string" + "replayed": { + "type": "boolean" }, - "scope_id": { + "run": { + "$ref": "#/components/schemas/workspaceCheckpointForkRunView" + }, + "source_run_id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "source_ref": { - "type": "string" + "target": { + "$ref": "#/components/schemas/Checkpoint" }, - "title": { - "type": "string" - } - }, - "required": [ - "scope", - "scope_id", - "title", - "content" - ], - "type": "object" - }, - "contextMemoryDeleteRequestView": { - "additionalProperties": false, - "properties": { - "expected_version": { - "format": "int64", - "type": "integer" + "transaction": { + "$ref": "#/components/schemas/Transaction" + }, + "workspace": { + "$ref": "#/components/schemas/workspaceCheckpointForkWorkspaceView" } }, "required": [ - "expected_version" + "protocol_version", + "source_run_id", + "target", + "workspace", + "mission", + "run", + "continuity_node", + "checkpoint", + "transaction", + "not_inherited", + "replayed" ], "type": "object" }, - "contextMemoryDeleteView": { + "workspaceCheckpointForkRunView": { "additionalProperties": false, "properties": { - "deleted": { - "type": "boolean" + "created_at": { + "format": "date-time", + "type": "string" }, "id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "recoverable": { - "type": "boolean" + "mission_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string" } }, "required": [ "id", - "deleted", - "recoverable" + "mission_id", + "session_id", + "status", + "created_at" ], "type": "object" }, - "contextMemoryUpdateRequestView": { + "workspaceCheckpointForkView": { "additionalProperties": false, "properties": { - "content": { + "branch": { "type": "string" }, - "expected_version": { - "format": "int64", - "type": "integer" - }, - "redact_sensitive": { + "confirm": { "type": "boolean" }, - "references": { - "items": { - "type": "string" - }, - "type": "array" + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "retention_until": {}, - "source_ref": { + "goal": { "type": "string" }, - "status": { + "operation_key": { "type": "string" }, - "title": { + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_name": { "type": "string" } }, "required": [ - "expected_version" + "target_checkpoint_id", + "expected_current_checkpoint_id", + "operation_key", + "workspace_name", + "branch", + "confirm" ], "type": "object" }, - "continuityBranchRequestView": { + "workspaceCheckpointForkWorkspaceView": { "additionalProperties": false, "properties": { - "goal": { + "created_at": { + "format": "date-time", "type": "string" - } - }, - "type": "object" - }, - "continuityBranchView": { - "additionalProperties": false, - "properties": { - "capability_grant": { - "type": "boolean" - }, - "inherited": { - "items": { - "type": "string" - }, - "type": "array" - }, - "mission": { - "$ref": "#/components/schemas/MissionView" }, - "node": { - "$ref": "#/components/schemas/ContinuityNode" - }, - "not_inherited": { - "items": { - "type": "string" - }, - "type": "array" + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" }, - "run": { - "$ref": "#/components/schemas/RunView" + "name": { + "type": "string" } }, "required": [ - "mission", - "run", - "node", - "inherited", - "not_inherited", - "capability_grant" + "id", + "name", + "created_at" ], "type": "object" }, - "continuityCheckpointRequestView": { + "workspaceCheckpointPreviewView": { "additionalProperties": false, "properties": { - "summary": { + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "title": { + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, + "required": [ + "target_checkpoint_id", + "expected_current_checkpoint_id" + ], "type": "object" }, - "projectInstructionRefreshRequestView": { + "workspaceCheckpointRewindView": { "additionalProperties": false, "properties": { "confirm": { "type": "boolean" }, - "expected_fingerprint": { + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "expected_live_fingerprint": { + "operation_key": { "type": "string" }, - "target_path": { + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, "required": [ - "expected_fingerprint", - "expected_live_fingerprint", + "target_checkpoint_id", + "expected_current_checkpoint_id", + "operation_key", "confirm" ], "type": "object" diff --git a/docs/usage.md b/docs/usage.md index 4250718f..3ce48c5d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -908,6 +908,40 @@ redacted projection at 128 KiB. File text is plain evidence with `instruction_authorized=false`; notes addressed to an automated assistant do not gain system, user, tool, or Policy authority by appearing in a repository file. +### Transactional Workspace Checkpoints + +Schema v117 adds an immutable, bounded checkpoint timeline for a Run's exact Workspace +and Git index. Automatic before/after boundaries cover FileEdit apply, model-callable +file tools, Run-owned command batches/background Jobs, typed Git mutations, and the +agent-merge contract. A manual capture and every restore use a stable operation key: + +```powershell +cyberagent workspace checkpoint timeline --run --limit 100 +cyberagent workspace checkpoint capture --run ` + --operation-key before-refactor --title "Before parser refactor" +cyberagent workspace checkpoint preview --run ` + --checkpoint --expected-current +cyberagent workspace checkpoint rewind --run ` + --checkpoint --expected-current ` + --operation-key rewind-parser-1 --confirm --enable-permission-control +``` + +Undo and Redo use the same `--expected-current`, `--operation-key`, and `--confirm` +contract. Restore is permitted only for a paused Code/Deliver Run with an active +Session, no live execution lease, a current non-conservative permission, and matching +process capability flags. It writes a new checkpoint after an exact three-way preview; +it never invokes `git reset --hard` or blanket-deletes untracked files. Fork additionally +requires a new Git branch and an absent destination path, creates an independent +Workspace/Mission/Run/Session, and restores no historical authority. + +The CLI supplies that exact destination with `--workspace-root`. HTTP/Desktop do not +accept renderer-provided absolute paths; Go derives a deterministic absent sibling from +the trusted source Workspace and operation key, and the response omits the root path. + +See [Workspace Checkpoints](workspace-checkpoints.md) for CLI, HTTP, Desktop, quota, +conflict, and recovery details, and [ADR 0118](adr/0118-transactional-workspace-checkpoints.md) +for the protocol and threat model. + ## Script Mode ```powershell diff --git a/docs/workspace-checkpoints.md b/docs/workspace-checkpoints.md new file mode 100644 index 00000000..b3a68639 --- /dev/null +++ b/docs/workspace-checkpoints.md @@ -0,0 +1,155 @@ +# Workspace Checkpoints, Rewind, and Fork + +`workspace-checkpoint.v1` provides a bounded, auditable recovery layer for files +and the Git index inside one Run's exact Workspace. It complements Git; it does not +commit automatically, rewrite Git history, or claim to reverse effects outside the +Workspace. + +## Recovery grades + +| Grade | Meaning | +|---|---| +| `complete` | Every changed item represented by the manifest has recoverable bytes and the boundary has complete attribution. | +| `partial` | The known in-root state is recorded, but at least one item or attribution source is excluded or uncertain. Command batches are partial while no filesystem watcher is installed. | +| `unavailable` | The root/Git identity, case, index, links, or required content cannot be materialized safely. Restore/Fork is blocked. | + +Manifest storage policies include `stored`, `missing`, `excluded_ignored`, +`excluded_generated`, `excluded_large`, `excluded_sensitive`, `excluded_link`, +`excluded_special`, and `unreadable`. Every exclusion has a bounded reason. Binary +files are supported when within quota; they are never converted to text. LF, CRLF, +mixed, and no-newline content is hashed and restored byte-for-byte. + +The global store is bounded to 2 GiB of blobs, 10,000 checkpoints, 2,000,000 +manifest entries, and 20,000 mutation transactions. SQLite enforces these limits +under concurrent writers. Reaching a hard metadata limit returns +`RESOURCE_EXHAUSTED`; immutable retained history is not silently pruned. + +## Safe operating sequence + +1. Pause the Run and ensure no execution lease or background mutation remains. +2. Read the timeline and retain `current.current_checkpoint_id`. +3. Preview the chosen target. Review every path, index change, recovery grade, and + conflict. +4. Confirm with that exact expected-current checkpoint and a new stable operation + key. The service re-runs all checks; a stale preview conflicts. +5. Inspect the appended post-checkpoint and transaction. The old timeline remains + unchanged. + +Manual capture returns `CONFLICT` while a mutation boundary for the same Run is +open. Finish or reconcile that writer first; capture never advances the cursor +underneath an in-flight file, command, Git, or merge operation. + +Restore also requires Code/Deliver mode and a current `approval`, `full_access`, or +`debug` permission allowed by the process startup gates. A historical permission is +never enough. + +## CLI + +Read-only and capture operations: + +```powershell +cyberagent workspace checkpoint timeline --run --limit 100 +cyberagent workspace checkpoint capture --run ` + --operation-key checkpoint-before-refactor --title "Before parser refactor" +cyberagent workspace checkpoint preview --run ` + --checkpoint --expected-current +``` + +Confirmed restore uses the process flags matching the Run's current permission. +For an `approval` Run: + +```powershell +cyberagent workspace checkpoint rewind --run ` + --checkpoint --expected-current ` + --operation-key rewind-parser-1 --confirm --enable-permission-control + +cyberagent workspace checkpoint undo --run ` + --expected-current --operation-key undo-parser-1 ` + --confirm --enable-permission-control + +cyberagent workspace checkpoint redo --run ` + --expected-current --operation-key redo-parser-1 ` + --confirm --enable-permission-control +``` + +Add `--enable-danger-full-access` for `full_access`, and both that flag and +`--enable-debug-maximum-access` for `debug`. + +Fork requires an absent destination under an existing real parent directory and a +new valid Git branch: + +```powershell +cyberagent workspace checkpoint fork --run ` + --checkpoint --expected-current ` + --operation-key fork-parser-1 --workspace-name parser-fork ` + --workspace-root D:\worktrees\parser-fork --branch codex/parser-fork ` + --goal "Continue from the reviewed checkpoint" --confirm ` + --enable-permission-control +``` + +`--workspace-root` is an operator-only CLI input. HTTP/Desktop Fork requests do +not accept an absolute host path: Go deterministically selects an absent sibling +named `prayu-fork-` from the trusted source Workspace. +The renderer receives only the new Workspace/Run IDs and never the root path. +For crash recovery, SQLite privately retains the normalized destination and branch +on the prepared Fork transaction. Those fields have no JSON representation and are +not projected by timeline, HTTP, OpenAPI, Desktop, or events. If a crash occurs +before Run registration, startup verifies source/destination/branch/full commit, +the complete manifest, and the raw Git index before using Git's registered-worktree +removal; any drift stops cleanup, preserves later user edits, and leaves the +transaction open for a safe retry. +Startup also refuses to reconcile any open transaction whose Run still has an +unexpired execution lease. This prevents a second process from closing a live +writer's boundary; after a real crash, retry startup after that lease expires. +If a crash lands between boundary preparation and the `before` cursor CAS, startup +advances only the exact expected cursor before recording the interrupted partial +result. If a non-Fork transaction is already terminal but its final cursor CAS was +not committed, startup advances only from its exact `before` checkpoint to its +terminal `after` checkpoint. Fork never advances the source cursor. + +CLI results are JSON so checkpoint IDs, recovery reasons, conflicts, and replay +status remain machine-readable. + +## HTTP/OpenAPI + +```text +GET /api/v1/runs/{run_id}/workspace-checkpoints?limit=100 +POST /api/v1/runs/{run_id}/workspace-checkpoints +POST /api/v1/runs/{run_id}/workspace-checkpoints/preview +POST /api/v1/runs/{run_id}/workspace-checkpoints/rewind +POST /api/v1/runs/{run_id}/workspace-checkpoints/undo +POST /api/v1/runs/{run_id}/workspace-checkpoints/redo +POST /api/v1/runs/{run_id}/workspace-checkpoints/fork +``` + +GET uses the read bearer. POST uses the distinct control bearer, strict JSON with +duplicate/unknown-field rejection, and bounded bodies. Rewind, Undo, Redo, and Fork +require `confirm: true`; operation keys are carried in the body and may also be +bound to the normal `Idempotency-Key` header by clients. See `docs/openapi.json` for +the exact generated schemas. + +## Desktop + +Open a Run and choose **工作区检查点 / Checkpoints**. The left column is the +immutable timeline; the detail pane explains trigger receipt, attempt, capability +generation, Git identity, stored bytes, recovery level, and incomplete reasons. +Preview lists affected paths and index drift before the confirmation button is +enabled. Conflicts remain visible and disable confirmation. Fork switches to the +new Run only after the Go-derived independent worktree and final checkpoint are +verified; React does not submit or receive an absolute worktree path. + +## Failure interpretation + +- `CONFLICT`: the cursor or a touched path/index changed after review. Refresh and + preview again; never retry with a fabricated expected cursor. This also covers a + manual capture attempted while another mutation boundary is open. +- `FAILED_PRECONDITION`: the Run is not paused, a lease is live, there is no + reversible boundary, or the checkpoint cannot be materialized. +- `POLICY_DENIED`: current mode/permission/process capability does not authorize a + restore. Changing a historical checkpoint cannot change this decision. +- `RESOURCE_EXHAUSTED`: a per-file, per-checkpoint, preview, conflict, or global + content-store bound was reached. Exclusions remain explicit; the implementation + does not silently truncate into a successful complete restore. + +Full design and residual risks are recorded in +[ADR 0118](adr/0118-transactional-workspace-checkpoints.md). diff --git a/internal/app/api_command.go b/internal/app/api_command.go index 44d202e5..0c750019 100644 --- a/internal/app/api_command.go +++ b/internal/app/api_command.go @@ -130,6 +130,15 @@ func (a *App) apiServeCommand(ctx context.Context, args []string) error { if err := a.ensureStore(); err != nil { return err } + workspaceCheckpoints, err := application.NewWorkspaceCheckpointService(a.store, + permissionCapabilities) + if err != nil { + return err + } + if _, err := workspaceCheckpoints.Reconcile(ctx); err != nil { + return apperror.Wrap(apperror.CodeUnavailable, + "Workspace checkpoint startup reconciliation failed", err) + } dockerSandbox, err := a.newDockerSandboxService(*dockerExecution, permissionCapabilities) if err != nil { @@ -177,7 +186,8 @@ func (a *App) apiServeCommand(ctx context.Context, args []string) error { WithRegistryReload(a.models, a.store) fileEditReview := application.NewFileEditReviewService(a.store) fileEditProposal := application.NewFileEditProposalService(a.store, a.checker) - fileEditApply := application.NewFileEditApplyService(a.store, a.checker) + fileEditApply := application.NewFileEditApplyService(a.store, a.checker, + workspaceCheckpoints) runWakeControl := application.NewRunWakeControlService(a.store) runWakeExecution := application.NewForegroundRunWakeConsumer(a.store, executionControl) @@ -249,6 +259,7 @@ func (a *App) apiServeCommand(ctx context.Context, args []string) error { EvidenceAttachmentEnabled: controlToken != "", VerificationEvidenceEnabled: controlToken != "", EmbeddedAnalyzerExecutionEnabled: controlToken != "", + WorkspaceCheckpointControlEnabled: controlToken != "", RunLifecycleController: lifecycleControl, RunExecutionController: executionControl, PublicModelStreamSource: executionControl, @@ -270,6 +281,7 @@ func (a *App) apiServeCommand(ctx context.Context, args []string) error { RunWakeWorkerHealthSource: workerHealth, SkillInstallationController: skillInstallation, EmbeddedAnalyzerExecutionController: embeddedAnalyzerExecution, + WorkspaceCheckpointController: workspaceCheckpoints, DockerSandboxController: dockerSandbox, ModelRegistry: a.models, AppVersion: Version, diff --git a/internal/app/app.go b/internal/app/app.go index 4dcf5292..288d9745 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -255,7 +255,7 @@ func (a *App) printHelp() { fmt.Fprintln(a.out, " cyberagent version") fmt.Fprintln(a.out, " cyberagent doctor portable [--json]") fmt.Fprintln(a.out, " cyberagent doctor browser-readiness --product [--json]") - fmt.Fprintln(a.out, " cyberagent workspace init|list|show|tree|read") + fmt.Fprintln(a.out, " cyberagent workspace init|list|show|tree|read|checkpoint") fmt.Fprintln(a.out, " cyberagent script new|run") fmt.Fprintln(a.out, " cyberagent ctf init|analyze|writeup") fmt.Fprintln(a.out, " cyberagent learn ask") @@ -365,6 +365,8 @@ func (a *App) workspaceCommand(ctx context.Context, args []string) error { return a.workspaceTree(ctx, args[1:]) case "read": return a.workspaceRead(ctx, args[1:]) + case "checkpoint": + return a.workspaceCheckpointCommand(ctx, args[1:]) default: return fmt.Errorf("unknown workspace subcommand %q", args[0]) } diff --git a/internal/app/model_diff_wake_command_test.go b/internal/app/model_diff_wake_command_test.go index 5b98aaa7..1307b764 100644 --- a/internal/app/model_diff_wake_command_test.go +++ b/internal/app/model_diff_wake_command_test.go @@ -93,6 +93,19 @@ func TestModelRouteFileReviewAndWakeCommandsUseDurableBoundaries(t *testing.T) { if err != nil || string(written) != "do not write during review\n" { t.Fatalf("CLI apply wrote %q err=%v", written, err) } + checkpointStore, err := store.Open(filepath.Join(home, "cyberagent.db")) + if err != nil { + t.Fatal(err) + } + transactions, err := checkpointStore.ListWorkspaceCheckpointTransactions( + t.Context(), run.ID, 10) + closeErr := checkpointStore.Close() + if err != nil || closeErr != nil || len(transactions) != 1 || + transactions[0].Kind != "file_tool" || transactions[0].Status != "completed" || + transactions[0].BeforeCheckpointID == "" || transactions[0].AfterCheckpointID == "" { + t.Fatalf("CLI apply checkpoint transaction=%+v err=%v close=%v", + transactions, err, closeErr) + } output, stderr, code = executeTestCommand(t, "run", "wake", "schedule", run.ID, "--operation-key", "cli-wake-schedule-0001") diff --git a/internal/app/workspace_checkpoint_command.go b/internal/app/workspace_checkpoint_command.go new file mode 100644 index 00000000..57dddb22 --- /dev/null +++ b/internal/app/workspace_checkpoint_command.go @@ -0,0 +1,289 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "flag" + "strings" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +const workspaceCheckpointUsage = "usage: cyberagent workspace checkpoint " + + "timeline|capture|preview|rewind|undo|redo|fork --run [options]" + +type workspaceCheckpointAuthorityFlags struct { + operator *string + confirm *bool + permissionControl *bool + dangerFullAccess *bool + debugMaximum *bool +} + +func addWorkspaceCheckpointAuthorityFlags(fs *flag.FlagSet) workspaceCheckpointAuthorityFlags { + return workspaceCheckpointAuthorityFlags{ + operator: fs.String("operator", "cli_operator", "operator identity"), + confirm: fs.Bool("confirm", false, + "confirm the exact Workspace restore or Fork intent"), + permissionControl: fs.Bool("enable-permission-control", false, + "enable operator-selected Run permission for this process"), + dangerFullAccess: fs.Bool("enable-danger-full-access", false, + "enable danger-full-access for this process"), + debugMaximum: fs.Bool("enable-debug-maximum-access", false, + "enable maximum Debug permission for this process"), + } +} + +func (f workspaceCheckpointAuthorityFlags) capabilities() ( + domain.ExecutionPermissionRuntimeCapabilities, error, +) { + value := domain.ExecutionPermissionRuntimeCapabilities{ + OperatorApprovalEnabled: *f.permissionControl, + DangerFullAccessEnabled: *f.dangerFullAccess, + DebugMaximumAccessEnabled: *f.debugMaximum, + } + if err := value.Validate(); err != nil { + return domain.ExecutionPermissionRuntimeCapabilities{}, + apperror.Wrap(apperror.CodeInvalidArgument, err.Error(), err) + } + return value, nil +} + +func (a *App) workspaceCheckpointCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New(workspaceCheckpointUsage) + } + switch args[0] { + case "timeline": + return a.workspaceCheckpointTimeline(ctx, args[1:]) + case "capture": + return a.workspaceCheckpointCapture(ctx, args[1:]) + case "preview": + return a.workspaceCheckpointPreview(ctx, args[1:]) + case "rewind": + return a.workspaceCheckpointRewind(ctx, args[1:]) + case "undo", "redo": + return a.workspaceCheckpointCursorAction(ctx, args[0], args[1:]) + case "fork": + return a.workspaceCheckpointFork(ctx, args[1:]) + default: + return errors.New(workspaceCheckpointUsage) + } +} + +func (a *App) newWorkspaceCheckpointService( + capabilities domain.ExecutionPermissionRuntimeCapabilities, +) (*application.WorkspaceCheckpointService, error) { + if err := a.ensureStore(); err != nil { + return nil, err + } + return application.NewWorkspaceCheckpointService(a.store, capabilities) +} + +func (a *App) workspaceCheckpointTimeline(ctx context.Context, args []string) error { + fs := newFlagSet("workspace checkpoint timeline", a.errOut) + runID := fs.String("run", "", "Run identity") + limit := fs.Int("limit", 100, "maximum checkpoints and transactions") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" { + return errors.New("usage: cyberagent workspace checkpoint timeline --run [--limit ]") + } + service, err := a.newWorkspaceCheckpointService( + domain.ExecutionPermissionRuntimeCapabilities{}) + if err != nil { + return err + } + value, err := service.Timeline(ctx, *runID, *limit) + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, value) +} + +func (a *App) workspaceCheckpointCapture(ctx context.Context, args []string) error { + fs := newFlagSet("workspace checkpoint capture", a.errOut) + runID := fs.String("run", "", "Run identity") + operationKey := fs.String("operation-key", "", "stable idempotency key") + title := fs.String("title", "", "optional timeline title") + operator := fs.String("operator", "cli_operator", "operator identity") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" || + strings.TrimSpace(*operationKey) == "" { + return errors.New("usage: cyberagent workspace checkpoint capture --run --operation-key [--title ] [--operator ]") + } + service, err := a.newWorkspaceCheckpointService( + domain.ExecutionPermissionRuntimeCapabilities{}) + if err != nil { + return err + } + checkpoint, replayed, err := service.Capture(ctx, + application.WorkspaceCheckpointCaptureRequest{RunID: *runID, + OperationKey: *operationKey, RequestedBy: *operator, Title: *title}) + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, struct { + Checkpoint workspacecheckpoint.Checkpoint `json:"checkpoint"` + Replayed bool `json:"replayed"` + }{Checkpoint: checkpoint, Replayed: replayed}) +} + +func (a *App) workspaceCheckpointPreview(ctx context.Context, args []string) error { + fs := newFlagSet("workspace checkpoint preview", a.errOut) + runID := fs.String("run", "", "Run identity") + target := fs.String("checkpoint", "", "target checkpoint identity") + expected := fs.String("expected-current", "", "reviewed current checkpoint identity") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" || strings.TrimSpace(*target) == "" { + return errors.New("usage: cyberagent workspace checkpoint preview --run --checkpoint [--expected-current ]") + } + service, err := a.newWorkspaceCheckpointService( + domain.ExecutionPermissionRuntimeCapabilities{}) + if err != nil { + return err + } + value, err := service.Restore(ctx, application.WorkspaceRestoreRequest{ + RunID: *runID, TargetCheckpointID: *target, + ExpectedCurrentCheckpointID: *expected, RequestedBy: "cli_operator", + Kind: workspacecheckpoint.TransactionRewind, + }) + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, value) +} + +func (a *App) workspaceCheckpointRewind(ctx context.Context, args []string) error { + fs := newFlagSet("workspace checkpoint rewind", a.errOut) + runID := fs.String("run", "", "Run identity") + target := fs.String("checkpoint", "", "target checkpoint identity") + expected := fs.String("expected-current", "", "reviewed current checkpoint identity") + operationKey := fs.String("operation-key", "", "stable idempotency key") + authority := addWorkspaceCheckpointAuthorityFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" || + strings.TrimSpace(*target) == "" || strings.TrimSpace(*expected) == "" || + strings.TrimSpace(*operationKey) == "" || !*authority.confirm { + return errors.New("usage: cyberagent workspace checkpoint rewind --run --checkpoint --expected-current --operation-key --confirm [runtime permission flags]") + } + capabilities, err := authority.capabilities() + if err != nil { + return err + } + service, err := a.newWorkspaceCheckpointService(capabilities) + if err != nil { + return err + } + value, err := service.Restore(ctx, application.WorkspaceRestoreRequest{ + RunID: *runID, TargetCheckpointID: *target, + ExpectedCurrentCheckpointID: *expected, OperationKey: *operationKey, + RequestedBy: *authority.operator, Kind: workspacecheckpoint.TransactionRewind, + TriggerReceiptID: *target, Confirm: true, + }) + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, value) +} + +func (a *App) workspaceCheckpointCursorAction(ctx context.Context, action string, + args []string, +) error { + fs := newFlagSet("workspace checkpoint "+action, a.errOut) + runID := fs.String("run", "", "Run identity") + expected := fs.String("expected-current", "", "reviewed current checkpoint identity") + operationKey := fs.String("operation-key", "", "stable idempotency key") + authority := addWorkspaceCheckpointAuthorityFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" || + strings.TrimSpace(*expected) == "" || strings.TrimSpace(*operationKey) == "" || + !*authority.confirm { + return errors.New("usage: cyberagent workspace checkpoint " + action + + " --run --expected-current --operation-key --confirm [runtime permission flags]") + } + capabilities, err := authority.capabilities() + if err != nil { + return err + } + service, err := a.newWorkspaceCheckpointService(capabilities) + if err != nil { + return err + } + var value application.WorkspaceRestoreResult + if action == "undo" { + value, err = service.Undo(ctx, *runID, *expected, *operationKey, + *authority.operator, true) + } else { + value, err = service.Redo(ctx, *runID, *expected, *operationKey, + *authority.operator, true) + } + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, value) +} + +func (a *App) workspaceCheckpointFork(ctx context.Context, args []string) error { + fs := newFlagSet("workspace checkpoint fork", a.errOut) + runID := fs.String("run", "", "source Run identity") + target := fs.String("checkpoint", "", "target checkpoint identity") + expected := fs.String("expected-current", "", "reviewed current checkpoint identity") + operationKey := fs.String("operation-key", "", "stable idempotency key") + workspaceName := fs.String("workspace-name", "", "new Workspace name") + workspaceRoot := fs.String("workspace-root", "", "new Git worktree path") + branch := fs.String("branch", "", "new Git branch") + goal := fs.String("goal", "", "optional new Run goal") + authority := addWorkspaceCheckpointAuthorityFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" || + strings.TrimSpace(*target) == "" || strings.TrimSpace(*expected) == "" || + strings.TrimSpace(*operationKey) == "" || strings.TrimSpace(*workspaceName) == "" || + strings.TrimSpace(*workspaceRoot) == "" || strings.TrimSpace(*branch) == "" || + !*authority.confirm { + return errors.New("usage: cyberagent workspace checkpoint fork --run --checkpoint --expected-current --operation-key --workspace-name --workspace-root --branch --confirm [--goal ] [runtime permission flags]") + } + capabilities, err := authority.capabilities() + if err != nil { + return err + } + service, err := a.newWorkspaceCheckpointService(capabilities) + if err != nil { + return err + } + value, err := service.Fork(ctx, application.WorkspaceForkRequest{ + RunID: *runID, TargetCheckpointID: *target, + ExpectedCurrentCheckpointID: *expected, OperationKey: *operationKey, + RequestedBy: *authority.operator, WorkspaceName: *workspaceName, + WorkspaceRoot: *workspaceRoot, Branch: *branch, Goal: *goal, + Confirm: true, + }) + if err != nil { + return err + } + return writeWorkspaceCheckpointJSON(a.out, value) +} + +func writeWorkspaceCheckpointJSON(destination interface { + Write([]byte) (int, error) +}, value any) error { + encoder := json.NewEncoder(destination) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/internal/app/workspace_checkpoint_command_test.go b/internal/app/workspace_checkpoint_command_test.go new file mode 100644 index 00000000..e85b50fb --- /dev/null +++ b/internal/app/workspace_checkpoint_command_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +func TestWorkspaceCheckpointCLIProvidesIdempotentCaptureTimelineAndPreview(t *testing.T) { + home := t.TempDir() + t.Setenv("CYBERAGENT_HOME", home) + if stdout, stderr, code := executeTestCommand(t, "workspace", "init", "checkpoint-cli"); code != 0 || stderr != "" || !strings.Contains(stdout, "initialized") { + t.Fatalf("workspace init output=%q stderr=%q code=%d", stdout, stderr, code) + } + created, stderr, code := executeTestCommand(t, "run", "create", + "checkpoint CLI contract", "--workspace", "checkpoint-cli", "--profile", "code", + "--phase", "deliver") + if code != 0 || stderr != "" { + t.Fatalf("run create output=%q stderr=%q code=%d", created, stderr, code) + } + runID := runIDPattern.FindString(created) + if runID == "" { + t.Fatalf("run identity missing: %s", created) + } + + first, stderr, code := executeTestCommand(t, "workspace", "checkpoint", "capture", + "--run", runID, "--operation-key", "cli-capture-0001", "--title", "before edit") + if code != 0 || stderr != "" { + t.Fatalf("capture output=%q stderr=%q code=%d", first, stderr, code) + } + var capture struct { + Checkpoint workspacecheckpoint.Checkpoint `json:"checkpoint"` + Replayed bool `json:"replayed"` + } + if err := json.Unmarshal([]byte(first), &capture); err != nil { + t.Fatal(err) + } + if capture.Checkpoint.ID == "" || capture.Replayed { + t.Fatalf("unexpected first capture: %#v", capture) + } + + replay, stderr, code := executeTestCommand(t, "workspace", "checkpoint", "capture", + "--run", runID, "--operation-key", "cli-capture-0001", "--title", "before edit") + if code != 0 || stderr != "" { + t.Fatalf("capture replay output=%q stderr=%q code=%d", replay, stderr, code) + } + if err := json.Unmarshal([]byte(replay), &capture); err != nil { + t.Fatal(err) + } + if !capture.Replayed { + t.Fatalf("capture replay was not identified: %#v", capture) + } + + timelineJSON, stderr, code := executeTestCommand(t, "workspace", "checkpoint", "timeline", + "--run", runID, "--limit", "10") + if code != 0 || stderr != "" { + t.Fatalf("timeline output=%q stderr=%q code=%d", timelineJSON, stderr, code) + } + var timeline application.WorkspaceCheckpointTimeline + if err := json.Unmarshal([]byte(timelineJSON), &timeline); err != nil { + t.Fatal(err) + } + if timeline.Current == nil || timeline.Current.CurrentCheckpointID != capture.Checkpoint.ID || + len(timeline.Checkpoints) != 1 { + t.Fatalf("unexpected timeline: %#v", timeline) + } + + previewJSON, stderr, code := executeTestCommand(t, "workspace", "checkpoint", "preview", + "--run", runID, "--checkpoint", capture.Checkpoint.ID, + "--expected-current", capture.Checkpoint.ID) + if code != 0 || stderr != "" { + t.Fatalf("preview output=%q stderr=%q code=%d", previewJSON, stderr, code) + } + var preview application.WorkspaceRestoreResult + if err := json.Unmarshal([]byte(previewJSON), &preview); err != nil { + t.Fatal(err) + } + if preview.Confirmed || preview.ProtocolVersion != + application.WorkspaceCheckpointAPIProtocolVersion { + t.Fatalf("preview unexpectedly mutated state: %#v", preview) + } +} + +func TestWorkspaceCheckpointCLIRequiresExplicitMutationConfirmation(t *testing.T) { + t.Setenv("CYBERAGENT_HOME", t.TempDir()) + _, stderr, code := executeTestCommand(t, "workspace", "checkpoint", "rewind", + "--run", "run-placeholder", "--checkpoint", "target", + "--expected-current", "current", "--operation-key", "op") + if code == 0 || !strings.Contains(stderr, "--confirm") { + t.Fatalf("mutation did not fail closed: stderr=%q code=%d", stderr, code) + } +} diff --git a/internal/application/agent_code_tools.go b/internal/application/agent_code_tools.go index 6c495170..f9532340 100644 --- a/internal/application/agent_code_tools.go +++ b/internal/application/agent_code_tools.go @@ -37,8 +37,10 @@ type AgentCodeToolExecutor struct { func NewAgentCodeToolExecutor(store AgentCodeToolStore, checker policy.Checker, ) *AgentCodeToolExecutor { + checkpoints := embeddedWorkspaceCheckpointService(store, + domain.ExecutionPermissionRuntimeCapabilities{}) return &AgentCodeToolExecutor{store: store, manager: fileedit.NewManager(store), - apply: NewFileEditApplyService(store, checker)} + apply: NewFileEditApplyService(store, checker, checkpoints)} } func (e *AgentCodeToolExecutor) ExecuteAgentCode(ctx context.Context, @@ -387,7 +389,9 @@ func (e *AgentCodeToolExecutor) applyChange(ctx context.Context, } return e.apply.Apply(ctx, ApplyFileEditRequest{Version: fileedit.FileEditApplyProtocolVersion, RunID: scope.RunID, EditID: edit.ID, OperationKey: scope.OperationKey, - AppliedBy: scope.RootAgentID}) + AppliedBy: scope.RootAgentID, InvocationID: scope.InvocationID, + CapabilityGeneration: scope.CapabilityGeneration, LeaseID: scope.LeaseID, + LeaseGeneration: scope.LeaseGeneration}) } func (e *AgentCodeToolExecutor) applyDelete(ctx context.Context, @@ -405,7 +409,9 @@ func (e *AgentCodeToolExecutor) applyDelete(ctx context.Context, } return e.apply.Apply(ctx, ApplyFileEditRequest{Version: fileedit.FileEditApplyProtocolVersion, RunID: scope.RunID, EditID: edit.ID, OperationKey: scope.OperationKey, - AppliedBy: scope.RootAgentID}) + AppliedBy: scope.RootAgentID, InvocationID: scope.InvocationID, + CapabilityGeneration: scope.CapabilityGeneration, LeaseID: scope.LeaseID, + LeaseGeneration: scope.LeaseGeneration}) } func agentCodeEditID(operationKey string) string { diff --git a/internal/application/command_runtime.go b/internal/application/command_runtime.go index 95051b20..a0854477 100644 --- a/internal/application/command_runtime.go +++ b/internal/application/command_runtime.go @@ -15,6 +15,7 @@ import ( "cyberagent-workbench/internal/runner" "cyberagent-workbench/internal/session" "cyberagent-workbench/internal/toolgateway" + "cyberagent-workbench/internal/workspacecheckpoint" ) type CommandRuntimeStore interface { @@ -36,6 +37,7 @@ type CommandRuntimeService struct { store CommandRuntimeStore manager *runner.CommandRuntimeManager capabilities domain.ExecutionPermissionRuntimeCapabilities + checkpoints *WorkspaceCheckpointService } type commandRuntimeBindings struct { @@ -61,7 +63,8 @@ func NewCommandRuntimeService(store CommandRuntimeStore, "command runtime requires the danger-full-access startup gate") } return &CommandRuntimeService{store: store, manager: manager, - capabilities: capabilities}, nil + capabilities: capabilities, + checkpoints: embeddedWorkspaceCheckpointService(store, capabilities)}, nil } func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, @@ -84,20 +87,54 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, } switch input.Action { case toolgateway.CommandRuntimeActionRun: - return s.runForeground(ctx, scope, input, bindings, result) + boundaryRequest := s.commandRuntimeBoundaryRequest(scope, + commandRuntimeWorkspaceBoundaryKey("foreground", scope.OperationKey), + scope.InvocationID) + if s.checkpoints != nil { + if _, err := s.checkpoints.BeginBoundary(ctx, boundaryRequest); err != nil { + return result, err + } + } + value, runErr := s.runForeground(ctx, scope, input, bindings, result) + boundaryCause := runErr + if boundaryCause == nil { + boundaryCause = commandRuntimeMutationResultError(value) + } + boundaryErr := s.completeCommandRuntimeBoundary(ctx, boundaryRequest, + boundaryCause) + return value, errors.Join(runErr, boundaryErr) case toolgateway.CommandRuntimeActionStart: + if err := s.completeTerminalCommandRuntimeBoundaries(ctx, scope.RunID); err != nil { + return result, err + } resolved, err := runner.NormalizeCommandRuntimeSpec(input.Commands[0], bindings.workspace.RootPath) if err != nil { return result, commandRuntimeError(err) } + operationDigest, jobID := runner.CommandRuntimeOperationIdentity(scope.RunID, + scope.OperationKey) + boundaryRequest := s.commandRuntimeBoundaryRequest(scope, operationDigest, jobID) + if s.checkpoints != nil { + if _, err := s.checkpoints.BeginBoundary(ctx, boundaryRequest); err != nil { + return result, err + } + } job, replayed, err := s.manager.Start(ctx, runner.CommandRuntimeStartRequest{ Scope: s.runnerScope(scope, bindings, scope.OperationKey), Spec: resolved}) if err != nil { - return result, commandRuntimeError(err) + operationErr := commandRuntimeError(err) + return result, errors.Join(operationErr, + s.completeCommandRuntimeBoundary(ctx, boundaryRequest, operationErr)) } result.Jobs = append(result.Jobs, job) result.Replayed = replayed + if job.State.Terminal() { + if boundaryErr := s.completeCommandRuntimeBoundary(ctx, boundaryRequest, + commandRuntimeJobStateError(job.State)); boundaryErr != nil { + return result, boundaryErr + } + } return result, nil case toolgateway.CommandRuntimeActionList: jobs, err := s.manager.List(ctx, runner.CommandRuntimeListFilter{ @@ -128,6 +165,9 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, return result, commandRuntimeError(err) } appendCommandRuntimeArtifact(&result, record) + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return result, err + } } return result, nil case toolgateway.CommandRuntimeActionWriteStdin: @@ -141,6 +181,15 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, } result.Jobs = append(result.Jobs, job) result.Replayed = replayed + if job.State.Terminal() { + record, getErr := s.store.GetCommandRuntimeJob(ctx, input.JobID) + if getErr != nil { + return result, commandRuntimeError(getErr) + } + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return result, err + } + } return result, nil case toolgateway.CommandRuntimeActionCancel, toolgateway.CommandRuntimeActionKill: record, err := s.authorizeJob(ctx, input.JobID, bindings) @@ -151,6 +200,9 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, result.Jobs = append(result.Jobs, runner.ProjectCommandRuntimeJob(record)) result.Replayed = true appendCommandRuntimeArtifact(&result, record) + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return result, err + } return result, nil } if _, err := s.authorizeActiveJob(ctx, input.JobID, bindings); err != nil { @@ -166,6 +218,15 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, return result, commandRuntimeError(err) } result.Jobs = append(result.Jobs, job) + if job.State.Terminal() { + record, getErr := s.store.GetCommandRuntimeJob(ctx, input.JobID) + if getErr != nil { + return result, commandRuntimeError(getErr) + } + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return result, err + } + } return result, nil default: return result, apperror.New(apperror.CodeInvalidArgument, @@ -219,6 +280,93 @@ func (s *CommandRuntimeService) runForeground(ctx context.Context, return result, nil } +func (s *CommandRuntimeService) commandRuntimeBoundaryRequest( + scope toolgateway.CommandRuntimeContext, operationKey, receiptID string, +) WorkspaceMutationBoundaryRequest { + return WorkspaceMutationBoundaryRequest{RunID: scope.RunID, + Kind: workspacecheckpoint.TransactionCommandBatch, OperationKey: operationKey, + TriggerReceiptID: receiptID, InvocationID: scope.InvocationID, + CapabilityGeneration: scope.CapabilityGeneration, LeaseID: scope.LeaseID, + LeaseGeneration: scope.LeaseGeneration, + IncompleteReasons: []string{ + "filesystem watcher attribution is unavailable; shell writes are inferred from bounded manifests and Git state", + }} +} + +func (s *CommandRuntimeService) completeCommandRuntimeBoundary(ctx context.Context, + request WorkspaceMutationBoundaryRequest, cause error, +) error { + if s == nil || s.checkpoints == nil { + return nil + } + completionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), + 30*time.Second) + defer cancel() + _, err := s.checkpoints.CompleteBoundary(completionCtx, request, cause) + return err +} + +func (s *CommandRuntimeService) completeCommandRuntimeJobBoundary(ctx context.Context, + job runner.CommandRuntimeJob, +) error { + if s == nil || s.checkpoints == nil || !job.State.Terminal() { + return nil + } + operationDigest := workspaceBoundaryOperationDigest(job.RunID, + workspacecheckpoint.TransactionCommandBatch, job.OperationDigest) + if _, found, err := s.checkpoints.store.GetWorkspaceCheckpointTransactionByOperation(ctx, + operationDigest); err != nil { + return apperror.Normalize(err) + } else if !found { + // Foreground batch Jobs are covered by one batch-level boundary. Only + // background Jobs have a per-Job boundary keyed by OperationDigest. + return nil + } + return s.completeCommandRuntimeBoundary(ctx, WorkspaceMutationBoundaryRequest{ + RunID: job.RunID, Kind: workspacecheckpoint.TransactionCommandBatch, + OperationKey: job.OperationDigest, TriggerReceiptID: job.ID, + InvocationID: job.InvocationID}, commandRuntimeJobStateError(job.State)) +} + +func (s *CommandRuntimeService) completeTerminalCommandRuntimeBoundaries(ctx context.Context, + runID string, +) error { + if s == nil || s.checkpoints == nil { + return nil + } + jobs, err := s.store.ListCommandRuntimeJobs(ctx, + runner.CommandRuntimeListFilter{RunID: runID, Limit: 500}) + if err != nil { + return commandRuntimeError(err) + } + for _, job := range jobs { + if job.State.Terminal() { + if err := s.completeCommandRuntimeJobBoundary(ctx, job); err != nil { + return err + } + } + } + return nil +} + +func commandRuntimeMutationResultError( + result toolgateway.CommandRuntimeExecutionResult, +) error { + for _, job := range result.Jobs { + if err := commandRuntimeJobStateError(job.State); err != nil { + return err + } + } + return nil +} + +func commandRuntimeJobStateError(state runner.CommandRuntimeJobState) error { + if state == runner.CommandRuntimeJobCompleted { + return nil + } + return fmt.Errorf("command runtime workspace mutation ended in state %s", state) +} + func (s *CommandRuntimeService) cancelForegroundJob(jobID string) error { cleanupCtx, cancel := context.WithTimeout(context.Background(), runner.MaxCommandRuntimeCancelGrace+2*time.Second) @@ -401,12 +549,18 @@ func (s *CommandRuntimeService) Reconcile(ctx context.Context) (int, error) { return 0, commandRuntimeError(err) } jobs, err := s.store.ListCommandRuntimeJobs(ctx, - runner.CommandRuntimeListFilter{ActiveOnly: true, Limit: 500}) + runner.CommandRuntimeListFilter{Limit: 500}) if err != nil { return 0, commandRuntimeError(err) } stopped := reconciled for _, job := range jobs { + if job.State.Terminal() { + if completeErr := s.completeCommandRuntimeJobBoundary(ctx, job); completeErr != nil { + return stopped, completeErr + } + continue + } if !s.manager.OwnsActiveJob(job) { continue } @@ -415,9 +569,20 @@ func (s *CommandRuntimeService) Reconcile(ctx context.Context) (int, error) { return stopped, bindErr } if !current { - if _, stopErr := s.manager.Stop(context.WithoutCancel(ctx), job.ID, - true, 0); stopErr == nil { + stoppedJob, stopErr := s.manager.Stop(context.WithoutCancel(ctx), job.ID, + true, 0) + if stopErr == nil { stopped++ + if stoppedJob.State.Terminal() { + record, getErr := s.store.GetCommandRuntimeJob(ctx, job.ID) + if getErr != nil { + return stopped, commandRuntimeError(getErr) + } + if completeErr := s.completeCommandRuntimeJobBoundary(ctx, + record); completeErr != nil { + return stopped, completeErr + } + } } } } @@ -531,6 +696,12 @@ func commandRuntimeBatchOperationKey(operationKey string, index int) string { return "command-runtime-" + hex.EncodeToString(digest[:]) } +func commandRuntimeWorkspaceBoundaryKey(action, operationKey string) string { + digest := sha256.Sum256([]byte("command-runtime-workspace-boundary.v1\x00" + + action + "\x00" + operationKey)) + return hex.EncodeToString(digest[:]) +} + func commandRuntimeError(err error) error { if err == nil { return nil diff --git a/internal/application/command_runtime_test.go b/internal/application/command_runtime_test.go index 024ff0cf..ce1238b6 100644 --- a/internal/application/command_runtime_test.go +++ b/internal/application/command_runtime_test.go @@ -63,7 +63,8 @@ func TestCommandRuntimeServiceRunsAndReplaysFencedForegroundCommand(t *testing.T OperationKey: "command-runtime-operation-1", RunID: runRecord.ID, RootAgentID: root.ID, SessionID: runRecord.SessionID, WorkspaceID: "workspace-command-runtime-app", LeaseID: lease.LeaseID, - LeaseGeneration: lease.Generation, RequestedBy: "run_supervisor", + CapabilityGeneration: strings.Repeat("a", 64), + LeaseGeneration: lease.Generation, RequestedBy: "run_supervisor", PolicyDecision: toolgateway.Decision{Allowed: true, Approval: toolgateway.ApprovalAutomatic, Risk: "high", Reason: "test"}, } @@ -508,7 +509,8 @@ func commandRuntimeTestScope(runRecord domain.Run, root domain.AgentNode, InvocationID: "command-runtime-invocation-" + operationKey, OperationKey: operationKey, RunID: runRecord.ID, RootAgentID: root.ID, SessionID: runRecord.SessionID, WorkspaceID: "workspace-command-runtime-app", - LeaseID: lease.LeaseID, LeaseGeneration: lease.Generation, + CapabilityGeneration: strings.Repeat("a", 64), + LeaseID: lease.LeaseID, LeaseGeneration: lease.Generation, RequestedBy: "run_supervisor", PolicyDecision: toolgateway.Decision{ Allowed: true, Approval: toolgateway.ApprovalAutomatic, Risk: "high", Reason: "test"}, diff --git a/internal/application/context_continuity.go b/internal/application/context_continuity.go index 5527c325..2b805c4f 100644 --- a/internal/application/context_continuity.go +++ b/internal/application/context_continuity.go @@ -58,6 +58,9 @@ type BranchContinuityRequest struct { Kind contextmgr.ContinuityNodeKind Goal string RequestedBy string + WorkspaceID string + GitBranch string + GitHead string } type ContinuityBranchResult struct { @@ -132,33 +135,52 @@ func (s *ContextContinuityService) Checkpoint(ctx context.Context, func (s *ContextContinuityService) Branch(ctx context.Context, request BranchContinuityRequest, ) (ContinuityBranchResult, error) { + prepared, node, result, err := s.prepareBranch(ctx, request) + if err != nil { + return ContinuityBranchResult{}, err + } + if err := s.store.CreateMissionRunWithContinuity(ctx, prepared.Mission, prepared.Run, + prepared.Mode, prepared.Session, prepared.CreateSession, prepared.InitialEvents, node); err != nil { + return ContinuityBranchResult{}, err + } + return result, nil +} + +func (s *ContextContinuityService) prepareBranch(ctx context.Context, + request BranchContinuityRequest, +) (preparedRun, contextmgr.ContinuityNode, ContinuityBranchResult, error) { if s == nil || s.store == nil { - return ContinuityBranchResult{}, errors.New("context continuity store is required") + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + errors.New("context continuity store is required") } request.SourceNodeID = strings.TrimSpace(request.SourceNodeID) request.RequestedBy = strings.TrimSpace(request.RequestedBy) + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + request.GitBranch = strings.TrimSpace(request.GitBranch) + request.GitHead = strings.TrimSpace(request.GitHead) if err := contextmgr.ValidateMemoryActor(request.RequestedBy); err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } if request.Kind != contextmgr.ContinuityNodeFork && request.Kind != contextmgr.ContinuityNodeResume { - return ContinuityBranchResult{}, errors.New("continuity branch kind must be fork or resume") + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + errors.New("continuity branch kind must be fork or resume") } source, err := s.store.GetSessionContinuityNode(ctx, request.SourceNodeID) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } sourceRun, err := s.store.GetRun(ctx, source.RunID) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } sourceMission, err := s.store.GetMission(ctx, sourceRun.MissionID) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } mode, err := s.store.GetRunMode(ctx, sourceRun.ID) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } goal := boundedContinuityText(request.Goal, 16*1024) if goal == "" { @@ -168,10 +190,12 @@ func (s *ContextContinuityService) Branch(ctx context.Context, if len(sourceRun.Config.ProjectConfig) > 0 { var value projectconfig.Effective if err := json.Unmarshal(sourceRun.Config.ProjectConfig, &value); err != nil { - return ContinuityBranchResult{}, fmt.Errorf("decode pinned project config: %w", err) + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + fmt.Errorf("decode pinned project config: %w", err) } if value.Fingerprint() != sourceRun.Config.ProjectConfigFingerprint { - return ContinuityBranchResult{}, errors.New("pinned project config fingerprint mismatch") + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + errors.New("pinned project config fingerprint mismatch") } project = &value } @@ -179,24 +203,47 @@ func (s *ContextContinuityService) Branch(ctx context.Context, if len(sourceRun.Config.ProjectInstructions) > 0 { var value projectconfig.InstructionSnapshot if err := json.Unmarshal(sourceRun.Config.ProjectInstructions, &value); err != nil { - return ContinuityBranchResult{}, fmt.Errorf("decode pinned project instructions: %w", err) + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + fmt.Errorf("decode pinned project instructions: %w", err) } if err := value.Validate(); err != nil { - return ContinuityBranchResult{}, fmt.Errorf("pinned project instructions: %w", err) + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, + fmt.Errorf("pinned project instructions: %w", err) } instructions = &value } continuity := source.Snapshot + workspaceID := sourceMission.WorkspaceID + if request.WorkspaceID != "" { + workspaceID = request.WorkspaceID + continuity.WorkspaceID = workspaceID + continuity.GitBranch, continuity.GitHead = request.GitBranch, request.GitHead + inherited := make([]string, 0, len(continuity.InheritedContext)+1) + for _, value := range continuity.InheritedContext { + if !strings.HasPrefix(value, "git:") { + inherited = append(inherited, value) + } + } + if continuity.GitHead != "" { + inherited = append(inherited, "git:"+continuity.GitBranch+":"+continuity.GitHead) + } + continuity.InheritedContext = inherited + continuity.Fingerprint = "" + continuity, err = contextmgr.SealContinuitySnapshot(continuity) + if err != nil { + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err + } + } prepared, err := prepareRun(ctx, CreateRunRequest{ Goal: goal, Profile: string(sourceMission.Profile), Surface: string(mode.Surface), - Phase: string(mode.Phase), WorkspaceID: sourceMission.WorkspaceID, + Phase: string(mode.Phase), WorkspaceID: workspaceID, ModelRoute: sourceRun.Config.ModelRoute, Interactive: sourceRun.Config.Interactive, Budget: sourceRun.Budget, RequestedBy: request.RequestedBy, ProjectConfig: project, ProjectInstructions: instructions, ContinuityContext: &continuity, }, s.store.GetSession) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } title := "Fork from " + source.ID if request.Kind == contextmgr.ContinuityNodeResume { @@ -205,20 +252,20 @@ func (s *ContextContinuityService) Branch(ctx context.Context, node, err := contextmgr.NewContinuityNode(idgen.New("continuity"), request.Kind, prepared.Run.SessionID, prepared.Run.ID, prepared.Mission.WorkspaceID, "", source.ID, title, "Explicit context snapshot inherited; all authority reset", - request.RequestedBy, source.Snapshot, time.Now().UTC()) + request.RequestedBy, continuity, time.Now().UTC()) if err != nil { - return ContinuityBranchResult{}, err + return preparedRun{}, contextmgr.ContinuityNode{}, ContinuityBranchResult{}, err } - if err := s.store.CreateMissionRunWithContinuity(ctx, prepared.Mission, prepared.Run, - prepared.Mode, prepared.Session, prepared.CreateSession, prepared.InitialEvents, node); err != nil { - return ContinuityBranchResult{}, err - } - return ContinuityBranchResult{Mission: prepared.Mission, Run: prepared.Run, Node: node, + result := ContinuityBranchResult{Mission: prepared.Mission, Run: prepared.Run, Node: node, Inherited: append([]string{}, source.Snapshot.InheritedContext...), NotInherited: []string{"approvals", "capability grants", "credentials", "debug sessions", "execution leases", "network authorization", "processes", "terminal leases", "execution profiles"}, - CapabilityGrant: false}, nil + CapabilityGrant: false} + if request.WorkspaceID != "" { + result.Inherited = append([]string{}, continuity.InheritedContext...) + } + return prepared, node, result, nil } func (s *ContextContinuityService) Tree(ctx context.Context, diff --git a/internal/application/file_edit_apply.go b/internal/application/file_edit_apply.go index f96f66a8..6c58f997 100644 --- a/internal/application/file_edit_apply.go +++ b/internal/application/file_edit_apply.go @@ -14,6 +14,7 @@ import ( "cyberagent-workbench/internal/runmutation" "cyberagent-workbench/internal/session" "cyberagent-workbench/internal/tools" + "cyberagent-workbench/internal/workspacecheckpoint" ) type FileEditApplyStore interface { @@ -33,18 +34,23 @@ type FileEditApplyStore interface { } type FileEditApplyService struct { - store FileEditApplyStore - manager *fileedit.Manager - checker policy.Checker - now func() time.Time + store FileEditApplyStore + manager *fileedit.Manager + checker policy.Checker + checkpoints *WorkspaceCheckpointService + now func() time.Time } type ApplyFileEditRequest struct { - Version string - RunID string - EditID string - OperationKey string - AppliedBy string + Version string + RunID string + EditID string + OperationKey string + AppliedBy string + InvocationID string + CapabilityGeneration string + LeaseID string + LeaseGeneration int64 } type ApplyFileEditResult struct { @@ -57,15 +63,21 @@ type ApplyFileEditResult struct { } func NewFileEditApplyService(store FileEditApplyStore, - checker policy.Checker, + checker policy.Checker, checkpoints ...*WorkspaceCheckpointService, ) *FileEditApplyService { + checkpointService := embeddedWorkspaceCheckpointService(store, + domain.ExecutionPermissionRuntimeCapabilities{}) + if len(checkpoints) != 0 { + checkpointService = checkpoints[0] + } return &FileEditApplyService{store: store, manager: fileedit.NewManager(store), - checker: checker, now: func() time.Time { return time.Now().UTC() }} + checkpoints: checkpointService, + checker: checker, now: func() time.Time { return time.Now().UTC() }} } func (s *FileEditApplyService) Apply(ctx context.Context, request ApplyFileEditRequest, -) (ApplyFileEditResult, error) { +) (value ApplyFileEditResult, returnErr error) { if s == nil || s.store == nil || s.manager == nil || s.checker == nil || s.now == nil { return ApplyFileEditResult{}, apperror.New(apperror.CodeFailedPrecondition, "FileEdit apply dependencies are required") @@ -198,6 +210,24 @@ func (s *FileEditApplyService) Apply(ctx context.Context, return ApplyFileEditResult{}, policyErr } } + boundaryRequest := WorkspaceMutationBoundaryRequest{RunID: operation.RunID, + Kind: workspacecheckpoint.TransactionFileTool, OperationKey: operation.KeyDigest, + TriggerReceiptID: operation.EditID, InvocationID: normalized.InvocationID, + CapabilityGeneration: normalized.CapabilityGeneration, + LeaseID: normalized.LeaseID, LeaseGeneration: normalized.LeaseGeneration} + if s.checkpoints != nil { + if _, err := s.checkpoints.BeginBoundary(ctx, boundaryRequest); err != nil { + return ApplyFileEditResult{}, err + } + defer func() { + completionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), + 30*time.Second) + defer cancel() + _, boundaryErr := s.checkpoints.CompleteBoundary(completionCtx, + boundaryRequest, returnErr) + returnErr = errors.Join(returnErr, boundaryErr) + }() + } applied := binding.edit var applyErr error switch binding.edit.Status { @@ -246,7 +276,7 @@ func (s *FileEditApplyService) Apply(ctx context.Context, return ApplyFileEditResult{}, apperror.Normalize(completionErr) } cleanup := s.cleanupStaging(ctx, operation) - value := ApplyFileEditResult{Operation: operation, Result: result, Edit: applied, + value = ApplyFileEditResult{Operation: operation, Result: result, Edit: applied, StagingCleanup: cleanup, Replayed: preparedReplay || completionReplay, FileWritten: fileWritten} if applyErr != nil { @@ -394,5 +424,13 @@ func normalizeFileEditApplyRequest(request ApplyFileEditRequest) ( } request.OperationKey = key request.AppliedBy = strings.TrimSpace(request.AppliedBy) + request.InvocationID = strings.TrimSpace(request.InvocationID) + request.CapabilityGeneration = strings.TrimSpace(request.CapabilityGeneration) + request.LeaseID = strings.TrimSpace(request.LeaseID) + if (request.LeaseID == "") != (request.LeaseGeneration == 0) || + request.LeaseGeneration < 0 { + return ApplyFileEditRequest{}, apperror.New(apperror.CodeInvalidArgument, + "FileEdit apply execution lease is invalid") + } return request, nil } diff --git a/internal/application/git_mutation_service.go b/internal/application/git_mutation_service.go index e3173467..adc7c774 100644 --- a/internal/application/git_mutation_service.go +++ b/internal/application/git_mutation_service.go @@ -3,6 +3,7 @@ package application import ( "context" "encoding/json" + "errors" "strings" "time" @@ -12,6 +13,7 @@ import ( "cyberagent-workbench/internal/repository" "cyberagent-workbench/internal/runmutation" "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/workspacecheckpoint" ) // GitMutationStore is the bounded store surface for the typed Git workflow. @@ -27,12 +29,21 @@ type GitMutationStore interface { // GitMutationService owns the review-then-execute flow. Every execution is // bound to a Run, a Workspace, and the exact reviewed repository state. type GitMutationService struct { - store GitMutationStore - executor *repository.MutationExecutor + store GitMutationStore + executor *repository.MutationExecutor + checkpoints *WorkspaceCheckpointService } -func NewGitMutationService(store GitMutationStore, executor *repository.MutationExecutor) *GitMutationService { - return &GitMutationService{store: store, executor: executor} +func NewGitMutationService(store GitMutationStore, executor *repository.MutationExecutor, + checkpoints ...*WorkspaceCheckpointService, +) *GitMutationService { + checkpointService := embeddedWorkspaceCheckpointService(store, + domain.ExecutionPermissionRuntimeCapabilities{}) + if len(checkpoints) != 0 { + checkpointService = checkpoints[0] + } + return &GitMutationService{store: store, executor: executor, + checkpoints: checkpointService} } type GitMutationRequest struct { @@ -123,28 +134,54 @@ func (s *GitMutationService) Execute(ctx context.Context, request GitMutationReq RunID: run.ID, WorkspaceID: workspace.ID, Operation: request.Spec.Operation, SpecJSON: string(specJSON), PreHead: binding.Head, CreatedAt: time.Now().UTC(), } + boundaryRequest := WorkspaceMutationBoundaryRequest{RunID: run.ID, + Kind: workspacecheckpoint.TransactionGitMutation, OperationKey: keyDigest, + TriggerReceiptID: keyDigest} + if s.checkpoints != nil { + if _, err := s.checkpoints.BeginBoundary(ctx, boundaryRequest); err != nil { + return GitMutationExecuteResult{}, err + } + } + completeBoundary := func(cause error) error { + if s.checkpoints == nil { + return nil + } + completionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), + 30*time.Second) + defer cancel() + _, err := s.checkpoints.CompleteBoundary(completionCtx, boundaryRequest, cause) + return err + } created, replayed, err := s.store.CreateGitMutationOperation(ctx, record) if err != nil { - return GitMutationExecuteResult{}, apperror.Normalize(err) + operationErr := apperror.Normalize(err) + return GitMutationExecuteResult{}, errors.Join(operationErr, + completeBoundary(operationErr)) } if replayed { if created.CompletedAt == nil { - return GitMutationExecuteResult{Record: created, Replayed: true}, apperror.New( + operationErr := apperror.New( apperror.CodeFailedPrecondition, "previous git mutation did not record a terminal receipt; reconcile repository state before using a new operation key") + return GitMutationExecuteResult{Record: created, Replayed: true}, + errors.Join(operationErr, completeBoundary(operationErr)) } - return GitMutationExecuteResult{Record: created, Replayed: true}, nil + return GitMutationExecuteResult{Record: created, Replayed: true}, + completeBoundary(nil) } receipt, err := s.executor.Execute(ctx, workspace.RootPath, request.Spec, binding) if err != nil { - return GitMutationExecuteResult{}, err + return GitMutationExecuteResult{}, errors.Join(err, completeBoundary(err)) } completed, _, err := s.store.CompleteGitMutationOperation(ctx, created.ID, repository.MutationRecord{ PostHead: receipt.PostHead, Branch: receipt.Branch, CommitID: receipt.CommitID, Conflicted: receipt.Conflicted, Clean: receipt.Clean, StderrPrefix: receipt.StderrPrefix, }, time.Now().UTC()) if err != nil { - return GitMutationExecuteResult{}, apperror.Normalize(err) + operationErr := apperror.Normalize(err) + return GitMutationExecuteResult{}, errors.Join(operationErr, + completeBoundary(operationErr)) } - return GitMutationExecuteResult{Record: completed, Receipt: receipt}, nil + result := GitMutationExecuteResult{Record: completed, Receipt: receipt} + return result, completeBoundary(nil) } diff --git a/internal/application/workspace_checkpoints.go b/internal/application/workspace_checkpoints.go new file mode 100644 index 00000000..65f0bce6 --- /dev/null +++ b/internal/application/workspace_checkpoints.go @@ -0,0 +1,2120 @@ +package application + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/contextmgr" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/idgen" + "cyberagent-workbench/internal/repository" + "cyberagent-workbench/internal/runmutation" + "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +const ( + WorkspaceCheckpointAPIProtocolVersion = "workspace-checkpoint-api.v1" + workspaceCheckpointListLimit = 2_000 +) + +type WorkspaceCheckpointStore interface { + GetRun(context.Context, string) (domain.Run, error) + GetMission(context.Context, string) (domain.Mission, error) + GetSession(context.Context, string) (session.Session, error) + GetWorkspaceInfo(context.Context, string) (session.WorkspaceInfo, error) + GetRunMode(context.Context, string) (domain.RunModeSnapshot, error) + GetRunExecutionPermission(context.Context, string) ( + domain.RunExecutionPermissionSnapshot, error) + GetRunExecutionLease(context.Context, string) (domain.RunExecutionLease, bool, error) + GetWorkspaceCheckpointInvocationAttempt(context.Context, string, string) (string, bool, error) + + CreateWorkspaceCheckpoint(context.Context, workspacecheckpoint.Snapshot) ( + workspacecheckpoint.Checkpoint, bool, error) + GetWorkspaceCheckpoint(context.Context, string) (workspacecheckpoint.Checkpoint, error) + GetWorkspaceCheckpointSnapshot(context.Context, string) (workspacecheckpoint.Snapshot, error) + ListWorkspaceCheckpoints(context.Context, string, int) ([]workspacecheckpoint.Checkpoint, error) + WorkspaceCheckpointStorageUsage(context.Context) (workspacecheckpoint.StorageUsage, error) + + CreateWorkspaceCheckpointTransaction(context.Context, workspacecheckpoint.Transaction) ( + workspacecheckpoint.Transaction, bool, error) + UpdateWorkspaceCheckpointTransaction(context.Context, workspacecheckpoint.Transaction) ( + workspacecheckpoint.Transaction, bool, error) + GetWorkspaceCheckpointTransaction(context.Context, string) ( + workspacecheckpoint.Transaction, bool, error) + GetWorkspaceCheckpointTransactionByOperation(context.Context, string) ( + workspacecheckpoint.Transaction, bool, error) + ListWorkspaceCheckpointTransactions(context.Context, string, int) ( + []workspacecheckpoint.Transaction, error) + ListOpenWorkspaceCheckpointTransactions(context.Context, int) ( + []workspacecheckpoint.Transaction, error) + ListWorkspaceCheckpointTransactionsPendingCursor(context.Context, int) ( + []workspacecheckpoint.Transaction, error) + + GetWorkspaceCheckpointRunState(context.Context, string) ( + workspacecheckpoint.RunState, bool, error) + AdvanceWorkspaceCheckpointRunState(context.Context, workspacecheckpoint.RunState, string) ( + workspacecheckpoint.RunState, bool, error) +} + +type WorkspaceCheckpointForkStore interface { + WorkspaceCheckpointStore + ContextContinuityStore + CreateWorkspaceMissionRunWithContinuity(context.Context, session.WorkspaceRecord, + domain.Mission, domain.Run, domain.RunModeSnapshot, session.Session, bool, + []events.Event, contextmgr.ContinuityNode) error +} + +type WorkspaceCheckpointService struct { + store WorkspaceCheckpointStore + capabilities domain.ExecutionPermissionRuntimeCapabilities + now func() time.Time +} + +func NewWorkspaceCheckpointService(store WorkspaceCheckpointStore, + capabilities domain.ExecutionPermissionRuntimeCapabilities, +) (*WorkspaceCheckpointService, error) { + if store == nil || capabilities.Validate() != nil { + return nil, apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint service dependencies are invalid") + } + return &WorkspaceCheckpointService{store: store, capabilities: capabilities, + now: func() time.Time { return time.Now().UTC() }}, nil +} + +type WorkspaceCheckpointCaptureRequest struct { + RunID string + OperationKey string + RequestedBy string + Title string +} + +type WorkspaceMutationBoundaryRequest struct { + RunID string + Kind workspacecheckpoint.TransactionKind + OperationKey string + TriggerReceiptID string + InvocationID string + AttemptID string + CapabilityGeneration string + LeaseID string + LeaseGeneration int64 + IncompleteReasons []string +} + +type WorkspaceMutationBoundary struct { + Transaction workspacecheckpoint.Transaction `json:"transaction"` + Before workspacecheckpoint.Checkpoint `json:"before"` + After *workspacecheckpoint.Checkpoint `json:"after,omitempty"` + Replayed bool `json:"replayed"` +} + +type WorkspaceCheckpointTimeline struct { + ProtocolVersion string `json:"protocol_version"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + Current *workspacecheckpoint.RunState `json:"current,omitempty"` + Checkpoints []workspacecheckpoint.Checkpoint `json:"checkpoints"` + Transactions []workspacecheckpoint.Transaction `json:"transactions"` + StorageUsage workspacecheckpoint.StorageUsage `json:"storage_usage"` +} + +type WorkspaceRestoreRequest struct { + RunID string + TargetCheckpointID string + ExpectedCurrentCheckpointID string + OperationKey string + RequestedBy string + Kind workspacecheckpoint.TransactionKind + TriggerReceiptID string + Confirm bool +} + +type WorkspaceRestoreResult struct { + ProtocolVersion string `json:"protocol_version"` + Preview workspacecheckpoint.Preview `json:"preview"` + Transaction *workspacecheckpoint.Transaction `json:"transaction,omitempty"` + Before workspacecheckpoint.Checkpoint `json:"before"` + After *workspacecheckpoint.Checkpoint `json:"after,omitempty"` + Confirmed bool `json:"confirmed"` + Replayed bool `json:"replayed"` +} + +type WorkspaceForkRequest struct { + RunID string + TargetCheckpointID string + ExpectedCurrentCheckpointID string + OperationKey string + RequestedBy string + WorkspaceName string + WorkspaceRoot string + Branch string + Goal string + Confirm bool +} + +type WorkspaceForkResult struct { + ProtocolVersion string `json:"protocol_version"` + SourceRunID string `json:"source_run_id"` + Target workspacecheckpoint.Checkpoint `json:"target"` + Workspace session.WorkspaceRecord `json:"workspace"` + Mission domain.Mission `json:"mission"` + Run domain.Run `json:"run"` + Node contextmgr.ContinuityNode `json:"continuity_node"` + Checkpoint workspacecheckpoint.Checkpoint `json:"checkpoint"` + Transaction workspacecheckpoint.Transaction `json:"transaction"` + NotInherited []string `json:"not_inherited"` + Replayed bool `json:"replayed"` +} + +type workspaceCheckpointBinding struct { + run domain.Run + mission domain.Mission + session session.Session + workspace session.WorkspaceInfo +} + +func (s *WorkspaceCheckpointService) Capture(ctx context.Context, + request WorkspaceCheckpointCaptureRequest, +) (workspacecheckpoint.Checkpoint, bool, error) { + if s == nil || s.store == nil || s.now == nil { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeFailedPrecondition, "workspace checkpoint service is unavailable") + } + request.RunID = strings.TrimSpace(request.RunID) + request.OperationKey = strings.TrimSpace(request.OperationKey) + request.RequestedBy = normalizeWorkspaceCheckpointOperator(request.RequestedBy) + request.Title = strings.TrimSpace(request.Title) + if request.RunID == "" || request.OperationKey == "" || request.RequestedBy == "" { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeInvalidArgument, "workspace checkpoint capture request is invalid") + } + binding, err := s.loadBinding(ctx, request.RunID) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + openTransactions, err := s.store.ListOpenWorkspaceCheckpointTransactions(ctx, + workspaceCheckpointListLimit) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, apperror.Normalize(err) + } + if len(openTransactions) == workspaceCheckpointListLimit { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeResourceExhausted, + "workspace checkpoint open-transaction backlog reached its safe scan limit") + } + for _, transaction := range openTransactions { + if transaction.RunID == binding.run.ID { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeConflict, + "a workspace mutation boundary is still open for this Run") + } + } + operationDigest := runmutation.OperationKeyDigest("workspace_checkpoint_manual.v1", + binding.run.ID, request.OperationKey) + checkpointID := workspaceCheckpointID(operationDigest, "manual") + if existing, getErr := s.store.GetWorkspaceCheckpoint(ctx, checkpointID); getErr == nil { + if existing.RunID != binding.run.ID || existing.MissionID != binding.mission.ID || + existing.SessionID != binding.session.ID || + existing.WorkspaceID != binding.workspace.ID || + existing.Trigger != workspacecheckpoint.TriggerManual || + existing.Phase != workspacecheckpoint.PhaseStandalone || + existing.TriggerReceiptID != request.OperationKey || + existing.RequestedBy != request.RequestedBy || existing.Title != request.Title { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint operation key was reused") + } + if err := s.advanceManualCheckpointCursor(ctx, binding, existing); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + return existing, true, nil + } else if apperror.CodeOf(apperror.Normalize(getErr)) != apperror.CodeNotFound { + return workspacecheckpoint.Checkpoint{}, false, apperror.Normalize(getErr) + } + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, apperror.Normalize(err) + } + parentID := "" + if found { + parentID = state.CurrentCheckpointID + } + snapshot, replayed, err := s.capture(ctx, binding, workspacecheckpoint.CaptureRequest{ + ID: checkpointID, + Trigger: workspacecheckpoint.TriggerManual, Phase: workspacecheckpoint.PhaseStandalone, + TriggerReceiptID: request.OperationKey, RequestedBy: request.RequestedBy, + Title: request.Title, ParentCheckpointID: parentID, + CreatedAt: s.now().UTC(), + }) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + _, cursorReplay, err := s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: snapshot.Checkpoint.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, parentID) + if err != nil { + // A retry after the cursor commit is successful if it still points at + // this exact immutable checkpoint. + current, currentFound, currentErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if currentErr != nil || !currentFound || current.CurrentCheckpointID != snapshot.Checkpoint.ID { + return workspacecheckpoint.Checkpoint{}, false, apperror.Normalize(err) + } + cursorReplay = true + } + return snapshot.Checkpoint, replayed || cursorReplay, nil +} + +func (s *WorkspaceCheckpointService) advanceManualCheckpointCursor(ctx context.Context, + binding workspaceCheckpointBinding, checkpoint workspacecheckpoint.Checkpoint, +) error { + _, _, err := s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, CurrentCheckpointID: checkpoint.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, checkpoint.ParentCheckpointID) + if err == nil { + return nil + } + current, found, getErr := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if getErr == nil && found && current.WorkspaceID == binding.workspace.ID && + current.CurrentCheckpointID == checkpoint.ID { + return nil + } + if getErr != nil { + return apperror.Normalize(getErr) + } + return apperror.Normalize(err) +} + +func (s *WorkspaceCheckpointService) BeginBoundary(ctx context.Context, + request WorkspaceMutationBoundaryRequest, +) (WorkspaceMutationBoundary, error) { + request = normalizeWorkspaceMutationBoundaryRequest(request) + if s == nil || s.store == nil || s.now == nil || request.RunID == "" || + request.OperationKey == "" || request.TriggerReceiptID == "" || + !workspaceBoundaryKind(request.Kind) { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeInvalidArgument, + "workspace mutation boundary request is invalid") + } + binding, err := s.loadBinding(ctx, request.RunID) + if err != nil { + return WorkspaceMutationBoundary{}, err + } + operationDigest := workspaceBoundaryOperationDigest(binding.run.ID, request.Kind, + request.OperationKey) + fingerprint := workspaceBoundaryFingerprint(binding, request) + if existing, found, err := s.store.GetWorkspaceCheckpointTransactionByOperation(ctx, + operationDigest); err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } else if found { + if existing.RequestFingerprint != fingerprint || existing.RunID != binding.run.ID || + existing.WorkspaceID != binding.workspace.ID || existing.Kind != request.Kind || + existing.TriggerReceiptID != request.TriggerReceiptID { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeConflict, + "workspace mutation operation key was reused for different intent") + } + return s.replayBoundary(ctx, binding, existing) + } + openTransactions, err := s.store.ListOpenWorkspaceCheckpointTransactions(ctx, + workspaceCheckpointListLimit) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + if len(openTransactions) == workspaceCheckpointListLimit { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeResourceExhausted, + "workspace checkpoint open-transaction backlog reached its safe scan limit") + } + for _, openTransaction := range openTransactions { + if openTransaction.RunID == binding.run.ID { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeConflict, + "another workspace mutation boundary is still open for this Run") + } + } + incomplete := append([]string{}, request.IncompleteReasons...) + attemptID := request.AttemptID + if attemptID == "" && request.InvocationID != "" { + resolved, found, resolveErr := s.store.GetWorkspaceCheckpointInvocationAttempt(ctx, + binding.run.ID, request.InvocationID) + if resolveErr != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(resolveErr) + } + if found { + attemptID = resolved + } else { + incomplete = append(incomplete, + "triggering supervisor attempt could not be attributed") + } + } + if request.CapabilityGeneration == "" && request.InvocationID != "" { + incomplete = append(incomplete, "triggering capability generation is unavailable") + } + if request.LeaseID != "" { + if err := s.requireBoundaryLease(ctx, binding, request); err != nil { + return WorkspaceMutationBoundary{}, err + } + } else if request.InvocationID != "" { + incomplete = append(incomplete, "triggering execution lease is unavailable") + } + state, stateFound, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + previousID := "" + if stateFound { + previousID = state.CurrentCheckpointID + } + before, _, err := s.capture(ctx, binding, workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(operationDigest, "before"), AttemptID: attemptID, + CapabilityGeneration: request.CapabilityGeneration, + Trigger: workspaceBoundaryTrigger(request.Kind), Phase: workspacecheckpoint.PhaseBefore, + TriggerReceiptID: request.TriggerReceiptID, ParentCheckpointID: previousID, + IncompleteReasons: incomplete, CreatedAt: s.now().UTC(), + }) + if err != nil { + return WorkspaceMutationBoundary{}, err + } + now := s.now().UTC() + transaction := workspacecheckpoint.Transaction{ID: workspaceTransactionID(operationDigest), + ProtocolVersion: workspacecheckpoint.ProtocolVersion, + OperationKeyDigest: operationDigest, RequestFingerprint: fingerprint, + RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, Kind: request.Kind, + TriggerReceiptID: request.TriggerReceiptID, + BeforeCheckpointID: before.Checkpoint.ID, + ExpectedCurrentCheckpointID: previousID, + Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: before.Checkpoint.RecoveryLevel, ConflictJSON: "[]", + CreatedAt: now, UpdatedAt: now} + transaction, replayed, err := s.store.CreateWorkspaceCheckpointTransaction(ctx, + transaction) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + _, cursorReplay, err := s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: before.Checkpoint.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, previousID) + if err != nil { + current, found, getErr := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if getErr != nil || !found || current.CurrentCheckpointID != before.Checkpoint.ID { + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionFailed + transaction.AfterCheckpointID = before.Checkpoint.ID + transaction.ErrorCode = "workspace_cursor_race" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + _, _, _ = s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + cursorReplay = true + } + return WorkspaceMutationBoundary{Transaction: transaction, Before: before.Checkpoint, + Replayed: replayed || cursorReplay}, nil +} + +func (s *WorkspaceCheckpointService) CompleteBoundary(ctx context.Context, + request WorkspaceMutationBoundaryRequest, mutationErr error, +) (WorkspaceMutationBoundary, error) { + request = normalizeWorkspaceMutationBoundaryRequest(request) + if s == nil || s.store == nil || request.RunID == "" || request.OperationKey == "" || + !workspaceBoundaryKind(request.Kind) { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeInvalidArgument, + "workspace mutation completion request is invalid") + } + binding, err := s.loadBinding(ctx, request.RunID) + if err != nil { + return WorkspaceMutationBoundary{}, err + } + digest := workspaceBoundaryOperationDigest(binding.run.ID, request.Kind, + request.OperationKey) + transaction, found, err := s.store.GetWorkspaceCheckpointTransactionByOperation(ctx, digest) + if err != nil || !found { + if err == nil { + err = apperror.New(apperror.CodeFailedPrecondition, + "workspace mutation boundary was not prepared") + } + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + if transaction.RequestFingerprint != workspaceBoundaryFingerprint(binding, request) || + transaction.Kind != request.Kind || transaction.TriggerReceiptID != request.TriggerReceiptID { + return WorkspaceMutationBoundary{}, apperror.New(apperror.CodeConflict, + "workspace mutation completion does not match its prepared boundary") + } + if transaction.Status.Terminal() { + return s.replayBoundary(ctx, binding, transaction) + } + before, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.BeforeCheckpointID) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + after, _, err := s.capture(ctx, binding, workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(digest, "after"), AttemptID: before.Checkpoint.AttemptID, + CapabilityGeneration: before.Checkpoint.CapabilityGeneration, + Trigger: workspaceBoundaryTrigger(request.Kind), Phase: workspacecheckpoint.PhaseAfter, + TriggerReceiptID: request.TriggerReceiptID, + ParentCheckpointID: before.Checkpoint.ID, CreatedAt: s.now().UTC(), + IncompleteReasons: append([]string{}, before.Checkpoint.IncompleteReasons...), + }) + if err != nil { + return WorkspaceMutationBoundary{}, err + } + completedAt := s.now().UTC() + transaction.AfterCheckpointID = after.Checkpoint.ID + transaction.RecoveryLevel = weakestWorkspaceRecovery(before.Checkpoint.RecoveryLevel, + after.Checkpoint.RecoveryLevel) + transaction.Status = workspacecheckpoint.TransactionCompleted + transaction.ErrorCode = "" + if mutationErr != nil { + transaction.Status = workspacecheckpoint.TransactionFailed + transaction.ErrorCode = strings.ToLower(string(apperror.CodeOf( + apperror.Normalize(mutationErr)))) + if transaction.ErrorCode == "" { + transaction.ErrorCode = "workspace_mutation_failed" + } + } + transaction.UpdatedAt = completedAt + transaction.CompletedAt = &completedAt + transaction, replayed, err := s.store.UpdateWorkspaceCheckpointTransaction(ctx, + transaction) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + _, cursorReplay, err := s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: after.Checkpoint.ID, LastTransactionID: transaction.ID, + UpdatedAt: s.now().UTC()}, before.Checkpoint.ID) + if err != nil { + current, currentFound, getErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if getErr != nil || !currentFound || current.CurrentCheckpointID != after.Checkpoint.ID || + current.LastTransactionID != transaction.ID { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + cursorReplay = true + } + checkpoint := after.Checkpoint + return WorkspaceMutationBoundary{Transaction: transaction, Before: before.Checkpoint, + After: &checkpoint, Replayed: replayed || cursorReplay}, nil +} + +func (s *WorkspaceCheckpointService) Timeline(ctx context.Context, runID string, + limit int, +) (WorkspaceCheckpointTimeline, error) { + binding, err := s.loadBinding(ctx, strings.TrimSpace(runID)) + if err != nil { + return WorkspaceCheckpointTimeline{}, err + } + if limit < 1 || limit > 2_000 { + return WorkspaceCheckpointTimeline{}, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint timeline limit is invalid") + } + checkpoints, err := s.store.ListWorkspaceCheckpoints(ctx, binding.run.ID, limit) + if err != nil { + return WorkspaceCheckpointTimeline{}, apperror.Normalize(err) + } + transactions, err := s.store.ListWorkspaceCheckpointTransactions(ctx, + binding.run.ID, limit) + if err != nil { + return WorkspaceCheckpointTimeline{}, apperror.Normalize(err) + } + usage, err := s.store.WorkspaceCheckpointStorageUsage(ctx) + if err != nil { + return WorkspaceCheckpointTimeline{}, apperror.Normalize(err) + } + value := WorkspaceCheckpointTimeline{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + Checkpoints: checkpoints, Transactions: transactions, StorageUsage: usage} + if state, found, stateErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID); stateErr != nil { + return WorkspaceCheckpointTimeline{}, apperror.Normalize(stateErr) + } else if found { + value.Current = &state + } + return value, nil +} + +func (s *WorkspaceCheckpointService) Restore(ctx context.Context, + request WorkspaceRestoreRequest, +) (WorkspaceRestoreResult, error) { + request = normalizeWorkspaceRestoreRequest(request) + if s == nil || s.store == nil || request.RunID == "" || + request.TargetCheckpointID == "" || + (request.Confirm && (request.OperationKey == "" || + request.ExpectedCurrentCheckpointID == "")) || !workspaceRestoreKind(request.Kind) { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeInvalidArgument, + "workspace restore request is invalid") + } + binding, err := s.loadBinding(ctx, request.RunID) + if err != nil { + return WorkspaceRestoreResult{}, err + } + target, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, request.TargetCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + if target.Checkpoint.RunID != binding.run.ID || + target.Checkpoint.WorkspaceID != binding.workspace.ID { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeNotFound, + "target checkpoint was not found in this Run") + } + digest := "" + if request.Confirm { + digest = runmutation.OperationKeyDigest("workspace_restore_operation.v1."+ + string(request.Kind), binding.run.ID, request.OperationKey) + existing, exists, getErr := s.store.GetWorkspaceCheckpointTransactionByOperation(ctx, + digest) + if getErr != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(getErr) + } + if exists { + if request.ExpectedCurrentCheckpointID == "" { + request.ExpectedCurrentCheckpointID = existing.ExpectedCurrentCheckpointID + } + fingerprint := runmutation.Fingerprint("workspace_restore_request.v1", + binding.run.ID, binding.workspace.ID, request.TargetCheckpointID, + request.ExpectedCurrentCheckpointID, string(request.Kind), request.RequestedBy) + if existing.RequestFingerprint != fingerprint || + existing.TargetCheckpointID != request.TargetCheckpointID || + existing.ExpectedCurrentCheckpointID != request.ExpectedCurrentCheckpointID || + existing.Kind != request.Kind { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeConflict, + "workspace restore operation key was reused for different intent") + } + if existing.Status.Terminal() { + return s.replayRestore(ctx, existing) + } + state, stateFound, stateErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if stateErr != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(stateErr) + } + if !stateFound { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeConflict, + "workspace restore cursor disappeared") + } + if state.CurrentCheckpointID != existing.ExpectedCurrentCheckpointID && + state.CurrentCheckpointID != existing.BeforeCheckpointID && + state.CurrentCheckpointID != existing.AfterCheckpointID { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeConflict, + "workspace restore cursor moved to another operation") + } + if err := s.requireRestoreAuthority(ctx, binding, request.RequestedBy); err != nil { + return WorkspaceRestoreResult{}, err + } + if state.CurrentCheckpointID == existing.ExpectedCurrentCheckpointID && + state.CurrentCheckpointID != existing.BeforeCheckpointID { + if _, _, stateErr = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: existing.BeforeCheckpointID, + LastTransactionID: "", + UpdatedAt: s.now().UTC()}, state.CurrentCheckpointID); stateErr != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(stateErr) + } + } + return s.resumeRestore(ctx, binding, request, existing, target) + } + } + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil || !found { + if err == nil { + err = apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint cursor is not initialized") + } + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + if request.ExpectedCurrentCheckpointID != "" && + request.ExpectedCurrentCheckpointID != state.CurrentCheckpointID { + return WorkspaceRestoreResult{}, apperror.New(apperror.CodeConflict, + "workspace checkpoint cursor changed after preview") + } + request.ExpectedCurrentCheckpointID = state.CurrentCheckpointID + current, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, state.CurrentCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + if !request.Confirm { + observed, captureErr := workspacecheckpoint.Capture(ctx, + s.workspaceCaptureRequest(binding, workspacecheckpoint.CaptureRequest{ + ID: idgen.New("workspace-preview"), Trigger: workspacecheckpoint.TriggerRewindPreflight, + Phase: workspacecheckpoint.PhasePreflight, + TriggerReceiptID: request.TargetCheckpointID, + ParentCheckpointID: state.CurrentCheckpointID, CreatedAt: s.now().UTC()})) + if captureErr != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(captureErr) + } + preview, previewErr := workspacecheckpoint.PreviewRestore(current, target, observed) + return WorkspaceRestoreResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + Preview: preview, Before: current.Checkpoint}, apperror.Normalize(previewErr) + } + if err := s.requireRestoreAuthority(ctx, binding, request.RequestedBy); err != nil { + return WorkspaceRestoreResult{}, err + } + fingerprint := runmutation.Fingerprint("workspace_restore_request.v1", binding.run.ID, + binding.workspace.ID, request.TargetCheckpointID, + request.ExpectedCurrentCheckpointID, string(request.Kind), request.RequestedBy) + preflight, _, err := s.capture(ctx, binding, workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(digest, "preflight"), + Trigger: workspacecheckpoint.TriggerRewindPreflight, + Phase: workspacecheckpoint.PhasePreflight, TriggerReceiptID: request.TriggerReceiptID, + ParentCheckpointID: state.CurrentCheckpointID, CreatedAt: s.now().UTC(), + }) + if err != nil { + return WorkspaceRestoreResult{}, err + } + preview, err := workspacecheckpoint.PreviewRestore(current, target, preflight) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + now := s.now().UTC() + transaction := workspacecheckpoint.Transaction{ID: workspaceTransactionID(digest), + ProtocolVersion: workspacecheckpoint.ProtocolVersion, OperationKeyDigest: digest, + RequestFingerprint: fingerprint, RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, Kind: request.Kind, + TriggerReceiptID: request.TriggerReceiptID, + BeforeCheckpointID: preflight.Checkpoint.ID, + ExpectedCurrentCheckpointID: state.CurrentCheckpointID, + TargetCheckpointID: target.Checkpoint.ID, + Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: preview.RecoveryLevel, + ConflictJSON: workspaceConflictJSON(preview.Conflicts), CreatedAt: now, UpdatedAt: now} + transaction, _, err = s.store.CreateWorkspaceCheckpointTransaction(ctx, transaction) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + _, _, err = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: preflight.Checkpoint.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, + state.CurrentCheckpointID) + if err != nil { + currentState, currentFound, getErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if getErr != nil || !currentFound || + currentState.CurrentCheckpointID != preflight.Checkpoint.ID { + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionFailed + transaction.AfterCheckpointID = preflight.Checkpoint.ID + transaction.ErrorCode = "workspace_cursor_race" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + _, _, updateErr := s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + return WorkspaceRestoreResult{}, errors.Join(apperror.Normalize(err), + apperror.Normalize(getErr), apperror.Normalize(updateErr)) + } + } + if len(preview.Conflicts) != 0 { + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionFailed + transaction.AfterCheckpointID = preflight.Checkpoint.ID + transaction.ErrorCode = "restore_conflict" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + transaction, _, _ = s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + _, _, _ = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: preflight.Checkpoint.ID, + LastTransactionID: transaction.ID, UpdatedAt: s.now().UTC()}, + preflight.Checkpoint.ID) + return WorkspaceRestoreResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + Preview: preview, Transaction: &transaction, Before: preflight.Checkpoint, + Confirmed: true}, &workspacecheckpoint.ConflictError{Conflicts: preview.Conflicts} + } + return s.resumeRestore(ctx, binding, request, transaction, target) +} + +func (s *WorkspaceCheckpointService) Undo(ctx context.Context, runID, + expectedCurrentCheckpointID, operationKey, requestedBy string, confirm bool, +) (WorkspaceRestoreResult, error) { + if result, found, err := s.replayRestoreOperation(ctx, runID, operationKey, + requestedBy, workspacecheckpoint.TransactionUndo); found || err != nil { + return result, err + } + transaction, err := s.findUndoSource(ctx, strings.TrimSpace(runID)) + if err != nil { + return WorkspaceRestoreResult{}, err + } + return s.Restore(ctx, WorkspaceRestoreRequest{RunID: runID, + TargetCheckpointID: transaction.BeforeCheckpointID, + ExpectedCurrentCheckpointID: expectedCurrentCheckpointID, + OperationKey: operationKey, RequestedBy: requestedBy, + Kind: workspacecheckpoint.TransactionUndo, + TriggerReceiptID: transaction.ID, Confirm: confirm}) +} + +func (s *WorkspaceCheckpointService) Redo(ctx context.Context, runID, + expectedCurrentCheckpointID, operationKey, requestedBy string, confirm bool, +) (WorkspaceRestoreResult, error) { + if result, found, err := s.replayRestoreOperation(ctx, runID, operationKey, + requestedBy, workspacecheckpoint.TransactionRedo); found || err != nil { + return result, err + } + undo, source, err := s.findRedoSource(ctx, strings.TrimSpace(runID)) + if err != nil { + return WorkspaceRestoreResult{}, err + } + return s.Restore(ctx, WorkspaceRestoreRequest{RunID: runID, + TargetCheckpointID: source.AfterCheckpointID, + ExpectedCurrentCheckpointID: expectedCurrentCheckpointID, + OperationKey: operationKey, RequestedBy: requestedBy, + Kind: workspacecheckpoint.TransactionRedo, + TriggerReceiptID: undo.ID, Confirm: confirm}) +} + +// Fork materializes a historical checkpoint into a new branch-backed +// worktree, registers a distinct Workspace, and creates an authority-reset Run. +// The source Run cursor and all historical checkpoints remain unchanged. +func (s *WorkspaceCheckpointService) Fork(ctx context.Context, + request WorkspaceForkRequest, +) (WorkspaceForkResult, error) { + request = normalizeWorkspaceForkRequest(request) + if s == nil || s.store == nil { + return WorkspaceForkResult{}, apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint fork store is unavailable") + } + forkStore, ok := s.store.(WorkspaceCheckpointForkStore) + if !ok { + return WorkspaceForkResult{}, apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint fork store is unavailable") + } + if request.RunID == "" || request.TargetCheckpointID == "" || + request.ExpectedCurrentCheckpointID == "" || request.OperationKey == "" || + request.RequestedBy == "" || request.WorkspaceName == "" || + !request.Confirm || repository.ValidateBranchName(request.Branch) != nil { + return WorkspaceForkResult{}, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint fork request is invalid") + } + binding, err := s.loadBinding(ctx, request.RunID) + if err != nil { + return WorkspaceForkResult{}, err + } + if err := s.requireRestoreAuthority(ctx, binding, request.RequestedBy); err != nil { + return WorkspaceForkResult{}, err + } + target, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, + request.TargetCheckpointID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + if target.Checkpoint.RunID != binding.run.ID || + target.Checkpoint.WorkspaceID != binding.workspace.ID || + target.Checkpoint.RecoveryLevel == workspacecheckpoint.RecoveryUnavailable { + return WorkspaceForkResult{}, apperror.New(apperror.CodeFailedPrecondition, + "target checkpoint is not materializable in this Run") + } + if target.Checkpoint.BaseCommit == "unborn" { + return WorkspaceForkResult{}, apperror.New(apperror.CodeFailedPrecondition, + "workspace fork requires a committed Git base") + } + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil || !found || state.CurrentCheckpointID != request.ExpectedCurrentCheckpointID { + if err == nil { + err = apperror.New(apperror.CodeConflict, + "workspace checkpoint cursor changed before fork") + } + return WorkspaceForkResult{}, apperror.Normalize(err) + } + digest := runmutation.OperationKeyDigest("workspace_fork_operation.v1", + binding.run.ID, request.OperationKey) + if request.WorkspaceRoot == "" { + request.WorkspaceRoot, err = defaultWorkspaceForkRoot(binding.workspace.RootPath, digest) + if err != nil { + return WorkspaceForkResult{}, err + } + } + request.WorkspaceRoot, err = repository.NormalizeWorktreeDestination( + binding.workspace.RootPath, request.WorkspaceRoot) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + fingerprint := runmutation.Fingerprint("workspace_fork_request.v1", binding.run.ID, + binding.workspace.ID, request.TargetCheckpointID, + request.ExpectedCurrentCheckpointID, request.WorkspaceName, + request.WorkspaceRoot, request.Branch, request.Goal, request.RequestedBy) + transaction, exists, err := s.store.GetWorkspaceCheckpointTransactionByOperation(ctx, + digest) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + if exists { + if transaction.RequestFingerprint != fingerprint || + transaction.RunID != binding.run.ID || + transaction.WorkspaceID != binding.workspace.ID || + transaction.Kind != workspacecheckpoint.TransactionFork || + transaction.TargetCheckpointID != target.Checkpoint.ID || + transaction.ExpectedCurrentCheckpointID != request.ExpectedCurrentCheckpointID || + transaction.ForkWorkspaceRoot != request.WorkspaceRoot || + transaction.ForkBranch != request.Branch { + return WorkspaceForkResult{}, apperror.New(apperror.CodeConflict, + "workspace fork operation key was reused for different intent") + } + if transaction.Status.Terminal() { + return s.replayFork(ctx, forkStore, transaction, target.Checkpoint) + } + } + openTransactions, err := s.store.ListOpenWorkspaceCheckpointTransactions(ctx, + workspaceCheckpointListLimit) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + if len(openTransactions) == workspaceCheckpointListLimit { + return WorkspaceForkResult{}, apperror.New(apperror.CodeResourceExhausted, + "workspace checkpoint open-transaction backlog reached its safe scan limit") + } + for _, openTransaction := range openTransactions { + if openTransaction.RunID == binding.run.ID && + openTransaction.OperationKeyDigest != digest { + return WorkspaceForkResult{}, apperror.New(apperror.CodeConflict, + "another workspace mutation boundary is still open for this Run") + } + } + sourceNodeID, err := workspaceForkSourceNode(ctx, forkStore, binding.run) + if err != nil { + return WorkspaceForkResult{}, err + } + workspaceID := "workspace-fork-" + digest[:20] + continuity := NewContextContinuityService(forkStore) + prepared, node, continuityResult, err := continuity.prepareBranch(ctx, + BranchContinuityRequest{SourceNodeID: sourceNodeID, + Kind: contextmgr.ContinuityNodeFork, Goal: request.Goal, + RequestedBy: request.RequestedBy, WorkspaceID: workspaceID, + GitBranch: request.Branch, GitHead: target.Checkpoint.BaseCommit}) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + if exists { + prepared, node, err = rekeyWorkspaceForkPreparation(prepared, node, + transaction.TriggerReceiptID) + if err != nil { + return WorkspaceForkResult{}, err + } + } else { + now := s.now().UTC() + transaction = workspacecheckpoint.Transaction{ID: workspaceTransactionID(digest), + ProtocolVersion: workspacecheckpoint.ProtocolVersion, + OperationKeyDigest: digest, RequestFingerprint: fingerprint, + RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + Kind: workspacecheckpoint.TransactionFork, + TriggerReceiptID: prepared.Run.ID, + BeforeCheckpointID: state.CurrentCheckpointID, + ExpectedCurrentCheckpointID: state.CurrentCheckpointID, + TargetCheckpointID: target.Checkpoint.ID, + ForkWorkspaceRoot: request.WorkspaceRoot, + ForkBranch: request.Branch, + Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: target.Checkpoint.RecoveryLevel, + ConflictJSON: "[]", CreatedAt: now, UpdatedAt: now} + transaction, _, err = s.store.CreateWorkspaceCheckpointTransaction(ctx, + transaction) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + } + if existingRun, getErr := forkStore.GetRun(ctx, transaction.TriggerReceiptID); getErr == nil { + return s.completeExistingFork(ctx, forkStore, transaction, target.Checkpoint, + existingRun) + } else if apperror.CodeOf(apperror.Normalize(getErr)) != apperror.CodeNotFound { + return WorkspaceForkResult{}, apperror.Normalize(getErr) + } + createdRoot, err := materializeWorkspaceFork(ctx, binding.workspace.RootPath, + request.WorkspaceRoot, request.Branch, target) + if err != nil { + return WorkspaceForkResult{}, s.failForkTransaction(ctx, transaction, + "fork_materialization_failed", err, false) + } + workspaceRecord := session.WorkspaceRecord{ID: workspaceID, Name: request.WorkspaceName, + RootPath: createdRoot, CreatedAt: s.now().UTC()} + if err := forkStore.CreateWorkspaceMissionRunWithContinuity(ctx, workspaceRecord, + prepared.Mission, prepared.Run, prepared.Mode, prepared.Session, + prepared.CreateSession, prepared.InitialEvents, node); err != nil { + cleanupErr := s.cleanupInterruptedWorkspaceFork(context.WithoutCancel(ctx), + binding.workspace.RootPath, createdRoot, request.Branch, target.Checkpoint) + return WorkspaceForkResult{}, s.failForkTransaction(ctx, transaction, + "fork_registration_failed", errors.Join(apperror.Normalize(err), cleanupErr), + cleanupErr != nil) + } + newBinding, err := s.loadBinding(ctx, prepared.Run.ID) + if err != nil { + return WorkspaceForkResult{}, err + } + forkSnapshot, _, err := s.capture(ctx, newBinding, workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(digest, "fork"), Trigger: workspacecheckpoint.TriggerFork, + Phase: workspacecheckpoint.PhaseStandalone, TriggerReceiptID: transaction.ID, + CreatedAt: s.now().UTC()}) + if err != nil { + // The new Run and worktree are already durable. Keep the prepared + // transaction replayable so a restart or identical retry can finish + // capture without recreating either resource. + return WorkspaceForkResult{}, err + } + if forkSnapshot.Checkpoint.ManifestSHA256 != target.Checkpoint.ManifestSHA256 || + forkSnapshot.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256 || + forkSnapshot.Checkpoint.BaseCommit != target.Checkpoint.BaseCommit || + forkSnapshot.Checkpoint.Branch != request.Branch { + err = errors.New("forked Workspace failed final checkpoint verification") + return WorkspaceForkResult{}, s.failForkTransaction(ctx, transaction, + "fork_checkpoint_failed", err, true) + } + if _, _, err = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: prepared.Run.ID, WorkspaceID: workspaceID, + CurrentCheckpointID: forkSnapshot.Checkpoint.ID, LastTransactionID: "", + UpdatedAt: s.now().UTC()}, ""); err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + transaction, err = s.completeForkTransaction(ctx, transaction) + if err != nil { + return WorkspaceForkResult{}, err + } + continuityResult.Run = prepared.Run + continuityResult.Mission = prepared.Mission + continuityResult.Node = node + return WorkspaceForkResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + SourceRunID: binding.run.ID, Target: target.Checkpoint, Workspace: workspaceRecord, + Mission: prepared.Mission, Run: prepared.Run, Node: node, + Checkpoint: forkSnapshot.Checkpoint, Transaction: transaction, + NotInherited: continuityResult.NotInherited}, nil +} + +func (s *WorkspaceCheckpointService) replayRestoreOperation(ctx context.Context, runID, + operationKey, requestedBy string, kind workspacecheckpoint.TransactionKind, +) (WorkspaceRestoreResult, bool, error) { + runID, operationKey = strings.TrimSpace(runID), strings.TrimSpace(operationKey) + if !workspaceRestoreKind(kind) || runID == "" || operationKey == "" { + return WorkspaceRestoreResult{}, false, nil + } + binding, err := s.loadBinding(ctx, runID) + if err != nil { + return WorkspaceRestoreResult{}, false, err + } + digest := runmutation.OperationKeyDigest("workspace_restore_operation.v1."+ + string(kind), binding.run.ID, operationKey) + transaction, found, err := s.store.GetWorkspaceCheckpointTransactionByOperation(ctx, + digest) + if err != nil || !found { + return WorkspaceRestoreResult{}, false, apperror.Normalize(err) + } + result, err := s.Restore(ctx, WorkspaceRestoreRequest{RunID: runID, + TargetCheckpointID: transaction.TargetCheckpointID, + ExpectedCurrentCheckpointID: transaction.ExpectedCurrentCheckpointID, + OperationKey: operationKey, + RequestedBy: requestedBy, + Kind: kind, + TriggerReceiptID: transaction.TriggerReceiptID, + Confirm: true}) + return result, true, err +} + +func (s *WorkspaceCheckpointService) Reconcile(ctx context.Context) (int, error) { + if s == nil || s.store == nil { + return 0, apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint service is unavailable") + } + reconciled := 0 + for { + transactions, err := s.store.ListOpenWorkspaceCheckpointTransactions(ctx, + workspaceCheckpointListLimit) + if err != nil { + return reconciled, apperror.Normalize(err) + } + if len(transactions) == 0 { + break + } + for _, transaction := range transactions { + if err := s.requireWorkspaceCheckpointReconciliationQuiescence(ctx, + transaction.RunID); err != nil { + return reconciled, err + } + if transaction.Kind == workspacecheckpoint.TransactionFork { + if err := s.reconcileForkTransaction(ctx, transaction); err != nil { + return reconciled, err + } + reconciled++ + continue + } + binding, bindErr := s.loadBinding(ctx, transaction.RunID) + if bindErr != nil { + return reconciled, bindErr + } + before, getErr := s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.BeforeCheckpointID) + if getErr != nil { + return reconciled, apperror.Normalize(getErr) + } + state, stateFound, stateErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if stateErr != nil { + return reconciled, apperror.Normalize(stateErr) + } + if !stateFound { + return reconciled, apperror.New(apperror.CodeConflict, + "workspace checkpoint cursor disappeared before restart reconciliation") + } + if state.CurrentCheckpointID == transaction.ExpectedCurrentCheckpointID && + state.CurrentCheckpointID != transaction.BeforeCheckpointID { + state, _, stateErr = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: transaction.BeforeCheckpointID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, + state.CurrentCheckpointID) + if stateErr != nil { + return reconciled, apperror.Normalize(stateErr) + } + } + if state.CurrentCheckpointID != transaction.BeforeCheckpointID { + return reconciled, apperror.New(apperror.CodeConflict, + "workspace checkpoint cursor moved before restart reconciliation") + } + incompleteReasons := append([]string{}, before.Checkpoint.IncompleteReasons...) + incompleteReasons = append(incompleteReasons, + "process restart interrupted the mutation boundary") + observed, _, captureErr := s.capture(ctx, binding, workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(transaction.OperationKeyDigest, "reconciled"), + AttemptID: before.Checkpoint.AttemptID, + CapabilityGeneration: before.Checkpoint.CapabilityGeneration, + Trigger: workspacecheckpoint.TriggerRewindResult, + Phase: workspacecheckpoint.PhaseAfter, + TriggerReceiptID: transaction.ID, + ParentCheckpointID: before.Checkpoint.ID, + IncompleteReasons: incompleteReasons, + CreatedAt: s.now().UTC()}) + if captureErr != nil { + return reconciled, captureErr + } + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionInterrupted + transaction.AfterCheckpointID = observed.Checkpoint.ID + transaction.RecoveryLevel = weakestWorkspaceRecovery(transaction.RecoveryLevel, + observed.Checkpoint.RecoveryLevel) + transaction.ErrorCode = "process_restart_reconciliation" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + transaction, _, updateErr := s.store.UpdateWorkspaceCheckpointTransaction(ctx, + transaction) + if updateErr != nil { + return reconciled, apperror.Normalize(updateErr) + } + if state.CurrentCheckpointID != observed.Checkpoint.ID { + if _, _, stateErr = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: observed.Checkpoint.ID, + LastTransactionID: transaction.ID, UpdatedAt: s.now().UTC()}, + transaction.BeforeCheckpointID); stateErr != nil { + return reconciled, apperror.Normalize(stateErr) + } + } + reconciled++ + } + if len(transactions) < workspaceCheckpointListLimit { + break + } + } + for { + transactions, err := s.store.ListWorkspaceCheckpointTransactionsPendingCursor(ctx, + workspaceCheckpointListLimit) + if err != nil { + return reconciled, apperror.Normalize(err) + } + if len(transactions) == 0 { + return reconciled, nil + } + for _, transaction := range transactions { + if err := s.requireWorkspaceCheckpointReconciliationQuiescence(ctx, + transaction.RunID); err != nil { + return reconciled, err + } + if _, _, err := s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: transaction.RunID, + WorkspaceID: transaction.WorkspaceID, + CurrentCheckpointID: transaction.AfterCheckpointID, + LastTransactionID: transaction.ID, UpdatedAt: s.now().UTC()}, + transaction.BeforeCheckpointID); err != nil { + return reconciled, apperror.Normalize(err) + } + reconciled++ + } + if len(transactions) < workspaceCheckpointListLimit { + return reconciled, nil + } + } +} + +func (s *WorkspaceCheckpointService) requireWorkspaceCheckpointReconciliationQuiescence( + ctx context.Context, runID string, +) error { + lease, found, err := s.store.GetRunExecutionLease(ctx, runID) + if err != nil { + return apperror.Normalize(err) + } + if found && lease.ActiveAt(s.now().UTC()) { + return apperror.New(apperror.CodeConflict, + "workspace checkpoint reconciliation requires an expired or released execution lease") + } + return nil +} + +func (s *WorkspaceCheckpointService) reconcileForkTransaction(ctx context.Context, + transaction workspacecheckpoint.Transaction, +) error { + forkStore, ok := s.store.(WorkspaceCheckpointForkStore) + if !ok { + return apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint fork store is unavailable") + } + target, err := s.store.GetWorkspaceCheckpoint(ctx, transaction.TargetCheckpointID) + if err != nil { + return apperror.Normalize(err) + } + run, err := forkStore.GetRun(ctx, transaction.TriggerReceiptID) + if err == nil { + _, err = s.completeExistingFork(ctx, forkStore, transaction, target, run) + return err + } + if apperror.CodeOf(apperror.Normalize(err)) != apperror.CodeNotFound { + return apperror.Normalize(err) + } + binding, err := s.loadBinding(ctx, transaction.RunID) + if err != nil { + return err + } + if err = s.cleanupInterruptedWorkspaceFork(ctx, binding.workspace.RootPath, + transaction.ForkWorkspaceRoot, transaction.ForkBranch, target); err != nil { + // Keep the transaction open so a later startup can retry. A drifted + // worktree or branch is never deleted speculatively. + return apperror.Normalize(err) + } + return s.markForkTransactionFailure(ctx, transaction, + "process_restart_reconciliation", true) +} + +func (s *WorkspaceCheckpointService) cleanupInterruptedWorkspaceFork(ctx context.Context, + sourceRoot, destinationRoot, branch string, target workspacecheckpoint.Checkpoint, +) error { + if _, err := os.Lstat(destinationRoot); err == nil { + observed, captureErr := workspacecheckpoint.Capture(ctx, + workspacecheckpoint.CaptureRequest{ID: idgen.New("workspace-fork-cleanup"), + RunID: target.RunID, MissionID: target.MissionID, + SessionID: target.SessionID, WorkspaceID: target.WorkspaceID, + WorkspaceRoot: destinationRoot, Trigger: workspacecheckpoint.TriggerFork, + Phase: workspacecheckpoint.PhasePreflight, + TriggerReceiptID: target.ID, CreatedAt: s.now().UTC()}) + if captureErr != nil { + return apperror.Normalize(captureErr) + } + if observed.Checkpoint.ManifestSHA256 != target.ManifestSHA256 || + observed.Checkpoint.IndexSHA256 != target.IndexSHA256 || + observed.Checkpoint.BaseCommit != target.BaseCommit || + observed.Checkpoint.Branch != branch { + return fmt.Errorf("interrupted fork Workspace changed before cleanup: "+ + "manifest %s/%s index %s/%s commit %s/%s branch %s/%s", + observed.Checkpoint.ManifestSHA256, target.ManifestSHA256, + observed.Checkpoint.IndexSHA256, target.IndexSHA256, + observed.Checkpoint.BaseCommit, target.BaseCommit, + observed.Checkpoint.Branch, branch) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect interrupted fork Workspace: %w", err) + } + return repository.CleanupInterruptedWorktree(ctx, sourceRoot, destinationRoot, + branch, target.BaseCommit) +} + +func (s *WorkspaceCheckpointService) completeForkTransaction(ctx context.Context, + transaction workspacecheckpoint.Transaction, +) (workspacecheckpoint.Transaction, error) { + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionCompleted + transaction.AfterCheckpointID = transaction.TargetCheckpointID + transaction.ErrorCode = "" + transaction.ConflictJSON = "[]" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + value, _, err := s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + return value, apperror.Normalize(err) +} + +func (s *WorkspaceCheckpointService) failForkTransaction(ctx context.Context, + transaction workspacecheckpoint.Transaction, code string, cause error, interrupted bool, +) error { + return errors.Join(apperror.Normalize(cause), + s.markForkTransactionFailure(ctx, transaction, code, interrupted)) +} + +func (s *WorkspaceCheckpointService) markForkTransactionFailure(ctx context.Context, + transaction workspacecheckpoint.Transaction, code string, interrupted bool, +) error { + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionFailed + if interrupted { + transaction.Status = workspacecheckpoint.TransactionInterrupted + } + transaction.AfterCheckpointID = transaction.BeforeCheckpointID + transaction.RecoveryLevel = workspacecheckpoint.RecoveryUnavailable + transaction.ErrorCode = code + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + _, _, updateErr := s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + return apperror.Normalize(updateErr) +} + +func (s *WorkspaceCheckpointService) completeExistingFork(ctx context.Context, + store WorkspaceCheckpointForkStore, transaction workspacecheckpoint.Transaction, + target workspacecheckpoint.Checkpoint, run domain.Run, +) (WorkspaceForkResult, error) { + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, run.ID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + if !found { + binding, bindErr := s.loadBinding(ctx, run.ID) + if bindErr != nil { + return WorkspaceForkResult{}, bindErr + } + checkpoint, _, captureErr := s.capture(ctx, binding, + workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(transaction.OperationKeyDigest, "fork"), + Trigger: workspacecheckpoint.TriggerFork, + Phase: workspacecheckpoint.PhaseStandalone, + TriggerReceiptID: transaction.ID, CreatedAt: s.now().UTC()}) + if captureErr != nil { + return WorkspaceForkResult{}, captureErr + } + if verifyErr := verifyWorkspaceForkCheckpoint(ctx, store, transaction, + target, run, checkpoint.Checkpoint); verifyErr != nil { + return WorkspaceForkResult{}, s.failForkTransaction(ctx, transaction, + "fork_checkpoint_failed", verifyErr, true) + } + transaction.RecoveryLevel = weakestWorkspaceRecovery(transaction.RecoveryLevel, + checkpoint.Checkpoint.RecoveryLevel) + state = workspacecheckpoint.RunState{RunID: run.ID, + WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: checkpoint.Checkpoint.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()} + if _, _, err = s.store.AdvanceWorkspaceCheckpointRunState(ctx, state, ""); err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + } else { + checkpoint, getErr := s.store.GetWorkspaceCheckpoint(ctx, state.CurrentCheckpointID) + if getErr != nil { + return WorkspaceForkResult{}, apperror.Normalize(getErr) + } + if verifyErr := verifyWorkspaceForkCheckpoint(ctx, store, transaction, + target, run, checkpoint); verifyErr != nil { + return WorkspaceForkResult{}, s.failForkTransaction(ctx, transaction, + "fork_checkpoint_failed", verifyErr, true) + } + transaction.RecoveryLevel = weakestWorkspaceRecovery(transaction.RecoveryLevel, + checkpoint.RecoveryLevel) + } + transaction, err = s.completeForkTransaction(ctx, transaction) + if err != nil { + return WorkspaceForkResult{}, err + } + return s.workspaceForkResult(ctx, store, transaction, target, run, state, false) +} + +func verifyWorkspaceForkCheckpoint(ctx context.Context, store WorkspaceCheckpointForkStore, + transaction workspacecheckpoint.Transaction, target workspacecheckpoint.Checkpoint, + run domain.Run, checkpoint workspacecheckpoint.Checkpoint, +) error { + if checkpoint.RunID != run.ID || checkpoint.Trigger != workspacecheckpoint.TriggerFork || + checkpoint.TriggerReceiptID != transaction.ID || + checkpoint.ManifestSHA256 != target.ManifestSHA256 || + checkpoint.IndexSHA256 != target.IndexSHA256 || + checkpoint.BaseCommit != target.BaseCommit { + return errors.New("forked Workspace checkpoint does not match the historical target") + } + nodes, err := store.ListSessionContinuityNodes(ctx, run.SessionID, 2_000) + if err != nil { + return apperror.Normalize(err) + } + for _, node := range nodes { + if node.RunID == run.ID && node.Kind == contextmgr.ContinuityNodeFork { + if node.WorkspaceID != checkpoint.WorkspaceID || node.GitBranch == "" || + node.GitBranch != checkpoint.Branch || node.GitHead != checkpoint.BaseCommit { + return errors.New("forked Workspace drifted from its continuity identity") + } + return nil + } + } + return errors.New("forked Run continuity identity is unavailable") +} + +func (s *WorkspaceCheckpointService) replayFork(ctx context.Context, + store WorkspaceCheckpointForkStore, transaction workspacecheckpoint.Transaction, + target workspacecheckpoint.Checkpoint, +) (WorkspaceForkResult, error) { + if transaction.Status != workspacecheckpoint.TransactionCompleted { + return WorkspaceForkResult{}, apperror.New(apperror.CodeFailedPrecondition, + "workspace fork operation is terminal but incomplete") + } + run, err := store.GetRun(ctx, transaction.TriggerReceiptID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, run.ID) + if err != nil || !found { + return WorkspaceForkResult{}, apperror.New(apperror.CodeConflict, + "completed workspace fork lost its checkpoint cursor") + } + return s.workspaceForkResult(ctx, store, transaction, target, run, state, true) +} + +func (s *WorkspaceCheckpointService) workspaceForkResult(ctx context.Context, + store WorkspaceCheckpointForkStore, transaction workspacecheckpoint.Transaction, + target workspacecheckpoint.Checkpoint, run domain.Run, + state workspacecheckpoint.RunState, replayed bool, +) (WorkspaceForkResult, error) { + mission, err := store.GetMission(ctx, run.MissionID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + workspace, err := store.GetWorkspaceInfo(ctx, mission.WorkspaceID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + checkpoint, err := s.store.GetWorkspaceCheckpoint(ctx, state.CurrentCheckpointID) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + nodes, err := store.ListSessionContinuityNodes(ctx, run.SessionID, 2_000) + if err != nil { + return WorkspaceForkResult{}, apperror.Normalize(err) + } + var node contextmgr.ContinuityNode + for _, value := range nodes { + if value.RunID == run.ID && value.Kind == contextmgr.ContinuityNodeFork { + node = value + break + } + } + if node.ID == "" { + return WorkspaceForkResult{}, apperror.New(apperror.CodeConflict, + "forked Run continuity node is unavailable") + } + return WorkspaceForkResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + SourceRunID: transaction.RunID, Target: target, + Workspace: session.WorkspaceRecord{ID: workspace.ID, Name: workspace.Name, + RootPath: workspace.RootPath}, Mission: mission, Run: run, Node: node, + Checkpoint: checkpoint, Transaction: transaction, + NotInherited: []string{"approvals", "capability grants", "credentials", + "debug sessions", "execution leases", "network authorization", "processes", + "terminal leases", "execution profiles"}, Replayed: replayed}, nil +} + +func (s *WorkspaceCheckpointService) resumeRestore(ctx context.Context, + binding workspaceCheckpointBinding, request WorkspaceRestoreRequest, + transaction workspacecheckpoint.Transaction, target workspacecheckpoint.Snapshot, +) (WorkspaceRestoreResult, error) { + before, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.BeforeCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + observedRequest := s.workspaceCaptureRequest(binding, workspacecheckpoint.CaptureRequest{ + ID: idgen.New("workspace-restore-observed"), + Trigger: workspacecheckpoint.TriggerRewindPreflight, + Phase: workspacecheckpoint.PhasePreflight, + TriggerReceiptID: transaction.ID, ParentCheckpointID: before.Checkpoint.ID, + CreatedAt: s.now().UTC()}) + observed, err := workspacecheckpoint.Capture(ctx, observedRequest) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + preview, err := workspacecheckpoint.PreviewRestore(before, target, observed) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + if len(preview.Conflicts) != 0 { + persisted, _, captureErr := s.capture(ctx, binding, + workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(transaction.OperationKeyDigest, "conflict"), + Trigger: workspacecheckpoint.TriggerRewindResult, + Phase: workspacecheckpoint.PhaseAfter, + TriggerReceiptID: transaction.ID, + ParentCheckpointID: before.Checkpoint.ID, + IncompleteReasons: []string{"external change blocked Workspace restore"}, + CreatedAt: s.now().UTC()}) + if captureErr != nil { + return WorkspaceRestoreResult{}, captureErr + } + return s.finishRestoreFailureWithAfter(ctx, binding, transaction, before, + persisted, preview, + &workspacecheckpoint.ConflictError{Conflicts: preview.Conflicts}) + } + if err := s.requireRestoreAuthority(ctx, binding, request.RequestedBy); err != nil { + return WorkspaceRestoreResult{}, err + } + if transaction.Status == workspacecheckpoint.TransactionPrepared { + transaction.Status = workspacecheckpoint.TransactionApplying + transaction.UpdatedAt = s.now().UTC() + transaction, _, err = s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + } + _, applyErr := workspacecheckpoint.ApplyRestore(ctx, binding.workspace.RootPath, + before, target, observed) + resultSnapshot, _, captureErr := s.capture(ctx, binding, + workspacecheckpoint.CaptureRequest{ + ID: workspaceCheckpointID(transaction.OperationKeyDigest, "result"), + Trigger: workspacecheckpoint.TriggerRewindResult, + Phase: workspacecheckpoint.PhaseAfter, + TriggerReceiptID: transaction.ID, ParentCheckpointID: before.Checkpoint.ID, + CreatedAt: s.now().UTC()}) + if captureErr != nil { + return WorkspaceRestoreResult{}, errors.Join(apperror.Normalize(applyErr), captureErr) + } + if applyErr != nil { + previewAfter, _ := workspacecheckpoint.PreviewRestore(before, target, resultSnapshot) + return s.finishRestoreFailureWithAfter(ctx, binding, transaction, before, + resultSnapshot, previewAfter, applyErr) + } + verification, verifyErr := workspacecheckpoint.PreviewRestore(target, target, + resultSnapshot) + if verifyErr != nil || len(verification.Conflicts) != 0 || + resultSnapshot.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256 || + resultSnapshot.Checkpoint.ManifestSHA256 != target.Checkpoint.ManifestSHA256 { + if verifyErr == nil { + verifyErr = &workspacecheckpoint.ConflictError{Conflicts: verification.Conflicts} + } + return s.finishRestoreFailureWithAfter(ctx, binding, transaction, before, + resultSnapshot, verification, errors.Join( + errors.New("workspace restore final verification failed"), verifyErr)) + } + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionCompleted + transaction.AfterCheckpointID = resultSnapshot.Checkpoint.ID + transaction.RecoveryLevel = weakestWorkspaceRecovery(target.Checkpoint.RecoveryLevel, + resultSnapshot.Checkpoint.RecoveryLevel) + transaction.ErrorCode = "" + transaction.ConflictJSON = "[]" + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + transaction, replayed, err := s.store.UpdateWorkspaceCheckpointTransaction(ctx, + transaction) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + if err := s.advanceRestoreCursor(ctx, binding, transaction, resultSnapshot.Checkpoint.ID); err != nil { + return WorkspaceRestoreResult{}, err + } + after := resultSnapshot.Checkpoint + return WorkspaceRestoreResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + Preview: preview, Transaction: &transaction, Before: before.Checkpoint, + After: &after, Confirmed: true, Replayed: replayed}, nil +} + +func (s *WorkspaceCheckpointService) finishRestoreFailureWithAfter(ctx context.Context, + binding workspaceCheckpointBinding, transaction workspacecheckpoint.Transaction, + before, after workspacecheckpoint.Snapshot, preview workspacecheckpoint.Preview, cause error, +) (WorkspaceRestoreResult, error) { + wasApplying := transaction.Status == workspacecheckpoint.TransactionApplying + completedAt := s.now().UTC() + transaction.Status = workspacecheckpoint.TransactionFailed + if wasApplying || + after.Checkpoint.ManifestSHA256 != before.Checkpoint.ManifestSHA256 || + after.Checkpoint.IndexSHA256 != before.Checkpoint.IndexSHA256 { + transaction.Status = workspacecheckpoint.TransactionInterrupted + } + transaction.AfterCheckpointID = after.Checkpoint.ID + transaction.RecoveryLevel = workspacecheckpoint.RecoveryUnavailable + transaction.ErrorCode = "restore_failed" + transaction.ConflictJSON = workspaceConflictJSON(preview.Conflicts) + transaction.UpdatedAt, transaction.CompletedAt = completedAt, &completedAt + transaction, _, updateErr := s.store.UpdateWorkspaceCheckpointTransaction(ctx, transaction) + if updateErr == nil { + updateErr = s.advanceRestoreCursor(ctx, binding, transaction, after.Checkpoint.ID) + } + checkpoint := after.Checkpoint + result := WorkspaceRestoreResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + Preview: preview, Transaction: &transaction, Before: before.Checkpoint, + After: &checkpoint, Confirmed: true} + return result, errors.Join(apperror.Normalize(cause), apperror.Normalize(updateErr)) +} + +func (s *WorkspaceCheckpointService) advanceRestoreCursor(ctx context.Context, + binding workspaceCheckpointBinding, transaction workspacecheckpoint.Transaction, + afterCheckpointID string, +) error { + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil || !found { + if err == nil { + err = errors.New("workspace restore cursor disappeared") + } + return apperror.Normalize(err) + } + if state.CurrentCheckpointID == afterCheckpointID && + state.LastTransactionID == transaction.ID { + return nil + } + if state.CurrentCheckpointID != transaction.BeforeCheckpointID { + return apperror.New(apperror.CodeConflict, + "workspace restore cursor changed while applying") + } + _, _, err = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: afterCheckpointID, LastTransactionID: transaction.ID, + UpdatedAt: s.now().UTC()}, transaction.BeforeCheckpointID) + return apperror.Normalize(err) +} + +func (s *WorkspaceCheckpointService) replayBoundary(ctx context.Context, + binding workspaceCheckpointBinding, transaction workspacecheckpoint.Transaction, +) (WorkspaceMutationBoundary, error) { + before, err := s.store.GetWorkspaceCheckpoint(ctx, transaction.BeforeCheckpointID) + if err != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(err) + } + result := WorkspaceMutationBoundary{Transaction: transaction, Before: before, Replayed: true} + if !transaction.Status.Terminal() { + state, stateFound, stateErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if stateErr != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(stateErr) + } + if !stateFound || state.CurrentCheckpointID != before.ID { + expected := transaction.ExpectedCurrentCheckpointID + if _, _, stateErr = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, + WorkspaceID: binding.workspace.ID, CurrentCheckpointID: before.ID, + LastTransactionID: "", UpdatedAt: s.now().UTC()}, + expected); stateErr != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(stateErr) + } + } + } + if transaction.AfterCheckpointID != "" { + after, getErr := s.store.GetWorkspaceCheckpoint(ctx, transaction.AfterCheckpointID) + if getErr != nil { + return WorkspaceMutationBoundary{}, apperror.Normalize(getErr) + } + result.After = &after + if transaction.Status.Terminal() { + _ = s.advanceBoundaryReplayCursor(ctx, binding, transaction, after.ID) + } + } + return result, nil +} + +func (s *WorkspaceCheckpointService) advanceBoundaryReplayCursor(ctx context.Context, + binding workspaceCheckpointBinding, transaction workspacecheckpoint.Transaction, + afterCheckpointID string, +) error { + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, binding.run.ID) + if err != nil || !found { + return apperror.Normalize(err) + } + if state.CurrentCheckpointID == afterCheckpointID { + return nil + } + if state.CurrentCheckpointID != transaction.BeforeCheckpointID { + return apperror.New(apperror.CodeConflict, + "workspace mutation cursor changed after transaction completion") + } + _, _, err = s.store.AdvanceWorkspaceCheckpointRunState(ctx, + workspacecheckpoint.RunState{RunID: binding.run.ID, WorkspaceID: binding.workspace.ID, + CurrentCheckpointID: afterCheckpointID, LastTransactionID: transaction.ID, + UpdatedAt: s.now().UTC()}, transaction.BeforeCheckpointID) + return apperror.Normalize(err) +} + +func (s *WorkspaceCheckpointService) replayRestore(ctx context.Context, + transaction workspacecheckpoint.Transaction, +) (WorkspaceRestoreResult, error) { + binding, err := s.loadBinding(ctx, transaction.RunID) + if err != nil { + return WorkspaceRestoreResult{}, err + } + before, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.BeforeCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + target, err := s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.TargetCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + after := before + if transaction.AfterCheckpointID != "" { + after, err = s.store.GetWorkspaceCheckpointSnapshot(ctx, + transaction.AfterCheckpointID) + if err != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(err) + } + state, stateFound, stateErr := s.store.GetWorkspaceCheckpointRunState(ctx, + binding.run.ID) + if stateErr != nil { + return WorkspaceRestoreResult{}, apperror.Normalize(stateErr) + } + if stateFound && state.CurrentCheckpointID == transaction.BeforeCheckpointID { + if err := s.advanceRestoreCursor(ctx, binding, transaction, + transaction.AfterCheckpointID); err != nil { + return WorkspaceRestoreResult{}, err + } + } + } + preview, _ := workspacecheckpoint.PreviewRestore(before, target, after) + checkpoint := after.Checkpoint + return WorkspaceRestoreResult{ProtocolVersion: WorkspaceCheckpointAPIProtocolVersion, + Preview: preview, Transaction: &transaction, Before: before.Checkpoint, + After: &checkpoint, Confirmed: true, Replayed: true}, nil +} + +func (s *WorkspaceCheckpointService) findUndoSource(ctx context.Context, + runID string, +) (workspacecheckpoint.Transaction, error) { + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, runID) + if err != nil || !found { + return workspacecheckpoint.Transaction{}, apperror.New( + apperror.CodeFailedPrecondition, "workspace checkpoint cursor is not initialized") + } + transactions, err := s.store.ListWorkspaceCheckpointTransactions(ctx, runID, 2_000) + if err != nil { + return workspacecheckpoint.Transaction{}, apperror.Normalize(err) + } + for _, transaction := range transactions { + if transaction.AfterCheckpointID == state.CurrentCheckpointID && + (transaction.Kind == workspacecheckpoint.TransactionFileTool || + transaction.Kind == workspacecheckpoint.TransactionCommandBatch || + transaction.Kind == workspacecheckpoint.TransactionGitMutation || + transaction.Kind == workspacecheckpoint.TransactionAgentMerge) { + return transaction, nil + } + } + return workspacecheckpoint.Transaction{}, apperror.New(apperror.CodeFailedPrecondition, + "no reversible workspace mutation is at the current cursor") +} + +func (s *WorkspaceCheckpointService) findRedoSource(ctx context.Context, + runID string, +) (workspacecheckpoint.Transaction, workspacecheckpoint.Transaction, error) { + state, found, err := s.store.GetWorkspaceCheckpointRunState(ctx, runID) + if err != nil || !found { + return workspacecheckpoint.Transaction{}, workspacecheckpoint.Transaction{}, + apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint cursor is not initialized") + } + transactions, err := s.store.ListWorkspaceCheckpointTransactions(ctx, runID, 2_000) + if err != nil { + return workspacecheckpoint.Transaction{}, workspacecheckpoint.Transaction{}, + apperror.Normalize(err) + } + for _, undo := range transactions { + if undo.Kind != workspacecheckpoint.TransactionUndo || + undo.Status != workspacecheckpoint.TransactionCompleted || + undo.AfterCheckpointID != state.CurrentCheckpointID { + continue + } + source, sourceFound, getErr := s.store.GetWorkspaceCheckpointTransaction(ctx, + undo.TriggerReceiptID) + if getErr != nil || !sourceFound || source.AfterCheckpointID == "" { + return workspacecheckpoint.Transaction{}, workspacecheckpoint.Transaction{}, + apperror.New(apperror.CodeFailedPrecondition, + "workspace redo source transaction is unavailable") + } + return undo, source, nil + } + return workspacecheckpoint.Transaction{}, workspacecheckpoint.Transaction{}, + apperror.New(apperror.CodeFailedPrecondition, + "no workspace undo is available to redo") +} + +func (s *WorkspaceCheckpointService) capture(ctx context.Context, + binding workspaceCheckpointBinding, request workspacecheckpoint.CaptureRequest, +) (workspacecheckpoint.Snapshot, bool, error) { + request = s.workspaceCaptureRequest(binding, request) + snapshot, err := workspacecheckpoint.Capture(ctx, request) + if err != nil { + return workspacecheckpoint.Snapshot{}, false, apperror.Normalize(err) + } + _, replayed, err := s.store.CreateWorkspaceCheckpoint(ctx, snapshot) + return snapshot, replayed, apperror.Normalize(err) +} + +func (s *WorkspaceCheckpointService) workspaceCaptureRequest( + binding workspaceCheckpointBinding, request workspacecheckpoint.CaptureRequest, +) workspacecheckpoint.CaptureRequest { + request.RunID = binding.run.ID + request.MissionID = binding.mission.ID + request.SessionID = binding.session.ID + request.WorkspaceID = binding.workspace.ID + request.WorkspaceRoot = binding.workspace.RootPath + return request +} + +func (s *WorkspaceCheckpointService) loadBinding(ctx context.Context, + runID string, +) (workspaceCheckpointBinding, error) { + var value workspaceCheckpointBinding + var err error + if runID == "" { + return value, apperror.New(apperror.CodeInvalidArgument, "Run id is required") + } + if value.run, err = s.store.GetRun(ctx, runID); err != nil { + return value, apperror.Normalize(err) + } + if value.mission, err = s.store.GetMission(ctx, value.run.MissionID); err != nil { + return value, apperror.Normalize(err) + } + if value.session, err = s.store.GetSession(ctx, value.run.SessionID); err != nil { + return value, apperror.Normalize(err) + } + if value.workspace, err = s.store.GetWorkspaceInfo(ctx, + value.mission.WorkspaceID); err != nil { + return value, apperror.Normalize(err) + } + if value.run.MissionID != value.mission.ID || value.run.SessionID != value.session.ID || + value.mission.WorkspaceID == "" || + value.mission.WorkspaceID != value.session.WorkspaceID || + value.mission.WorkspaceID != value.workspace.ID || + strings.TrimSpace(value.workspace.RootPath) == "" { + return value, apperror.New(apperror.CodeFailedPrecondition, + "workspace checkpoint Run binding is invalid") + } + return value, nil +} + +func workspaceForkSourceNode(ctx context.Context, store WorkspaceCheckpointForkStore, + run domain.Run, +) (string, error) { + nodes, err := store.ListSessionContinuityNodes(ctx, run.SessionID, 2_000) + if err != nil { + return "", apperror.Normalize(err) + } + for _, node := range nodes { + if node.RunID == run.ID && node.Kind == contextmgr.ContinuityNodeRoot { + return node.ID, nil + } + } + return "", apperror.New(apperror.CodeFailedPrecondition, + "source Run continuity root is unavailable") +} + +func rekeyWorkspaceForkPreparation(prepared preparedRun, node contextmgr.ContinuityNode, + runID string, +) (preparedRun, contextmgr.ContinuityNode, error) { + runID = strings.TrimSpace(runID) + if runID == "" { + return preparedRun{}, contextmgr.ContinuityNode{}, errors.New( + "fork Run identity is invalid") + } + oldRunID := prepared.Run.ID + prepared.Run.ID = runID + prepared.Mode.RunID = runID + for index := range prepared.InitialEvents { + prepared.InitialEvents[index].RunID = runID + if prepared.InitialEvents[index].SubjectID == oldRunID { + prepared.InitialEvents[index].SubjectID = runID + } + } + if err := prepared.Run.Validate(); err != nil { + return preparedRun{}, contextmgr.ContinuityNode{}, err + } + if err := prepared.Mode.Validate(); err != nil { + return preparedRun{}, contextmgr.ContinuityNode{}, err + } + for _, event := range prepared.InitialEvents { + if err := event.Validate(); err != nil { + return preparedRun{}, contextmgr.ContinuityNode{}, err + } + } + rebound, err := contextmgr.NewContinuityNode(node.ID, node.Kind, + prepared.Run.SessionID, prepared.Run.ID, prepared.Mission.WorkspaceID, + node.ParentID, node.SourceNodeID, node.Title, node.Summary, node.CreatedBy, + node.Snapshot, node.CreatedAt) + return prepared, rebound, err +} + +func materializeWorkspaceFork(ctx context.Context, sourceRoot, destinationRoot, + branch string, target workspacecheckpoint.Snapshot, +) (string, error) { + createdRoot, err := repository.CreateWorktree(ctx, sourceRoot, destinationRoot, + branch, target.Checkpoint.BaseCommit) + if err != nil { + return "", apperror.Normalize(err) + } + cleanupOnError := func(cause error) (string, error) { + cleanupErr := repository.RemoveCreatedWorktree(context.WithoutCancel(ctx), + sourceRoot, createdRoot, branch) + return "", errors.Join(cause, cleanupErr) + } + now := time.Now().UTC() + observed, err := workspacecheckpoint.Capture(ctx, workspacecheckpoint.CaptureRequest{ + ID: idgen.New("workspace-fork-observed"), RunID: target.Checkpoint.RunID, + MissionID: target.Checkpoint.MissionID, SessionID: target.Checkpoint.SessionID, + WorkspaceID: target.Checkpoint.WorkspaceID, WorkspaceRoot: createdRoot, + Trigger: workspacecheckpoint.TriggerFork, Phase: workspacecheckpoint.PhasePreflight, + TriggerReceiptID: target.Checkpoint.ID, CreatedAt: now}) + if err != nil { + return cleanupOnError(apperror.Normalize(err)) + } + if observed.Checkpoint.BaseCommit != target.Checkpoint.BaseCommit || + observed.Checkpoint.Branch != branch { + return cleanupOnError(errors.New("new Git worktree identity drifted before materialization")) + } + rebound := rebindWorkspaceForkTarget(target, observed.Checkpoint) + if err := rebound.Validate(); err != nil { + return cleanupOnError(err) + } + preview, err := workspacecheckpoint.PreviewRestore(observed, rebound, observed) + if err != nil || len(preview.Conflicts) != 0 { + return cleanupOnError(errors.Join(err, + &workspacecheckpoint.ConflictError{Conflicts: preview.Conflicts})) + } + if _, err := workspacecheckpoint.ApplyRestore(ctx, createdRoot, observed, rebound, + observed); err != nil { + return cleanupOnError(err) + } + verified, err := workspacecheckpoint.Capture(ctx, workspacecheckpoint.CaptureRequest{ + ID: idgen.New("workspace-fork-verified"), RunID: target.Checkpoint.RunID, + MissionID: target.Checkpoint.MissionID, SessionID: target.Checkpoint.SessionID, + WorkspaceID: target.Checkpoint.WorkspaceID, WorkspaceRoot: createdRoot, + Trigger: workspacecheckpoint.TriggerFork, Phase: workspacecheckpoint.PhaseStandalone, + TriggerReceiptID: target.Checkpoint.ID, CreatedAt: time.Now().UTC()}) + if err != nil || verified.Checkpoint.ManifestSHA256 != rebound.Checkpoint.ManifestSHA256 || + verified.Checkpoint.IndexSHA256 != rebound.Checkpoint.IndexSHA256 { + if err == nil { + err = errors.New("new Git worktree content failed exact verification") + } + return cleanupOnError(err) + } + return createdRoot, nil +} + +func rebindWorkspaceForkTarget(target workspacecheckpoint.Snapshot, + identity workspacecheckpoint.Checkpoint, +) workspacecheckpoint.Snapshot { + checkpoint := identity + checkpoint.ID = idgen.New("workspace-fork-target") + checkpoint.IndexSHA256 = target.Checkpoint.IndexSHA256 + checkpoint.IndexBlobSHA256 = target.Checkpoint.IndexBlobSHA256 + checkpoint.ManifestSHA256 = target.Checkpoint.ManifestSHA256 + checkpoint.RecoveryLevel = target.Checkpoint.RecoveryLevel + checkpoint.IncompleteReasons = append([]string{}, target.Checkpoint.IncompleteReasons...) + checkpoint.EntryCount = target.Checkpoint.EntryCount + checkpoint.StoredBytes = target.Checkpoint.StoredBytes + return workspacecheckpoint.Snapshot{Checkpoint: checkpoint, + Entries: append([]workspacecheckpoint.Entry{}, target.Entries...), + Blobs: append([]workspacecheckpoint.Blob{}, target.Blobs...)} +} + +func (s *WorkspaceCheckpointService) requireBoundaryLease(ctx context.Context, + binding workspaceCheckpointBinding, request WorkspaceMutationBoundaryRequest, +) error { + lease, found, err := s.store.GetRunExecutionLease(ctx, binding.run.ID) + if err != nil { + return apperror.Normalize(err) + } + if !found || binding.run.Status != domain.RunRunning || + binding.session.Status != session.StatusActive || lease.Status != domain.RunExecutionLeaseActive || + lease.LeaseID != request.LeaseID || lease.Generation != request.LeaseGeneration || + !lease.ExpiresAt.After(s.now().UTC()) { + return apperror.New(apperror.CodeConflict, + "workspace mutation execution lease is stale") + } + return nil +} + +func (s *WorkspaceCheckpointService) requireRestoreAuthority(ctx context.Context, + binding workspaceCheckpointBinding, requestedBy string, +) error { + if normalizeWorkspaceCheckpointOperator(requestedBy) == "" { + return apperror.New(apperror.CodePolicyDenied, + "workspace restore requires an explicit operator") + } + if binding.run.Status != domain.RunPaused || binding.session.Status != session.StatusActive { + return apperror.New(apperror.CodeFailedPrecondition, + "workspace restore requires a paused Run and active Session") + } + mode, err := s.store.GetRunMode(ctx, binding.run.ID) + if err != nil { + return apperror.Normalize(err) + } + permission, err := s.store.GetRunExecutionPermission(ctx, binding.run.ID) + if err != nil { + return apperror.Normalize(err) + } + lease, found, err := s.store.GetRunExecutionLease(ctx, binding.run.ID) + if err != nil { + return apperror.Normalize(err) + } + if found && lease.Status == domain.RunExecutionLeaseActive && + lease.ExpiresAt.After(s.now().UTC()) { + return apperror.New(apperror.CodeConflict, + "workspace restore requires a quiescent Run with no active execution lease") + } + if mode.RunID != binding.run.ID || mode.MissionID != binding.mission.ID || + mode.Surface != domain.ExecutionSurfaceCode || mode.Phase != domain.ExecutionPhaseDeliver || + permission.RunID != binding.run.ID || permission.MissionID != binding.mission.ID || + permission.Mode == domain.RunExecutionPermissionConservative || + !s.capabilities.Allows(permission.Mode) { + return apperror.New(apperror.CodePolicyDenied, + "workspace restore is not authorized by the current execution permission") + } + return nil +} + +func normalizeWorkspaceMutationBoundaryRequest( + request WorkspaceMutationBoundaryRequest, +) WorkspaceMutationBoundaryRequest { + request.RunID = strings.TrimSpace(request.RunID) + request.OperationKey = strings.TrimSpace(request.OperationKey) + request.TriggerReceiptID = strings.TrimSpace(request.TriggerReceiptID) + request.InvocationID = strings.TrimSpace(request.InvocationID) + request.AttemptID = strings.TrimSpace(request.AttemptID) + request.CapabilityGeneration = strings.TrimSpace(request.CapabilityGeneration) + request.LeaseID = strings.TrimSpace(request.LeaseID) + for index := range request.IncompleteReasons { + request.IncompleteReasons[index] = strings.TrimSpace(request.IncompleteReasons[index]) + } + return request +} + +func normalizeWorkspaceRestoreRequest(request WorkspaceRestoreRequest) WorkspaceRestoreRequest { + request.RunID = strings.TrimSpace(request.RunID) + request.TargetCheckpointID = strings.TrimSpace(request.TargetCheckpointID) + request.ExpectedCurrentCheckpointID = strings.TrimSpace(request.ExpectedCurrentCheckpointID) + request.OperationKey = strings.TrimSpace(request.OperationKey) + request.RequestedBy = normalizeWorkspaceCheckpointOperator(request.RequestedBy) + request.TriggerReceiptID = strings.TrimSpace(request.TriggerReceiptID) + if request.Kind == "" { + request.Kind = workspacecheckpoint.TransactionRewind + } + if request.TriggerReceiptID == "" { + request.TriggerReceiptID = request.TargetCheckpointID + } + return request +} + +func normalizeWorkspaceForkRequest(request WorkspaceForkRequest) WorkspaceForkRequest { + request.RunID = strings.TrimSpace(request.RunID) + request.TargetCheckpointID = strings.TrimSpace(request.TargetCheckpointID) + request.ExpectedCurrentCheckpointID = strings.TrimSpace( + request.ExpectedCurrentCheckpointID) + request.OperationKey = strings.TrimSpace(request.OperationKey) + request.RequestedBy = normalizeWorkspaceCheckpointOperator(request.RequestedBy) + request.WorkspaceName = strings.TrimSpace(request.WorkspaceName) + request.WorkspaceRoot = strings.TrimSpace(request.WorkspaceRoot) + request.Branch = strings.TrimSpace(request.Branch) + request.Goal = strings.TrimSpace(request.Goal) + return request +} + +func defaultWorkspaceForkRoot(sourceRoot, operationDigest string) (string, error) { + if len(operationDigest) < 20 { + return "", apperror.New(apperror.CodeInvalidArgument, + "workspace fork operation identity is invalid") + } + source, err := filepath.Abs(strings.TrimSpace(sourceRoot)) + if err != nil || strings.TrimSpace(sourceRoot) == "" { + return "", apperror.New(apperror.CodeFailedPrecondition, + "source Workspace root is unavailable") + } + return filepath.Join(filepath.Dir(filepath.Clean(source)), + "prayu-fork-"+operationDigest[:20]), nil +} + +func normalizeWorkspaceCheckpointOperator(value string) string { + value = strings.TrimSpace(value) + switch value { + case "cli_operator", "desktop_operator", "api_operator": + return value + default: + return "" + } +} + +func workspaceBoundaryKind(kind workspacecheckpoint.TransactionKind) bool { + return kind == workspacecheckpoint.TransactionFileTool || + kind == workspacecheckpoint.TransactionCommandBatch || + kind == workspacecheckpoint.TransactionGitMutation || + kind == workspacecheckpoint.TransactionAgentMerge +} + +func workspaceRestoreKind(kind workspacecheckpoint.TransactionKind) bool { + return kind == workspacecheckpoint.TransactionRewind || + kind == workspacecheckpoint.TransactionUndo || + kind == workspacecheckpoint.TransactionRedo +} + +func workspaceBoundaryTrigger(kind workspacecheckpoint.TransactionKind) workspacecheckpoint.TriggerKind { + switch kind { + case workspacecheckpoint.TransactionFileTool: + return workspacecheckpoint.TriggerFileTool + case workspacecheckpoint.TransactionCommandBatch: + return workspacecheckpoint.TriggerCommandBatch + case workspacecheckpoint.TransactionGitMutation: + return workspacecheckpoint.TriggerGitMutation + case workspacecheckpoint.TransactionAgentMerge: + return workspacecheckpoint.TriggerAgentMerge + default: + return workspacecheckpoint.TriggerManual + } +} + +func workspaceBoundaryOperationDigest(runID string, + kind workspacecheckpoint.TransactionKind, operationKey string, +) string { + return runmutation.OperationKeyDigest("workspace_mutation_boundary.v1."+string(kind), + runID, operationKey) +} + +func workspaceBoundaryFingerprint(binding workspaceCheckpointBinding, + request WorkspaceMutationBoundaryRequest, +) string { + return runmutation.Fingerprint("workspace_mutation_boundary_request.v1", + binding.run.ID, binding.mission.ID, binding.session.ID, binding.workspace.ID, + string(request.Kind), request.TriggerReceiptID, request.InvocationID) +} + +func embeddedWorkspaceCheckpointService(store any, + capabilities domain.ExecutionPermissionRuntimeCapabilities, +) *WorkspaceCheckpointService { + checkpointStore, ok := store.(WorkspaceCheckpointStore) + if !ok { + return nil + } + service, err := NewWorkspaceCheckpointService(checkpointStore, capabilities) + if err != nil { + return nil + } + return service +} + +func workspaceCheckpointID(digest, phase string) string { + sum := sha256.Sum256([]byte("workspace-checkpoint.v1\x00" + digest + "\x00" + phase)) + return "wcp-" + hex.EncodeToString(sum[:16]) +} + +func workspaceTransactionID(digest string) string { + sum := sha256.Sum256([]byte("workspace-checkpoint-transaction.v1\x00" + digest)) + return "wctx-" + hex.EncodeToString(sum[:16]) +} + +func workspaceConflictJSON(conflicts []workspacecheckpoint.Conflict) string { + value := (&workspacecheckpoint.ConflictError{Conflicts: conflicts}).ConflictJSON() + if value == "" { + return "[]" + } + return value +} + +func weakestWorkspaceRecovery(left, + right workspacecheckpoint.RecoveryLevel, +) workspacecheckpoint.RecoveryLevel { + if left == workspacecheckpoint.RecoveryUnavailable || + right == workspacecheckpoint.RecoveryUnavailable { + return workspacecheckpoint.RecoveryUnavailable + } + if left == workspacecheckpoint.RecoveryPartial || + right == workspacecheckpoint.RecoveryPartial { + return workspacecheckpoint.RecoveryPartial + } + return workspacecheckpoint.RecoveryComplete +} diff --git a/internal/application/workspace_checkpoints_test.go b/internal/application/workspace_checkpoints_test.go new file mode 100644 index 00000000..f3af553a --- /dev/null +++ b/internal/application/workspace_checkpoints_test.go @@ -0,0 +1,734 @@ +package application_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/repository" + "cyberagent-workbench/internal/runmutation" + "cyberagent-workbench/internal/store" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +func TestWorkspaceCheckpointServiceBoundaryUndoRedoAndTimeline(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + ctx := context.Background() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-boundary-file-0001", TriggerReceiptID: "file-edit-receipt-1", + AttemptID: "attempt-workspace-1", CapabilityGeneration: strings.Repeat("a", 64), + LeaseID: fixture.lease.LeaseID, LeaseGeneration: fixture.lease.Generation} + boundary, err := fixture.service.BeginBoundary(ctx, request) + if err != nil { + t.Fatal(err) + } + if boundary.Before.AttemptID != request.AttemptID || + boundary.Before.CapabilityGeneration != request.CapabilityGeneration || + boundary.Transaction.Status != workspacecheckpoint.TransactionPrepared { + t.Fatalf("unexpected prepared boundary: %+v", boundary) + } + if _, _, err = fixture.service.Capture(ctx, + application.WorkspaceCheckpointCaptureRequest{RunID: fixture.run.ID, + OperationKey: "manual-during-open-boundary", RequestedBy: "cli_operator"}); apperror.CodeOf(err) != apperror.CodeConflict { + t.Fatalf("manual capture during open boundary error=%v", err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("after\r\n")) + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "binary.dat"), + []byte{0, 1, 2, 3}) + completed, err := fixture.service.CompleteBoundary(ctx, request, nil) + if err != nil { + t.Fatal(err) + } + if completed.After == nil || + completed.Transaction.Status != workspacecheckpoint.TransactionCompleted { + t.Fatalf("unexpected completed boundary: %+v", completed) + } + + fixture.pause(t) + timeline, err := fixture.service.Timeline(ctx, fixture.run.ID, 100) + if err != nil || timeline.Current == nil || + timeline.Current.CurrentCheckpointID != completed.After.ID || + len(timeline.Checkpoints) < 2 || len(timeline.Transactions) != 1 { + t.Fatalf("timeline=%+v err=%v", timeline, err) + } + preview, err := fixture.service.Undo(ctx, fixture.run.ID, + timeline.Current.CurrentCheckpointID, "workspace-undo-preview", "desktop_operator", false) + if err != nil || preview.Confirmed || len(preview.Preview.Conflicts) != 0 { + t.Fatalf("undo preview=%+v err=%v", preview, err) + } + undo, err := fixture.service.Undo(ctx, fixture.run.ID, + timeline.Current.CurrentCheckpointID, "workspace-undo-0001", "desktop_operator", true) + if err != nil || undo.After == nil || !undo.Confirmed { + t.Fatalf("undo=%+v err=%v", undo, err) + } + data, err := os.ReadFile(filepath.Join(fixture.root, "file.txt")) + if err != nil || string(data) != "before\n" { + t.Fatalf("undo file=%q err=%v", data, err) + } + if _, err := os.Stat(filepath.Join(fixture.root, "binary.dat")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("undo retained untracked binary: %v", err) + } + + current, found, err := fixture.state.GetWorkspaceCheckpointRunState(ctx, fixture.run.ID) + if err != nil || !found { + t.Fatalf("undo cursor found=%t err=%v", found, err) + } + redo, err := fixture.service.Redo(ctx, fixture.run.ID, current.CurrentCheckpointID, + "workspace-redo-0001", "desktop_operator", true) + if err != nil || redo.After == nil { + t.Fatalf("redo=%+v err=%v", redo, err) + } + data, err = os.ReadFile(filepath.Join(fixture.root, "file.txt")) + if err != nil || string(data) != "after\r\n" { + t.Fatalf("redo file=%q err=%v", data, err) + } + if binary, readErr := os.ReadFile(filepath.Join(fixture.root, "binary.dat")); readErr != nil || string(binary) != string([]byte{0, 1, 2, 3}) { + t.Fatalf("redo binary=%v err=%v", binary, readErr) + } + replay, err := fixture.service.Redo(ctx, fixture.run.ID, current.CurrentCheckpointID, + "workspace-redo-0001", "desktop_operator", true) + if err != nil || !replay.Replayed || replay.Transaction == nil || + replay.Transaction.ID != redo.Transaction.ID { + t.Fatalf("redo replay=%+v err=%v", replay, err) + } +} + +func TestWorkspaceCheckpointServiceRestoreFailsClosedOnExternalChange(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionCommandBatch, + OperationKey: "workspace-command-boundary-0001", TriggerReceiptID: "command-receipt-1", + AttemptID: "attempt-command-1", CapabilityGeneration: strings.Repeat("b", 64), + LeaseID: fixture.lease.LeaseID, LeaseGeneration: fixture.lease.Generation} + before, err := fixture.service.BeginBoundary(t.Context(), request) + if err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("command change\n")) + after, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil { + t.Fatal(err) + } + fixture.pause(t) + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("external change\n")) + result, err := fixture.service.Restore(t.Context(), application.WorkspaceRestoreRequest{ + RunID: fixture.run.ID, TargetCheckpointID: before.Before.ID, + ExpectedCurrentCheckpointID: after.After.ID, + OperationKey: "workspace-rewind-conflict-0001", RequestedBy: "desktop_operator", + Kind: workspacecheckpoint.TransactionRewind, + TriggerReceiptID: "operator-rewind-1", Confirm: true}) + var conflict *workspacecheckpoint.ConflictError + if !errors.As(err, &conflict) || result.Transaction == nil || + result.Transaction.Status != workspacecheckpoint.TransactionFailed || + len(result.Preview.Conflicts) == 0 { + t.Fatalf("conflicting restore=%+v err=%v", result, err) + } + data, readErr := os.ReadFile(filepath.Join(fixture.root, "file.txt")) + if readErr != nil || string(data) != "external change\n" { + t.Fatalf("external file was overwritten: %q err=%v", data, readErr) + } +} + +func TestWorkspaceCheckpointServiceReconcilesInterruptedMutation(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + initial, _, err := fixture.service.Capture(t.Context(), + application.WorkspaceCheckpointCaptureRequest{RunID: fixture.run.ID, + OperationKey: "workspace-reconciliation-initial-0001", + RequestedBy: "cli_operator"}) + if err != nil { + t.Fatal(err) + } + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionAgentMerge, + OperationKey: "workspace-agent-merge-boundary-0001", + TriggerReceiptID: "agent-merge-receipt-1", AttemptID: "attempt-agent-merge-1", + CapabilityGeneration: strings.Repeat("c", 64), LeaseID: fixture.lease.LeaseID, + LeaseGeneration: fixture.lease.Generation} + boundary, err := fixture.service.BeginBoundary(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if _, _, err = fixture.state.AdvanceWorkspaceCheckpointRunState(t.Context(), + workspacecheckpoint.RunState{RunID: fixture.run.ID, + WorkspaceID: initial.WorkspaceID, CurrentCheckpointID: initial.ID, + UpdatedAt: time.Now().UTC()}, boundary.Before.ID); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "partial.txt"), + []byte("partial merge\n")) + reconciled, err := fixture.service.Reconcile(t.Context()) + if reconciled != 0 || apperror.CodeOf(err) != apperror.CodeConflict { + t.Fatalf("live-lease reconciliation count=%d err=%v", reconciled, err) + } + if _, _, err = fixture.state.ReleaseRunExecutionLease(t.Context(), fixture.lease); err != nil { + t.Fatal(err) + } + reconciled, err = fixture.service.Reconcile(t.Context()) + if err != nil || reconciled != 1 { + t.Fatalf("reconciled=%d err=%v", reconciled, err) + } + transaction, found, err := fixture.state.GetWorkspaceCheckpointTransaction(t.Context(), + boundary.Transaction.ID) + if err != nil || !found || transaction.Status != workspacecheckpoint.TransactionInterrupted || + transaction.AfterCheckpointID == "" || transaction.ErrorCode != "process_restart_reconciliation" { + t.Fatalf("reconciled transaction=%+v found=%t err=%v", transaction, found, err) + } + after, err := fixture.state.GetWorkspaceCheckpointSnapshot(t.Context(), + transaction.AfterCheckpointID) + if err != nil || after.Checkpoint.RecoveryLevel != workspacecheckpoint.RecoveryPartial || + len(after.Checkpoint.IncompleteReasons) == 0 { + t.Fatalf("reconciled checkpoint=%+v err=%v", after.Checkpoint, err) + } +} + +func TestWorkspaceCheckpointServiceReconcilesTerminalCursorCommitWindow(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-terminal-cursor-window-0001", + TriggerReceiptID: "terminal-cursor-edit-1", AttemptID: "attempt-terminal-cursor-1", + CapabilityGeneration: strings.Repeat("2", 64), LeaseID: fixture.lease.LeaseID, + LeaseGeneration: fixture.lease.Generation} + boundary, err := fixture.service.BeginBoundary(t.Context(), request) + if err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("terminal cursor state\n")) + completed, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil || completed.After == nil { + t.Fatalf("completed boundary=%+v err=%v", completed, err) + } + if _, _, err = fixture.state.AdvanceWorkspaceCheckpointRunState(t.Context(), + workspacecheckpoint.RunState{RunID: fixture.run.ID, + WorkspaceID: boundary.Before.WorkspaceID, + CurrentCheckpointID: boundary.Before.ID, LastTransactionID: "", + UpdatedAt: time.Now().UTC()}, completed.After.ID); err != nil { + t.Fatal(err) + } + if _, _, err = fixture.state.ReleaseRunExecutionLease(t.Context(), fixture.lease); err != nil { + t.Fatal(err) + } + reconciled, err := fixture.service.Reconcile(t.Context()) + if err != nil || reconciled != 1 { + t.Fatalf("terminal cursor reconciliation count=%d err=%v", reconciled, err) + } + state, found, err := fixture.state.GetWorkspaceCheckpointRunState(t.Context(), + fixture.run.ID) + if err != nil || !found || state.CurrentCheckpointID != completed.After.ID || + state.LastTransactionID != completed.Transaction.ID { + t.Fatalf("terminal cursor state=%+v found=%t err=%v", state, found, err) + } +} + +func TestWorkspaceCheckpointServiceForkCreatesIndependentWorktreeAndRun(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-fork-source-change-0001", TriggerReceiptID: "fork-edit-1", + AttemptID: "attempt-fork-1", CapabilityGeneration: strings.Repeat("d", 64), + LeaseID: fixture.lease.LeaseID, LeaseGeneration: fixture.lease.Generation} + if _, err := fixture.service.BeginBoundary(t.Context(), request); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("forked content\n")) + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "fork-only.bin"), + []byte{0, 9, 8, 7}) + boundary, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil || boundary.After == nil { + t.Fatalf("fork source boundary=%+v err=%v", boundary, err) + } + fixture.pause(t) + forkRequest := application.WorkspaceForkRequest{RunID: fixture.run.ID, + TargetCheckpointID: boundary.After.ID, + ExpectedCurrentCheckpointID: boundary.After.ID, + OperationKey: "workspace-fork-operation-0001", RequestedBy: "desktop_operator", + WorkspaceName: "workspace-checkpoint-fork", + Branch: "checkpoint/fork-test", Goal: "continue independently from checkpoint", + Confirm: true} + unconfirmed := forkRequest + unconfirmed.Confirm = false + if _, err := fixture.service.Fork(t.Context(), unconfirmed); apperror.CodeOf(err) != apperror.CodeInvalidArgument { + t.Fatalf("unconfirmed application fork error=%v", err) + } + result, err := fixture.service.Fork(t.Context(), forkRequest) + if err != nil { + t.Fatal(err) + } + destination := result.Workspace.RootPath + defer func() { + if err := repository.RemoveCreatedWorktree(context.Background(), fixture.root, + destination, forkRequest.Branch); err != nil { + t.Errorf("cleanup fork worktree: %v", err) + } + }() + if result.Run.ID == fixture.run.ID || result.Workspace.ID == "workspace-checkpoint" || + filepath.Dir(destination) != filepath.Dir(fixture.root) || + !strings.HasPrefix(filepath.Base(destination), "prayu-fork-") || + result.Run.Status != domain.RunCreated || result.Checkpoint.RunID != result.Run.ID || + result.Checkpoint.ManifestSHA256 != boundary.After.ManifestSHA256 || + result.Transaction.Status != workspacecheckpoint.TransactionCompleted || + result.Replayed || len(result.NotInherited) == 0 { + t.Fatalf("unexpected fork result: %+v", result) + } + data, err := os.ReadFile(filepath.Join(destination, "file.txt")) + if err != nil || string(data) != "forked content\n" { + t.Fatalf("forked text=%q err=%v", data, err) + } + binary, err := os.ReadFile(filepath.Join(destination, "fork-only.bin")) + if err != nil || string(binary) != string([]byte{0, 9, 8, 7}) { + t.Fatalf("forked binary=%v err=%v", binary, err) + } + branchOutput := exec.Command("git", "-C", destination, "branch", "--show-current") + branch, err := branchOutput.Output() + if err != nil || strings.TrimSpace(string(branch)) != forkRequest.Branch { + t.Fatalf("fork branch=%q err=%v", branch, err) + } + replayed, err := fixture.service.Fork(t.Context(), forkRequest) + if err != nil || !replayed.Replayed || replayed.Run.ID != result.Run.ID || + replayed.Checkpoint.ID != result.Checkpoint.ID { + t.Fatalf("fork replay=%+v err=%v", replayed, err) + } +} + +func TestWorkspaceCheckpointServiceForkReplaysAfterRegistrationCaptureFailure(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-fork-recovery-source-0001", + TriggerReceiptID: "fork-recovery-edit-1", AttemptID: "attempt-fork-recovery-1", + CapabilityGeneration: strings.Repeat("e", 64), LeaseID: fixture.lease.LeaseID, + LeaseGeneration: fixture.lease.Generation} + if _, err := fixture.service.BeginBoundary(t.Context(), request); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("recoverable fork content\n")) + boundary, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil || boundary.After == nil { + t.Fatalf("fork recovery source=%+v err=%v", boundary, err) + } + fixture.pause(t) + destination := filepath.Join(t.TempDir(), "fork-recovery-worktree") + forkRequest := application.WorkspaceForkRequest{RunID: fixture.run.ID, + TargetCheckpointID: boundary.After.ID, ExpectedCurrentCheckpointID: boundary.After.ID, + OperationKey: "workspace-fork-recovery-0001", RequestedBy: "desktop_operator", + WorkspaceName: "workspace-checkpoint-fork-recovery", WorkspaceRoot: destination, + Branch: "checkpoint/fork-recovery", Goal: "resume a partially registered fork", + Confirm: true} + failingStore := &failOnceForkCheckpointStore{SQLiteStore: fixture.state, + sourceRunID: fixture.run.ID} + service, err := application.NewWorkspaceCheckpointService(failingStore, + domain.ExecutionPermissionRuntimeCapabilities{OperatorApprovalEnabled: true}) + if err != nil { + t.Fatal(err) + } + if _, err := service.Fork(t.Context(), forkRequest); err == nil || !failingStore.failed { + t.Fatalf("fork capture fault was not observed: failed=%t err=%v", + failingStore.failed, err) + } + open, err := fixture.state.ListOpenWorkspaceCheckpointTransactions(t.Context(), 10) + if err != nil || len(open) != 1 || open[0].Kind != workspacecheckpoint.TransactionFork { + t.Fatalf("replayable fork transaction=%+v err=%v", open, err) + } + restarted, err := application.NewWorkspaceCheckpointService(fixture.state, + domain.ExecutionPermissionRuntimeCapabilities{OperatorApprovalEnabled: true}) + if err != nil { + t.Fatal(err) + } + if reconciled, reconcileErr := restarted.Reconcile(t.Context()); reconcileErr != nil || + reconciled != 1 { + t.Fatalf("fork reconciliation count=%d err=%v", reconciled, reconcileErr) + } + result, err := restarted.Fork(t.Context(), forkRequest) + if err != nil || result.Run.ID == "" || result.Checkpoint.RunID != result.Run.ID || + result.Transaction.Status != workspacecheckpoint.TransactionCompleted || !result.Replayed { + t.Fatalf("resumed fork=%+v err=%v", result, err) + } + defer func() { + if err := repository.RemoveCreatedWorktree(context.Background(), fixture.root, + destination, forkRequest.Branch); err != nil { + t.Errorf("cleanup recovered fork worktree: %v", err) + } + }() + data, err := os.ReadFile(filepath.Join(destination, "file.txt")) + if err != nil || string(data) != "recoverable fork content\n" { + t.Fatalf("recovered fork text=%q err=%v", data, err) + } +} + +func TestWorkspaceCheckpointServiceReconcilesForkBeforeRunRegistration(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-fork-crash-source-0001", + TriggerReceiptID: "fork-crash-source-edit-1", AttemptID: "attempt-fork-crash-1", + CapabilityGeneration: strings.Repeat("f", 64), LeaseID: fixture.lease.LeaseID, + LeaseGeneration: fixture.lease.Generation} + if _, err := fixture.service.BeginBoundary(t.Context(), request); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("fork crash recovery content\n")) + boundary, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil || boundary.After == nil { + t.Fatalf("fork crash source=%+v err=%v", boundary, err) + } + fixture.pause(t) + + operationKey := "workspace-fork-crash-operation-0001" + digest := runmutation.OperationKeyDigest("workspace_fork_operation.v1", + fixture.run.ID, operationKey) + destination := filepath.Join(t.TempDir(), "fork-crash-worktree") + destination, err = repository.NormalizeWorktreeDestination(fixture.root, destination) + if err != nil { + t.Fatal(err) + } + branch := "checkpoint/fork-crash-recovery" + if _, err = repository.CreateWorktree(t.Context(), fixture.root, destination, branch, + boundary.After.BaseCommit); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(destination, "file.txt"), + []byte("fork crash recovery content\n")) + copyWorkspaceCheckpointApplicationIndex(t, fixture.root, destination) + t.Cleanup(func() { + _ = repository.CleanupInterruptedWorktree(context.Background(), fixture.root, + destination, branch, boundary.After.BaseCommit) + }) + wrongCommit := strings.Repeat("0", len(boundary.After.BaseCommit)) + if err = repository.CleanupInterruptedWorktree(t.Context(), fixture.root, destination, + branch, wrongCommit); err == nil { + t.Fatal("fork cleanup accepted a drifted commit identity") + } + if _, err = os.Stat(destination); err != nil { + t.Fatalf("failed-closed fork cleanup removed the worktree: %v", err) + } + now := time.Now().UTC() + transaction := workspacecheckpoint.Transaction{ + ID: "workspace-fork-crash-" + digest[:20], + ProtocolVersion: workspacecheckpoint.ProtocolVersion, OperationKeyDigest: digest, + RequestFingerprint: runmutation.Fingerprint("workspace-fork-crash-test.v1", digest), + RunID: fixture.run.ID, WorkspaceID: boundary.After.WorkspaceID, + Kind: workspacecheckpoint.TransactionFork, + TriggerReceiptID: "missing-fork-run-" + digest[:20], + BeforeCheckpointID: boundary.After.ID, + ExpectedCurrentCheckpointID: boundary.After.ID, + TargetCheckpointID: boundary.After.ID, ForkWorkspaceRoot: destination, + ForkBranch: branch, Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: boundary.After.RecoveryLevel, ConflictJSON: "[]", + CreatedAt: now, UpdatedAt: now, + } + transaction, replayed, err := fixture.state.CreateWorkspaceCheckpointTransaction( + t.Context(), transaction) + if err != nil || replayed { + t.Fatalf("prepare interrupted fork transaction replayed=%t err=%v", replayed, err) + } + encoded, err := json.Marshal(transaction) + if err != nil || strings.Contains(string(encoded), destination) || + strings.Contains(string(encoded), branch) || + strings.Contains(string(encoded), "fork_workspace_root") { + t.Fatalf("private fork recovery metadata leaked: %s err=%v", encoded, err) + } + + reconciled, err := fixture.service.Reconcile(t.Context()) + if err != nil || reconciled != 1 { + t.Fatalf("fork crash reconciliation count=%d err=%v", reconciled, err) + } + if _, err = os.Stat(destination); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("interrupted fork worktree was not removed: %v", err) + } + if err = exec.Command("git", "-C", fixture.root, "show-ref", "--verify", + "refs/heads/"+branch).Run(); err == nil { + t.Fatal("interrupted fork branch was not removed") + } + stored, found, err := fixture.state.GetWorkspaceCheckpointTransaction(t.Context(), + transaction.ID) + if err != nil || !found || stored.Status != workspacecheckpoint.TransactionInterrupted || + stored.ErrorCode != "process_restart_reconciliation" || + stored.ForkWorkspaceRoot != destination || stored.ForkBranch != branch { + t.Fatalf("reconciled fork transaction=%+v found=%t err=%v", stored, found, err) + } +} + +func TestWorkspaceCheckpointServiceForkReconciliationPreservesExternalDrift(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + request := application.WorkspaceMutationBoundaryRequest{RunID: fixture.run.ID, + Kind: workspacecheckpoint.TransactionFileTool, + OperationKey: "workspace-fork-drift-source-0001", + TriggerReceiptID: "fork-drift-source-edit-1", AttemptID: "attempt-fork-drift-1", + CapabilityGeneration: strings.Repeat("1", 64), LeaseID: fixture.lease.LeaseID, + LeaseGeneration: fixture.lease.Generation} + if _, err := fixture.service.BeginBoundary(t.Context(), request); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(fixture.root, "file.txt"), + []byte("fork drift target\n")) + boundary, err := fixture.service.CompleteBoundary(t.Context(), request, nil) + if err != nil || boundary.After == nil { + t.Fatalf("fork drift source=%+v err=%v", boundary, err) + } + fixture.pause(t) + operationKey := "workspace-fork-drift-operation-0001" + digest := runmutation.OperationKeyDigest("workspace_fork_operation.v1", + fixture.run.ID, operationKey) + destination := filepath.Join(t.TempDir(), "fork-drift-worktree") + destination, err = repository.NormalizeWorktreeDestination(fixture.root, destination) + if err != nil { + t.Fatal(err) + } + branch := "checkpoint/fork-drift-recovery" + if _, err = repository.CreateWorktree(t.Context(), fixture.root, destination, branch, + boundary.After.BaseCommit); err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(destination, "file.txt"), + []byte("fork drift target\n")) + copyWorkspaceCheckpointApplicationIndex(t, fixture.root, destination) + t.Cleanup(func() { + _ = os.Remove(filepath.Join(destination, "external.txt")) + _ = repository.CleanupInterruptedWorktree(context.Background(), fixture.root, + destination, branch, boundary.After.BaseCommit) + }) + now := time.Now().UTC() + transaction := workspacecheckpoint.Transaction{ + ID: "workspace-fork-drift-" + digest[:20], + ProtocolVersion: workspacecheckpoint.ProtocolVersion, OperationKeyDigest: digest, + RequestFingerprint: runmutation.Fingerprint("workspace-fork-drift-test.v1", digest), + RunID: fixture.run.ID, WorkspaceID: boundary.After.WorkspaceID, + Kind: workspacecheckpoint.TransactionFork, + TriggerReceiptID: "missing-drift-run-" + digest[:20], + BeforeCheckpointID: boundary.After.ID, + ExpectedCurrentCheckpointID: boundary.After.ID, + TargetCheckpointID: boundary.After.ID, ForkWorkspaceRoot: destination, + ForkBranch: branch, Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: boundary.After.RecoveryLevel, ConflictJSON: "[]", + CreatedAt: now, UpdatedAt: now, + } + transaction, _, err = fixture.state.CreateWorkspaceCheckpointTransaction(t.Context(), + transaction) + if err != nil { + t.Fatal(err) + } + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(destination, "external.txt"), + []byte("do not delete me\n")) + reconciled, err := fixture.service.Reconcile(t.Context()) + if reconciled != 0 || err == nil { + t.Fatalf("drifted fork reconciliation count=%d err=%v", reconciled, err) + } + if data, readErr := os.ReadFile(filepath.Join(destination, "external.txt")); readErr != nil || string(data) != "do not delete me\n" { + t.Fatalf("external fork content changed: %q err=%v", data, readErr) + } + stored, found, err := fixture.state.GetWorkspaceCheckpointTransaction(t.Context(), + transaction.ID) + if err != nil || !found || stored.Status != workspacecheckpoint.TransactionPrepared { + t.Fatalf("drifted fork transaction=%+v found=%t err=%v", stored, found, err) + } + if err = os.Remove(filepath.Join(destination, "external.txt")); err != nil { + t.Fatal(err) + } + reconciled, err = fixture.service.Reconcile(t.Context()) + if err != nil || reconciled != 1 { + t.Fatalf("fork drift retry reconciliation count=%d err=%v", reconciled, err) + } + if _, err = os.Stat(destination); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("verified fork worktree was not removed: %v", err) + } +} + +func TestWorkspaceCheckpointServiceFailsClosedAtOpenTransactionScanLimit(t *testing.T) { + fixture := newWorkspaceCheckpointApplicationFixture(t) + defer fixture.state.Close() + service, err := application.NewWorkspaceCheckpointService( + &saturatedWorkspaceCheckpointStore{SQLiteStore: fixture.state}, + domain.ExecutionPermissionRuntimeCapabilities{OperatorApprovalEnabled: true}) + if err != nil { + t.Fatal(err) + } + _, _, err = service.Capture(t.Context(), application.WorkspaceCheckpointCaptureRequest{ + RunID: fixture.run.ID, OperationKey: "checkpoint-backlog-limit-0001", + RequestedBy: "cli_operator"}) + if apperror.CodeOf(err) != apperror.CodeResourceExhausted { + t.Fatalf("open transaction scan limit error=%v", err) + } +} + +type saturatedWorkspaceCheckpointStore struct{ *store.SQLiteStore } + +func (s *saturatedWorkspaceCheckpointStore) ListOpenWorkspaceCheckpointTransactions( + context.Context, int, +) ([]workspacecheckpoint.Transaction, error) { + return make([]workspacecheckpoint.Transaction, 2_000), nil +} + +type failOnceForkCheckpointStore struct { + *store.SQLiteStore + sourceRunID string + failed bool +} + +func (s *failOnceForkCheckpointStore) CreateWorkspaceCheckpoint(ctx context.Context, + snapshot workspacecheckpoint.Snapshot, +) (workspacecheckpoint.Checkpoint, bool, error) { + if !s.failed && snapshot.Checkpoint.Trigger == workspacecheckpoint.TriggerFork && + snapshot.Checkpoint.RunID != s.sourceRunID { + s.failed = true + return workspacecheckpoint.Checkpoint{}, false, errors.New( + "injected fork checkpoint persistence failure") + } + return s.SQLiteStore.CreateWorkspaceCheckpoint(ctx, snapshot) +} + +type workspaceCheckpointApplicationFixture struct { + state *store.SQLiteStore + service *application.WorkspaceCheckpointService + run domain.Run + lease domain.RunExecutionLease + root string +} + +func newWorkspaceCheckpointApplicationFixture(t *testing.T) workspaceCheckpointApplicationFixture { + t.Helper() + ctx := context.Background() + root := newWorkspaceCheckpointApplicationRepository(t) + state, err := store.Open(filepath.Join(t.TempDir(), "workspace-checkpoint-application.db")) + if err != nil { + t.Fatal(err) + } + workspace := store.WorkspaceRecord{ID: "workspace-checkpoint-application", + Name: "workspace-checkpoint-application", RootPath: root} + if err := state.SaveWorkspace(ctx, workspace); err != nil { + state.Close() + t.Fatal(err) + } + runs := application.NewRunService(state) + _, created, err := runs.Create(ctx, application.CreateRunRequest{ + Goal: "test workspace restore", Profile: "code", Surface: "code", Phase: "deliver", + WorkspaceID: workspace.ID, + Budget: domain.Budget{MaxTurns: 8, MaxTokens: 2000, MaxToolCalls: 16}}) + if err != nil { + state.Close() + t.Fatal(err) + } + capabilities := domain.ExecutionPermissionRuntimeCapabilities{OperatorApprovalEnabled: true} + if _, err := application.NewRunExecutionPermissionService(state, capabilities).Change(ctx, + application.ChangeRunExecutionPermissionRequest{RunID: created.ID, + Mode: string(domain.RunExecutionPermissionApproval), + OperationKey: "workspace-checkpoint-permission-0001", RequestedBy: "cli_operator", + Reason: "test explicit workspace restore", ConfirmUserApproval: true}); err != nil { + state.Close() + t.Fatal(err) + } + runRecord, err := runs.Start(ctx, created.ID) + if err != nil { + state.Close() + t.Fatal(err) + } + leaseResult, err := state.AcquireRunExecutionLease(ctx, + domain.AcquireRunExecutionLeaseRequest{RunID: runRecord.ID, + OwnerID: "workspace-checkpoint-test", TTL: time.Minute}) + if err != nil { + state.Close() + t.Fatal(err) + } + service, err := application.NewWorkspaceCheckpointService(state, capabilities) + if err != nil { + state.Close() + t.Fatal(err) + } + return workspaceCheckpointApplicationFixture{state: state, service: service, + run: runRecord, lease: leaseResult.Lease, root: root} +} + +func (f *workspaceCheckpointApplicationFixture) pause(t *testing.T) { + t.Helper() + if _, _, err := f.state.ReleaseRunExecutionLease(t.Context(), f.lease); err != nil { + t.Fatal(err) + } + runRecord, err := application.NewRunService(f.state).Pause(t.Context(), f.run.ID) + if err != nil { + t.Fatal(err) + } + f.run = runRecord +} + +func newWorkspaceCheckpointApplicationRepository(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is unavailable") + } + root := t.TempDir() + runWorkspaceCheckpointApplicationGit(t, root, "init", "-q") + runWorkspaceCheckpointApplicationGit(t, root, "config", "user.email", + "checkpoint@example.invalid") + runWorkspaceCheckpointApplicationGit(t, root, "config", "user.name", "Checkpoint Test") + mustWorkspaceCheckpointApplicationWrite(t, filepath.Join(root, "file.txt"), []byte("before\n")) + runWorkspaceCheckpointApplicationGit(t, root, "add", ".") + runWorkspaceCheckpointApplicationGit(t, root, "commit", "-m", "baseline") + return root +} + +func runWorkspaceCheckpointApplicationGit(t *testing.T, root string, args ...string) { + t.Helper() + command := exec.Command("git", append([]string{"-C", root}, args...)...) + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } +} + +func mustWorkspaceCheckpointApplicationWrite(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } +} + +func copyWorkspaceCheckpointApplicationIndex(t *testing.T, sourceRoot, targetRoot string) { + t.Helper() + indexPath := func(root string) string { + command := exec.Command("git", "-C", root, "rev-parse", "--git-path", "index") + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("resolve Git index for %s: %v: %s", root, err, output) + } + value := strings.TrimSpace(string(output)) + if !filepath.IsAbs(value) { + value = filepath.Join(root, value) + } + return filepath.Clean(value) + } + content, err := os.ReadFile(indexPath(sourceRoot)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(indexPath(targetRoot), content, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/desktop/bridge.go b/internal/desktop/bridge.go index 7af50054..16fe454c 100644 --- a/internal/desktop/bridge.go +++ b/internal/desktop/bridge.go @@ -71,6 +71,7 @@ type ConnectionBootstrap struct { EvidenceAttachmentEnabled bool `json:"evidence_attachment_enabled"` VerificationEvidenceEnabled bool `json:"verification_evidence_enabled"` EmbeddedAnalyzerExecutionEnabled bool `json:"embedded_analyzer_execution_enabled"` + WorkspaceCheckpointControlEnabled bool `json:"workspace_checkpoint_control_enabled"` UserTerminalEnabled bool `json:"user_terminal_enabled"` AgentTerminalInputDefault bool `json:"agent_terminal_input_default"` WorkspaceOpenEnabled bool `json:"workspace_open_enabled"` @@ -227,6 +228,7 @@ func NewDesktopBridge(config DesktopBridgeConfig) (*DesktopBridge, error) { config.SkillInstallationEnabled || config.EvidenceAttachmentEnabled || config.VerificationEvidenceEnabled || config.EmbeddedAnalyzerExecutionEnabled || + config.ControlToken != "" || config.UserTerminalEnabled || config.DockerExecutionEnabled if controlEnabled && config.ControlToken == "" { return nil, apperror.New(apperror.CodeInvalidArgument, @@ -357,6 +359,7 @@ func NewDesktopBridge(config DesktopBridgeConfig) (*DesktopBridge, error) { EvidenceAttachmentEnabled: config.EvidenceAttachmentEnabled, VerificationEvidenceEnabled: config.VerificationEvidenceEnabled, EmbeddedAnalyzerExecutionEnabled: config.EmbeddedAnalyzerExecutionEnabled, + WorkspaceCheckpointControlEnabled: config.ControlToken != "", UserTerminalEnabled: config.UserTerminalEnabled, AgentTerminalInputDefault: false, WorkspaceOpenEnabled: config.WorkspaceResolver != nil, diff --git a/internal/desktop/bridge_test.go b/internal/desktop/bridge_test.go index 1f9a13bd..f10d4067 100644 --- a/internal/desktop/bridge_test.go +++ b/internal/desktop/bridge_test.go @@ -184,6 +184,7 @@ func TestDesktopBridgeBootstrapsMemoryOnlyClosedAuthority(t *testing.T) { bootstrap.VerificationEvidenceEnabled || bootstrap.EmbeddedAnalyzerExecutionEnabled || bootstrap.UserTerminalEnabled || bootstrap.AgentTerminalInputDefault || + !bootstrap.WorkspaceCheckpointControlEnabled || bootstrap.WorkspaceOpenEnabled || bootstrap.WorkspaceImportEnabled || bootstrap.RendererPathInputSupported { @@ -215,6 +216,7 @@ func TestDesktopBridgeBootstrapsMemoryOnlyClosedAuthority(t *testing.T) { "verification_evidence_enabled", "embedded_analyzer_execution_enabled", "user_terminal_enabled", "agent_terminal_input_default", "ui_digest", + "workspace_checkpoint_control_enabled", "workspace_import_enabled", "workspace_open_enabled", }) diff --git a/internal/desktop/control_plane.go b/internal/desktop/control_plane.go index 2ea870fb..61516684 100644 --- a/internal/desktop/control_plane.go +++ b/internal/desktop/control_plane.go @@ -143,6 +143,17 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { return nil, err } checker := policy.NewDefaultChecker() + workspaceCheckpoints, err := application.NewWorkspaceCheckpointService(stateStore, + config.ExecutionPermissionCapabilities) + if err != nil { + _ = stateStore.Close() + return nil, err + } + if _, err := workspaceCheckpoints.Reconcile(context.Background()); err != nil { + _ = stateStore.Close() + return nil, apperror.Wrap(apperror.CodeUnavailable, + "desktop Workspace checkpoint startup reconciliation failed", err) + } dockerSandbox, err := newDesktopDockerSandboxService(context.Background(), stateStore, home, config.DockerExecutionEnabled, config.ExecutionPermissionCapabilities) @@ -199,7 +210,8 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { WithRegistryReload(models, stateStore) fileEditReview := application.NewFileEditReviewService(stateStore) fileEditProposal := application.NewFileEditProposalService(stateStore, checker) - fileEditApply := application.NewFileEditApplyService(stateStore, checker) + fileEditApply := application.NewFileEditApplyService(stateStore, checker, + workspaceCheckpoints) runWakeControl := application.NewRunWakeControlService(stateStore) runWakeExecution := application.NewForegroundRunWakeConsumer(stateStore, executionControl) @@ -329,6 +341,7 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { EvidenceAttachmentEnabled: config.EvidenceAttachmentEnabled, VerificationEvidenceEnabled: config.VerificationEvidenceEnabled, EmbeddedAnalyzerExecutionEnabled: config.EmbeddedAnalyzerExecutionEnabled, + WorkspaceCheckpointControlEnabled: config.ControlToken != "", RunLifecycleController: lifecycleControl, RunExecutionController: executionControl, PublicModelStreamSource: executionControl, @@ -350,6 +363,7 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { RunWakeWorkerHealthSource: workerHealth, SkillInstallationController: skillInstaller, EmbeddedAnalyzerExecutionController: embeddedAnalyzerExecution, + WorkspaceCheckpointController: workspaceCheckpoints, DockerSandboxController: dockerSandbox, ModelRegistry: models, AppVersion: config.AppVersion, UIHandler: config.UIHandler, diff --git a/internal/events/event.go b/internal/events/event.go index 373f6198..83c73385 100644 --- a/internal/events/event.go +++ b/internal/events/event.go @@ -182,6 +182,10 @@ const ( OnceCommandExecutedEvent = "once_command.executed" GitMutationCompletedEvent = "git.mutation_completed" GitRemoteCompletedEvent = "git.remote_completed" + WorkspaceCheckpointCreatedEvent = "workspace.checkpoint_created" + WorkspaceCheckpointTransactionPreparedEvent = "workspace.checkpoint_transaction_prepared" + WorkspaceCheckpointTransactionCompletedEvent = "workspace.checkpoint_transaction_completed" + WorkspaceCheckpointTransactionFailedEvent = "workspace.checkpoint_transaction_failed" SandboxManifestPreparedEvent = "sandbox.manifest_prepared" SandboxManifestValidatedEvent = "sandbox.manifest_validated" SandboxExecutionCandidateValidatedEvent = "sandbox.execution_candidate_validated" diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index d777e116..d148a477 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -31,6 +31,7 @@ import ( "cyberagent-workbench/internal/toolgateway" "cyberagent-workbench/internal/verification" "cyberagent-workbench/internal/workspace" + "cyberagent-workbench/internal/workspacecheckpoint" ) const ( @@ -333,6 +334,49 @@ func openAPIOperationSpecs() []openAPIOperationSpec { Tag: "Control", Description: "Captures redacted compaction, recent-message provenance, memory references, pinned config fingerprints, and Git identity with an all-false authority projection.", DataType: reflect.TypeOf(contextmgr.ContinuityNode{}), RequestType: reflect.TypeOf(continuityCheckpointRequestView{}), Control: true, NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusCreated}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints", + OperationID: "listWorkspaceCheckpoints", Summary: "Browse the Workspace checkpoint timeline", + Tag: "Runs", Description: "Returns immutable pre/post Workspace manifests, mutation transactions, the CAS cursor, recovery completeness, and content-store usage without exposing blob content.", + DataType: reflect.TypeOf(application.WorkspaceCheckpointTimeline{}), NotFound: true, + Parameters: []openAPIParameter{runID, {Name: "limit", In: "query", + Description: "Maximum checkpoints and transactions", Schema: map[string]any{ + "type": "integer", "minimum": 1, "maximum": 2000, "default": 100}}}}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints", Method: http.MethodPost, + OperationID: "createWorkspaceCheckpoint", Summary: "Create a manual Workspace checkpoint", + Tag: "Control", Description: "Captures a bounded content-addressed manifest and exact Git index under an idempotency key; excluded content and incomplete attribution remain explicit.", + DataType: reflect.TypeOf(workspacecheckpoint.Checkpoint{}), + RequestType: reflect.TypeOf(workspaceCheckpointCaptureView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusCreated}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints/preview", Method: http.MethodPost, + OperationID: "previewWorkspaceRewind", Summary: "Preview a Workspace rewind", + Tag: "Control", Description: "Computes a bounded three-way diff against live files and the Git index without writing. External drift is returned as explicit conflicts.", + DataType: reflect.TypeOf(application.WorkspaceRestoreResult{}), + RequestType: reflect.TypeOf(workspaceCheckpointPreviewView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusOK}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints/rewind", Method: http.MethodPost, + OperationID: "rewindWorkspaceCheckpoint", Summary: "Rewind to a Workspace checkpoint", + Tag: "Control", Description: "Requires confirm=true, a paused quiescent Run, current operator authorization, and an unchanged preview cursor. Restore is an auditable new write and never uses hard reset or blanket untracked deletion.", + DataType: reflect.TypeOf(application.WorkspaceRestoreResult{}), + RequestType: reflect.TypeOf(workspaceCheckpointRewindView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusOK}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints/undo", Method: http.MethodPost, + OperationID: "undoWorkspaceMutation", Summary: "Undo the current Workspace mutation", + Tag: "Control", Description: "Restores the current mutation's before checkpoint with three-way conflict detection and appends a new immutable undo transaction.", + DataType: reflect.TypeOf(application.WorkspaceRestoreResult{}), + RequestType: reflect.TypeOf(workspaceCheckpointCursorActionView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusOK}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints/redo", Method: http.MethodPost, + OperationID: "redoWorkspaceMutation", Summary: "Redo the latest Workspace undo", + Tag: "Control", Description: "Restores the original mutation's after checkpoint under the same paused-Run, CAS, permission, and conflict checks as rewind.", + DataType: reflect.TypeOf(application.WorkspaceRestoreResult{}), + RequestType: reflect.TypeOf(workspaceCheckpointCursorActionView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusOK}, + {Path: "/api/v1/runs/{run_id}/workspace-checkpoints/fork", Method: http.MethodPost, + OperationID: "forkWorkspaceCheckpoint", Summary: "Fork a checkpoint into an independent Run", + Tag: "Control", Description: "Creates an exact branch-backed Git worktree, a distinct Workspace and a new Run. Context is bounded; approvals, credentials, capabilities, leases, processes, terminals, network authority, and execution profiles are reset.", + DataType: reflect.TypeOf(workspaceCheckpointForkResultView{}), + RequestType: reflect.TypeOf(workspaceCheckpointForkView{}), Control: true, + NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusCreated}, {Path: "/api/v1/continuity-nodes/{node_id}/fork", Method: http.MethodPost, OperationID: "forkContinuityNode", Summary: "Fork a new Run from a checkpoint", Tag: "Control", Description: "Creates a new Run and Session with the exact bounded context snapshot while resetting approvals, capabilities, credentials, processes, leases, network authorization, and execution profiles.", diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index 4af3e1d4..56c5e761 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -511,6 +511,8 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { fixture.store, executionController) fixture.api.embeddedAnalyzerExecutionController = application.NewEmbeddedAnalyzerExecutionService(fixture.store) + fixture.api.workspaceCheckpointControlEnabled = true + fixture.api.workspaceCheckpointController = &workspaceCheckpointControllerStub{} fixture.api.skillInstallationController = application.NewSkillPackageRegistryService( fixture.store, objects, builtins) steering, err := fixture.store.EnqueueOperatorSteering(t.Context(), @@ -727,6 +729,27 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { body = string(encoded) } else if spec.OperationID == "createContinuityCheckpoint" { body = `{"title":"OpenAPI live checkpoint"}` + } else if spec.OperationID == "createWorkspaceCheckpoint" { + body = `{"operation_key":"openapi-workspace-checkpoint-create-0001",` + + `"title":"OpenAPI Workspace checkpoint"}` + } else if spec.OperationID == "previewWorkspaceRewind" { + body = `{"target_checkpoint_id":"checkpoint-target",` + + `"expected_current_checkpoint_id":"checkpoint-current"}` + } else if spec.OperationID == "rewindWorkspaceCheckpoint" { + body = `{"target_checkpoint_id":"checkpoint-target",` + + `"expected_current_checkpoint_id":"checkpoint-current",` + + `"operation_key":"openapi-workspace-rewind-0001","confirm":true}` + } else if spec.OperationID == "undoWorkspaceMutation" || + spec.OperationID == "redoWorkspaceMutation" { + body = `{"expected_current_checkpoint_id":"checkpoint-current",` + + `"operation_key":"openapi-workspace-cursor-` + spec.OperationID + `",` + + `"confirm":true}` + } else if spec.OperationID == "forkWorkspaceCheckpoint" { + body = `{"target_checkpoint_id":"checkpoint-target",` + + `"expected_current_checkpoint_id":"checkpoint-current",` + + `"operation_key":"openapi-workspace-fork-0001",` + + `"workspace_name":"openapi-fork",` + + `"branch":"codex/openapi-workspace-fork","confirm":true}` } else if spec.OperationID == "forkContinuityNode" || spec.OperationID == "resumeContinuityNode" { body = `{"goal":"OpenAPI continuity branch"}` diff --git a/internal/httpapi/runtime_capabilities.go b/internal/httpapi/runtime_capabilities.go index 8f4fc041..4946628b 100644 --- a/internal/httpapi/runtime_capabilities.go +++ b/internal/httpapi/runtime_capabilities.go @@ -57,6 +57,7 @@ type RuntimeCapabilitiesView struct { EvidenceAttachmentEnabled bool `json:"evidence_attachment_enabled"` VerificationEvidenceEnabled bool `json:"verification_evidence_enabled"` EmbeddedAnalyzerExecutionEnabled bool `json:"embedded_analyzer_execution_enabled"` + WorkspaceCheckpointControlEnabled bool `json:"workspace_checkpoint_control_enabled"` ProcessExecutionEnabled bool `json:"process_execution_enabled"` ShellExecutionEnabled bool `json:"shell_execution_enabled"` DockerExecutionEnabled bool `json:"docker_execution_enabled"` @@ -111,25 +112,26 @@ func (a *API) runtimeCapabilities(request *http.Request) (any, *Page, error) { SessionMessageEnabled: a.sessionMessageEnabled, SessionSteeringControlEnabled: a.sessionSteeringControlEnabled, RunLifecycleEnabled: a.runLifecycleEnabled, RunExecutionEnabled: a.runExecutionEnabled, - PlanDeliveryControlEnabled: a.planDeliveryControlEnabled, - ApprovalControlEnabled: a.approvalControlEnabled, - ControlledCommandProposalEnabled: a.controlledCommandProposalControlEnabled, - HostCommandProposalEnabled: a.hostCommandProposalControlEnabled, - ModelControlEnabled: a.modelControlEnabled, - ProviderCredentialEnabled: a.providerCredentialEnabled, - FileEditReviewEnabled: a.fileEditReviewEnabled, - FileEditProposalEnabled: a.fileEditProposalEnabled, - FileEditApplyEnabled: a.fileEditApplyEnabled, - RunWakeControlEnabled: a.runWakeControlEnabled, - RunWakeExecutionEnabled: a.runWakeExecutionEnabled, - RunWakeWorkerEnabled: a.runWakeWorkerEnabled, - SkillInstallationEnabled: a.skillInstallationEnabled, - EvidenceAttachmentEnabled: a.evidenceAttachmentEnabled, - VerificationEvidenceEnabled: a.verificationEvidenceEnabled, - EmbeddedAnalyzerExecutionEnabled: a.embeddedAnalyzerExecutionEnabled, - ProcessExecutionEnabled: commandRuntimeEnabled, - ShellExecutionEnabled: commandRuntimeEnabled, - DockerExecutionEnabled: a.dockerExecutionEnabled, AgentCodeToolsEnabled: true, + PlanDeliveryControlEnabled: a.planDeliveryControlEnabled, + ApprovalControlEnabled: a.approvalControlEnabled, + ControlledCommandProposalEnabled: a.controlledCommandProposalControlEnabled, + HostCommandProposalEnabled: a.hostCommandProposalControlEnabled, + ModelControlEnabled: a.modelControlEnabled, + ProviderCredentialEnabled: a.providerCredentialEnabled, + FileEditReviewEnabled: a.fileEditReviewEnabled, + FileEditProposalEnabled: a.fileEditProposalEnabled, + FileEditApplyEnabled: a.fileEditApplyEnabled, + RunWakeControlEnabled: a.runWakeControlEnabled, + RunWakeExecutionEnabled: a.runWakeExecutionEnabled, + RunWakeWorkerEnabled: a.runWakeWorkerEnabled, + SkillInstallationEnabled: a.skillInstallationEnabled, + EvidenceAttachmentEnabled: a.evidenceAttachmentEnabled, + VerificationEvidenceEnabled: a.verificationEvidenceEnabled, + EmbeddedAnalyzerExecutionEnabled: a.embeddedAnalyzerExecutionEnabled, + WorkspaceCheckpointControlEnabled: a.workspaceCheckpointControlEnabled, + ProcessExecutionEnabled: commandRuntimeEnabled, + ShellExecutionEnabled: commandRuntimeEnabled, + DockerExecutionEnabled: a.dockerExecutionEnabled, AgentCodeToolsEnabled: true, WakeWorker: worker, }, nil, nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index dc531675..bea7404f 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -250,6 +250,7 @@ type Config struct { EvidenceAttachmentEnabled bool VerificationEvidenceEnabled bool EmbeddedAnalyzerExecutionEnabled bool + WorkspaceCheckpointControlEnabled bool ExecutionPermissionCapabilities domain.ExecutionPermissionRuntimeCapabilities BrowserCDPPermissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities RunLifecycleController RunLifecycleController @@ -272,6 +273,7 @@ type Config struct { RunWakeWorkerHealthSource RunWakeWorkerHealthSource SkillInstallationController SkillInstallationController EmbeddedAnalyzerExecutionController EmbeddedAnalyzerExecutionController + WorkspaceCheckpointController WorkspaceCheckpointController DockerSandboxController DockerSandboxController ModelRegistry *modelregistry.Registry AppVersion string @@ -307,6 +309,7 @@ type API struct { evidenceAttachmentEnabled bool verificationEvidenceEnabled bool embeddedAnalyzerExecutionEnabled bool + workspaceCheckpointControlEnabled bool dockerSandboxControlEnabled bool dockerExecutionEnabled bool executionPermissionCapabilities domain.ExecutionPermissionRuntimeCapabilities @@ -331,6 +334,7 @@ type API struct { runWakeWorkerHealthSource RunWakeWorkerHealthSource skillInstallationController SkillInstallationController embeddedAnalyzerExecutionController EmbeddedAnalyzerExecutionController + workspaceCheckpointController WorkspaceCheckpointController dockerSandboxController DockerSandboxController modelRegistry *modelregistry.Registry appVersion string @@ -376,7 +380,8 @@ func New(store Store, config Config) (*API, error) { config.FileEditApplyEnabled || config.RunWakeExecutionEnabled || config.RunWakeWorkerEnabled || config.SkillInstallationEnabled || config.EvidenceAttachmentEnabled || - config.VerificationEvidenceEnabled || config.EmbeddedAnalyzerExecutionEnabled) && + config.VerificationEvidenceEnabled || config.EmbeddedAnalyzerExecutionEnabled || + config.WorkspaceCheckpointControlEnabled) && !controlTokenPresent { return nil, apperror.New(apperror.CodeInvalidArgument, "HTTP API control capabilities require a control token") @@ -453,6 +458,11 @@ func New(store Store, config Config) (*API, error) { return nil, apperror.New(apperror.CodeInvalidArgument, "HTTP API embedded analyzer execution controller is required when enabled") } + if config.WorkspaceCheckpointControlEnabled && + config.WorkspaceCheckpointController == nil { + return nil, apperror.New(apperror.CodeInvalidArgument, + "HTTP API Workspace checkpoint controller is required when enabled") + } dockerExecutionEnabled := false if config.DockerSandboxController != nil { capabilities, epochFingerprint, err := @@ -531,18 +541,19 @@ func New(store Store, config Config) (*API, error) { config.ControlledCommandProposalControlEnabled, hostCommandProposalControlEnabled: controlTokenPresent && config.HostCommandProposalControlEnabled, - modelControlEnabled: controlTokenPresent && config.ModelControlEnabled, - providerCredentialEnabled: controlTokenPresent && config.ProviderCredentialEnabled, - fileEditReviewEnabled: controlTokenPresent && config.FileEditReviewEnabled, - fileEditProposalEnabled: controlTokenPresent && config.FileEditProposalEnabled, - runWakeControlEnabled: controlTokenPresent && config.RunWakeControlEnabled, - fileEditApplyEnabled: controlTokenPresent && config.FileEditApplyEnabled, - runWakeExecutionEnabled: controlTokenPresent && config.RunWakeExecutionEnabled, - runWakeWorkerEnabled: controlTokenPresent && config.RunWakeWorkerEnabled, - skillInstallationEnabled: controlTokenPresent && config.SkillInstallationEnabled, - evidenceAttachmentEnabled: controlTokenPresent && config.EvidenceAttachmentEnabled, - verificationEvidenceEnabled: controlTokenPresent && config.VerificationEvidenceEnabled, - embeddedAnalyzerExecutionEnabled: controlTokenPresent && config.EmbeddedAnalyzerExecutionEnabled, + modelControlEnabled: controlTokenPresent && config.ModelControlEnabled, + providerCredentialEnabled: controlTokenPresent && config.ProviderCredentialEnabled, + fileEditReviewEnabled: controlTokenPresent && config.FileEditReviewEnabled, + fileEditProposalEnabled: controlTokenPresent && config.FileEditProposalEnabled, + runWakeControlEnabled: controlTokenPresent && config.RunWakeControlEnabled, + fileEditApplyEnabled: controlTokenPresent && config.FileEditApplyEnabled, + runWakeExecutionEnabled: controlTokenPresent && config.RunWakeExecutionEnabled, + runWakeWorkerEnabled: controlTokenPresent && config.RunWakeWorkerEnabled, + skillInstallationEnabled: controlTokenPresent && config.SkillInstallationEnabled, + evidenceAttachmentEnabled: controlTokenPresent && config.EvidenceAttachmentEnabled, + verificationEvidenceEnabled: controlTokenPresent && config.VerificationEvidenceEnabled, + embeddedAnalyzerExecutionEnabled: controlTokenPresent && config.EmbeddedAnalyzerExecutionEnabled, + workspaceCheckpointControlEnabled: controlTokenPresent && config.WorkspaceCheckpointControlEnabled, dockerSandboxControlEnabled: config.DockerSandboxController != nil && controlTokenPresent && config.ExecutionPermissionControlEnabled, dockerExecutionEnabled: dockerExecutionEnabled, @@ -568,6 +579,7 @@ func New(store Store, config Config) (*API, error) { runWakeWorkerHealthSource: config.RunWakeWorkerHealthSource, skillInstallationController: config.SkillInstallationController, embeddedAnalyzerExecutionController: config.EmbeddedAnalyzerExecutionController, + workspaceCheckpointController: config.WorkspaceCheckpointController, dockerSandboxController: config.DockerSandboxController, modelRegistry: modelRegistry, openAPI: document, eventStream: eventStream, @@ -687,6 +699,10 @@ func (a *API) ServeHTTP(writer http.ResponseWriter, request *http.Request) { a.serveDockerSandbox(tracked, request, requestID) return } + if runID, action, matched := matchWorkspaceCheckpointPath(request.URL.Path); matched { + a.serveWorkspaceCheckpoint(tracked, request, requestID, runID, action) + return + } if request.Method != http.MethodGet && isContextContinuityMutationPath(request.URL.Path) { a.serveContextContinuityMutation(tracked, request, requestID) return diff --git a/internal/httpapi/workspace_checkpoints.go b/internal/httpapi/workspace_checkpoints.go new file mode 100644 index 00000000..ba242542 --- /dev/null +++ b/internal/httpapi/workspace_checkpoints.go @@ -0,0 +1,412 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +const MaxWorkspaceCheckpointRequestBodyBytes = 64 * 1024 + +type WorkspaceCheckpointController interface { + Capture(context.Context, application.WorkspaceCheckpointCaptureRequest) ( + workspacecheckpoint.Checkpoint, bool, error) + Timeline(context.Context, string, int) (application.WorkspaceCheckpointTimeline, error) + Restore(context.Context, application.WorkspaceRestoreRequest) ( + application.WorkspaceRestoreResult, error) + Undo(context.Context, string, string, string, string, bool) ( + application.WorkspaceRestoreResult, error) + Redo(context.Context, string, string, string, string, bool) ( + application.WorkspaceRestoreResult, error) + Fork(context.Context, application.WorkspaceForkRequest) ( + application.WorkspaceForkResult, error) +} + +type workspaceCheckpointCaptureView struct { + OperationKey string `json:"operation_key"` + Title string `json:"title,omitempty"` +} + +type workspaceCheckpointPreviewView struct { + TargetCheckpointID string `json:"target_checkpoint_id"` + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id"` +} + +type workspaceCheckpointRewindView struct { + TargetCheckpointID string `json:"target_checkpoint_id"` + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id"` + OperationKey string `json:"operation_key"` + Confirm *bool `json:"confirm"` +} + +type workspaceCheckpointCursorActionView struct { + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id"` + OperationKey string `json:"operation_key"` + Confirm *bool `json:"confirm"` +} + +type workspaceCheckpointForkView struct { + TargetCheckpointID string `json:"target_checkpoint_id"` + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id"` + OperationKey string `json:"operation_key"` + WorkspaceName string `json:"workspace_name"` + Branch string `json:"branch"` + Goal string `json:"goal,omitempty"` + Confirm *bool `json:"confirm"` +} + +type workspaceCheckpointForkResultView struct { + ProtocolVersion string `json:"protocol_version"` + SourceRunID string `json:"source_run_id"` + Target workspacecheckpoint.Checkpoint `json:"target"` + Workspace workspaceCheckpointForkWorkspaceView `json:"workspace"` + Mission workspaceCheckpointForkMissionView `json:"mission"` + Run workspaceCheckpointForkRunView `json:"run"` + Continuity workspaceCheckpointForkContinuityView `json:"continuity_node"` + Checkpoint workspacecheckpoint.Checkpoint `json:"checkpoint"` + Transaction workspacecheckpoint.Transaction `json:"transaction"` + NotInherited []string `json:"not_inherited"` + Replayed bool `json:"replayed"` +} + +type workspaceCheckpointForkWorkspaceView struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type workspaceCheckpointForkMissionView struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Profile string `json:"profile"` +} + +type workspaceCheckpointForkRunView struct { + ID string `json:"id"` + MissionID string `json:"mission_id"` + SessionID string `json:"session_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type workspaceCheckpointForkContinuityView struct { + ID string `json:"id"` + Kind string `json:"kind"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + SourceNodeID string `json:"source_node_id,omitempty"` + GitBranch string `json:"git_branch,omitempty"` + GitHead string `json:"git_head,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func matchWorkspaceCheckpointPath(value string) (string, string, bool) { + segments := strings.Split(strings.TrimPrefix(value, "/api/v1/"), "/") + if len(segments) == 3 && segments[0] == "runs" && + segments[2] == "workspace-checkpoints" && segments[1] != "" { + return segments[1], "", true + } + if len(segments) == 4 && segments[0] == "runs" && + segments[2] == "workspace-checkpoints" && segments[1] != "" { + switch segments[3] { + case "preview", "rewind", "undo", "redo", "fork": + return segments[1], segments[3], true + } + } + return "", "", false +} + +func (a *API) serveWorkspaceCheckpoint(writer http.ResponseWriter, + request *http.Request, requestID, runID, action string, +) { + if a.workspaceCheckpointController == nil { + a.writeError(writer, requestID, + apperror.New(apperror.CodeNotFound, "HTTP API endpoint was not found"), + http.StatusNotFound) + return + } + if err := validatePathIdentity(runID); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if request.Method == http.MethodGet { + a.serveWorkspaceCheckpointTimeline(writer, request, requestID, runID, action) + return + } + if !a.workspaceCheckpointControlEnabled { + a.writeError(writer, requestID, + apperror.New(apperror.CodeNotFound, "HTTP API endpoint was not found"), + http.StatusNotFound) + return + } + if !a.authorized(request, a.controlTokenHash) { + writer.Header().Set("WWW-Authenticate", `Bearer realm="CyberAgent Control API"`) + a.writeError(writer, requestID, apperror.New(apperror.CodePolicyDenied, + "valid control bearer authorization is required"), http.StatusUnauthorized) + return + } + if request.Method != http.MethodPost { + writer.Header().Set("Allow", http.MethodPost) + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace checkpoint mutation only supports POST"), http.StatusMethodNotAllowed) + return + } + if err := rejectQuery(request.URL.Query()); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := validateJSONContentType(request.Header); err != nil { + a.writeError(writer, requestID, err, http.StatusUnsupportedMediaType) + return + } + body, err := readBoundedRequestBody(request, MaxWorkspaceCheckpointRequestBodyBytes) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := rejectDuplicateJSONObjectFields(body, "Workspace checkpoint"); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.serveWorkspaceCheckpointMutation(writer, request, requestID, runID, action, body) +} + +func (a *API) serveWorkspaceCheckpointTimeline(writer http.ResponseWriter, + request *http.Request, requestID, runID, action string, +) { + if action != "" { + writer.Header().Set("Allow", http.MethodPost) + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace checkpoint action only supports POST"), http.StatusMethodNotAllowed) + return + } + if !a.authorized(request, a.tokenHash) { + writer.Header().Set("WWW-Authenticate", `Bearer realm="CyberAgent API"`) + a.writeError(writer, requestID, apperror.New(apperror.CodePolicyDenied, + "valid bearer authorization is required"), http.StatusUnauthorized) + return + } + if request.ContentLength != 0 || len(request.TransferEncoding) != 0 { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "read-only HTTP API requests cannot contain a body"), 0) + return + } + if err := validateSingleQueryValues(request.URL.Query(), "limit"); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + limit := 100 + if raw := request.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace checkpoint limit must be an integer"), 0) + return + } + limit = parsed + } + value, err := a.workspaceCheckpointController.Timeline(request.Context(), runID, limit) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) +} + +func (a *API) serveWorkspaceCheckpointMutation(writer http.ResponseWriter, + request *http.Request, requestID, runID, action string, body []byte, +) { + decode := func(destination any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return apperror.Wrap(apperror.CodeInvalidArgument, + "Workspace checkpoint body must be one JSON object", err) + } + return ensureJSONEOF(decoder) + } + switch action { + case "": + var view workspaceCheckpointCaptureView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := requireWorkspaceCheckpointFields("operation_key", view.OperationKey); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + value, _, err := a.workspaceCheckpointController.Capture(request.Context(), + application.WorkspaceCheckpointCaptureRequest{RunID: runID, + OperationKey: view.OperationKey, RequestedBy: "api_operator", Title: view.Title}) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccessStatus(writer, requestID, value, nil, http.StatusCreated) + case "preview": + var view workspaceCheckpointPreviewView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := requireWorkspaceCheckpointFields( + "target_checkpoint_id", view.TargetCheckpointID, + "expected_current_checkpoint_id", view.ExpectedCurrentCheckpointID, + ); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + value, err := a.workspaceCheckpointController.Restore(request.Context(), + application.WorkspaceRestoreRequest{RunID: runID, + TargetCheckpointID: view.TargetCheckpointID, + ExpectedCurrentCheckpointID: view.ExpectedCurrentCheckpointID, + RequestedBy: "api_operator", Kind: workspacecheckpoint.TransactionRewind, + TriggerReceiptID: view.TargetCheckpointID}) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) + case "rewind": + var view workspaceCheckpointRewindView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := requireWorkspaceCheckpointFields( + "target_checkpoint_id", view.TargetCheckpointID, + "expected_current_checkpoint_id", view.ExpectedCurrentCheckpointID, + "operation_key", view.OperationKey, + ); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if view.Confirm == nil || !*view.Confirm { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace rewind requires confirm=true"), 0) + return + } + value, err := a.workspaceCheckpointController.Restore(request.Context(), + application.WorkspaceRestoreRequest{RunID: runID, + TargetCheckpointID: view.TargetCheckpointID, + ExpectedCurrentCheckpointID: view.ExpectedCurrentCheckpointID, + OperationKey: view.OperationKey, RequestedBy: "api_operator", + Kind: workspacecheckpoint.TransactionRewind, + TriggerReceiptID: view.TargetCheckpointID, Confirm: true}) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) + case "undo", "redo": + var view workspaceCheckpointCursorActionView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := requireWorkspaceCheckpointFields( + "expected_current_checkpoint_id", view.ExpectedCurrentCheckpointID, + "operation_key", view.OperationKey, + ); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if view.Confirm == nil || !*view.Confirm { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace checkpoint action requires confirm=true"), 0) + return + } + var value application.WorkspaceRestoreResult + var err error + if action == "undo" { + value, err = a.workspaceCheckpointController.Undo(request.Context(), runID, + view.ExpectedCurrentCheckpointID, view.OperationKey, "api_operator", true) + } else { + value, err = a.workspaceCheckpointController.Redo(request.Context(), runID, + view.ExpectedCurrentCheckpointID, view.OperationKey, "api_operator", true) + } + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) + case "fork": + var view workspaceCheckpointForkView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := requireWorkspaceCheckpointFields( + "target_checkpoint_id", view.TargetCheckpointID, + "expected_current_checkpoint_id", view.ExpectedCurrentCheckpointID, + "operation_key", view.OperationKey, + "workspace_name", view.WorkspaceName, + "branch", view.Branch, + ); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if view.Confirm == nil || !*view.Confirm { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "Workspace fork requires confirm=true"), 0) + return + } + value, err := a.workspaceCheckpointController.Fork(request.Context(), + application.WorkspaceForkRequest{RunID: runID, + TargetCheckpointID: view.TargetCheckpointID, + ExpectedCurrentCheckpointID: view.ExpectedCurrentCheckpointID, + OperationKey: view.OperationKey, RequestedBy: "api_operator", + WorkspaceName: view.WorkspaceName, Branch: view.Branch, Goal: view.Goal, + Confirm: true}) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccessStatus(writer, requestID, workspaceCheckpointForkResultProjection(value), + nil, http.StatusCreated) + } +} + +func workspaceCheckpointForkResultProjection( + value application.WorkspaceForkResult, +) workspaceCheckpointForkResultView { + return workspaceCheckpointForkResultView{ + ProtocolVersion: value.ProtocolVersion, + SourceRunID: value.SourceRunID, + Target: value.Target, + Workspace: workspaceCheckpointForkWorkspaceView{ID: value.Workspace.ID, + Name: value.Workspace.Name, CreatedAt: value.Workspace.CreatedAt}, + Mission: workspaceCheckpointForkMissionView{ID: value.Mission.ID, + WorkspaceID: value.Mission.WorkspaceID, Profile: string(value.Mission.Profile)}, + Run: workspaceCheckpointForkRunView{ID: value.Run.ID, + MissionID: value.Run.MissionID, SessionID: value.Run.SessionID, + Status: string(value.Run.Status), CreatedAt: value.Run.CreatedAt}, + Continuity: workspaceCheckpointForkContinuityView{ID: value.Node.ID, + Kind: string(value.Node.Kind), SessionID: value.Node.SessionID, + RunID: value.Node.RunID, WorkspaceID: value.Node.WorkspaceID, + SourceNodeID: value.Node.SourceNodeID, GitBranch: value.Node.GitBranch, + GitHead: value.Node.GitHead, CreatedAt: value.Node.CreatedAt}, + Checkpoint: value.Checkpoint, Transaction: value.Transaction, + NotInherited: append([]string(nil), value.NotInherited...), Replayed: value.Replayed, + } +} + +func requireWorkspaceCheckpointFields(nameValuePairs ...string) error { + for index := 0; index+1 < len(nameValuePairs); index += 2 { + if strings.TrimSpace(nameValuePairs[index+1]) == "" { + return apperror.New(apperror.CodeInvalidArgument, + "Workspace checkpoint "+nameValuePairs[index]+" is required") + } + } + return nil +} diff --git a/internal/httpapi/workspace_checkpoints_test.go b/internal/httpapi/workspace_checkpoints_test.go new file mode 100644 index 00000000..c6678d06 --- /dev/null +++ b/internal/httpapi/workspace_checkpoints_test.go @@ -0,0 +1,197 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +type workspaceCheckpointControllerStub struct { + captureRequest application.WorkspaceCheckpointCaptureRequest + restoreRequest application.WorkspaceRestoreRequest + forkRequest application.WorkspaceForkRequest + action string +} + +func (s *workspaceCheckpointControllerStub) Capture(_ context.Context, + request application.WorkspaceCheckpointCaptureRequest, +) (workspacecheckpoint.Checkpoint, bool, error) { + s.captureRequest = request + return workspacecheckpoint.Checkpoint{ID: "checkpoint-created", + ProtocolVersion: workspacecheckpoint.ProtocolVersion}, false, nil +} + +func (s *workspaceCheckpointControllerStub) Timeline(_ context.Context, runID string, + _ int, +) (application.WorkspaceCheckpointTimeline, error) { + return application.WorkspaceCheckpointTimeline{ + ProtocolVersion: application.WorkspaceCheckpointAPIProtocolVersion, + RunID: runID, + Checkpoints: []workspacecheckpoint.Checkpoint{{ID: "checkpoint-current", + ProtocolVersion: workspacecheckpoint.ProtocolVersion}}, + }, nil +} + +func (s *workspaceCheckpointControllerStub) Restore(_ context.Context, + request application.WorkspaceRestoreRequest, +) (application.WorkspaceRestoreResult, error) { + s.action = string(request.Kind) + s.restoreRequest = request + return application.WorkspaceRestoreResult{ + ProtocolVersion: application.WorkspaceCheckpointAPIProtocolVersion, + Confirmed: request.Confirm, + }, nil +} + +func (s *workspaceCheckpointControllerStub) Undo(_ context.Context, runID, + expectedCurrentCheckpointID, operationKey, requestedBy string, confirm bool, +) (application.WorkspaceRestoreResult, error) { + s.action = "undo" + s.restoreRequest = application.WorkspaceRestoreRequest{RunID: runID, + ExpectedCurrentCheckpointID: expectedCurrentCheckpointID, + OperationKey: operationKey, RequestedBy: requestedBy, Confirm: confirm} + return application.WorkspaceRestoreResult{ + ProtocolVersion: application.WorkspaceCheckpointAPIProtocolVersion, + Confirmed: confirm, + }, nil +} + +func (s *workspaceCheckpointControllerStub) Redo(_ context.Context, runID, + expectedCurrentCheckpointID, operationKey, requestedBy string, confirm bool, +) (application.WorkspaceRestoreResult, error) { + s.action = "redo" + s.restoreRequest = application.WorkspaceRestoreRequest{RunID: runID, + ExpectedCurrentCheckpointID: expectedCurrentCheckpointID, + OperationKey: operationKey, RequestedBy: requestedBy, Confirm: confirm} + return application.WorkspaceRestoreResult{ + ProtocolVersion: application.WorkspaceCheckpointAPIProtocolVersion, + Confirmed: confirm, + }, nil +} + +func (s *workspaceCheckpointControllerStub) Fork(_ context.Context, + request application.WorkspaceForkRequest, +) (application.WorkspaceForkResult, error) { + s.action = "fork" + s.forkRequest = request + return application.WorkspaceForkResult{ + ProtocolVersion: application.WorkspaceCheckpointAPIProtocolVersion, + SourceRunID: request.RunID, + Workspace: session.WorkspaceRecord{ID: "workspace-fork", Name: "fork", + RootPath: `D:\private\workspace-fork`}, + Mission: domain.Mission{ID: "mission-fork", WorkspaceID: "workspace-fork", + Profile: domain.ProfileCode}, + Run: domain.Run{ID: "run-fork", MissionID: "mission-fork", + SessionID: "session-fork", Status: domain.RunCreated}, + }, nil +} + +func TestWorkspaceCheckpointHTTPRoutesAuthenticateAndBindIntent(t *testing.T) { + fixture := newAPIFixture(t) + controller := &workspaceCheckpointControllerStub{} + fixture.api.workspaceCheckpointController = controller + fixture.api.workspaceCheckpointControlEnabled = true + + timeline := fixture.get(t, "/api/v1/runs/"+fixture.run.ID+"/workspace-checkpoints?limit=7") + if timeline.Code != http.StatusOK || !strings.Contains(timeline.Body.String(), "checkpoint-current") { + t.Fatalf("timeline status=%d body=%s", timeline.Code, timeline.Body.String()) + } + + capture := performControlMethodPathRequest(t, fixture.api, http.MethodPost, + "/api/v1/runs/"+fixture.run.ID+"/workspace-checkpoints", + "workspace-checkpoint-http-create-0001", + strings.NewReader(`{"operation_key":"capture-op","title":"before refactor"}`)) + if capture.Code != http.StatusCreated || controller.captureRequest.RunID != fixture.run.ID || + controller.captureRequest.OperationKey != "capture-op" || + controller.captureRequest.RequestedBy != "api_operator" { + t.Fatalf("capture status=%d request=%#v body=%s", capture.Code, + controller.captureRequest, capture.Body.String()) + } + + rewind := performControlMethodPathRequest(t, fixture.api, http.MethodPost, + "/api/v1/runs/"+fixture.run.ID+"/workspace-checkpoints/rewind", + "workspace-checkpoint-http-rewind-0001", strings.NewReader( + `{"target_checkpoint_id":"target","expected_current_checkpoint_id":"current",`+ + `"operation_key":"rewind-op","confirm":true}`)) + if rewind.Code != http.StatusOK || !controller.restoreRequest.Confirm || + controller.restoreRequest.TargetCheckpointID != "target" || + controller.restoreRequest.ExpectedCurrentCheckpointID != "current" { + t.Fatalf("rewind status=%d request=%#v body=%s", rewind.Code, + controller.restoreRequest, rewind.Body.String()) + } + + fork := performControlMethodPathRequest(t, fixture.api, http.MethodPost, + "/api/v1/runs/"+fixture.run.ID+"/workspace-checkpoints/fork", + "workspace-checkpoint-http-fork-0001", strings.NewReader( + `{"target_checkpoint_id":"target","expected_current_checkpoint_id":"current",`+ + `"operation_key":"fork-op","workspace_name":"fork",`+ + `"branch":"codex/fork","confirm":true}`)) + if fork.Code != http.StatusCreated || controller.forkRequest.Branch != "codex/fork" || + controller.forkRequest.RequestedBy != "api_operator" || + controller.forkRequest.WorkspaceRoot != "" || + !strings.Contains(fork.Body.String(), `"run":{"id":"run-fork"`) || + strings.Contains(fork.Body.String(), `D:\private`) || + strings.Contains(fork.Body.String(), "RootPath") { + t.Fatalf("fork status=%d request=%#v body=%s", fork.Code, + controller.forkRequest, fork.Body.String()) + } +} + +func TestWorkspaceCheckpointHTTPRoutesFailClosed(t *testing.T) { + fixture := newAPIFixture(t) + fixture.api.workspaceCheckpointController = &workspaceCheckpointControllerStub{} + fixture.api.workspaceCheckpointControlEnabled = true + path := "/api/v1/runs/" + fixture.run.ID + "/workspace-checkpoints/rewind" + + request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1"+path, + strings.NewReader(`{"target_checkpoint_id":"target","confirm":true}`)) + request.Host = "127.0.0.1:8765" + request.RemoteAddr = "127.0.0.1:45000" + request.Header.Set("Authorization", "Bearer "+testAccessToken) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + fixture.api.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("read token authorized mutation: status=%d body=%s", response.Code, + response.Body.String()) + } + + for name, body := range map[string]string{ + "confirmation missing": `{"target_checkpoint_id":"target",` + + `"expected_current_checkpoint_id":"current","operation_key":"op"}`, + "expected cursor missing": `{"target_checkpoint_id":"target",` + + `"operation_key":"op","confirm":true}`, + "operation key missing": `{"target_checkpoint_id":"target",` + + `"expected_current_checkpoint_id":"current","confirm":true}`, + "duplicate intent": `{"target_checkpoint_id":"target",` + + `"target_checkpoint_id":"other","confirm":true}`, + "unknown field": `{"target_checkpoint_id":"target","confirm":true,"force":true}`, + } { + t.Run(name, func(t *testing.T) { + result := performControlMethodPathRequest(t, fixture.api, http.MethodPost, path, + "workspace-checkpoint-http-invalid-"+strings.ReplaceAll(name, " ", "-"), + strings.NewReader(body)) + if result.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", result.Code, result.Body.String()) + } + }) + } + forkWithRendererPath := performControlMethodPathRequest(t, fixture.api, http.MethodPost, + "/api/v1/runs/"+fixture.run.ID+"/workspace-checkpoints/fork", + "workspace-checkpoint-http-renderer-path-0001", strings.NewReader( + `{"target_checkpoint_id":"target","expected_current_checkpoint_id":"current",`+ + `"operation_key":"fork-op","workspace_name":"fork",`+ + `"workspace_root":"D:\\private\\fork",`+ + `"branch":"codex/fork","confirm":true}`)) + if forkWithRendererPath.Code != http.StatusBadRequest { + t.Fatalf("renderer path accepted: status=%d body=%s", forkWithRendererPath.Code, + forkWithRendererPath.Body.String()) + } +} diff --git a/internal/repository/mutation.go b/internal/repository/mutation.go index 9d470485..887c356e 100644 --- a/internal/repository/mutation.go +++ b/internal/repository/mutation.go @@ -454,9 +454,8 @@ func normalizeRepositoryRoot(root string) (string, error) { if strings.TrimSpace(root) == "" || strings.ContainsRune(root, 0) { return "", errors.New("repository root is invalid") } - info, err := os.Stat(filepath.Join(root, ".git")) - if err != nil || !info.IsDir() { - return "", errors.New("repository root does not contain a .git directory") + if _, err := os.Stat(filepath.Join(root, ".git")); err != nil { + return "", errors.New("repository root does not contain Git metadata") } return root, nil } diff --git a/internal/repository/worktree.go b/internal/repository/worktree.go new file mode 100644 index 00000000..21b66f23 --- /dev/null +++ b/internal/repository/worktree.go @@ -0,0 +1,347 @@ +package repository + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ValidateBranchName exposes the same closed branch-name contract used by +// typed Git mutations so Workspace forks cannot smuggle arbitrary argv. +func ValidateBranchName(branch string) error { + return validateBranchName(strings.TrimSpace(branch)) +} + +// NormalizeWorktreeDestination returns one absolute, symlink-resolved +// destination identity whether the final directory exists yet or not. Callers +// can persist it before materialization and later use the same identity for +// crash recovery. +func NormalizeWorktreeDestination(sourceRoot, destinationRoot string) (string, error) { + source, err := canonicalExistingRepositoryRoot(sourceRoot) + if err != nil { + return "", err + } + destination, err := canonicalWorktreeRoot(destinationRoot) + if err != nil { + return "", err + } + if pathsOverlap(source, destination) { + return "", errors.New("git worktree destination overlaps the source repository") + } + return destination, nil +} + +// CreateWorktree creates one new branch-backed Git worktree at an exact full +// commit. The destination must not exist and must be outside the source root. +func CreateWorktree(ctx context.Context, sourceRoot, destinationRoot, branch, + commit string, +) (string, error) { + if ctx == nil || ctx.Err() != nil { + return "", errors.New("git worktree context is unavailable") + } + branch, commit = strings.TrimSpace(branch), strings.TrimSpace(commit) + if err := ValidateBranchName(branch); err != nil || !validWorktreeCommit(commit) { + return "", errors.New("git worktree branch or commit is invalid") + } + source, err := canonicalExistingRepositoryRoot(sourceRoot) + if err != nil { + return "", err + } + destination, err := canonicalAbsentWorktreeRoot(destinationRoot) + if err != nil { + return "", err + } + if pathsOverlap(source, destination) { + return "", errors.New("git worktree destination overlaps the source repository") + } + gitPath, err := exec.LookPath("git") + if err != nil { + return "", fmt.Errorf("git binary is unavailable: %w", err) + } + commandCtx, cancel := context.WithTimeout(ctx, MaxGitDuration) + defer cancel() + command := exec.CommandContext(commandCtx, gitPath, "-C", source, + "--no-optional-locks", "worktree", "add", "--no-track", "-b", branch, + destination, commit) + command.Env = hardenedGitEnvironment() + command.Dir = source + var stdout, stderr boundedBuffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + return "", fmt.Errorf("create Git worktree: %w: %s", err, + strings.TrimSpace(stderr.String())) + } + if err := verifyCreatedWorktree(commandCtx, gitPath, destination, branch, commit); err != nil { + cleanupErr := RemoveCreatedWorktree(context.WithoutCancel(ctx), source, + destination, branch) + return "", errors.Join(err, cleanupErr) + } + return destination, nil +} + +// RemoveCreatedWorktree removes only an exact registered worktree and its +// newly-created branch. It never falls back to recursive filesystem deletion. +func RemoveCreatedWorktree(ctx context.Context, sourceRoot, destinationRoot, + branch string, +) error { + if ctx == nil { + return errors.New("git worktree cleanup context is unavailable") + } + branch = strings.TrimSpace(branch) + if err := ValidateBranchName(branch); err != nil { + return err + } + source, err := canonicalExistingRepositoryRoot(sourceRoot) + if err != nil { + return err + } + destination, err := filepath.Abs(strings.TrimSpace(destinationRoot)) + if err != nil || pathsOverlap(source, destination) { + return errors.New("git worktree cleanup destination is invalid") + } + gitPath, err := exec.LookPath("git") + if err != nil { + return err + } + commandCtx, cancel := context.WithTimeout(ctx, MaxGitDuration) + defer cancel() + remove := exec.CommandContext(commandCtx, gitPath, "-C", source, + "--no-optional-locks", "worktree", "remove", "--force", destination) + remove.Env, remove.Dir = hardenedGitEnvironment(), source + var removeOutput boundedBuffer + remove.Stderr = &removeOutput + if err := remove.Run(); err != nil { + return fmt.Errorf("remove Git worktree: %w: %s", err, + strings.TrimSpace(removeOutput.String())) + } + deleteBranch := exec.CommandContext(commandCtx, gitPath, "-C", source, + "--no-optional-locks", "branch", "-D", "--", branch) + deleteBranch.Env, deleteBranch.Dir = hardenedGitEnvironment(), source + var branchOutput boundedBuffer + deleteBranch.Stderr = &branchOutput + if err := deleteBranch.Run(); err != nil { + return fmt.Errorf("remove Git worktree branch: %w: %s", err, + strings.TrimSpace(branchOutput.String())) + } + return nil +} + +// CleanupInterruptedWorktree removes only the exact branch-backed worktree +// recorded before a fork was materialized. Every observable identity is +// checked first; unexpected filesystem, branch, or commit drift fails closed. +func CleanupInterruptedWorktree(ctx context.Context, sourceRoot, destinationRoot, + branch, expectedCommit string, +) error { + if ctx == nil || ctx.Err() != nil { + return errors.New("git worktree recovery context is unavailable") + } + branch, expectedCommit = strings.TrimSpace(branch), strings.TrimSpace(expectedCommit) + if err := ValidateBranchName(branch); err != nil || !validWorktreeCommit(expectedCommit) { + return errors.New("git worktree recovery identity is invalid") + } + source, err := canonicalExistingRepositoryRoot(sourceRoot) + if err != nil { + return err + } + requested, err := filepath.Abs(strings.TrimSpace(destinationRoot)) + if err != nil || strings.ContainsRune(destinationRoot, 0) { + return errors.New("git worktree recovery destination is invalid") + } + destination, err := canonicalWorktreeRoot(destinationRoot) + if err != nil { + return err + } + if !sameFilesystemPath(requested, destination) || pathsOverlap(source, destination) { + return errors.New("git worktree recovery destination identity changed") + } + info, statErr := os.Lstat(destination) + if statErr == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("git worktree recovery destination is not a real directory") + } + gitPath, lookupErr := exec.LookPath("git") + if lookupErr != nil { + return fmt.Errorf("git binary is unavailable: %w", lookupErr) + } + verifyCtx, cancel := context.WithTimeout(ctx, MaxGitDuration) + verifyErr := verifyCreatedWorktree(verifyCtx, gitPath, destination, branch, + expectedCommit) + cancel() + if verifyErr != nil { + return verifyErr + } + return RemoveCreatedWorktree(ctx, source, destination, branch) + } + if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("inspect Git worktree recovery destination: %w", statErr) + } + return removeInterruptedWorktreeBranch(ctx, source, branch, expectedCommit) +} + +func canonicalExistingRepositoryRoot(root string) (string, error) { + root = strings.TrimSpace(root) + abs, err := filepath.Abs(root) + if err != nil || root == "" || strings.ContainsRune(root, 0) { + return "", errors.New("repository root is invalid") + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", fmt.Errorf("resolve repository root: %w", err) + } + if _, err := os.Stat(filepath.Join(resolved, ".git")); err != nil { + return "", errors.New("repository root does not contain Git metadata") + } + return filepath.Clean(resolved), nil +} + +func canonicalAbsentWorktreeRoot(root string) (string, error) { + root = strings.TrimSpace(root) + if root == "" || strings.ContainsRune(root, 0) { + return "", errors.New("git worktree destination is invalid") + } + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + if _, err := os.Lstat(abs); err == nil || !errors.Is(err, os.ErrNotExist) { + return "", errors.New("git worktree destination already exists or is inaccessible") + } + parent, err := filepath.EvalSymlinks(filepath.Dir(abs)) + if err != nil { + return "", fmt.Errorf("resolve Git worktree parent: %w", err) + } + info, err := os.Stat(parent) + if err != nil || !info.IsDir() { + return "", errors.New("git worktree parent is not a directory") + } + return filepath.Join(parent, filepath.Base(abs)), nil +} + +func canonicalWorktreeRoot(root string) (string, error) { + root = strings.TrimSpace(root) + if root == "" || strings.ContainsRune(root, 0) { + return "", errors.New("git worktree destination is invalid") + } + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + info, err := os.Lstat(abs) + if err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("git worktree destination is not a real directory") + } + resolved, resolveErr := filepath.EvalSymlinks(abs) + if resolveErr != nil { + return "", fmt.Errorf("resolve Git worktree destination: %w", resolveErr) + } + return filepath.Clean(resolved), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("inspect Git worktree destination: %w", err) + } + parent, err := filepath.EvalSymlinks(filepath.Dir(abs)) + if err != nil { + return "", fmt.Errorf("resolve Git worktree parent: %w", err) + } + info, err = os.Stat(parent) + if err != nil || !info.IsDir() { + return "", errors.New("git worktree parent is not a directory") + } + return filepath.Join(filepath.Clean(parent), filepath.Base(abs)), nil +} + +func sameFilesystemPath(left, right string) bool { + relative, err := filepath.Rel(filepath.Clean(left), filepath.Clean(right)) + return err == nil && relative == "." +} + +func removeInterruptedWorktreeBranch(ctx context.Context, source, branch, + expectedCommit string, +) error { + gitPath, err := exec.LookPath("git") + if err != nil { + return fmt.Errorf("git binary is unavailable: %w", err) + } + commandCtx, cancel := context.WithTimeout(ctx, MaxGitDuration) + defer cancel() + ref := "refs/heads/" + branch + show := exec.CommandContext(commandCtx, gitPath, "-C", source, + "--no-optional-locks", "show-ref", "--verify", "--hash", ref) + show.Env, show.Dir = hardenedGitEnvironment(), source + var stdout, stderr boundedBuffer + show.Stdout, show.Stderr = &stdout, &stderr + if err := show.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && + strings.TrimSpace(stdout.String()) == "" { + return nil + } + return fmt.Errorf("inspect interrupted Git worktree branch: %w: %s", err, + strings.TrimSpace(stderr.String())) + } + if observed := strings.TrimSpace(stdout.String()); observed != expectedCommit { + return fmt.Errorf("interrupted Git worktree branch drifted: got %q, want %q", + observed, expectedCommit) + } + remove := exec.CommandContext(commandCtx, gitPath, "-C", source, + "--no-optional-locks", "branch", "-D", "--", branch) + remove.Env, remove.Dir = hardenedGitEnvironment(), source + var removeOutput boundedBuffer + remove.Stderr = &removeOutput + if err := remove.Run(); err != nil { + return fmt.Errorf("remove interrupted Git worktree branch: %w: %s", err, + strings.TrimSpace(removeOutput.String())) + } + return nil +} + +func pathsOverlap(left, right string) bool { + left, right = filepath.Clean(left), filepath.Clean(right) + for _, pair := range [][2]string{{left, right}, {right, left}} { + relative, err := filepath.Rel(pair[0], pair[1]) + if err == nil && relative != ".." && !strings.HasPrefix(relative, + ".."+string(filepath.Separator)) { + return true + } + } + return false +} + +func validWorktreeCommit(value string) bool { + if (len(value) != 40 && len(value) != 64) || value != strings.ToLower(value) { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded)*2 == len(value) +} + +func verifyCreatedWorktree(ctx context.Context, gitPath, root, branch, commit string) error { + for _, check := range []struct { + args []string + want string + }{ + {args: []string{"rev-parse", "--verify", "HEAD"}, want: commit}, + {args: []string{"branch", "--show-current"}, want: branch}, + } { + command := exec.CommandContext(ctx, gitPath, append([]string{"-C", root, + "--no-optional-locks"}, check.args...)...) + command.Env, command.Dir = hardenedGitEnvironment(), root + var stdout, stderr boundedBuffer + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + return fmt.Errorf("verify Git worktree %s: %w: %s", check.args[0], err, + strings.TrimSpace(stderr.String())) + } + if got := strings.TrimSpace(stdout.String()); got != check.want { + return fmt.Errorf("verify Git worktree %s: got %q, want %q", + check.args[0], got, check.want) + } + } + return nil +} diff --git a/internal/runner/command_runtime_job.go b/internal/runner/command_runtime_job.go index 02d4b364..df041711 100644 --- a/internal/runner/command_runtime_job.go +++ b/internal/runner/command_runtime_job.go @@ -330,6 +330,15 @@ type CommandRuntimeStartRequest struct { Spec CommandRuntimeResolvedSpec } +// CommandRuntimeOperationIdentity is the deterministic durable identity used +// before a process starts and again when its background Job reaches terminal +// state. It lets callers bind external transaction ledgers without retaining +// the plaintext idempotency key. +func CommandRuntimeOperationIdentity(runID, operationKey string) (string, string) { + digest := commandRuntimeDigest("command_runtime_operation.v2", runID, operationKey) + return digest, "command-job-" + digest[:24] +} + type CommandRuntimeJobSnapshot struct { ID string `json:"id"` State CommandRuntimeJobState `json:"state"` @@ -495,10 +504,9 @@ func (m *CommandRuntimeManager) Start(ctx context.Context, } m.mu.Unlock() - operationDigest := commandRuntimeDigest("command_runtime_operation.v2", - request.Scope.RunID, request.Scope.OperationKey) + operationDigest, jobID := CommandRuntimeOperationIdentity(request.Scope.RunID, + request.Scope.OperationKey) fingerprint := CommandRuntimeSpecFingerprint(request.Spec) - jobID := "command-job-" + operationDigest[:24] now := time.Now().UTC() ownerExpiresAt := now.Add(m.ownerLeaseTTL) record := CommandRuntimeJob{ diff --git a/internal/store/migration_v117.go b/internal/store/migration_v117.go new file mode 100644 index 00000000..8e6736f3 --- /dev/null +++ b/internal/store/migration_v117.go @@ -0,0 +1,413 @@ +package store + +// workspaceCheckpointStatements adds the workspace-checkpoint.v1 content- +// addressed manifest, mutation transaction, and Run cursor ledgers. Checkpoint +// rows are assembled unsealed inside one SQLite transaction and become visible +// only after the entry count and blob references are complete. +var workspaceCheckpointStatements = []string{ + `CREATE TABLE workspace_checkpoint_blobs ( + sha256 TEXT PRIMARY KEY, + size_bytes INTEGER NOT NULL, + content BLOB NOT NULL, + reference_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + CHECK(length(sha256) = 64 AND sha256 = lower(sha256) + AND sha256 NOT GLOB '*[^0-9a-f]*'), + CHECK(size_bytes BETWEEN 0 AND 33554432 + AND length(content) = size_bytes), + CHECK(reference_count >= 0), + CHECK(julianday(created_at) IS NOT NULL) + ) WITHOUT ROWID;`, + `CREATE TRIGGER trg_workspace_checkpoint_blob_store_quota + BEFORE INSERT ON workspace_checkpoint_blobs + WHEN NOT EXISTS (SELECT 1 FROM workspace_checkpoint_blobs + WHERE sha256 = NEW.sha256) + AND (SELECT COALESCE(SUM(size_bytes), 0) FROM workspace_checkpoint_blobs) + + NEW.size_bytes > 2147483648 + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint blob store quota exceeded'); END;`, + `CREATE TABLE workspace_checkpoints ( + id TEXT PRIMARY KEY, + protocol_version TEXT NOT NULL, + run_id TEXT NOT NULL, + mission_id TEXT NOT NULL, + session_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + capability_generation TEXT NOT NULL, + trigger_kind TEXT NOT NULL, + phase TEXT NOT NULL, + trigger_receipt_id TEXT NOT NULL, + requested_by TEXT NOT NULL, + title TEXT NOT NULL, + parent_checkpoint_id TEXT NOT NULL, + root_fingerprint TEXT NOT NULL, + root_path_sha256 TEXT NOT NULL, + base_commit TEXT NOT NULL, + branch TEXT NOT NULL, + index_sha256 TEXT NOT NULL, + index_blob_sha256 TEXT NOT NULL, + manifest_sha256 TEXT NOT NULL, + recovery_level TEXT NOT NULL, + incomplete_reasons_json TEXT NOT NULL, + entry_count INTEGER NOT NULL, + stored_bytes INTEGER NOT NULL, + sealed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE RESTRICT, + FOREIGN KEY(mission_id) REFERENCES missions(id) ON DELETE RESTRICT, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE RESTRICT, + CHECK(protocol_version = 'workspace-checkpoint.v1'), + CHECK(trigger_kind IN ('manual', 'file_tool', 'command_batch', 'git_mutation', + 'agent_merge', 'rewind_preflight', 'rewind_result', 'fork')), + CHECK(phase IN ('standalone', 'before', 'after', 'preflight')), + CHECK(recovery_level IN ('complete', 'partial', 'unavailable')), + CHECK(json_valid(incomplete_reasons_json) + AND json_type(incomplete_reasons_json) = 'array' + AND length(CAST(incomplete_reasons_json AS BLOB)) BETWEEN 2 AND 16384), + CHECK(entry_count BETWEEN 0 AND 20000), + CHECK(stored_bytes BETWEEN 0 AND 67108864), + CHECK(sealed IN (0, 1)), + CHECK(length(id) BETWEEN 1 AND 256 AND id = trim(id) AND instr(id, char(0)) = 0), + CHECK(length(run_id) BETWEEN 1 AND 256 AND run_id = trim(run_id) AND instr(run_id, char(0)) = 0), + CHECK(length(mission_id) BETWEEN 1 AND 256 AND mission_id = trim(mission_id) AND instr(mission_id, char(0)) = 0), + CHECK(length(session_id) BETWEEN 1 AND 256 AND session_id = trim(session_id) AND instr(session_id, char(0)) = 0), + CHECK(length(workspace_id) BETWEEN 1 AND 256 AND workspace_id = trim(workspace_id) AND instr(workspace_id, char(0)) = 0), + CHECK(length(attempt_id) <= 256 AND attempt_id = trim(attempt_id) AND instr(attempt_id, char(0)) = 0), + CHECK((capability_generation = '') OR (length(capability_generation) = 64 + AND capability_generation = lower(capability_generation) + AND capability_generation NOT GLOB '*[^0-9a-f]*')), + CHECK(length(trigger_receipt_id) BETWEEN 1 AND 256 + AND trigger_receipt_id = trim(trigger_receipt_id) + AND instr(trigger_receipt_id, char(0)) = 0), + CHECK(length(requested_by) <= 256 AND requested_by = trim(requested_by) + AND instr(requested_by, char(0)) = 0), + CHECK(length(title) <= 512 AND title = trim(title) + AND instr(title, char(0)) = 0), + CHECK(length(parent_checkpoint_id) <= 256 + AND parent_checkpoint_id = trim(parent_checkpoint_id) + AND instr(parent_checkpoint_id, char(0)) = 0), + CHECK(length(root_fingerprint) = 64 AND root_fingerprint = lower(root_fingerprint) + AND root_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(root_path_sha256) = 64 AND root_path_sha256 = lower(root_path_sha256) + AND root_path_sha256 NOT GLOB '*[^0-9a-f]*'), + CHECK(base_commit IN ('unborn', 'non-git') OR + ((length(base_commit) = 40 OR length(base_commit) = 64) + AND base_commit = lower(base_commit) + AND base_commit NOT GLOB '*[^0-9a-f]*')), + CHECK(length(branch) <= 255 AND instr(branch, char(0)) = 0), + CHECK(length(index_sha256) = 64 AND index_sha256 = lower(index_sha256) + AND index_sha256 NOT GLOB '*[^0-9a-f]*'), + CHECK((index_blob_sha256 = '') OR (length(index_blob_sha256) = 64 + AND index_blob_sha256 = lower(index_blob_sha256) + AND index_blob_sha256 NOT GLOB '*[^0-9a-f]*')), + CHECK(length(manifest_sha256) = 64 AND manifest_sha256 = lower(manifest_sha256) + AND manifest_sha256 NOT GLOB '*[^0-9a-f]*'), + CHECK(julianday(created_at) IS NOT NULL) + );`, + `CREATE TRIGGER trg_workspace_checkpoint_insert_unsealed + BEFORE INSERT ON workspace_checkpoints WHEN NEW.sealed != 0 + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint must be assembled unsealed'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_metadata_quota + BEFORE INSERT ON workspace_checkpoints + WHEN (SELECT COUNT(*) FROM workspace_checkpoints) >= 10000 + OR (SELECT COALESCE(SUM(entry_count), 0) FROM workspace_checkpoints) + + NEW.entry_count > 2000000 + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint metadata quota exceeded'); END;`, + `CREATE INDEX idx_workspace_checkpoints_run_created + ON workspace_checkpoints(run_id, created_at DESC, id DESC) WHERE sealed = 1;`, + `CREATE INDEX idx_workspace_checkpoints_workspace_created + ON workspace_checkpoints(workspace_id, created_at DESC, id DESC) WHERE sealed = 1;`, + `CREATE INDEX idx_workspace_checkpoints_receipt + ON workspace_checkpoints(run_id, trigger_kind, trigger_receipt_id, phase);`, + `CREATE TRIGGER trg_workspace_checkpoint_parent_binding + BEFORE INSERT ON workspace_checkpoints WHEN NEW.parent_checkpoint_id != '' + AND NOT EXISTS (SELECT 1 FROM workspace_checkpoints parent + WHERE parent.id = NEW.parent_checkpoint_id AND parent.sealed = 1 + AND parent.run_id = NEW.run_id AND parent.workspace_id = NEW.workspace_id) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint parent binding is invalid'); END;`, + `CREATE TABLE workspace_checkpoint_entries ( + checkpoint_id TEXT NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL, + worktree_state TEXT NOT NULL, + storage_policy TEXT NOT NULL, + mode INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + worktree_sha256 TEXT NOT NULL, + blob_sha256 TEXT NOT NULL, + index_oid TEXT NOT NULL, + index_mode TEXT NOT NULL, + tracked INTEGER NOT NULL, + staged INTEGER NOT NULL, + binary INTEGER NOT NULL, + line_endings TEXT NOT NULL, + recoverable INTEGER NOT NULL, + reason TEXT NOT NULL, + PRIMARY KEY(checkpoint_id, path), + FOREIGN KEY(checkpoint_id) REFERENCES workspace_checkpoints(id) ON DELETE RESTRICT, + CHECK(kind IN ('file', 'directory', 'symlink', 'other')), + CHECK(worktree_state IN ('present', 'missing', 'ignored', 'generated', 'external')), + CHECK(storage_policy IN ('stored', 'missing', 'excluded_ignored', + 'excluded_generated', 'excluded_large', 'excluded_sensitive', + 'excluded_link', 'excluded_special', 'unreadable')), + CHECK(mode BETWEEN 0 AND 4095), + CHECK(size_bytes >= 0), + CHECK(worktree_sha256 = 'missing' OR (length(worktree_sha256) = 64 + AND worktree_sha256 = lower(worktree_sha256) + AND worktree_sha256 NOT GLOB '*[^0-9a-f]*')), + CHECK((blob_sha256 = '') OR (length(blob_sha256) = 64 + AND blob_sha256 = lower(blob_sha256) + AND blob_sha256 NOT GLOB '*[^0-9a-f]*')), + CHECK(tracked IN (0, 1) AND staged IN (0, 1) AND binary IN (0, 1) + AND recoverable IN (0, 1)), + CHECK(line_endings IN ('', 'lf', 'crlf', 'mixed', 'none')), + CHECK(length(path) BETWEEN 1 AND 4096 AND path = trim(path) + AND instr(path, char(0)) = 0), + CHECK(length(index_oid) <= 128 AND instr(index_oid, char(0)) = 0), + CHECK(length(index_mode) <= 16 AND instr(index_mode, char(0)) = 0), + CHECK(length(reason) <= 2048 AND reason = trim(reason) AND instr(reason, char(0)) = 0), + CHECK((storage_policy = 'stored' AND kind = 'file' AND worktree_state = 'present' + AND recoverable = 1 AND blob_sha256 = worktree_sha256 AND size_bytes <= 4194304) + OR (storage_policy = 'missing' AND worktree_state = 'missing' + AND worktree_sha256 = 'missing' AND recoverable = 1 AND blob_sha256 = '') + OR (storage_policy NOT IN ('stored', 'missing') + AND recoverable = 0 AND blob_sha256 = '')) + ) WITHOUT ROWID;`, + `CREATE INDEX idx_workspace_checkpoint_entries_blob + ON workspace_checkpoint_entries(blob_sha256) WHERE blob_sha256 != '';`, + `CREATE TRIGGER trg_workspace_checkpoint_entry_insert_unsealed + BEFORE INSERT ON workspace_checkpoint_entries + WHEN NOT EXISTS (SELECT 1 FROM workspace_checkpoints + WHERE id = NEW.checkpoint_id AND sealed = 0) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint entries require an unsealed checkpoint'); END;`, + `CREATE TABLE workspace_checkpoint_transactions ( + id TEXT PRIMARY KEY, + protocol_version TEXT NOT NULL, + operation_key_digest TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL, + run_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + kind TEXT NOT NULL, + trigger_receipt_id TEXT NOT NULL, + before_checkpoint_id TEXT NOT NULL, + after_checkpoint_id TEXT NOT NULL, + expected_current_checkpoint_id TEXT NOT NULL, + target_checkpoint_id TEXT NOT NULL, + fork_workspace_root TEXT NOT NULL, + fork_branch TEXT NOT NULL, + status TEXT NOT NULL, + recovery_level TEXT NOT NULL, + error_code TEXT NOT NULL, + conflict_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE RESTRICT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE RESTRICT, + FOREIGN KEY(before_checkpoint_id) REFERENCES workspace_checkpoints(id) ON DELETE RESTRICT, + CHECK(protocol_version = 'workspace-checkpoint.v1'), + CHECK(kind IN ('file_tool', 'command_batch', 'git_mutation', 'agent_merge', + 'rewind', 'undo', 'redo', 'fork')), + CHECK(status IN ('prepared', 'applying', 'completed', 'failed', 'interrupted')), + CHECK(recovery_level IN ('complete', 'partial', 'unavailable')), + CHECK(length(operation_key_digest) = 64 AND operation_key_digest = lower(operation_key_digest) + AND operation_key_digest NOT GLOB '*[^0-9a-f]*'), + CHECK(length(request_fingerprint) = 64 AND request_fingerprint = lower(request_fingerprint) + AND request_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(id) BETWEEN 1 AND 256 AND id = trim(id) AND instr(id, char(0)) = 0), + CHECK(length(run_id) BETWEEN 1 AND 256 AND run_id = trim(run_id) AND instr(run_id, char(0)) = 0), + CHECK(length(workspace_id) BETWEEN 1 AND 256 AND workspace_id = trim(workspace_id) AND instr(workspace_id, char(0)) = 0), + CHECK(length(trigger_receipt_id) BETWEEN 1 AND 256 + AND trigger_receipt_id = trim(trigger_receipt_id) + AND instr(trigger_receipt_id, char(0)) = 0), + CHECK(length(before_checkpoint_id) BETWEEN 1 AND 256), + CHECK(length(after_checkpoint_id) <= 256), + CHECK(length(expected_current_checkpoint_id) <= 256), + CHECK(length(target_checkpoint_id) <= 256), + CHECK((kind = 'fork' + AND length(fork_workspace_root) BETWEEN 1 AND 4096 + AND fork_workspace_root = trim(fork_workspace_root) + AND instr(fork_workspace_root, char(0)) = 0 + AND length(fork_branch) BETWEEN 1 AND 255 + AND fork_branch = trim(fork_branch) AND instr(fork_branch, char(0)) = 0) + OR (kind != 'fork' AND fork_workspace_root = '' AND fork_branch = '')), + CHECK(length(error_code) <= 512 AND error_code = trim(error_code) + AND instr(error_code, char(0)) = 0), + CHECK(json_valid(conflict_json) AND json_type(conflict_json) = 'array' + AND length(CAST(conflict_json AS BLOB)) BETWEEN 2 AND 262144), + CHECK(julianday(created_at) IS NOT NULL AND julianday(updated_at) IS NOT NULL + AND julianday(updated_at) >= julianday(created_at)), + CHECK(completed_at IS NULL OR (julianday(completed_at) IS NOT NULL + AND julianday(completed_at) >= julianday(updated_at))), + CHECK((status IN ('prepared', 'applying') AND after_checkpoint_id = '' + AND error_code = '' AND completed_at IS NULL) + OR (status = 'completed' AND after_checkpoint_id != '' + AND error_code = '' AND completed_at IS NOT NULL) + OR (status IN ('failed', 'interrupted') AND error_code != '' + AND completed_at IS NOT NULL)) + );`, + `CREATE TRIGGER trg_workspace_checkpoint_transaction_quota + BEFORE INSERT ON workspace_checkpoint_transactions + WHEN (SELECT COUNT(*) FROM workspace_checkpoint_transactions) >= 20000 + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint transaction quota exceeded'); END;`, + `CREATE INDEX idx_workspace_checkpoint_transactions_run_created + ON workspace_checkpoint_transactions(run_id, created_at DESC, id DESC);`, + `CREATE INDEX idx_workspace_checkpoint_transactions_open + ON workspace_checkpoint_transactions(status, updated_at) + WHERE status IN ('prepared', 'applying');`, + `CREATE TRIGGER trg_workspace_checkpoint_transaction_insert_binding + BEFORE INSERT ON workspace_checkpoint_transactions + WHEN NOT EXISTS (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.before_checkpoint_id AND checkpoint.sealed = 1 + AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id) + OR (NEW.expected_current_checkpoint_id != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.expected_current_checkpoint_id + AND checkpoint.sealed = 1 AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id)) + OR (NEW.target_checkpoint_id != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.target_checkpoint_id + AND checkpoint.sealed = 1 AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id)) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint transaction binding is invalid'); END;`, + `CREATE TABLE workspace_checkpoint_run_state ( + run_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + current_checkpoint_id TEXT NOT NULL, + last_transaction_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE RESTRICT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE RESTRICT, + CHECK(length(current_checkpoint_id) BETWEEN 1 AND 256), + CHECK(length(last_transaction_id) <= 256), + CHECK(julianday(updated_at) IS NOT NULL) + ) WITHOUT ROWID;`, + `CREATE TRIGGER trg_workspace_checkpoint_run_state_insert_binding + BEFORE INSERT ON workspace_checkpoint_run_state + WHEN NOT EXISTS (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.current_checkpoint_id AND checkpoint.sealed = 1 + AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id) + OR (NEW.last_transaction_id != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoint_transactions transaction_record + WHERE transaction_record.id = NEW.last_transaction_id + AND transaction_record.run_id = NEW.run_id + AND transaction_record.workspace_id = NEW.workspace_id + AND transaction_record.status IN ('completed', 'failed', 'interrupted') + AND transaction_record.after_checkpoint_id = NEW.current_checkpoint_id)) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint Run state binding is invalid'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_run_state_update_binding + BEFORE UPDATE ON workspace_checkpoint_run_state + WHEN NEW.run_id != OLD.run_id OR NEW.workspace_id != OLD.workspace_id + OR julianday(NEW.updated_at) < julianday(OLD.updated_at) + OR NOT EXISTS (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.current_checkpoint_id AND checkpoint.sealed = 1 + AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id) + OR (NEW.last_transaction_id != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoint_transactions transaction_record + WHERE transaction_record.id = NEW.last_transaction_id + AND transaction_record.run_id = NEW.run_id + AND transaction_record.workspace_id = NEW.workspace_id + AND transaction_record.status IN ('completed', 'failed', 'interrupted') + AND transaction_record.after_checkpoint_id = NEW.current_checkpoint_id)) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint Run state transition is invalid'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_insert_scope + BEFORE INSERT ON workspace_checkpoints + WHEN NOT EXISTS ( + SELECT 1 FROM runs run + JOIN missions mission ON mission.id = run.mission_id + JOIN sessions session_record ON session_record.id = run.session_id + WHERE run.id = NEW.run_id AND mission.id = NEW.mission_id + AND session_record.id = NEW.session_id + AND mission.workspace_id = NEW.workspace_id + AND session_record.workspace_id = NEW.workspace_id + ) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint Run binding is invalid'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_blob_reference + AFTER INSERT ON workspace_checkpoints WHEN NEW.index_blob_sha256 != '' + BEGIN UPDATE workspace_checkpoint_blobs + SET reference_count = reference_count + 1 + WHERE sha256 = NEW.index_blob_sha256; END;`, + `CREATE TRIGGER trg_workspace_checkpoint_entry_blob_reference + AFTER INSERT ON workspace_checkpoint_entries WHEN NEW.blob_sha256 != '' + BEGIN UPDATE workspace_checkpoint_blobs + SET reference_count = reference_count + 1 + WHERE sha256 = NEW.blob_sha256; END;`, + `CREATE TRIGGER trg_workspace_checkpoint_seal + BEFORE UPDATE ON workspace_checkpoints + WHEN OLD.sealed != 0 OR NEW.sealed != 1 + OR NEW.id != OLD.id OR NEW.protocol_version != OLD.protocol_version + OR NEW.run_id != OLD.run_id OR NEW.mission_id != OLD.mission_id + OR NEW.session_id != OLD.session_id OR NEW.workspace_id != OLD.workspace_id + OR NEW.attempt_id != OLD.attempt_id + OR NEW.capability_generation != OLD.capability_generation + OR NEW.trigger_kind != OLD.trigger_kind OR NEW.phase != OLD.phase + OR NEW.trigger_receipt_id != OLD.trigger_receipt_id + OR NEW.parent_checkpoint_id != OLD.parent_checkpoint_id + OR NEW.root_fingerprint != OLD.root_fingerprint + OR NEW.root_path_sha256 != OLD.root_path_sha256 + OR NEW.base_commit != OLD.base_commit OR NEW.branch != OLD.branch + OR NEW.index_sha256 != OLD.index_sha256 + OR NEW.index_blob_sha256 != OLD.index_blob_sha256 + OR NEW.manifest_sha256 != OLD.manifest_sha256 + OR NEW.recovery_level != OLD.recovery_level + OR NEW.incomplete_reasons_json != OLD.incomplete_reasons_json + OR NEW.entry_count != OLD.entry_count OR NEW.stored_bytes != OLD.stored_bytes + OR NEW.created_at != OLD.created_at + OR NEW.entry_count != (SELECT COUNT(*) FROM workspace_checkpoint_entries + WHERE checkpoint_id = NEW.id) + OR (NEW.index_blob_sha256 != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoint_blobs + WHERE sha256 = NEW.index_blob_sha256)) + OR EXISTS (SELECT 1 FROM workspace_checkpoint_entries entry + WHERE entry.checkpoint_id = NEW.id AND entry.blob_sha256 != '' + AND NOT EXISTS (SELECT 1 FROM workspace_checkpoint_blobs blob + WHERE blob.sha256 = entry.blob_sha256)) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint seal is invalid'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_delete_immutable + BEFORE DELETE ON workspace_checkpoints + BEGIN SELECT RAISE(ABORT, 'workspace checkpoints are immutable'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_entry_update_immutable + BEFORE UPDATE ON workspace_checkpoint_entries + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint entries are immutable'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_entry_delete_immutable + BEFORE DELETE ON workspace_checkpoint_entries + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint entries are immutable'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_blob_content_immutable + BEFORE UPDATE OF sha256, size_bytes, content, created_at ON workspace_checkpoint_blobs + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint blob content is immutable'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_blob_delete_referenced + BEFORE DELETE ON workspace_checkpoint_blobs WHEN OLD.reference_count != 0 + BEGIN SELECT RAISE(ABORT, 'referenced workspace checkpoint blobs cannot be deleted'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_transaction_update + BEFORE UPDATE ON workspace_checkpoint_transactions + WHEN NEW.id != OLD.id OR NEW.protocol_version != OLD.protocol_version + OR NEW.operation_key_digest != OLD.operation_key_digest + OR NEW.request_fingerprint != OLD.request_fingerprint + OR NEW.run_id != OLD.run_id OR NEW.workspace_id != OLD.workspace_id + OR NEW.kind != OLD.kind OR NEW.trigger_receipt_id != OLD.trigger_receipt_id + OR NEW.before_checkpoint_id != OLD.before_checkpoint_id + OR NEW.expected_current_checkpoint_id != OLD.expected_current_checkpoint_id + OR NEW.target_checkpoint_id != OLD.target_checkpoint_id + OR NEW.fork_workspace_root != OLD.fork_workspace_root + OR NEW.fork_branch != OLD.fork_branch + OR NEW.created_at != OLD.created_at OR julianday(NEW.updated_at) < julianday(OLD.updated_at) + OR (NEW.after_checkpoint_id != '' AND NOT EXISTS + (SELECT 1 FROM workspace_checkpoints checkpoint + WHERE checkpoint.id = NEW.after_checkpoint_id AND checkpoint.sealed = 1 + AND checkpoint.run_id = NEW.run_id + AND checkpoint.workspace_id = NEW.workspace_id)) + OR OLD.status IN ('completed', 'failed', 'interrupted') + OR (OLD.status = 'prepared' AND NEW.status NOT IN + ('prepared', 'applying', 'completed', 'failed', 'interrupted')) + OR (OLD.status = 'applying' AND NEW.status NOT IN + ('applying', 'completed', 'failed', 'interrupted')) + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint transaction transition is invalid'); END;`, + `CREATE TRIGGER trg_workspace_checkpoint_transaction_delete_immutable + BEFORE DELETE ON workspace_checkpoint_transactions + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint transactions are immutable'); END;`, +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 881e1fc0..5becc40d 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -11,7 +11,7 @@ import ( "time" ) -const LatestSchemaVersion = 116 +const LatestSchemaVersion = 117 type migration struct { Version int diff --git a/internal/store/session_continuity.go b/internal/store/session_continuity.go index 8ee58e37..5508a69d 100644 --- a/internal/store/session_continuity.go +++ b/internal/store/session_continuity.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strings" + "time" "cyberagent-workbench/internal/contextmgr" "cyberagent-workbench/internal/domain" @@ -150,6 +151,52 @@ func (s *SQLiteStore) CreateMissionRunWithContinuity(ctx context.Context, return err } defer func() { _ = tx.Rollback() }() + if err := createMissionRunWithContinuityTx(ctx, tx, mission, run, mode, + linkedSession, createSession, initialEvents, node); err != nil { + return err + } + return tx.Commit() +} + +// CreateWorkspaceMissionRunWithContinuity atomically registers an independent +// Workspace and its authority-reset fork Run. Filesystem worktree creation is +// verified before this transaction begins. +func (s *SQLiteStore) CreateWorkspaceMissionRunWithContinuity(ctx context.Context, + workspace session.WorkspaceRecord, mission domain.Mission, run domain.Run, + mode domain.RunModeSnapshot, linkedSession session.Session, createSession bool, + initialEvents []events.Event, node contextmgr.ContinuityNode, +) error { + workspace.ID, workspace.Name, workspace.RootPath = strings.TrimSpace(workspace.ID), + strings.TrimSpace(workspace.Name), strings.TrimSpace(workspace.RootPath) + if workspace.ID == "" || workspace.Name == "" || workspace.RootPath == "" || + strings.ContainsRune(workspace.ID+workspace.Name+workspace.RootPath, 0) { + return errors.New("fork Workspace registration is invalid") + } + if workspace.CreatedAt.IsZero() { + workspace.CreatedAt = time.Now().UTC() + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `INSERT INTO workspaces + (id, name, root_path, created_at) VALUES (?, ?, ?, ?)`, workspace.ID, + workspace.Name, workspace.RootPath, ts(workspace.CreatedAt)); err != nil { + return err + } + if err := createMissionRunWithContinuityTx(ctx, tx, mission, run, mode, + linkedSession, createSession, initialEvents, node); err != nil { + return err + } + return tx.Commit() +} + +func createMissionRunWithContinuityTx(ctx context.Context, tx *sql.Tx, + mission domain.Mission, run domain.Run, mode domain.RunModeSnapshot, + linkedSession session.Session, createSession bool, initialEvents []events.Event, + node contextmgr.ContinuityNode, +) error { if err := createMissionRunTx(ctx, tx, mission, run, mode, linkedSession, createSession, initialEvents); err != nil { return err @@ -173,7 +220,7 @@ func (s *SQLiteStore) CreateMissionRunWithContinuity(ctx context.Context, if _, err := insertRunEventTx(ctx, tx, event); err != nil { return err } - return tx.Commit() + return nil } type continuityScanner interface { diff --git a/internal/store/skill_catalog_test.go b/internal/store/skill_catalog_test.go index c75b0f4e..3b23fdf0 100644 --- a/internal/store/skill_catalog_test.go +++ b/internal/store/skill_catalog_test.go @@ -132,7 +132,7 @@ func removeSchemaV114ForTestStatements() []string { } func removeSchemaV116ForTestStatements() []string { - statements := []string{ + statements := append(removeSchemaV117ForTestStatements(), `DROP TRIGGER trg_command_runtime_job_delete_immutable`, `DROP TRIGGER trg_command_runtime_job_update_transition`, `DROP TRIGGER trg_command_runtime_job_insert_limit`, @@ -140,7 +140,7 @@ func removeSchemaV116ForTestStatements() []string { `DROP INDEX idx_command_runtime_jobs_active`, `DROP INDEX idx_command_runtime_jobs_run_created`, `DROP TABLE command_runtime_jobs`, - } + ) statements = append(statements, debugTerminalSupervisorLedgerStatements...) return append(statements, `DELETE FROM schema_migrations WHERE version = 116`) } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index ad354f4a..ebe703a8 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -343,6 +343,7 @@ func migrationPlan() []migration { {Version: 114, Name: "project instructions, explicit memory, and session continuity", Statements: contextContinuityStatements}, {Version: 115, Name: "model-callable agent code tools and guarded file mutations", Statements: agentCodeToolStatements}, {Version: 116, Name: "Run-owned command runtime jobs", Statements: commandRuntimeStatements}, + {Version: 117, Name: "content-addressed reversible Workspace checkpoints", Statements: workspaceCheckpointStatements}, } } diff --git a/internal/store/supervisor_tool_registry_migration_test.go b/internal/store/supervisor_tool_registry_migration_test.go index 9f262ff5..9440f403 100644 --- a/internal/store/supervisor_tool_registry_migration_test.go +++ b/internal/store/supervisor_tool_registry_migration_test.go @@ -270,8 +270,8 @@ func TestSchemaV116PreservesAgentCodeAuthorityAndAdmitsCommandRuntime(t *testing t.Fatal(err) } defer upgraded.Close() - if version, err := upgraded.SchemaVersion(ctx); err != nil || version != 116 { - t.Fatalf("schema version=%d want=116 err=%v", version, err) + if version, err := upgraded.SchemaVersion(ctx); err != nil || version != LatestSchemaVersion { + t.Fatalf("schema version=%d want=%d err=%v", version, LatestSchemaVersion, err) } if _, err := upgraded.db.ExecContext(ctx, `INSERT INTO run_supervisor_tool_calls (run_id, turn, attempt_id, round, position, model_attempt, call_id, tool_name, diff --git a/internal/store/workspace_checkpoints.go b/internal/store/workspace_checkpoints.go new file mode 100644 index 00000000..e9f01530 --- /dev/null +++ b/internal/store/workspace_checkpoints.go @@ -0,0 +1,836 @@ +package store + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/events" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +const workspaceCheckpointColumns = `id, protocol_version, run_id, mission_id, + session_id, workspace_id, attempt_id, capability_generation, trigger_kind, phase, + trigger_receipt_id, requested_by, title, parent_checkpoint_id, root_fingerprint, root_path_sha256, + base_commit, branch, index_sha256, index_blob_sha256, manifest_sha256, + recovery_level, incomplete_reasons_json, entry_count, stored_bytes, created_at` + +const workspaceCheckpointTransactionColumns = `id, protocol_version, + operation_key_digest, request_fingerprint, run_id, workspace_id, kind, + trigger_receipt_id, before_checkpoint_id, after_checkpoint_id, + expected_current_checkpoint_id, target_checkpoint_id, fork_workspace_root, + fork_branch, status, recovery_level, + error_code, conflict_json, created_at, updated_at, completed_at` + +type workspaceCheckpointQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +// CreateWorkspaceCheckpoint commits blobs, manifest metadata, entries, and the +// final seal in one SQLite transaction. A repeated exact ID is a read-only +// replay; an ID reused for different content fails closed. +func (s *SQLiteStore) CreateWorkspaceCheckpoint(ctx context.Context, + snapshot workspacecheckpoint.Snapshot, +) (workspacecheckpoint.Checkpoint, bool, error) { + if err := snapshot.Validate(); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + defer tx.Rollback() + if existing, getErr := getWorkspaceCheckpoint(ctx, tx, snapshot.Checkpoint.ID); getErr == nil { + if !sameWorkspaceCheckpoint(existing, snapshot.Checkpoint) { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint ID was reused for different content") + } + return existing, true, nil + } else if !errors.Is(getErr, sql.ErrNoRows) { + return workspacecheckpoint.Checkpoint{}, false, getErr + } + for _, blob := range snapshot.Blobs { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO workspace_checkpoint_blobs + (sha256, size_bytes, content, reference_count, created_at) + VALUES (?, ?, ?, 0, ?)`, blob.SHA256, len(blob.Content), blob.Content, + ts(blob.CreatedAt)); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + var size int + var content []byte + if err := tx.QueryRowContext(ctx, `SELECT size_bytes, content + FROM workspace_checkpoint_blobs WHERE sha256 = ?`, blob.SHA256). + Scan(&size, &content); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + if size != len(blob.Content) || !bytes.Equal(content, blob.Content) { + return workspacecheckpoint.Checkpoint{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint blob digest collision") + } + } + reasonsJSON, err := json.Marshal(snapshot.Checkpoint.IncompleteReasons) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + c := snapshot.Checkpoint + if _, err := tx.ExecContext(ctx, `INSERT INTO workspace_checkpoints + (id, protocol_version, run_id, mission_id, session_id, workspace_id, attempt_id, + capability_generation, trigger_kind, phase, trigger_receipt_id, + requested_by, title, parent_checkpoint_id, root_fingerprint, root_path_sha256, base_commit, branch, + index_sha256, index_blob_sha256, manifest_sha256, recovery_level, + incomplete_reasons_json, entry_count, stored_bytes, sealed, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + c.ID, c.ProtocolVersion, c.RunID, c.MissionID, c.SessionID, c.WorkspaceID, + c.AttemptID, c.CapabilityGeneration, c.Trigger, c.Phase, c.TriggerReceiptID, + c.RequestedBy, c.Title, c.ParentCheckpointID, c.RootFingerprint, c.RootPathSHA256, c.BaseCommit, + c.Branch, c.IndexSHA256, c.IndexBlobSHA256, c.ManifestSHA256, + c.RecoveryLevel, string(reasonsJSON), c.EntryCount, c.StoredBytes, + ts(c.CreatedAt)); err != nil { + return workspacecheckpoint.Checkpoint{}, false, normalizeWorkspaceCheckpointStoreError(err) + } + for _, entry := range snapshot.Entries { + if _, err := tx.ExecContext(ctx, `INSERT INTO workspace_checkpoint_entries + (checkpoint_id, path, kind, worktree_state, storage_policy, mode, + size_bytes, worktree_sha256, blob_sha256, index_oid, index_mode, + tracked, staged, binary, line_endings, recoverable, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, entry.Path, entry.Kind, entry.State, entry.StoragePolicy, entry.Mode, + entry.Size, entry.WorktreeSHA256, entry.BlobSHA256, entry.IndexOID, + entry.IndexMode, boolInt(entry.Tracked), boolInt(entry.Staged), + boolInt(entry.Binary), entry.LineEndings, boolInt(entry.Recoverable), + entry.Reason); err != nil { + return workspacecheckpoint.Checkpoint{}, false, + normalizeWorkspaceCheckpointStoreError(err) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE workspace_checkpoints SET sealed = 1 + WHERE id = ? AND sealed = 0`, c.ID); err != nil { + return workspacecheckpoint.Checkpoint{}, false, normalizeWorkspaceCheckpointStoreError(err) + } + event, err := events.New(c.RunID, c.MissionID, events.WorkspaceCheckpointCreatedEvent, + "workspace_checkpoint", c.ID, map[string]any{ + "checkpoint_id": c.ID, "workspace_id": c.WorkspaceID, + "trigger": c.Trigger, "phase": c.Phase, + "trigger_receipt_id": c.TriggerReceiptID, + "requested_by": c.RequestedBy, + "title": c.Title, + "parent_checkpoint_id": c.ParentCheckpointID, + "recovery_level": c.RecoveryLevel, "entry_count": c.EntryCount, + "stored_bytes": c.StoredBytes, + }) + if err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + event.CreatedAt = c.CreatedAt + if _, err := insertRunEventTx(ctx, tx, event); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + if err := tx.Commit(); err != nil { + return workspacecheckpoint.Checkpoint{}, false, err + } + return c, false, nil +} + +func (s *SQLiteStore) GetWorkspaceCheckpoint(ctx context.Context, + id string, +) (workspacecheckpoint.Checkpoint, error) { + value, err := getWorkspaceCheckpoint(ctx, s.db, strings.TrimSpace(id)) + if errors.Is(err, sql.ErrNoRows) { + return workspacecheckpoint.Checkpoint{}, apperror.New( + apperror.CodeNotFound, "workspace checkpoint not found") + } + return value, err +} + +func (s *SQLiteStore) ListWorkspaceCheckpoints(ctx context.Context, runID string, + limit int, +) ([]workspacecheckpoint.Checkpoint, error) { + runID = strings.TrimSpace(runID) + if runID == "" || limit < 1 || limit > 2_000 { + return nil, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint list request is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT `+workspaceCheckpointColumns+` + FROM workspace_checkpoints WHERE run_id = ? AND sealed = 1 + ORDER BY created_at DESC, id DESC LIMIT ?`, runID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]workspacecheckpoint.Checkpoint, 0, limit) + for rows.Next() { + value, err := scanWorkspaceCheckpoint(rows) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func (s *SQLiteStore) GetWorkspaceCheckpointSnapshot(ctx context.Context, + id string, +) (workspacecheckpoint.Snapshot, error) { + checkpoint, err := s.GetWorkspaceCheckpoint(ctx, id) + if err != nil { + return workspacecheckpoint.Snapshot{}, err + } + rows, err := s.db.QueryContext(ctx, `SELECT path, kind, worktree_state, + storage_policy, mode, size_bytes, worktree_sha256, blob_sha256, index_oid, + index_mode, tracked, staged, binary, line_endings, recoverable, reason + FROM workspace_checkpoint_entries WHERE checkpoint_id = ? ORDER BY path`, checkpoint.ID) + if err != nil { + return workspacecheckpoint.Snapshot{}, err + } + entries := make([]workspacecheckpoint.Entry, 0, checkpoint.EntryCount) + blobIDs := make(map[string]struct{}) + for rows.Next() { + var entry workspacecheckpoint.Entry + var tracked, staged, binary, recoverable int + if err := rows.Scan(&entry.Path, &entry.Kind, &entry.State, &entry.StoragePolicy, + &entry.Mode, &entry.Size, &entry.WorktreeSHA256, &entry.BlobSHA256, + &entry.IndexOID, &entry.IndexMode, &tracked, &staged, &binary, + &entry.LineEndings, &recoverable, &entry.Reason); err != nil { + rows.Close() + return workspacecheckpoint.Snapshot{}, err + } + entry.Tracked = tracked != 0 + entry.Staged = staged != 0 + entry.Binary = binary != 0 + entry.Recoverable = recoverable != 0 + if entry.BlobSHA256 != "" { + blobIDs[entry.BlobSHA256] = struct{}{} + } + entries = append(entries, entry) + } + if err := rows.Close(); err != nil { + return workspacecheckpoint.Snapshot{}, err + } + if checkpoint.IndexBlobSHA256 != "" { + blobIDs[checkpoint.IndexBlobSHA256] = struct{}{} + } + ids := make([]string, 0, len(blobIDs)) + for id := range blobIDs { + ids = append(ids, id) + } + sort.Strings(ids) + blobs := make([]workspacecheckpoint.Blob, 0, len(ids)) + for _, digest := range ids { + var content []byte + var created string + if err := s.db.QueryRowContext(ctx, `SELECT content, created_at + FROM workspace_checkpoint_blobs WHERE sha256 = ?`, digest). + Scan(&content, &created); err != nil { + return workspacecheckpoint.Snapshot{}, err + } + blobs = append(blobs, workspacecheckpoint.Blob{SHA256: digest, + Content: append([]byte{}, content...), CreatedAt: parseTS(created)}) + } + snapshot := workspacecheckpoint.Snapshot{Checkpoint: checkpoint, + Entries: entries, Blobs: blobs} + if err := snapshot.Validate(); err != nil { + return workspacecheckpoint.Snapshot{}, fmt.Errorf( + "persisted workspace checkpoint failed validation: %w", err) + } + manifestJSON, err := json.Marshal(entries) + if err != nil { + return workspacecheckpoint.Snapshot{}, err + } + manifestSHA := sha256.Sum256(manifestJSON) + if hex.EncodeToString(manifestSHA[:]) != checkpoint.ManifestSHA256 { + return workspacecheckpoint.Snapshot{}, errors.New( + "persisted workspace checkpoint manifest digest mismatch") + } + return snapshot, nil +} + +func (s *SQLiteStore) GarbageCollectWorkspaceCheckpointBlobs(ctx context.Context, + limit int, +) (int, error) { + if limit < 1 || limit > 10_000 { + return 0, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint GC limit is invalid") + } + result, err := s.db.ExecContext(ctx, `DELETE FROM workspace_checkpoint_blobs + WHERE sha256 IN (SELECT sha256 FROM workspace_checkpoint_blobs + WHERE reference_count = 0 ORDER BY created_at, sha256 LIMIT ?)`, limit) + if err != nil { + return 0, err + } + count, err := result.RowsAffected() + return int(count), err +} + +func (s *SQLiteStore) CreateWorkspaceCheckpointTransaction(ctx context.Context, + value workspacecheckpoint.Transaction, +) (workspacecheckpoint.Transaction, bool, error) { + if value.ConflictJSON == "" { + value.ConflictJSON = "[]" + } + if err := value.Validate(); err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO workspace_checkpoint_transactions + (id, protocol_version, operation_key_digest, request_fingerprint, run_id, + workspace_id, kind, trigger_receipt_id, before_checkpoint_id, + after_checkpoint_id, expected_current_checkpoint_id, target_checkpoint_id, + fork_workspace_root, fork_branch, status, recovery_level, error_code, + conflict_json, created_at, updated_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + value.ID, value.ProtocolVersion, value.OperationKeyDigest, value.RequestFingerprint, + value.RunID, value.WorkspaceID, value.Kind, value.TriggerReceiptID, + value.BeforeCheckpointID, value.AfterCheckpointID, + value.ExpectedCurrentCheckpointID, value.TargetCheckpointID, + value.ForkWorkspaceRoot, value.ForkBranch, value.Status, value.RecoveryLevel, + value.ErrorCode, value.ConflictJSON, ts(value.CreatedAt), + ts(value.UpdatedAt), nullableWorkspaceCheckpointTime(value.CompletedAt)) + if err == nil { + missionID, lookupErr := workspaceCheckpointMissionID(ctx, tx, value.RunID) + if lookupErr != nil { + return workspacecheckpoint.Transaction{}, false, lookupErr + } + event, eventErr := events.New(value.RunID, missionID, + events.WorkspaceCheckpointTransactionPreparedEvent, "workspace_checkpoint", + value.ID, workspaceCheckpointTransactionEventPayload(value)) + if eventErr != nil { + return workspacecheckpoint.Transaction{}, false, eventErr + } + event.CreatedAt = value.CreatedAt + if _, eventErr = insertRunEventTx(ctx, tx, event); eventErr != nil { + return workspacecheckpoint.Transaction{}, false, eventErr + } + if commitErr := tx.Commit(); commitErr != nil { + return workspacecheckpoint.Transaction{}, false, commitErr + } + return value, false, nil + } + _ = tx.Rollback() + existing, found, getErr := s.GetWorkspaceCheckpointTransactionByOperation(ctx, + value.OperationKeyDigest) + if getErr != nil { + return workspacecheckpoint.Transaction{}, false, + normalizeWorkspaceCheckpointStoreError(err) + } + if !found { + return workspacecheckpoint.Transaction{}, false, + normalizeWorkspaceCheckpointStoreError(err) + } + if !sameWorkspaceCheckpointTransactionIntent(existing, value) { + return workspacecheckpoint.Transaction{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint operation key was reused") + } + return existing, true, nil +} + +func (s *SQLiteStore) UpdateWorkspaceCheckpointTransaction(ctx context.Context, + value workspacecheckpoint.Transaction, +) (workspacecheckpoint.Transaction, bool, error) { + if value.ConflictJSON == "" { + value.ConflictJSON = "[]" + } + if err := value.Validate(); err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + current, found, err := s.GetWorkspaceCheckpointTransaction(ctx, value.ID) + if err != nil || !found { + return workspacecheckpoint.Transaction{}, false, err + } + if !sameWorkspaceCheckpointTransactionIntent(current, value) { + return workspacecheckpoint.Transaction{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint transaction binding changed") + } + if sameWorkspaceCheckpointTransaction(current, value) { + return current, true, nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE workspace_checkpoint_transactions + SET after_checkpoint_id = ?, status = ?, recovery_level = ?, error_code = ?, + conflict_json = ?, updated_at = ?, completed_at = ? + WHERE id = ? AND status = ? AND updated_at = ?`, value.AfterCheckpointID, + value.Status, value.RecoveryLevel, value.ErrorCode, value.ConflictJSON, + ts(value.UpdatedAt), nullableWorkspaceCheckpointTime(value.CompletedAt), value.ID, + current.Status, ts(current.UpdatedAt)) + if err != nil { + return workspacecheckpoint.Transaction{}, false, + normalizeWorkspaceCheckpointStoreError(err) + } + count, err := result.RowsAffected() + if err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + if count != 1 { + return workspacecheckpoint.Transaction{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint transaction changed concurrently") + } + if value.Status.Terminal() { + missionID, lookupErr := workspaceCheckpointMissionID(ctx, tx, value.RunID) + if lookupErr != nil { + return workspacecheckpoint.Transaction{}, false, lookupErr + } + eventType := events.WorkspaceCheckpointTransactionCompletedEvent + if value.Status != workspacecheckpoint.TransactionCompleted { + eventType = events.WorkspaceCheckpointTransactionFailedEvent + } + event, eventErr := events.New(value.RunID, missionID, eventType, + "workspace_checkpoint", value.ID, + workspaceCheckpointTransactionEventPayload(value)) + if eventErr != nil { + return workspacecheckpoint.Transaction{}, false, eventErr + } + event.CreatedAt = value.UpdatedAt + if _, eventErr = insertRunEventTx(ctx, tx, event); eventErr != nil { + return workspacecheckpoint.Transaction{}, false, eventErr + } + } + if err := tx.Commit(); err != nil { + return workspacecheckpoint.Transaction{}, false, err + } + return value, false, nil +} + +func (s *SQLiteStore) GetWorkspaceCheckpointTransaction(ctx context.Context, + id string, +) (workspacecheckpoint.Transaction, bool, error) { + value, err := scanWorkspaceCheckpointTransaction(s.db.QueryRowContext(ctx, + `SELECT `+workspaceCheckpointTransactionColumns+` + FROM workspace_checkpoint_transactions WHERE id = ?`, strings.TrimSpace(id))) + if errors.Is(err, sql.ErrNoRows) { + return workspacecheckpoint.Transaction{}, false, nil + } + return value, err == nil, err +} + +func (s *SQLiteStore) GetWorkspaceCheckpointTransactionByOperation(ctx context.Context, + digest string, +) (workspacecheckpoint.Transaction, bool, error) { + value, err := scanWorkspaceCheckpointTransaction(s.db.QueryRowContext(ctx, + `SELECT `+workspaceCheckpointTransactionColumns+` + FROM workspace_checkpoint_transactions WHERE operation_key_digest = ?`, + strings.TrimSpace(digest))) + if errors.Is(err, sql.ErrNoRows) { + return workspacecheckpoint.Transaction{}, false, nil + } + return value, err == nil, err +} + +func (s *SQLiteStore) ListOpenWorkspaceCheckpointTransactions(ctx context.Context, + limit int, +) ([]workspacecheckpoint.Transaction, error) { + if limit < 1 || limit > 2_000 { + return nil, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint transaction list limit is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT `+workspaceCheckpointTransactionColumns+` + FROM workspace_checkpoint_transactions WHERE status IN ('prepared', 'applying') + ORDER BY updated_at, id LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]workspacecheckpoint.Transaction, 0, limit) + for rows.Next() { + value, err := scanWorkspaceCheckpointTransaction(rows) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +// ListWorkspaceCheckpointTransactionsPendingCursor finds the narrow crash +// window where a non-Fork transaction became terminal but its Run cursor still +// points at the transaction's before checkpoint. Fork intentionally never moves +// the source Run cursor and is excluded. +func (s *SQLiteStore) ListWorkspaceCheckpointTransactionsPendingCursor(ctx context.Context, + limit int, +) ([]workspacecheckpoint.Transaction, error) { + if limit < 1 || limit > 2_000 { + return nil, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint pending-cursor list limit is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT `+workspaceCheckpointTransactionColumns+` + FROM workspace_checkpoint_transactions WHERE id IN ( + SELECT transaction_record.id + FROM workspace_checkpoint_transactions transaction_record + JOIN workspace_checkpoint_run_state state + ON state.run_id = transaction_record.run_id + WHERE transaction_record.status IN ('completed', 'failed', 'interrupted') + AND transaction_record.kind != 'fork' + AND transaction_record.after_checkpoint_id != '' + AND state.current_checkpoint_id = transaction_record.before_checkpoint_id + AND state.last_transaction_id = '') + ORDER BY completed_at, id LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]workspacecheckpoint.Transaction, 0, limit) + for rows.Next() { + value, scanErr := scanWorkspaceCheckpointTransaction(rows) + if scanErr != nil { + return nil, scanErr + } + values = append(values, value) + } + return values, rows.Err() +} + +func (s *SQLiteStore) ListWorkspaceCheckpointTransactions(ctx context.Context, + runID string, limit int, +) ([]workspacecheckpoint.Transaction, error) { + runID = strings.TrimSpace(runID) + if runID == "" || limit < 1 || limit > 2_000 { + return nil, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint transaction list request is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT `+workspaceCheckpointTransactionColumns+` + FROM workspace_checkpoint_transactions WHERE run_id = ? + ORDER BY created_at DESC, id DESC LIMIT ?`, runID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]workspacecheckpoint.Transaction, 0, limit) + for rows.Next() { + value, err := scanWorkspaceCheckpointTransaction(rows) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func (s *SQLiteStore) WorkspaceCheckpointStorageUsage(ctx context.Context) ( + workspacecheckpoint.StorageUsage, error, +) { + var value workspacecheckpoint.StorageUsage + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) + FROM workspace_checkpoint_blobs`).Scan(&value.BlobCount, &value.BlobBytes); err != nil { + return workspacecheckpoint.StorageUsage{}, err + } + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspace_checkpoints + WHERE sealed = 1`).Scan(&value.CheckpointCount); err != nil { + return workspacecheckpoint.StorageUsage{}, err + } + return value, value.Validate() +} + +func (s *SQLiteStore) GetWorkspaceCheckpointRunState(ctx context.Context, + runID string, +) (workspacecheckpoint.RunState, bool, error) { + var value workspacecheckpoint.RunState + var updated string + err := s.db.QueryRowContext(ctx, `SELECT run_id, workspace_id, + current_checkpoint_id, last_transaction_id, updated_at + FROM workspace_checkpoint_run_state WHERE run_id = ?`, strings.TrimSpace(runID)). + Scan(&value.RunID, &value.WorkspaceID, &value.CurrentCheckpointID, + &value.LastTransactionID, &updated) + if errors.Is(err, sql.ErrNoRows) { + return workspacecheckpoint.RunState{}, false, nil + } + if err != nil { + return workspacecheckpoint.RunState{}, false, err + } + value.UpdatedAt = parseTS(updated) + if err := value.Validate(); err != nil { + return workspacecheckpoint.RunState{}, false, err + } + return value, true, nil +} + +// AdvanceWorkspaceCheckpointRunState is the durable compare-and-swap cursor. +// expectedCheckpointID must be empty only for the first checkpoint of a Run. +func (s *SQLiteStore) AdvanceWorkspaceCheckpointRunState(ctx context.Context, + value workspacecheckpoint.RunState, expectedCheckpointID string, +) (workspacecheckpoint.RunState, bool, error) { + if err := value.Validate(); err != nil { + return workspacecheckpoint.RunState{}, false, err + } + if expectedCheckpointID != "" && + (expectedCheckpointID != strings.TrimSpace(expectedCheckpointID) || + len([]rune(expectedCheckpointID)) > 256 || strings.ContainsRune(expectedCheckpointID, 0)) { + return workspacecheckpoint.RunState{}, false, apperror.New( + apperror.CodeInvalidArgument, "workspace checkpoint cursor expectation is invalid") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return workspacecheckpoint.RunState{}, false, err + } + defer tx.Rollback() + var current workspacecheckpoint.RunState + var updated string + err = tx.QueryRowContext(ctx, `SELECT run_id, workspace_id, current_checkpoint_id, + last_transaction_id, updated_at FROM workspace_checkpoint_run_state WHERE run_id = ?`, + value.RunID).Scan(¤t.RunID, ¤t.WorkspaceID, ¤t.CurrentCheckpointID, + ¤t.LastTransactionID, &updated) + switch { + case errors.Is(err, sql.ErrNoRows): + if expectedCheckpointID != "" { + return workspacecheckpoint.RunState{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint cursor is absent") + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workspace_checkpoint_run_state + (run_id, workspace_id, current_checkpoint_id, last_transaction_id, updated_at) + VALUES (?, ?, ?, ?, ?)`, value.RunID, value.WorkspaceID, + value.CurrentCheckpointID, value.LastTransactionID, ts(value.UpdatedAt)); err != nil { + return workspacecheckpoint.RunState{}, false, + normalizeWorkspaceCheckpointStoreError(err) + } + case err != nil: + return workspacecheckpoint.RunState{}, false, err + default: + current.UpdatedAt = parseTS(updated) + if err := current.Validate(); err != nil { + return workspacecheckpoint.RunState{}, false, err + } + if current.WorkspaceID != value.WorkspaceID || + current.CurrentCheckpointID != expectedCheckpointID { + return workspacecheckpoint.RunState{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint cursor changed concurrently") + } + if current.CurrentCheckpointID == value.CurrentCheckpointID && + current.LastTransactionID == value.LastTransactionID { + return current, true, nil + } + result, updateErr := tx.ExecContext(ctx, `UPDATE workspace_checkpoint_run_state + SET current_checkpoint_id = ?, last_transaction_id = ?, updated_at = ? + WHERE run_id = ? AND workspace_id = ? AND current_checkpoint_id = ? + AND updated_at = ?`, value.CurrentCheckpointID, value.LastTransactionID, + ts(value.UpdatedAt), value.RunID, value.WorkspaceID, expectedCheckpointID, + ts(current.UpdatedAt)) + if updateErr != nil { + return workspacecheckpoint.RunState{}, false, + normalizeWorkspaceCheckpointStoreError(updateErr) + } + count, updateErr := result.RowsAffected() + if updateErr != nil || count != 1 { + if updateErr != nil { + return workspacecheckpoint.RunState{}, false, updateErr + } + return workspacecheckpoint.RunState{}, false, apperror.New( + apperror.CodeConflict, "workspace checkpoint cursor changed concurrently") + } + } + if err := tx.Commit(); err != nil { + return workspacecheckpoint.RunState{}, false, err + } + return value, false, nil +} + +func (s *SQLiteStore) GetWorkspaceCheckpointInvocationAttempt(ctx context.Context, + runID, invocationID string, +) (string, bool, error) { + runID, invocationID = strings.TrimSpace(runID), strings.TrimSpace(invocationID) + if runID == "" || invocationID == "" { + return "", false, apperror.New(apperror.CodeInvalidArgument, + "workspace checkpoint invocation binding is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT attempt_id + FROM run_supervisor_tool_calls WHERE run_id = ? AND call_id = ? LIMIT 2`, + runID, invocationID) + if err != nil { + return "", false, err + } + defer rows.Close() + values := make([]string, 0, 2) + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return "", false, err + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return "", false, err + } + if len(values) == 0 { + return "", false, nil + } + if len(values) != 1 { + return "", false, apperror.New(apperror.CodeConflict, + "workspace checkpoint invocation has ambiguous attempts") + } + return values[0], true, nil +} + +func workspaceCheckpointMissionID(ctx context.Context, queryer workspaceCheckpointQueryer, + runID string, +) (string, error) { + var missionID string + if err := queryer.QueryRowContext(ctx, `SELECT mission_id FROM runs WHERE id = ?`, + runID).Scan(&missionID); err != nil { + return "", err + } + return missionID, nil +} + +func workspaceCheckpointTransactionEventPayload(value workspacecheckpoint.Transaction) map[string]any { + var conflicts []workspacecheckpoint.Conflict + _ = json.Unmarshal([]byte(value.ConflictJSON), &conflicts) + return map[string]any{"transaction_id": value.ID, "kind": value.Kind, + "workspace_id": value.WorkspaceID, "trigger_receipt_id": value.TriggerReceiptID, + "before_checkpoint_id": value.BeforeCheckpointID, + "after_checkpoint_id": value.AfterCheckpointID, + "expected_current_checkpoint_id": value.ExpectedCurrentCheckpointID, + "target_checkpoint_id": value.TargetCheckpointID, "status": value.Status, + "recovery_level": value.RecoveryLevel, "error_code": value.ErrorCode, + "conflict_count": len(conflicts)} +} + +func getWorkspaceCheckpoint(ctx context.Context, queryer workspaceCheckpointQueryer, + id string, +) (workspacecheckpoint.Checkpoint, error) { + return scanWorkspaceCheckpoint(queryer.QueryRowContext(ctx, + `SELECT `+workspaceCheckpointColumns+` + FROM workspace_checkpoints WHERE id = ? AND sealed = 1`, id)) +} + +type workspaceCheckpointScanner interface { + Scan(...any) error +} + +func scanWorkspaceCheckpoint(scanner workspaceCheckpointScanner) ( + workspacecheckpoint.Checkpoint, error, +) { + var value workspacecheckpoint.Checkpoint + var reasonsJSON, created string + if err := scanner.Scan(&value.ID, &value.ProtocolVersion, &value.RunID, + &value.MissionID, &value.SessionID, &value.WorkspaceID, &value.AttemptID, + &value.CapabilityGeneration, &value.Trigger, &value.Phase, + &value.TriggerReceiptID, &value.RequestedBy, &value.Title, + &value.ParentCheckpointID, &value.RootFingerprint, + &value.RootPathSHA256, &value.BaseCommit, &value.Branch, &value.IndexSHA256, + &value.IndexBlobSHA256, &value.ManifestSHA256, &value.RecoveryLevel, + &reasonsJSON, &value.EntryCount, &value.StoredBytes, &created); err != nil { + return workspacecheckpoint.Checkpoint{}, err + } + if err := json.Unmarshal([]byte(reasonsJSON), &value.IncompleteReasons); err != nil { + return workspacecheckpoint.Checkpoint{}, err + } + value.CreatedAt = parseTS(created) + return value, value.Validate() +} + +func scanWorkspaceCheckpointTransaction(scanner workspaceCheckpointScanner) ( + workspacecheckpoint.Transaction, error, +) { + var value workspacecheckpoint.Transaction + var created, updated string + var completed sql.NullString + if err := scanner.Scan(&value.ID, &value.ProtocolVersion, &value.OperationKeyDigest, + &value.RequestFingerprint, &value.RunID, &value.WorkspaceID, &value.Kind, + &value.TriggerReceiptID, &value.BeforeCheckpointID, &value.AfterCheckpointID, + &value.ExpectedCurrentCheckpointID, &value.TargetCheckpointID, + &value.ForkWorkspaceRoot, &value.ForkBranch, &value.Status, + &value.RecoveryLevel, &value.ErrorCode, &value.ConflictJSON, &created, &updated, + &completed); err != nil { + return workspacecheckpoint.Transaction{}, err + } + value.CreatedAt = parseTS(created) + value.UpdatedAt = parseTS(updated) + if completed.Valid { + at := parseTS(completed.String) + value.CompletedAt = &at + } + return value, value.Validate() +} + +func sameWorkspaceCheckpoint(left, right workspacecheckpoint.Checkpoint) bool { + return left.ID == right.ID && left.ProtocolVersion == right.ProtocolVersion && + left.RunID == right.RunID && left.MissionID == right.MissionID && + left.SessionID == right.SessionID && left.WorkspaceID == right.WorkspaceID && + left.AttemptID == right.AttemptID && + left.CapabilityGeneration == right.CapabilityGeneration && + left.Trigger == right.Trigger && left.Phase == right.Phase && + left.TriggerReceiptID == right.TriggerReceiptID && + left.RequestedBy == right.RequestedBy && left.Title == right.Title && + left.ParentCheckpointID == right.ParentCheckpointID && + left.RootFingerprint == right.RootFingerprint && + left.RootPathSHA256 == right.RootPathSHA256 && + left.BaseCommit == right.BaseCommit && left.Branch == right.Branch && + left.IndexSHA256 == right.IndexSHA256 && + left.IndexBlobSHA256 == right.IndexBlobSHA256 && + left.ManifestSHA256 == right.ManifestSHA256 && + left.RecoveryLevel == right.RecoveryLevel && left.EntryCount == right.EntryCount && + left.StoredBytes == right.StoredBytes && + strings.Join(left.IncompleteReasons, "\x00") == strings.Join(right.IncompleteReasons, "\x00") +} + +func sameWorkspaceCheckpointTransactionIntent(left, + right workspacecheckpoint.Transaction, +) bool { + return left.ProtocolVersion == right.ProtocolVersion && + left.OperationKeyDigest == right.OperationKeyDigest && + left.RequestFingerprint == right.RequestFingerprint && left.RunID == right.RunID && + left.WorkspaceID == right.WorkspaceID && left.Kind == right.Kind && + left.TriggerReceiptID == right.TriggerReceiptID && + left.BeforeCheckpointID == right.BeforeCheckpointID && + left.ExpectedCurrentCheckpointID == right.ExpectedCurrentCheckpointID && + left.TargetCheckpointID == right.TargetCheckpointID && + left.ForkWorkspaceRoot == right.ForkWorkspaceRoot && + left.ForkBranch == right.ForkBranch +} + +func sameWorkspaceCheckpointTransaction(left, right workspacecheckpoint.Transaction) bool { + if !sameWorkspaceCheckpointTransactionIntent(left, right) { + return false + } + if left.AfterCheckpointID != right.AfterCheckpointID || left.Status != right.Status || + left.RecoveryLevel != right.RecoveryLevel || left.ErrorCode != right.ErrorCode || + left.ConflictJSON != right.ConflictJSON || !left.UpdatedAt.Equal(right.UpdatedAt) { + return false + } + if left.CompletedAt == nil || right.CompletedAt == nil { + return left.CompletedAt == nil && right.CompletedAt == nil + } + return left.CompletedAt.Equal(*right.CompletedAt) +} + +func nullableWorkspaceCheckpointTime(value *time.Time) any { + if value == nil { + return nil + } + return ts(value.UTC()) +} + +func normalizeWorkspaceCheckpointStoreError(err error) error { + if err == nil { + return nil + } + message := strings.ToLower(err.Error()) + if strings.Contains(message, "workspace checkpoint blob store quota exceeded") { + return apperror.Wrap(apperror.CodeResourceExhausted, + "workspace checkpoint content store quota is exhausted", err) + } + if strings.Contains(message, "workspace checkpoint metadata quota exceeded") || + strings.Contains(message, "workspace checkpoint transaction quota exceeded") { + return apperror.Wrap(apperror.CodeResourceExhausted, + "workspace checkpoint metadata store quota is exhausted", err) + } + if strings.Contains(message, "unique") || strings.Contains(message, "constraint") || + strings.Contains(message, "workspace checkpoint") { + return apperror.Wrap(apperror.CodeConflict, + "workspace checkpoint persistence conflict", err) + } + return err +} diff --git a/internal/store/workspace_checkpoints_test.go b/internal/store/workspace_checkpoints_test.go new file mode 100644 index 00000000..7e70ad53 --- /dev/null +++ b/internal/store/workspace_checkpoints_test.go @@ -0,0 +1,371 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +func TestWorkspaceCheckpointStoreSealsContentAndReplaysSemanticIntent(t *testing.T) { + ctx := context.Background() + state, runRecord, mission, workspaceRoot := newWorkspaceCheckpointStoreFixture(t) + defer state.Close() + now := time.Date(2026, 8, 19, 4, 0, 0, 0, time.UTC) + before := captureStoreCheckpoint(t, runRecord, mission, workspaceRoot, + "checkpoint-before", "receipt-before", workspacecheckpoint.PhaseBefore, now) + created, replayed, err := state.CreateWorkspaceCheckpoint(ctx, before) + if err != nil || replayed || created.ID != before.Checkpoint.ID { + t.Fatalf("create=%+v replayed=%t err=%v", created, replayed, err) + } + replayedCheckpoint, replayed, err := state.CreateWorkspaceCheckpoint(ctx, before) + if err != nil || !replayed || replayedCheckpoint.ID != created.ID { + t.Fatalf("checkpoint replay=%+v replayed=%t err=%v", + replayedCheckpoint, replayed, err) + } + loaded, err := state.GetWorkspaceCheckpointSnapshot(ctx, created.ID) + if err != nil || loaded.Checkpoint.ManifestSHA256 != before.Checkpoint.ManifestSHA256 || + len(loaded.Blobs) != len(before.Blobs) { + t.Fatalf("loaded checkpoint=%+v err=%v", loaded.Checkpoint, err) + } + for _, blob := range loaded.Blobs { + var references int + if err := state.db.QueryRowContext(ctx, `SELECT reference_count + FROM workspace_checkpoint_blobs WHERE sha256 = ?`, blob.SHA256). + Scan(&references); err != nil || references < 1 { + t.Fatalf("blob %s references=%d err=%v", blob.SHA256, references, err) + } + } + if _, err := state.db.ExecContext(ctx, `UPDATE workspace_checkpoints + SET trigger_receipt_id = 'tampered' WHERE id = ?`, created.ID); err == nil { + t.Fatal("sealed checkpoint accepted an update") + } + if _, err := state.db.ExecContext(ctx, `INSERT INTO workspace_checkpoint_entries + (checkpoint_id, path, kind, worktree_state, storage_policy, mode, size_bytes, + worktree_sha256, blob_sha256, index_oid, index_mode, tracked, staged, + binary, line_endings, recoverable, reason) + VALUES (?, 'late.txt', 'file', 'missing', 'missing', 0, 0, 'missing', '', '', '', + 0, 0, 0, '', 1, '')`, created.ID); err == nil { + t.Fatal("sealed checkpoint accepted a late manifest entry") + } + + mustCheckpointStoreWrite(t, filepath.Join(workspaceRoot, "file.txt"), []byte("after\n")) + after := captureStoreCheckpointWithParent(t, runRecord, mission, workspaceRoot, + "checkpoint-after", "receipt-after", before.Checkpoint.ID, + workspacecheckpoint.PhaseAfter, now.Add(time.Minute)) + if _, _, err := state.CreateWorkspaceCheckpoint(ctx, after); err != nil { + t.Fatal(err) + } + + transaction := workspacecheckpoint.Transaction{ID: "transaction-generated-a", + ProtocolVersion: workspacecheckpoint.ProtocolVersion, + OperationKeyDigest: storeCheckpointDigest("operation"), + RequestFingerprint: storeCheckpointDigest("request"), RunID: runRecord.ID, + WorkspaceID: mission.WorkspaceID, Kind: workspacecheckpoint.TransactionFileTool, + TriggerReceiptID: "receipt-file-tool", BeforeCheckpointID: before.Checkpoint.ID, + ExpectedCurrentCheckpointID: before.Checkpoint.ID, + TargetCheckpointID: after.Checkpoint.ID, + Status: workspacecheckpoint.TransactionPrepared, + RecoveryLevel: workspacecheckpoint.RecoveryComplete, ConflictJSON: "[]", + CreatedAt: now.Add(2 * time.Minute), UpdatedAt: now.Add(2 * time.Minute)} + stored, replayed, err := state.CreateWorkspaceCheckpointTransaction(ctx, transaction) + if err != nil || replayed { + t.Fatalf("transaction=%+v replayed=%t err=%v", stored, replayed, err) + } + semanticReplay := transaction + semanticReplay.ID = "transaction-generated-b" + semanticReplay.CreatedAt = semanticReplay.CreatedAt.Add(time.Hour) + semanticReplay.UpdatedAt = semanticReplay.CreatedAt + converged, replayed, err := state.CreateWorkspaceCheckpointTransaction(ctx, semanticReplay) + if err != nil || !replayed || converged.ID != stored.ID { + t.Fatalf("semantic replay=%+v replayed=%t err=%v", converged, replayed, err) + } + completedAt := now.Add(3 * time.Minute) + stored.Status = workspacecheckpoint.TransactionCompleted + stored.AfterCheckpointID = after.Checkpoint.ID + stored.UpdatedAt = completedAt + stored.CompletedAt = &completedAt + completed, replayed, err := state.UpdateWorkspaceCheckpointTransaction(ctx, stored) + if err != nil || replayed || completed.Status != workspacecheckpoint.TransactionCompleted { + t.Fatalf("complete=%+v replayed=%t err=%v", completed, replayed, err) + } + terminalMutation := completed + terminalMutation.Status = workspacecheckpoint.TransactionFailed + terminalMutation.ErrorCode = "tampered" + terminalMutation.AfterCheckpointID = "" + terminalMutation.UpdatedAt = completedAt.Add(time.Minute) + terminalMutation.CompletedAt = &terminalMutation.UpdatedAt + if _, _, err := state.UpdateWorkspaceCheckpointTransaction(ctx, + terminalMutation); apperror.CodeOf(err) != apperror.CodeConflict { + t.Fatalf("terminal transaction mutation error=%v", err) + } + + orphan := []byte("unreferenced checkpoint blob") + orphanHash := sha256.Sum256(orphan) + if _, err := state.db.ExecContext(ctx, `INSERT INTO workspace_checkpoint_blobs + (sha256, size_bytes, content, reference_count, created_at) VALUES (?, ?, ?, 0, ?)`, + hex.EncodeToString(orphanHash[:]), len(orphan), orphan, ts(now)); err != nil { + t.Fatal(err) + } + removed, err := state.GarbageCollectWorkspaceCheckpointBlobs(ctx, 10) + if err != nil || removed != 1 { + t.Fatalf("GC removed=%d err=%v", removed, err) + } + if _, err := state.db.ExecContext(ctx, + `DROP TRIGGER trg_workspace_checkpoint_transaction_quota`); err != nil { + t.Fatal(err) + } + if _, err := state.db.ExecContext(ctx, `CREATE TRIGGER trg_workspace_checkpoint_transaction_quota + BEFORE INSERT ON workspace_checkpoint_transactions + WHEN (SELECT COUNT(*) FROM workspace_checkpoint_transactions) >= 1 + BEGIN SELECT RAISE(ABORT, 'workspace checkpoint transaction quota exceeded'); END;`); err != nil { + t.Fatal(err) + } + blocked := transaction + blocked.ID = "transaction-blocked-by-metadata-quota" + blocked.OperationKeyDigest = storeCheckpointDigest("operation-blocked-by-quota") + blocked.RequestFingerprint = storeCheckpointDigest("request-blocked-by-quota") + blocked.CreatedAt, blocked.UpdatedAt = now.Add(4*time.Minute), now.Add(4*time.Minute) + if _, _, err := state.CreateWorkspaceCheckpointTransaction(ctx, blocked); apperror.CodeOf(err) != apperror.CodeResourceExhausted { + t.Fatalf("transaction quota error=%v", err) + } +} + +func TestSchemaV117UpgradeAddsWorkspaceCheckpointLedgerWithoutRewritingRuns(t *testing.T) { + ctx := context.Background() + databasePath := filepath.Join(t.TempDir(), "workspace-checkpoint-v116.db") + state, err := Open(databasePath) + if err != nil { + t.Fatal(err) + } + workspaceRoot := newWorkspaceCheckpointGitRepository(t) + workspace := WorkspaceRecord{ID: "workspace-migration-117", Name: "migration-117", + RootPath: workspaceRoot} + if err := state.SaveWorkspace(ctx, workspace); err != nil { + t.Fatal(err) + } + _, runRecord, err := application.NewRunService(state).Create(ctx, + application.CreateRunRequest{Goal: "preserve Run across v117 migration", + Profile: "code", WorkspaceID: workspace.ID, + Budget: domain.Budget{MaxTurns: 2, MaxTokens: 500, MaxToolCalls: 4}}) + if err != nil { + t.Fatal(err) + } + for _, statement := range removeSchemaV117ForTestStatements() { + if _, err := state.db.ExecContext(ctx, statement); err != nil { + _ = state.Close() + t.Fatalf("downgrade v117 with %q: %v", statement, err) + } + } + if err := state.Close(); err != nil { + t.Fatal(err) + } + upgraded, err := Open(databasePath) + if err != nil { + t.Fatal(err) + } + defer upgraded.Close() + loadedRun, err := upgraded.GetRun(ctx, runRecord.ID) + if err != nil || loadedRun.ID != runRecord.ID { + t.Fatalf("Run after v117 migration=%+v err=%v", loadedRun, err) + } + var version int + if err := upgraded.db.QueryRowContext(ctx, `SELECT MAX(version) FROM schema_migrations`). + Scan(&version); err != nil || version != LatestSchemaVersion { + t.Fatalf("schema version=%d err=%v", version, err) + } + for _, table := range []string{"workspace_checkpoint_blobs", "workspace_checkpoints", + "workspace_checkpoint_entries", "workspace_checkpoint_transactions", + "workspace_checkpoint_run_state"} { + var count int + if err := upgraded.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil || count != 1 { + t.Fatalf("table %s count=%d err=%v", table, count, err) + } + } + var quotaTriggerSQL string + if err := upgraded.db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master + WHERE type = 'trigger' AND name = 'trg_workspace_checkpoint_blob_store_quota'`). + Scan("aTriggerSQL); err != nil { + t.Fatal(err) + } + if !strings.Contains(quotaTriggerSQL, "2147483648") || + !strings.Contains(quotaTriggerSQL, "SUM(size_bytes)") || + !strings.Contains(quotaTriggerSQL, "sha256 = NEW.sha256") { + t.Fatalf("unexpected workspace checkpoint blob quota trigger: %s", quotaTriggerSQL) + } + for name, fragments := range map[string][]string{ + "trg_workspace_checkpoint_insert_unsealed": {"NEW.sealed != 0"}, + "trg_workspace_checkpoint_metadata_quota": {"10000", "2000000", "SUM(entry_count)"}, + "trg_workspace_checkpoint_transaction_quota": {"20000", "COUNT(*)"}, + } { + var triggerSQL string + if err := upgraded.db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master + WHERE type = 'trigger' AND name = ?`, name).Scan(&triggerSQL); err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if !strings.Contains(triggerSQL, fragment) { + t.Fatalf("trigger %s does not contain %q: %s", name, fragment, triggerSQL) + } + } + } +} + +func TestWorkspaceCheckpointMetadataQuotaRollsBackCandidateBlobs(t *testing.T) { + ctx := t.Context() + state, runRecord, mission, workspaceRoot := newWorkspaceCheckpointStoreFixture(t) + defer state.Close() + candidate := captureStoreCheckpoint(t, runRecord, mission, workspaceRoot, + "checkpoint-after-metadata-quota", "receipt-after-metadata-quota", + workspacecheckpoint.PhaseStandalone, time.Now().UTC()) + c := candidate.Checkpoint + _, err := state.db.ExecContext(ctx, `WITH RECURSIVE counter(value) AS ( + SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < ? + ) INSERT INTO workspace_checkpoints + (id, protocol_version, run_id, mission_id, session_id, workspace_id, attempt_id, + capability_generation, trigger_kind, phase, trigger_receipt_id, requested_by, + title, parent_checkpoint_id, root_fingerprint, root_path_sha256, base_commit, + branch, index_sha256, index_blob_sha256, manifest_sha256, recovery_level, + incomplete_reasons_json, entry_count, stored_bytes, sealed, created_at) + SELECT 'quota-checkpoint-' || value, ?, ?, ?, ?, ?, '', '', 'manual', 'standalone', + 'quota-receipt-' || value, '', '', '', ?, ?, ?, ?, ?, '', ?, 'complete', + '[]', 0, 0, 0, ? FROM counter`, workspacecheckpoint.MaxStoreCheckpoints, + c.ProtocolVersion, c.RunID, c.MissionID, c.SessionID, c.WorkspaceID, + c.RootFingerprint, c.RootPathSHA256, c.BaseCommit, c.Branch, c.IndexSHA256, + c.ManifestSHA256, ts(c.CreatedAt)) + if err != nil { + t.Fatal(err) + } + var blobsBefore int + if err := state.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM workspace_checkpoint_blobs`).Scan(&blobsBefore); err != nil { + t.Fatal(err) + } + if _, _, err = state.CreateWorkspaceCheckpoint(ctx, candidate); apperror.CodeOf(err) != apperror.CodeResourceExhausted { + t.Fatalf("metadata quota error=%v", err) + } + var blobsAfter int + if err := state.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM workspace_checkpoint_blobs`).Scan(&blobsAfter); err != nil { + t.Fatal(err) + } + if blobsAfter != blobsBefore { + t.Fatalf("metadata quota left candidate blobs: before=%d after=%d", blobsBefore, blobsAfter) + } +} + +// removeSchemaV117ForTestStatements restores a v116 database. Historical +// migration tests all flow through removeSchemaV116ForTestStatements, so this +// helper must remain the first link in that downgrade chain. +func removeSchemaV117ForTestStatements() []string { + return []string{ + `DROP TABLE workspace_checkpoint_run_state`, + `DROP TABLE workspace_checkpoint_transactions`, + `DROP TABLE workspace_checkpoint_entries`, + `DROP TABLE workspace_checkpoints`, + `DROP TABLE workspace_checkpoint_blobs`, + `DELETE FROM schema_migrations WHERE version = 117`, + } +} + +func newWorkspaceCheckpointStoreFixture(t *testing.T) (*SQLiteStore, domain.Run, + domain.Mission, string, +) { + t.Helper() + ctx := context.Background() + state, err := Open(filepath.Join(t.TempDir(), "workspace-checkpoints.db")) + if err != nil { + t.Fatal(err) + } + workspaceRoot := newWorkspaceCheckpointGitRepository(t) + workspace := WorkspaceRecord{ID: "workspace-checkpoint", Name: "checkpoint", + RootPath: workspaceRoot} + if err := state.SaveWorkspace(ctx, workspace); err != nil { + state.Close() + t.Fatal(err) + } + mission, runRecord, err := application.NewRunService(state).Create(ctx, + application.CreateRunRequest{Goal: "test reversible Workspace checkpoints", + Profile: "code", WorkspaceID: workspace.ID, + Budget: domain.Budget{MaxTurns: 4, MaxTokens: 1000, MaxToolCalls: 8}}) + if err != nil { + state.Close() + t.Fatal(err) + } + return state, runRecord, mission, workspaceRoot +} + +func captureStoreCheckpoint(t *testing.T, runRecord domain.Run, mission domain.Mission, + root, id, receipt string, phase workspacecheckpoint.Phase, at time.Time, +) workspacecheckpoint.Snapshot { + t.Helper() + return captureStoreCheckpointWithParent(t, runRecord, mission, root, id, receipt, "", + phase, at) +} + +func captureStoreCheckpointWithParent(t *testing.T, runRecord domain.Run, + mission domain.Mission, root, id, receipt, parent string, + phase workspacecheckpoint.Phase, at time.Time, +) workspacecheckpoint.Snapshot { + t.Helper() + snapshot, err := workspacecheckpoint.Capture(t.Context(), workspacecheckpoint.CaptureRequest{ + ID: id, RunID: runRecord.ID, MissionID: mission.ID, SessionID: runRecord.SessionID, + WorkspaceID: mission.WorkspaceID, WorkspaceRoot: root, Trigger: workspacecheckpoint.TriggerFileTool, + Phase: phase, TriggerReceiptID: receipt, ParentCheckpointID: parent, CreatedAt: at}) + if err != nil { + t.Fatal(err) + } + return snapshot +} + +func newWorkspaceCheckpointGitRepository(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is unavailable") + } + root := t.TempDir() + runWorkspaceCheckpointGit(t, root, "init", "-q") + runWorkspaceCheckpointGit(t, root, "config", "user.email", "checkpoint@example.invalid") + runWorkspaceCheckpointGit(t, root, "config", "user.name", "Checkpoint Test") + mustCheckpointStoreWrite(t, filepath.Join(root, "file.txt"), []byte("before\n")) + runWorkspaceCheckpointGit(t, root, "add", ".") + runWorkspaceCheckpointGit(t, root, "commit", "-m", "baseline") + return root +} + +func runWorkspaceCheckpointGit(t *testing.T, root string, args ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", root}, args...)...) + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func mustCheckpointStoreWrite(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } +} + +func storeCheckpointDigest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/toolgateway/command_runtime.go b/internal/toolgateway/command_runtime.go index ce87400f..b119488b 100644 --- a/internal/toolgateway/command_runtime.go +++ b/internal/toolgateway/command_runtime.go @@ -139,16 +139,17 @@ func (i CommandRuntimeInput) Validate() error { } type CommandRuntimeContext struct { - InvocationID string - OperationKey string - RunID string - RootAgentID string - SessionID string - WorkspaceID string - LeaseID string - LeaseGeneration int64 - RequestedBy string - PolicyDecision Decision + InvocationID string + OperationKey string + RunID string + RootAgentID string + SessionID string + WorkspaceID string + CapabilityGeneration string + LeaseID string + LeaseGeneration int64 + RequestedBy string + PolicyDecision Decision } func (c CommandRuntimeContext) Validate() error { @@ -159,7 +160,8 @@ func (c CommandRuntimeContext) Validate() error { return errors.New("command runtime requires normalized bounded identities") } } - if !domain.ValidAgentID(c.RootAgentID) || c.LeaseGeneration <= 0 || + if !domain.ValidAgentID(c.RootAgentID) || + !validAgentCodeDigest(c.CapabilityGeneration, false) || c.LeaseGeneration <= 0 || c.RequestedBy != "run_supervisor" || c.PolicyDecision.Validate() != nil || !c.PolicyDecision.Allowed || c.PolicyDecision.Approval != ApprovalAutomatic { return errors.New("command runtime requires an automatically authorized fenced root scope") @@ -463,7 +465,8 @@ func (g *Gateway) invokeCommandRuntime(ctx context.Context, call ToolCall) ( scope := CommandRuntimeContext{InvocationID: call.InvocationID, OperationKey: call.OperationKey, RunID: call.RunID, RootAgentID: call.AgentID, SessionID: call.SessionID, WorkspaceID: call.WorkspaceID, - LeaseID: call.LeaseID, LeaseGeneration: call.LeaseGeneration, + CapabilityGeneration: call.CapabilityGeneration, + LeaseID: call.LeaseID, LeaseGeneration: call.LeaseGeneration, RequestedBy: call.RequestedBy, PolicyDecision: decision} if err := scope.Validate(); err != nil { return Outcome{}, err diff --git a/internal/toolgateway/command_runtime_test.go b/internal/toolgateway/command_runtime_test.go index 0ef85ac5..1f6b80e9 100644 --- a/internal/toolgateway/command_runtime_test.go +++ b/internal/toolgateway/command_runtime_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/policy" "cyberagent-workbench/internal/runner" ) @@ -189,5 +190,10 @@ func commandRuntimeToolCall(payload json.RawMessage) ToolCall { return ToolCall{Name: CommandRuntimeTool, Payload: payload, OperationKey: "command-runtime-operation-0001", RunID: "run-1", AgentID: "agent-root-1", SessionID: "session-1", WorkspaceID: "workspace-1", - LeaseID: "lease-1", LeaseGeneration: 7, RequestedBy: "run_supervisor"} + Surface: domain.ExecutionSurfaceCode, Phase: domain.ExecutionPhaseDeliver, + Role: domain.AgentRoleRoot, Profile: domain.ProfileCode, + PermissionMode: domain.RunExecutionPermissionFullAccess, + ModeRevision: 1, PermissionRevision: 1, + CapabilityGeneration: strings.Repeat("a", 64), + LeaseID: "lease-1", LeaseGeneration: 7, RequestedBy: "run_supervisor"} } diff --git a/internal/toolgateway/model.go b/internal/toolgateway/model.go index 746bc12b..a71e8573 100644 --- a/internal/toolgateway/model.go +++ b/internal/toolgateway/model.go @@ -252,14 +252,14 @@ func NormalizeToolCall(call ToolCall) (ToolCall, error) { if call.ModeRevision < 0 || call.PermissionRevision < 0 { return ToolCall{}, errors.New("tool capability revisions cannot be negative") } - if isAgentCodeTool(call.Name) { + if isAgentCodeTool(call.Name) || call.Name == CommandRuntimeTool { if !call.Surface.Valid() || !call.Phase.Valid() || !domain.ValidAgentRole(call.Role) || !call.PermissionMode.Valid() || call.ModeRevision <= 0 || call.PermissionRevision <= 0 || !validAgentCodeDigest(call.CapabilityGeneration, false) { - return ToolCall{}, errors.New("agent code tool capability binding is invalid") + return ToolCall{}, errors.New("workspace-affecting tool capability binding is invalid") } if _, err := domain.ParseProfile(string(call.Profile)); err != nil { - return ToolCall{}, errors.New("agent code tool profile binding is invalid") + return ToolCall{}, errors.New("workspace-affecting tool profile binding is invalid") } } if strings.ContainsRune(call.OperationKey, 0) { diff --git a/internal/workspace/agent_code.go b/internal/workspace/agent_code.go index a512a4d5..a67adeca 100644 --- a/internal/workspace/agent_code.go +++ b/internal/workspace/agent_code.go @@ -20,6 +20,7 @@ import ( "cyberagent-workbench/internal/apperror" "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/workspaceidentity" ) const ( @@ -597,13 +598,12 @@ func openAgentWorkspace(root string) (agentWorkspace, error) { return agentWorkspace{}, apperror.New(apperror.CodeFailedPrecondition, "workspace root identity could not be inspected") } - identity, err := agentCodeRootIdentity(canonical, canonicalInfo) + fingerprint, err := workspaceidentity.Fingerprint(canonical) if err != nil { return agentWorkspace{}, apperror.New(apperror.CodeFailedPrecondition, "workspace root identity could not be established") } - sum := sha256.Sum256([]byte(filepath.ToSlash(canonical) + "\x00" + identity)) - workspace := agentWorkspace{root: canonical, fingerprint: hex.EncodeToString(sum[:])} + workspace := agentWorkspace{root: canonical, fingerprint: fingerprint} workspace.ignore = loadAgentIgnoreRules(canonical) return workspace, nil } diff --git a/internal/workspacecheckpoint/capture.go b/internal/workspacecheckpoint/capture.go new file mode 100644 index 00000000..b9090964 --- /dev/null +++ b/internal/workspacecheckpoint/capture.go @@ -0,0 +1,766 @@ +package workspacecheckpoint + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/workspaceidentity" +) + +const maxGitCaptureOutputBytes = 32 * 1024 * 1024 + +type gitIndexEntry struct { + OID string + Mode string +} + +type captureInventory struct { + baseCommit string + branch string + indexPath string + tracked map[string]gitIndexEntry + untracked map[string]struct{} + ignored map[string]struct{} + staged map[string]struct{} + git bool + truncated bool +} + +// Capture builds a deterministic, bounded snapshot without modifying either +// the Workspace or its Git index. Raw content is returned only as verified, +// content-addressed blobs for the store to commit transactionally. +func Capture(ctx context.Context, request CaptureRequest) (Snapshot, error) { + if ctx == nil || ctx.Err() != nil { + return Snapshot{}, context.Canceled + } + if request.CreatedAt.IsZero() { + request.CreatedAt = time.Now().UTC() + } + if err := request.Validate(); err != nil { + return Snapshot{}, err + } + root, err := canonicalRoot(request.WorkspaceRoot) + if err != nil { + return Snapshot{}, err + } + rootFingerprint, err := workspaceidentity.Fingerprint(root) + if err != nil { + return Snapshot{}, fmt.Errorf("capture Workspace identity: %w", err) + } + rootPathDigest := sha256.Sum256([]byte(canonicalPathIdentity(root))) + inventory, err := inspectCaptureInventory(ctx, root) + if err != nil { + return Snapshot{}, err + } + + reasons := make(map[string]struct{}) + level := RecoveryComplete + for _, reason := range request.IncompleteReasons { + reasons[reason] = struct{}{} + level = RecoveryPartial + } + if !inventory.git { + level = RecoveryUnavailable + reasons["workspace is not an exact Git worktree"] = struct{}{} + } + if inventory.truncated { + level = RecoveryUnavailable + reasons["workspace manifest exceeds the entry limit"] = struct{}{} + } + + blobByDigest := make(map[string]Blob) + storedBytes := int64(0) + indexSHA := sha256.Sum256(nil) + indexBlobSHA := "" + if inventory.git && inventory.indexPath != "" { + indexRaw, readErr := os.ReadFile(inventory.indexPath) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return Snapshot{}, fmt.Errorf("read Git index: %w", readErr) + } + indexSHA = sha256.Sum256(indexRaw) + if errors.Is(readErr, os.ErrNotExist) { + // A missing index is a legitimate, exactly recoverable state for a + // newly initialized repository. The empty digest plus an absent blob + // distinguishes it from an existing zero-byte index. + } else if len(indexRaw) <= MaxStoredIndexBytes && int64(len(indexRaw)) <= MaxCheckpointBlobBytes { + indexBlobSHA = addCaptureBlob(blobByDigest, indexRaw, request.CreatedAt) + storedBytes = captureBlobBytes(blobByDigest) + } else { + level = RecoveryPartial + reasons["Git index exceeds the checkpoint limit"] = struct{}{} + } + } + + paths := capturePaths(inventory) + entries := make([]Entry, 0, len(paths)) + casePaths := make(map[string]string, len(paths)) + for _, path := range paths { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + caseKey := path + if runtime.GOOS == "windows" { + caseKey = strings.ToLower(path) + } + if previous, exists := casePaths[caseKey]; exists && previous != path { + level = RecoveryUnavailable + reasons["case-colliding Workspace paths cannot be restored safely"] = struct{}{} + } + casePaths[caseKey] = path + if runtime.GOOS == "windows" { + actualPath, exists, caseErr := actualCapturePathCase(root, path) + if caseErr != nil { + return Snapshot{}, caseErr + } + if exists && actualPath != path { + level = RecoveryUnavailable + reasons["Workspace path casing differs from the Git index"] = struct{}{} + } + } + entry, content, incomplete, entryErr := captureEntry(ctx, root, path, inventory) + if entryErr != nil { + return Snapshot{}, entryErr + } + if incomplete != "" { + if level == RecoveryComplete { + level = RecoveryPartial + } + reasons[incomplete] = struct{}{} + } + if content != nil { + digest := sha256.Sum256(content) + additionalBytes := int64(len(content)) + if _, exists := blobByDigest[hex.EncodeToString(digest[:])]; exists { + additionalBytes = 0 + } + if storedBytes+additionalBytes > MaxCheckpointBlobBytes { + entry.BlobSHA256 = "" + entry.StoragePolicy = StorageExcludedLarge + entry.Recoverable = false + entry.Reason = "checkpoint blob quota exceeded" + if level == RecoveryComplete { + level = RecoveryPartial + } + reasons["checkpoint blob quota excluded recoverable content"] = struct{}{} + } else { + entry.BlobSHA256 = addCaptureBlob(blobByDigest, content, request.CreatedAt) + storedBytes = captureBlobBytes(blobByDigest) + } + } + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + manifestJSON, err := json.Marshal(entries) + if err != nil { + return Snapshot{}, err + } + manifestSHA := sha256.Sum256(manifestJSON) + incompleteReasons := make([]string, 0, len(reasons)) + for reason := range reasons { + incompleteReasons = append(incompleteReasons, reason) + } + sort.Strings(incompleteReasons) + blobs := make([]Blob, 0, len(blobByDigest)) + for _, blob := range blobByDigest { + blobs = append(blobs, blob) + } + sort.Slice(blobs, func(i, j int) bool { return blobs[i].SHA256 < blobs[j].SHA256 }) + checkpoint := Checkpoint{ID: request.ID, ProtocolVersion: ProtocolVersion, + RunID: request.RunID, MissionID: request.MissionID, SessionID: request.SessionID, + WorkspaceID: request.WorkspaceID, AttemptID: request.AttemptID, + CapabilityGeneration: request.CapabilityGeneration, Trigger: request.Trigger, + Phase: request.Phase, TriggerReceiptID: request.TriggerReceiptID, + RequestedBy: request.RequestedBy, Title: request.Title, + ParentCheckpointID: request.ParentCheckpointID, + RootFingerprint: rootFingerprint, RootPathSHA256: hex.EncodeToString(rootPathDigest[:]), + BaseCommit: inventory.baseCommit, Branch: inventory.branch, + IndexSHA256: hex.EncodeToString(indexSHA[:]), IndexBlobSHA256: indexBlobSHA, + ManifestSHA256: hex.EncodeToString(manifestSHA[:]), RecoveryLevel: level, + IncompleteReasons: incompleteReasons, EntryCount: len(entries), + StoredBytes: storedBytes, CreatedAt: request.CreatedAt.UTC()} + snapshot := Snapshot{Checkpoint: checkpoint, Entries: entries, Blobs: blobs} + if err := snapshot.Validate(); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func actualCapturePathCase(root, normalized string) (string, bool, error) { + parts := strings.Split(normalized, "/") + current := root + actual := make([]string, 0, len(parts)) + for _, part := range parts { + entries, err := os.ReadDir(current) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("inspect Workspace path casing: %w", err) + } + matched := "" + for _, entry := range entries { + if entry.Name() == part { + matched = entry.Name() + break + } + if matched == "" && strings.EqualFold(entry.Name(), part) { + matched = entry.Name() + } + } + if matched == "" { + return "", false, nil + } + actual = append(actual, matched) + current = filepath.Join(current, matched) + } + return strings.Join(actual, "/"), true, nil +} + +func canonicalRoot(value string) (string, error) { + abs, err := filepath.Abs(strings.TrimSpace(value)) + if err != nil { + return "", err + } + requestedInfo, err := os.Lstat(abs) + if err != nil || !requestedInfo.IsDir() || requestedInfo.Mode()&os.ModeSymlink != 0 { + return "", errors.New("workspace checkpoint root identity is not a real directory") + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil || !sameCapturePath(abs, resolved) { + return "", errors.New("workspace checkpoint root identity is redirected") + } + info, err := os.Lstat(resolved) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("workspace checkpoint root must be a real directory") + } + return filepath.Clean(resolved), nil +} + +func canonicalPathIdentity(value string) string { + value = filepath.ToSlash(filepath.Clean(value)) + if runtime.GOOS == "windows" { + value = strings.ToLower(value) + } + return value +} + +func inspectCaptureInventory(ctx context.Context, root string) (captureInventory, error) { + result := captureInventory{baseCommit: "non-git", tracked: map[string]gitIndexEntry{}, + untracked: map[string]struct{}{}, ignored: map[string]struct{}{}, + staged: map[string]struct{}{}} + gitPath, err := exec.LookPath("git") + if err != nil { + paths, truncated, walkErr := walkNonGitFiles(ctx, root) + if walkErr != nil { + return result, walkErr + } + for _, path := range paths { + result.untracked[path] = struct{}{} + } + result.truncated = truncated + return result, nil + } + top, err := captureGitOutput(ctx, gitPath, root, "rev-parse", "--show-toplevel") + if err != nil || !sameCapturePath(strings.TrimSpace(string(top)), root) { + paths, truncated, walkErr := walkNonGitFiles(ctx, root) + if walkErr != nil { + return result, walkErr + } + for _, path := range paths { + result.untracked[path] = struct{}{} + } + result.truncated = truncated + return result, nil + } + result.git = true + if head, headErr := captureGitOutput(ctx, gitPath, root, "rev-parse", "--verify", "HEAD"); headErr == nil { + result.baseCommit = strings.TrimSpace(string(head)) + } else { + result.baseCommit = "unborn" + } + if branch, branchErr := captureGitOutput(ctx, gitPath, root, "branch", "--show-current"); branchErr == nil { + result.branch = strings.TrimSpace(string(branch)) + } + indexPath, err := captureGitOutput(ctx, gitPath, root, "rev-parse", "--git-path", "index") + if err != nil { + return result, fmt.Errorf("locate Git index: %w", err) + } + result.indexPath = strings.TrimSpace(string(indexPath)) + if !filepath.IsAbs(result.indexPath) { + result.indexPath = filepath.Join(root, result.indexPath) + } + result.indexPath = filepath.Clean(result.indexPath) + + tracked, err := captureGitOutput(ctx, gitPath, root, "ls-files", "-s", "-z") + if err != nil { + return result, err + } + for _, raw := range splitNUL(tracked) { + tab := strings.IndexByte(raw, '\t') + if tab <= 0 { + continue + } + fields := strings.Fields(raw[:tab]) + path, pathErr := normalizeCapturePath(raw[tab+1:]) + if len(fields) != 3 || pathErr != nil || fields[2] != "0" { + return result, errors.New("git index contains an unsupported or unsafe entry") + } + result.tracked[path] = gitIndexEntry{Mode: fields[0], OID: fields[1]} + if len(result.tracked) > MaxEntries { + result.truncated = true + break + } + } + if !result.truncated { + untracked, listErr := captureGitOutput(ctx, gitPath, root, + "ls-files", "-z", "--others", "--exclude-standard") + if listErr != nil { + return result, listErr + } + for _, raw := range splitNUL(untracked) { + path, pathErr := normalizeCapturePath(raw) + if pathErr != nil { + return result, pathErr + } + result.untracked[path] = struct{}{} + if len(result.tracked)+len(result.untracked) > MaxEntries { + result.truncated = true + break + } + } + } + ignored, _ := captureGitOutput(ctx, gitPath, root, + "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory") + for _, raw := range splitNUL(ignored) { + path, pathErr := normalizeCapturePath(strings.TrimSuffix(raw, "/")) + if pathErr == nil && path != "" { + result.ignored[path] = struct{}{} + } + if len(result.tracked)+len(result.untracked)+len(result.ignored) >= MaxEntries { + result.truncated = true + break + } + } + staged, _ := captureGitOutput(ctx, gitPath, root, + "diff", "--cached", "--name-only", "-z", "--diff-filter=ACDMRTUXB") + for _, raw := range splitNUL(staged) { + if path, pathErr := normalizeCapturePath(raw); pathErr == nil { + result.staged[path] = struct{}{} + } + } + return result, nil +} + +func capturePaths(inventory captureInventory) []string { + paths := make([]string, 0, len(inventory.tracked)+len(inventory.untracked)+len(inventory.ignored)) + seen := make(map[string]struct{}, cap(paths)) + for path := range inventory.tracked { + seen[path] = struct{}{} + paths = append(paths, path) + } + for path := range inventory.untracked { + if _, exists := seen[path]; !exists { + seen[path] = struct{}{} + paths = append(paths, path) + } + } + for path := range inventory.ignored { + if _, exists := seen[path]; !exists { + paths = append(paths, path) + } + } + sort.Strings(paths) + if len(paths) > MaxEntries { + paths = paths[:MaxEntries] + } + return paths +} + +func captureEntry(ctx context.Context, root, path string, + inventory captureInventory, +) (Entry, []byte, string, error) { + index, tracked := inventory.tracked[path] + _, staged := inventory.staged[path] + entry := Entry{Path: path, Kind: EntryFile, State: StatePresent, + StoragePolicy: StorageStored, WorktreeSHA256: "missing", Tracked: tracked, + Staged: staged, IndexOID: index.OID, IndexMode: index.Mode, Recoverable: true} + if _, ignored := inventory.ignored[path]; ignored && !tracked { + entry.Kind = EntryDirectory + entry.State = StateIgnored + entry.StoragePolicy = StorageExcludedIgnored + entry.Recoverable = false + entry.Reason = "ignored content is outside automatic restore scope" + return entry, nil, "ignored Workspace content is not captured", nil + } + target, err := captureTarget(root, path) + if err != nil { + return Entry{}, nil, "", err + } + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) && tracked { + entry.State = StateMissing + entry.StoragePolicy = StorageMissing + entry.Recoverable = true + return entry, nil, "", nil + } + if err != nil { + entry.State = StateExternal + entry.StoragePolicy = StorageUnreadable + entry.Recoverable = false + entry.Reason = "Workspace entry could not be read" + return entry, nil, "unreadable Workspace content is not captured", nil + } + entry.Mode = uint32(info.Mode().Perm()) + entry.Size = info.Size() + if info.Mode()&os.ModeSymlink != 0 { + entry.Kind = EntrySymlink + entry.StoragePolicy = StorageExcludedLink + entry.Recoverable = false + entry.Reason = "symlink or junction restore requires manual review" + return entry, nil, "linked Workspace content is not captured", nil + } + if info.IsDir() { + entry.Kind = EntryDirectory + entry.StoragePolicy = StorageExcludedSpecial + entry.Recoverable = false + entry.Reason = "directory metadata is informational" + return entry, nil, "directory content is not directly recoverable", nil + } + if !info.Mode().IsRegular() { + entry.Kind = EntryOther + entry.StoragePolicy = StorageExcludedSpecial + entry.Recoverable = false + entry.Reason = "special filesystem entry is unsupported" + return entry, nil, "special Workspace content is not captured", nil + } + digest, content, err := hashCaptureFile(ctx, target, info.Size() <= MaxStoredFileBytes) + if err != nil { + entry.StoragePolicy = StorageUnreadable + entry.Recoverable = false + entry.Reason = "Workspace file could not be read consistently" + return entry, nil, "unreadable Workspace content is not captured", nil + } + entry.WorktreeSHA256 = digest + if content != nil { + entry.Binary = looksBinary(content) + entry.LineEndings = detectLineEndings(content, entry.Binary) + } + if sensitiveCapturePath(path) || sensitiveCaptureContent(content) { + entry.StoragePolicy = StorageExcludedSensitive + entry.Recoverable = false + entry.Reason = "sensitive content is hash-only and never persisted" + return entry, nil, "sensitive Workspace content is excluded", nil + } + if info.Size() > MaxStoredFileBytes { + entry.StoragePolicy = StorageExcludedLarge + entry.Recoverable = false + entry.Reason = "file exceeds the per-blob checkpoint limit" + return entry, nil, "large Workspace content is hash-only", nil + } + if !tracked && generatedCapturePath(path) { + entry.State = StateGenerated + entry.StoragePolicy = StorageExcludedGenerated + entry.Recoverable = false + entry.Reason = "generated untracked content is not persisted" + return entry, nil, "generated Workspace content is not captured", nil + } + entry.BlobSHA256 = digest + return entry, content, "", nil +} + +func captureTarget(root, relative string) (string, error) { + target := filepath.Join(root, filepath.FromSlash(relative)) + rel, err := filepath.Rel(root, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("workspace checkpoint path escapes its root") + } + return target, nil +} + +func hashCaptureFile(ctx context.Context, path string, keep bool) (string, []byte, error) { + file, err := os.Open(path) + if err != nil { + return "", nil, err + } + defer file.Close() + hash := sha256.New() + var buffer bytes.Buffer + writer := io.Writer(hash) + if keep { + writer = io.MultiWriter(hash, &buffer) + } + chunk := make([]byte, 64*1024) + for { + if err := ctx.Err(); err != nil { + return "", nil, err + } + count, readErr := file.Read(chunk) + if count > 0 { + if _, err := writer.Write(chunk[:count]); err != nil { + return "", nil, err + } + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return "", nil, readErr + } + } + if !keep { + return hex.EncodeToString(hash.Sum(nil)), nil, nil + } + return hex.EncodeToString(hash.Sum(nil)), buffer.Bytes(), nil +} + +func addCaptureBlob(values map[string]Blob, content []byte, createdAt time.Time) string { + sum := sha256.Sum256(content) + digest := hex.EncodeToString(sum[:]) + if _, exists := values[digest]; !exists { + values[digest] = Blob{SHA256: digest, Content: append([]byte{}, content...), + CreatedAt: createdAt.UTC()} + } + return digest +} + +func captureBlobBytes(values map[string]Blob) int64 { + var total int64 + for _, value := range values { + total += int64(len(value.Content)) + } + return total +} + +func walkNonGitFiles(ctx context.Context, root string) ([]string, bool, error) { + values := make([]string, 0, 256) + truncated := false + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if sameCapturePath(path, root) { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if relative == ".git" || strings.HasPrefix(relative, ".git/") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.IsDir() { + return nil + } + if len(values) >= MaxEntries { + truncated = true + return filepath.SkipAll + } + normalized, err := normalizeCapturePath(relative) + if err != nil { + return err + } + values = append(values, normalized) + return nil + }) + sort.Strings(values) + return values, truncated, err +} + +func captureGitOutput(ctx context.Context, gitPath, root string, args ...string) ([]byte, error) { + base := []string{"-C", root, "--no-optional-locks", "-c", "core.autocrlf=false", + "-c", "core.hooksPath=", "-c", "diff.external=", "-c", "core.fsmonitor=false"} + command := exec.CommandContext(ctx, gitPath, append(base, args...)...) + command.Dir = root + command.Env = captureGitEnvironment() + var stdout, stderr limitedCaptureBuffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, + strings.TrimSpace(stderr.String())) + } + if stdout.exceeded { + return nil, errors.New("git checkpoint inventory exceeds its output limit") + } + return append([]byte{}, stdout.Bytes()...), nil +} + +type limitedCaptureBuffer struct { + bytes.Buffer + exceeded bool +} + +func (b *limitedCaptureBuffer) Write(value []byte) (int, error) { + remaining := maxGitCaptureOutputBytes - b.Len() + if remaining <= 0 { + b.exceeded = true + return len(value), nil + } + if len(value) > remaining { + _, _ = b.Buffer.Write(value[:remaining]) + b.exceeded = true + return len(value), nil + } + return b.Buffer.Write(value) +} + +func captureGitEnvironment() []string { + keys := []string{"PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP"} + values := make([]string, 0, len(keys)+8) + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok { + values = append(values, key+"="+value) + } + } + values = append(values, "LANG=C", "LC_ALL=C", "GIT_TERMINAL_PROMPT=0", + "GCM_INTERACTIVE=never", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_PAGER=cat", "GIT_EDITOR=true") + return values +} + +func splitNUL(value []byte) []string { + parts := bytes.Split(value, []byte{0}) + result := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) > 0 { + result = append(result, string(part)) + } + } + return result +} + +func normalizeCapturePath(value string) (string, error) { + if value == "" || strings.ContainsRune(value, 0) || !utf8.ValidString(value) || + filepath.IsAbs(value) || strings.Contains(value, "\\") { + return "", errors.New("workspace checkpoint path is invalid") + } + cleaned := filepath.ToSlash(filepath.Clean(value)) + if cleaned != value || !validPath(cleaned) || cleaned == ".git" || + strings.HasPrefix(cleaned, ".git/") { + return "", errors.New("workspace checkpoint path is not normalized") + } + return cleaned, nil +} + +func sameCapturePath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sensitiveCapturePath(path string) bool { + lower := strings.ToLower(filepath.ToSlash(path)) + base := strings.ToLower(filepath.Base(lower)) + if base == ".env" || strings.HasPrefix(base, ".env.") || base == "credentials" || + base == "credentials.json" || base == "id_rsa" || base == "id_ed25519" || + strings.HasSuffix(base, ".pem") || strings.HasSuffix(base, ".p12") || + strings.HasSuffix(base, ".pfx") || strings.HasSuffix(base, ".key") { + return true + } + return strings.Contains(lower, "/.ssh/") || strings.Contains(lower, "/.aws/") || + strings.Contains(lower, "/.azure/") || strings.Contains(lower, "/.config/gcloud/") +} + +func sensitiveCaptureContent(value []byte) bool { + if len(value) == 0 { + return false + } + upper := strings.ToUpper(string(value)) + if strings.Contains(upper, "-----BEGIN PRIVATE KEY-----") || + strings.Contains(upper, "-----BEGIN OPENSSH PRIVATE KEY-----") || + strings.Contains(upper, "-----BEGIN RSA PRIVATE KEY-----") { + return true + } + if utf8.Valid(value) { + text := string(value) + return redact.String(text) != text + } + return false +} + +func generatedCapturePath(path string) bool { + parts := strings.Split(strings.ToLower(filepath.ToSlash(path)), "/") + for _, part := range parts { + switch part { + case "node_modules", "dist", "build", "target", "coverage", ".cache", ".next", + "bin", "obj", "vendor": + return true + } + } + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".tmp" || ext == ".log" || ext == ".pyc" +} + +func looksBinary(value []byte) bool { + if bytes.IndexByte(value, 0) >= 0 || !utf8.Valid(value) { + return true + } + control := 0 + for _, current := range value { + if current < 0x20 && current != '\n' && current != '\r' && current != '\t' { + control++ + } + } + return len(value) > 0 && control*100/len(value) > 1 +} + +func detectLineEndings(value []byte, binary bool) string { + if binary { + return "" + } + crlf := bytes.Count(value, []byte("\r\n")) + lf := bytes.Count(value, []byte("\n")) - crlf + switch { + case crlf > 0 && lf > 0: + return "mixed" + case crlf > 0: + return "crlf" + case lf > 0: + return "lf" + default: + return "none" + } +} + +// ParseFileMode is exposed for restore and tests; persisted modes are always +// bounded permission bits rather than platform-specific os.FileMode flags. +func ParseFileMode(value uint32) (os.FileMode, error) { + if value > 0o7777 { + return 0, errors.New("workspace checkpoint file mode is invalid") + } + parsed, err := strconv.ParseUint(fmt.Sprintf("%o", value), 8, 32) + return os.FileMode(parsed), err +} diff --git a/internal/workspacecheckpoint/capture_test.go b/internal/workspacecheckpoint/capture_test.go new file mode 100644 index 00000000..359d295e --- /dev/null +++ b/internal/workspacecheckpoint/capture_test.go @@ -0,0 +1,239 @@ +package workspacecheckpoint + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestCapturePreservesTrackedUntrackedBinaryIndexAndLineEndings(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "tracked.txt"), []byte("one\r\ntwo\r\n")) + runCheckpointGit(t, root, "add", "tracked.txt") + runCheckpointGit(t, root, "commit", "-m", "baseline") + mustCheckpointWrite(t, filepath.Join(root, "tracked.txt"), []byte("changed\r\n")) + runCheckpointGit(t, root, "add", "tracked.txt") + mustCheckpointWrite(t, filepath.Join(root, "binary.dat"), []byte{0, 1, 2, 3}) + mustCheckpointWrite(t, filepath.Join(root, ".env"), []byte("TOKEN=secret-value\n")) + mustCheckpointWrite(t, filepath.Join(root, ".gitignore"), []byte("dist/\n")) + mustCheckpointWrite(t, filepath.Join(root, "dist", "generated.txt"), []byte("generated")) + + now := time.Date(2026, 8, 19, 1, 2, 3, 0, time.UTC) + snapshot, err := Capture(context.Background(), validCaptureRequest(root, now)) + if err != nil { + t.Fatal(err) + } + if snapshot.Checkpoint.ProtocolVersion != ProtocolVersion || + snapshot.Checkpoint.BaseCommit == "" || snapshot.Checkpoint.BaseCommit == "unborn" || + snapshot.Checkpoint.IndexBlobSHA256 == "" || snapshot.Checkpoint.EntryCount != 5 || + snapshot.Checkpoint.RecoveryLevel != RecoveryPartial { + t.Fatalf("unexpected checkpoint: %+v", snapshot.Checkpoint) + } + entries := checkpointEntriesByPath(snapshot.Entries) + tracked := entries["tracked.txt"] + if !tracked.Tracked || !tracked.Staged || !tracked.Recoverable || + tracked.StoragePolicy != StorageStored || tracked.LineEndings != "crlf" || + tracked.BlobSHA256 == "" { + t.Fatalf("unexpected tracked entry: %+v", tracked) + } + binary := entries["binary.dat"] + if binary.Tracked || !binary.Binary || !binary.Recoverable || binary.BlobSHA256 == "" { + t.Fatalf("unexpected binary entry: %+v", binary) + } + sensitive := entries[".env"] + if sensitive.StoragePolicy != StorageExcludedSensitive || sensitive.Recoverable || + sensitive.BlobSHA256 != "" { + t.Fatalf("unexpected sensitive entry: %+v", sensitive) + } + ignored := entries["dist"] + if ignored.State != StateIgnored || ignored.StoragePolicy != StorageExcludedIgnored || + ignored.Recoverable { + t.Fatalf("unexpected ignored entry: %+v", ignored) + } + if _, exists := entries["dist/generated.txt"]; exists { + t.Fatal("ignored directory contents must not be expanded into the manifest") + } + if err := snapshot.Validate(); err != nil { + t.Fatal(err) + } + tampered := snapshot + tampered.Entries = append([]Entry{}, snapshot.Entries...) + for index := range tampered.Entries { + if tampered.Entries[index].BlobSHA256 != "" { + tampered.Entries[index].Size++ + break + } + } + if err := tampered.Validate(); err == nil || !strings.Contains(err.Error(), "size") { + t.Fatalf("entry/blob size mismatch was accepted: %v", err) + } +} + +func TestCaptureManifestIsStableAndDeletedTrackedFilesRemainRecoverable(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "gone.txt"), []byte("before\n")) + runCheckpointGit(t, root, "add", "gone.txt") + runCheckpointGit(t, root, "commit", "-m", "baseline") + if err := os.Remove(filepath.Join(root, "gone.txt")); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 19, 2, 0, 0, 0, time.UTC) + first, err := Capture(t.Context(), validCaptureRequest(root, now)) + if err != nil { + t.Fatal(err) + } + request := validCaptureRequest(root, now.Add(time.Minute)) + request.ID = "checkpoint-2" + second, err := Capture(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if first.Checkpoint.ManifestSHA256 != second.Checkpoint.ManifestSHA256 || + first.Checkpoint.IndexSHA256 != second.Checkpoint.IndexSHA256 { + t.Fatalf("stable state produced different manifests: %s / %s", + first.Checkpoint.ManifestSHA256, second.Checkpoint.ManifestSHA256) + } + entry := checkpointEntriesByPath(first.Entries)["gone.txt"] + if entry.State != StateMissing || entry.StoragePolicy != StorageMissing || + !entry.Recoverable || entry.WorktreeSHA256 != "missing" { + t.Fatalf("unexpected deleted entry: %+v", entry) + } +} + +func TestCaptureExcludesLargeAndGeneratedUntrackedContent(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "baseline.txt"), []byte("base\n")) + runCheckpointGit(t, root, "add", "baseline.txt") + runCheckpointGit(t, root, "commit", "-m", "baseline") + mustCheckpointWrite(t, filepath.Join(root, "large.bin"), + bytes.Repeat([]byte{0x7f}, MaxStoredFileBytes+1)) + mustCheckpointWrite(t, filepath.Join(root, "build", "result.txt"), []byte("output")) + + snapshot, err := Capture(t.Context(), validCaptureRequest(root, time.Now().UTC())) + if err != nil { + t.Fatal(err) + } + entries := checkpointEntriesByPath(snapshot.Entries) + if entry := entries["large.bin"]; entry.StoragePolicy != StorageExcludedLarge || + entry.Recoverable || entry.WorktreeSHA256 == "" || entry.WorktreeSHA256 == "missing" { + t.Fatalf("unexpected large entry: %+v", entry) + } + if entry := entries["build/result.txt"]; entry.State != StateGenerated || + entry.StoragePolicy != StorageExcludedGenerated || entry.Recoverable { + t.Fatalf("unexpected generated entry: %+v", entry) + } +} + +func TestCaptureKeepsAMissingGitIndexAsAnExactState(t *testing.T) { + root := newCheckpointRepository(t) + snapshot, err := Capture(t.Context(), validCaptureRequest(root, time.Now().UTC())) + if err != nil { + t.Fatal(err) + } + if snapshot.Checkpoint.BaseCommit != "unborn" || + snapshot.Checkpoint.IndexBlobSHA256 != "" || + snapshot.Checkpoint.RecoveryLevel != RecoveryComplete { + t.Fatalf("missing Git index was not represented exactly: %+v", snapshot.Checkpoint) + } + if err := snapshot.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestCaptureRejectsLinkedWorkspaceRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating a directory symlink may require elevation on Windows") + } + root := t.TempDir() + linked := filepath.Join(t.TempDir(), "workspace-link") + if err := os.Symlink(root, linked); err != nil { + t.Fatal(err) + } + _, err := Capture(t.Context(), validCaptureRequest(linked, time.Now().UTC())) + if err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("expected linked root rejection, got %v", err) + } +} + +func TestCaptureMarksWindowsPathCaseDriftUnavailable(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows path casing behavior") + } + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "tracked.txt"), []byte("case\n")) + runCheckpointGit(t, root, "add", "tracked.txt") + runCheckpointGit(t, root, "commit", "-m", "case baseline") + original := filepath.Join(root, "tracked.txt") + intermediate := filepath.Join(root, "case-transition.tmp") + drifted := filepath.Join(root, "TRACKED.txt") + if err := os.Rename(original, intermediate); err != nil { + t.Fatal(err) + } + if err := os.Rename(intermediate, drifted); err != nil { + t.Fatal(err) + } + snapshot, err := Capture(t.Context(), validCaptureRequest(root, time.Now().UTC())) + if err != nil { + t.Fatal(err) + } + if snapshot.Checkpoint.RecoveryLevel != RecoveryUnavailable || + !strings.Contains(strings.Join(snapshot.Checkpoint.IncompleteReasons, "\n"), + "Workspace path casing differs from the Git index") { + t.Fatalf("case drift was not explicit: %+v", snapshot.Checkpoint) + } +} + +func validCaptureRequest(root string, at time.Time) CaptureRequest { + return CaptureRequest{ID: "checkpoint-1", RunID: "run-1", MissionID: "mission-1", + SessionID: "session-1", WorkspaceID: "workspace-1", WorkspaceRoot: root, + AttemptID: "attempt-1", CapabilityGeneration: strings.Repeat("a", 64), + Trigger: TriggerFileTool, Phase: PhaseBefore, TriggerReceiptID: "receipt-1", + CreatedAt: at} +} + +func checkpointEntriesByPath(values []Entry) map[string]Entry { + result := make(map[string]Entry, len(values)) + for _, value := range values { + result[value.Path] = value + } + return result +} + +func newCheckpointRepository(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is unavailable") + } + root := t.TempDir() + runCheckpointGit(t, root, "init", "-q") + runCheckpointGit(t, root, "config", "user.email", "checkpoint@example.invalid") + runCheckpointGit(t, root, "config", "user.name", "Checkpoint Test") + return root +} + +func runCheckpointGit(t *testing.T, root string, args ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", root}, args...)...) + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func mustCheckpointWrite(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/workspacecheckpoint/model.go b/internal/workspacecheckpoint/model.go new file mode 100644 index 00000000..fb171180 --- /dev/null +++ b/internal/workspacecheckpoint/model.go @@ -0,0 +1,631 @@ +package workspacecheckpoint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path" + "path/filepath" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +const ( + ProtocolVersion = "workspace-checkpoint.v1" + + MaxEntries = 20_000 + MaxPathRunes = 1_024 + MaxStoredFileBytes = 4 * 1024 * 1024 + MaxStoredIndexBytes = 32 * 1024 * 1024 + MaxCheckpointBlobBytes = 64 * 1024 * 1024 + MaxPreviewChanges = 2_000 + MaxConflictItems = 256 + MaxStoreBlobBytes = 2 * 1024 * 1024 * 1024 + MaxStoreCheckpoints = 10_000 + MaxStoreEntries = 2_000_000 + MaxStoreTransactions = 20_000 +) + +type RecoveryLevel string + +const ( + RecoveryComplete RecoveryLevel = "complete" + RecoveryPartial RecoveryLevel = "partial" + RecoveryUnavailable RecoveryLevel = "unavailable" +) + +func (l RecoveryLevel) Valid() bool { + return l == RecoveryComplete || l == RecoveryPartial || l == RecoveryUnavailable +} + +type TriggerKind string + +const ( + TriggerManual TriggerKind = "manual" + TriggerFileTool TriggerKind = "file_tool" + TriggerCommandBatch TriggerKind = "command_batch" + TriggerGitMutation TriggerKind = "git_mutation" + TriggerAgentMerge TriggerKind = "agent_merge" + TriggerRewindPreflight TriggerKind = "rewind_preflight" + TriggerRewindResult TriggerKind = "rewind_result" + TriggerFork TriggerKind = "fork" +) + +func (k TriggerKind) Valid() bool { + switch k { + case TriggerManual, TriggerFileTool, TriggerCommandBatch, TriggerGitMutation, + TriggerAgentMerge, TriggerRewindPreflight, TriggerRewindResult, TriggerFork: + return true + default: + return false + } +} + +type Phase string + +const ( + PhaseStandalone Phase = "standalone" + PhaseBefore Phase = "before" + PhaseAfter Phase = "after" + PhasePreflight Phase = "preflight" +) + +func (p Phase) Valid() bool { + return p == PhaseStandalone || p == PhaseBefore || p == PhaseAfter || p == PhasePreflight +} + +type EntryKind string + +const ( + EntryFile EntryKind = "file" + EntryDirectory EntryKind = "directory" + EntrySymlink EntryKind = "symlink" + EntryOther EntryKind = "other" +) + +func (k EntryKind) Valid() bool { + return k == EntryFile || k == EntryDirectory || k == EntrySymlink || k == EntryOther +} + +type WorktreeState string + +const ( + StatePresent WorktreeState = "present" + StateMissing WorktreeState = "missing" + StateIgnored WorktreeState = "ignored" + StateGenerated WorktreeState = "generated" + StateExternal WorktreeState = "external" +) + +func (s WorktreeState) Valid() bool { + switch s { + case StatePresent, StateMissing, StateIgnored, StateGenerated, StateExternal: + return true + default: + return false + } +} + +type StoragePolicy string + +const ( + StorageStored StoragePolicy = "stored" + StorageMissing StoragePolicy = "missing" + StorageExcludedIgnored StoragePolicy = "excluded_ignored" + StorageExcludedGenerated StoragePolicy = "excluded_generated" + StorageExcludedLarge StoragePolicy = "excluded_large" + StorageExcludedSensitive StoragePolicy = "excluded_sensitive" + StorageExcludedLink StoragePolicy = "excluded_link" + StorageExcludedSpecial StoragePolicy = "excluded_special" + StorageUnreadable StoragePolicy = "unreadable" +) + +func (p StoragePolicy) Valid() bool { + switch p { + case StorageStored, StorageMissing, StorageExcludedIgnored, StorageExcludedGenerated, + StorageExcludedLarge, StorageExcludedSensitive, StorageExcludedLink, + StorageExcludedSpecial, StorageUnreadable: + return true + default: + return false + } +} + +type Entry struct { + Path string `json:"path"` + Kind EntryKind `json:"kind"` + State WorktreeState `json:"state"` + StoragePolicy StoragePolicy `json:"storage_policy"` + Mode uint32 `json:"mode"` + Size int64 `json:"size"` + WorktreeSHA256 string `json:"worktree_sha256"` + BlobSHA256 string `json:"blob_sha256,omitempty"` + IndexOID string `json:"index_oid,omitempty"` + IndexMode string `json:"index_mode,omitempty"` + Tracked bool `json:"tracked"` + Staged bool `json:"staged"` + Binary bool `json:"binary"` + LineEndings string `json:"line_endings,omitempty"` + Recoverable bool `json:"recoverable"` + Reason string `json:"reason,omitempty"` +} + +func (e Entry) Validate() error { + if !validPath(e.Path) || !e.Kind.Valid() || !e.State.Valid() || + !e.StoragePolicy.Valid() || e.Size < 0 || e.Mode > 0o7777 || + !validReason(e.Reason) { + return errors.New("workspace checkpoint entry metadata is invalid") + } + if e.WorktreeSHA256 != "missing" && !validDigest(e.WorktreeSHA256, true) { + return errors.New("workspace checkpoint entry content digest is invalid") + } + if e.BlobSHA256 != "" && !validDigest(e.BlobSHA256, false) { + return errors.New("workspace checkpoint entry blob digest is invalid") + } + if e.StoragePolicy == StorageStored { + if !e.Recoverable || e.Kind != EntryFile || e.State != StatePresent || + e.BlobSHA256 == "" || e.WorktreeSHA256 != e.BlobSHA256 || + e.Size > MaxStoredFileBytes { + return errors.New("stored workspace checkpoint entry is inconsistent") + } + } else if e.BlobSHA256 != "" { + return errors.New("excluded workspace checkpoint entry cannot reference a blob") + } + if e.StoragePolicy == StorageMissing && + (e.State != StateMissing || e.WorktreeSHA256 != "missing" || !e.Recoverable) { + return errors.New("missing workspace checkpoint entry is inconsistent") + } + if e.Kind != EntryFile && e.Recoverable { + return errors.New("non-file workspace checkpoint entries cannot be recoverable") + } + if e.LineEndings != "" && e.LineEndings != "lf" && e.LineEndings != "crlf" && + e.LineEndings != "mixed" && e.LineEndings != "none" { + return errors.New("workspace checkpoint line-ending metadata is invalid") + } + if len(e.IndexOID) > 128 || len(e.IndexMode) > 16 || + strings.ContainsRune(e.IndexOID, 0) || strings.ContainsRune(e.IndexMode, 0) { + return errors.New("workspace checkpoint index metadata is invalid") + } + return nil +} + +type Blob struct { + SHA256 string + Content []byte + CreatedAt time.Time +} + +func (b Blob) Validate(maxBytes int) error { + if maxBytes <= 0 || len(b.Content) > maxBytes || !validDigest(b.SHA256, false) || + b.CreatedAt.IsZero() { + return errors.New("workspace checkpoint blob metadata is invalid") + } + sum := sha256.Sum256(b.Content) + if hex.EncodeToString(sum[:]) != b.SHA256 { + return errors.New("workspace checkpoint blob failed integrity validation") + } + return nil +} + +type Checkpoint struct { + ID string `json:"id"` + ProtocolVersion string `json:"protocol_version"` + RunID string `json:"run_id"` + MissionID string `json:"mission_id"` + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id"` + AttemptID string `json:"attempt_id,omitempty"` + CapabilityGeneration string `json:"capability_generation,omitempty"` + Trigger TriggerKind `json:"trigger"` + Phase Phase `json:"phase"` + TriggerReceiptID string `json:"trigger_receipt_id"` + RequestedBy string `json:"requested_by,omitempty"` + Title string `json:"title,omitempty"` + ParentCheckpointID string `json:"parent_checkpoint_id,omitempty"` + RootFingerprint string `json:"root_fingerprint"` + RootPathSHA256 string `json:"root_path_sha256"` + BaseCommit string `json:"base_commit"` + Branch string `json:"branch"` + IndexSHA256 string `json:"index_sha256"` + IndexBlobSHA256 string `json:"index_blob_sha256,omitempty"` + ManifestSHA256 string `json:"manifest_sha256"` + RecoveryLevel RecoveryLevel `json:"recovery_level"` + IncompleteReasons []string `json:"incomplete_reasons"` + EntryCount int `json:"entry_count"` + StoredBytes int64 `json:"stored_bytes"` + CreatedAt time.Time `json:"created_at"` +} + +func (c Checkpoint) Validate() error { + for _, value := range []string{c.ID, c.RunID, c.MissionID, c.SessionID, + c.WorkspaceID, c.TriggerReceiptID} { + if !validIdentity(value) { + return errors.New("workspace checkpoint identity is invalid") + } + } + for _, value := range []string{c.AttemptID, c.ParentCheckpointID} { + if value != "" && !validIdentity(value) { + return errors.New("workspace checkpoint optional identity is invalid") + } + } + if c.ProtocolVersion != ProtocolVersion || !c.Trigger.Valid() || !c.Phase.Valid() || + !c.RecoveryLevel.Valid() || !validDigest(c.RootFingerprint, false) || + !validDigest(c.RootPathSHA256, false) || !validDigest(c.IndexSHA256, false) || + !validDigest(c.ManifestSHA256, false) || c.EntryCount < 0 || + c.EntryCount > MaxEntries || c.StoredBytes < 0 || + c.StoredBytes > MaxCheckpointBlobBytes || c.CreatedAt.IsZero() { + return errors.New("workspace checkpoint metadata is invalid") + } + if (c.RequestedBy != "" && !validIdentity(c.RequestedBy)) || !validReason(c.Title) { + return errors.New("workspace checkpoint attribution is invalid") + } + if c.CapabilityGeneration != "" && !validDigest(c.CapabilityGeneration, false) { + return errors.New("workspace checkpoint capability generation is invalid") + } + if c.IndexBlobSHA256 != "" && !validDigest(c.IndexBlobSHA256, false) { + return errors.New("workspace checkpoint index blob digest is invalid") + } + if !validGitBaseCommit(c.BaseCommit) || + len(c.Branch) > 255 || strings.ContainsRune(c.Branch, 0) { + return errors.New("workspace checkpoint Git identity is invalid") + } + if len(c.IncompleteReasons) > 32 { + return errors.New("workspace checkpoint has too many incomplete reasons") + } + for _, value := range c.IncompleteReasons { + if !validReason(value) || value == "" { + return errors.New("workspace checkpoint incomplete reason is invalid") + } + } + return nil +} + +func validGitBaseCommit(value string) bool { + if value == "unborn" || value == "non-git" { + return true + } + if (len(value) != 40 && len(value) != 64) || value != strings.ToLower(value) { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded)*2 == len(value) +} + +type Snapshot struct { + Checkpoint Checkpoint + Entries []Entry + Blobs []Blob +} + +func (s Snapshot) Validate() error { + if err := s.Checkpoint.Validate(); err != nil { + return err + } + if len(s.Entries) != s.Checkpoint.EntryCount || len(s.Entries) > MaxEntries { + return errors.New("workspace checkpoint entry count is inconsistent") + } + entries := append([]Entry{}, s.Entries...) + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + blobs := make(map[string]Blob, len(s.Blobs)) + referencedBlobs := make(map[string]struct{}, len(s.Blobs)) + var storedBytes int64 + for _, blob := range s.Blobs { + limit := MaxStoredFileBytes + if blob.SHA256 == s.Checkpoint.IndexBlobSHA256 { + limit = MaxStoredIndexBytes + } + if err := blob.Validate(limit); err != nil { + return err + } + if _, exists := blobs[blob.SHA256]; exists { + return errors.New("workspace checkpoint contains a duplicate blob") + } + blobs[blob.SHA256] = blob + storedBytes += int64(len(blob.Content)) + } + if storedBytes != s.Checkpoint.StoredBytes { + return errors.New("workspace checkpoint stored byte count is inconsistent") + } + if s.Checkpoint.IndexBlobSHA256 != "" { + if s.Checkpoint.IndexBlobSHA256 != s.Checkpoint.IndexSHA256 { + return errors.New("workspace checkpoint index digest is inconsistent") + } + referencedBlobs[s.Checkpoint.IndexBlobSHA256] = struct{}{} + } + previous := "" + for _, entry := range entries { + if err := entry.Validate(); err != nil { + return err + } + if entry.Path == previous { + return errors.New("workspace checkpoint contains a duplicate path") + } + previous = entry.Path + if entry.BlobSHA256 != "" { + blob, ok := blobs[entry.BlobSHA256] + if !ok { + return fmt.Errorf("workspace checkpoint blob %s is absent", entry.BlobSHA256) + } + if int64(len(blob.Content)) != entry.Size { + return errors.New("workspace checkpoint entry size does not match its blob") + } + referencedBlobs[entry.BlobSHA256] = struct{}{} + } + } + if s.Checkpoint.IndexBlobSHA256 != "" { + if _, ok := blobs[s.Checkpoint.IndexBlobSHA256]; !ok { + return errors.New("workspace checkpoint index blob is absent") + } + } + if len(referencedBlobs) != len(blobs) { + return errors.New("workspace checkpoint contains an unreferenced blob") + } + manifestJSON, err := json.Marshal(entries) + if err != nil { + return err + } + manifestSHA := sha256.Sum256(manifestJSON) + if hex.EncodeToString(manifestSHA[:]) != s.Checkpoint.ManifestSHA256 { + return errors.New("workspace checkpoint manifest digest is inconsistent") + } + return nil +} + +type CaptureRequest struct { + ID string + RunID string + MissionID string + SessionID string + WorkspaceID string + WorkspaceRoot string + AttemptID string + CapabilityGeneration string + Trigger TriggerKind + Phase Phase + TriggerReceiptID string + RequestedBy string + Title string + ParentCheckpointID string + IncompleteReasons []string + CreatedAt time.Time +} + +type TransactionKind string + +const ( + TransactionFileTool TransactionKind = "file_tool" + TransactionCommandBatch TransactionKind = "command_batch" + TransactionGitMutation TransactionKind = "git_mutation" + TransactionAgentMerge TransactionKind = "agent_merge" + TransactionRewind TransactionKind = "rewind" + TransactionUndo TransactionKind = "undo" + TransactionRedo TransactionKind = "redo" + TransactionFork TransactionKind = "fork" +) + +func (k TransactionKind) Valid() bool { + switch k { + case TransactionFileTool, TransactionCommandBatch, TransactionGitMutation, + TransactionAgentMerge, TransactionRewind, TransactionUndo, TransactionRedo, + TransactionFork: + return true + default: + return false + } +} + +type TransactionStatus string + +const ( + TransactionPrepared TransactionStatus = "prepared" + TransactionApplying TransactionStatus = "applying" + TransactionCompleted TransactionStatus = "completed" + TransactionFailed TransactionStatus = "failed" + TransactionInterrupted TransactionStatus = "interrupted" +) + +func (s TransactionStatus) Valid() bool { + switch s { + case TransactionPrepared, TransactionApplying, TransactionCompleted, + TransactionFailed, TransactionInterrupted: + return true + default: + return false + } +} + +func (s TransactionStatus) Terminal() bool { + return s == TransactionCompleted || s == TransactionFailed || s == TransactionInterrupted +} + +type Transaction struct { + ID string `json:"id"` + ProtocolVersion string `json:"protocol_version"` + OperationKeyDigest string `json:"operation_key_digest"` + RequestFingerprint string `json:"request_fingerprint"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + Kind TransactionKind `json:"kind"` + TriggerReceiptID string `json:"trigger_receipt_id"` + BeforeCheckpointID string `json:"before_checkpoint_id"` + AfterCheckpointID string `json:"after_checkpoint_id,omitempty"` + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id,omitempty"` + TargetCheckpointID string `json:"target_checkpoint_id,omitempty"` + // ForkWorkspaceRoot and ForkBranch are durable crash-recovery metadata. They + // are intentionally excluded from every public JSON projection because the + // root can contain an operator-private absolute path. + ForkWorkspaceRoot string `json:"-"` + ForkBranch string `json:"-"` + Status TransactionStatus `json:"status"` + RecoveryLevel RecoveryLevel `json:"recovery_level"` + ErrorCode string `json:"error_code,omitempty"` + ConflictJSON string `json:"conflict_json,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type RunState struct { + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + CurrentCheckpointID string `json:"current_checkpoint_id"` + LastTransactionID string `json:"last_transaction_id,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type StorageUsage struct { + BlobCount int `json:"blob_count"` + BlobBytes int64 `json:"blob_bytes"` + CheckpointCount int `json:"checkpoint_count"` +} + +func (u StorageUsage) Validate() error { + if u.BlobCount < 0 || u.BlobBytes < 0 || u.CheckpointCount < 0 { + return errors.New("workspace checkpoint storage usage is invalid") + } + return nil +} + +func (s RunState) Validate() error { + if !validIdentity(s.RunID) || !validIdentity(s.WorkspaceID) || + !validIdentity(s.CurrentCheckpointID) || + (s.LastTransactionID != "" && !validIdentity(s.LastTransactionID)) || + s.UpdatedAt.IsZero() { + return errors.New("workspace checkpoint Run state is invalid") + } + return nil +} + +func (t Transaction) Validate() error { + for _, value := range []string{t.ID, t.RunID, t.WorkspaceID, t.TriggerReceiptID, + t.BeforeCheckpointID} { + if !validIdentity(value) { + return errors.New("workspace checkpoint transaction identity is invalid") + } + } + for _, value := range []string{t.AfterCheckpointID, t.ExpectedCurrentCheckpointID, + t.TargetCheckpointID} { + if value != "" && !validIdentity(value) { + return errors.New("workspace checkpoint transaction optional identity is invalid") + } + } + var conflicts []Conflict + if t.ConflictJSON == "" || json.Unmarshal([]byte(t.ConflictJSON), &conflicts) != nil { + return errors.New("workspace checkpoint transaction conflicts are invalid") + } + if len(conflicts) > MaxConflictItems { + return errors.New("workspace checkpoint transaction has too many conflicts") + } + for _, conflict := range conflicts { + if err := conflict.Validate(); err != nil { + return err + } + } + if t.ProtocolVersion != ProtocolVersion || !validDigest(t.OperationKeyDigest, false) || + !validDigest(t.RequestFingerprint, false) || !t.Kind.Valid() || !t.Status.Valid() || + !t.RecoveryLevel.Valid() || !validReason(t.ErrorCode) || t.CreatedAt.IsZero() || + t.UpdatedAt.Before(t.CreatedAt) || len([]byte(t.ConflictJSON)) > 256*1024 || + strings.ContainsRune(t.ConflictJSON, 0) { + return errors.New("workspace checkpoint transaction metadata is invalid") + } + if t.Kind == TransactionFork { + if t.ForkWorkspaceRoot == "" || !filepath.IsAbs(t.ForkWorkspaceRoot) || + filepath.Clean(t.ForkWorkspaceRoot) != t.ForkWorkspaceRoot || + len([]byte(t.ForkWorkspaceRoot)) > 4096 || + strings.TrimSpace(t.ForkWorkspaceRoot) != t.ForkWorkspaceRoot || + strings.ContainsRune(t.ForkWorkspaceRoot, 0) || t.ForkBranch == "" || + len([]byte(t.ForkBranch)) > 255 || + strings.TrimSpace(t.ForkBranch) != t.ForkBranch || + strings.ContainsRune(t.ForkBranch, 0) { + return errors.New("workspace checkpoint fork recovery metadata is invalid") + } + } else if t.ForkWorkspaceRoot != "" || t.ForkBranch != "" { + return errors.New("non-fork workspace checkpoint transaction has fork metadata") + } + if t.Status == TransactionCompleted { + if t.AfterCheckpointID == "" || t.ErrorCode != "" || t.CompletedAt == nil { + return errors.New("completed workspace checkpoint transaction is inconsistent") + } + } else if t.Status == TransactionFailed || t.Status == TransactionInterrupted { + if t.ErrorCode == "" || t.CompletedAt == nil { + return errors.New("failed workspace checkpoint transaction is inconsistent") + } + } else if t.CompletedAt != nil || t.ErrorCode != "" || t.AfterCheckpointID != "" { + return errors.New("active workspace checkpoint transaction is inconsistent") + } + if t.CompletedAt != nil && t.CompletedAt.Before(t.UpdatedAt) { + return errors.New("workspace checkpoint transaction completion time is invalid") + } + return nil +} + +func (r CaptureRequest) Validate() error { + checkpoint := Checkpoint{ID: r.ID, ProtocolVersion: ProtocolVersion, + RunID: r.RunID, MissionID: r.MissionID, SessionID: r.SessionID, + WorkspaceID: r.WorkspaceID, AttemptID: r.AttemptID, + CapabilityGeneration: r.CapabilityGeneration, Trigger: r.Trigger, + Phase: r.Phase, TriggerReceiptID: r.TriggerReceiptID, + RequestedBy: r.RequestedBy, Title: r.Title, + ParentCheckpointID: r.ParentCheckpointID, RootFingerprint: strings.Repeat("0", 64), + RootPathSHA256: strings.Repeat("0", 64), BaseCommit: "unborn", Branch: "", + IndexSHA256: strings.Repeat("0", 64), ManifestSHA256: strings.Repeat("0", 64), + RecoveryLevel: RecoveryComplete, EntryCount: 0, CreatedAt: r.CreatedAt} + if err := checkpoint.Validate(); err != nil { + return err + } + if strings.TrimSpace(r.WorkspaceRoot) == "" || strings.ContainsRune(r.WorkspaceRoot, 0) { + return errors.New("workspace checkpoint root is invalid") + } + if len(r.IncompleteReasons) > 32 { + return errors.New("workspace checkpoint capture has too many incomplete reasons") + } + for _, reason := range r.IncompleteReasons { + if reason == "" || !validReason(reason) { + return errors.New("workspace checkpoint capture reason is invalid") + } + } + return nil +} + +func validIdentity(value string) bool { + return value != "" && value == strings.TrimSpace(value) && utf8.ValidString(value) && + len([]rune(value)) <= 256 && !strings.ContainsRune(value, 0) +} + +func validPath(value string) bool { + if value == "" || value != strings.TrimSpace(value) || !utf8.ValidString(value) || + len([]rune(value)) > MaxPathRunes || strings.ContainsRune(value, 0) || + strings.HasPrefix(value, "/") || strings.ContainsAny(value, `\:`) || + path.Clean(value) != value || value == "." || value == ".." || + strings.HasPrefix(value, "../") || value == ".git" || + strings.HasPrefix(value, ".git/") { + return false + } + for _, current := range value { + if unicode.IsControl(current) { + return false + } + } + return true +} + +func validReason(value string) bool { + return value == strings.TrimSpace(value) && utf8.ValidString(value) && + len([]rune(value)) <= 512 && !strings.ContainsRune(value, 0) +} + +func validDigest(value string, allowMissing bool) bool { + if allowMissing && value == "missing" { + return true + } + if len(value) != sha256.Size*2 || value != strings.ToLower(value) { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size +} diff --git a/internal/workspacecheckpoint/restore.go b/internal/workspacecheckpoint/restore.go new file mode 100644 index 00000000..c67ebdff --- /dev/null +++ b/internal/workspacecheckpoint/restore.go @@ -0,0 +1,815 @@ +package workspacecheckpoint + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "cyberagent-workbench/internal/workspaceidentity" +) + +type ChangeKind string + +const ( + ChangeCreate ChangeKind = "create" + ChangeModify ChangeKind = "modify" + ChangeDelete ChangeKind = "delete" + ChangeRename ChangeKind = "rename" + ChangeMetadata ChangeKind = "metadata" +) + +type ConflictKind string + +const ( + ConflictRootDrift ConflictKind = "root_drift" + ConflictCommitDrift ConflictKind = "commit_drift" + ConflictBranchDrift ConflictKind = "branch_drift" + ConflictIndexDrift ConflictKind = "dirty_index" + ConflictExternalChange ConflictKind = "external_change" + ConflictUnrecoverable ConflictKind = "unrecoverable" + ConflictRedirectedPath ConflictKind = "redirected_path" + ConflictCheckpointBinding ConflictKind = "checkpoint_binding" +) + +type Change struct { + Kind ChangeKind `json:"kind"` + Path string `json:"path"` + PreviousPath string `json:"previous_path,omitempty"` + FromSHA256 string `json:"from_sha256"` + ToSHA256 string `json:"to_sha256"` + Binary bool `json:"binary"` + Recoverable bool `json:"recoverable"` + Reason string `json:"reason,omitempty"` +} + +type Conflict struct { + Kind ConflictKind `json:"kind"` + Path string `json:"path,omitempty"` + ExpectedSHA256 string `json:"expected_sha256,omitempty"` + CurrentSHA256 string `json:"current_sha256,omitempty"` + TargetSHA256 string `json:"target_sha256,omitempty"` + Reason string `json:"reason"` +} + +func (c Conflict) Validate() error { + if !c.Kind.Valid() || (c.Path != "" && !validPath(c.Path)) || + !validConflictValue(c.ExpectedSHA256) || + !validConflictValue(c.CurrentSHA256) || + !validConflictValue(c.TargetSHA256) || !validReason(c.Reason) || + c.Reason == "" { + return errors.New("workspace checkpoint conflict is invalid") + } + return nil +} + +func (k ConflictKind) Valid() bool { + switch k { + case ConflictRootDrift, ConflictCommitDrift, ConflictBranchDrift, + ConflictIndexDrift, ConflictExternalChange, ConflictUnrecoverable, + ConflictRedirectedPath, ConflictCheckpointBinding: + return true + default: + return false + } +} + +type Preview struct { + ProtocolVersion string `json:"protocol_version"` + ExpectedCurrentCheckpointID string `json:"expected_current_checkpoint_id"` + TargetCheckpointID string `json:"target_checkpoint_id"` + ObservedCheckpointID string `json:"observed_checkpoint_id"` + RecoveryLevel RecoveryLevel `json:"recovery_level"` + IndexChanged bool `json:"index_changed"` + Changes []Change `json:"changes"` + Conflicts []Conflict `json:"conflicts"` + Truncated bool `json:"truncated"` +} + +type RestoreResult struct { + Preview Preview `json:"preview"` + AppliedPaths []string `json:"applied_paths"` + DeletedPaths []string `json:"deleted_paths"` + IndexRestored bool `json:"index_restored"` +} + +type ConflictError struct { + Conflicts []Conflict +} + +func (e *ConflictError) Error() string { + if e == nil || len(e.Conflicts) == 0 { + return "workspace restore conflict" + } + return fmt.Sprintf("workspace restore stopped with %d conflict(s)", len(e.Conflicts)) +} + +func (e *ConflictError) ConflictJSON() string { + if e == nil { + return "[]" + } + value, err := json.Marshal(e.Conflicts) + if err != nil { + return "[]" + } + return string(value) +} + +// PreviewRestore performs a three-way comparison. expected is the immutable +// state the caller believes is live, target is the requested historical state, +// and observed is a fresh read-only capture. Any fourth state fails closed. +func PreviewRestore(expected, target, observed Snapshot) (Preview, error) { + for _, snapshot := range []Snapshot{expected, target, observed} { + if err := snapshot.Validate(); err != nil { + return Preview{}, err + } + } + preview := Preview{ProtocolVersion: ProtocolVersion, + ExpectedCurrentCheckpointID: expected.Checkpoint.ID, + TargetCheckpointID: target.Checkpoint.ID, + ObservedCheckpointID: observed.Checkpoint.ID, + RecoveryLevel: weakestRecovery(expected.Checkpoint.RecoveryLevel, + target.Checkpoint.RecoveryLevel, observed.Checkpoint.RecoveryLevel), + IndexChanged: expected.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256, + Changes: []Change{}, Conflicts: []Conflict{}} + + if expected.Checkpoint.RunID != target.Checkpoint.RunID || + expected.Checkpoint.WorkspaceID != target.Checkpoint.WorkspaceID || + observed.Checkpoint.RunID != expected.Checkpoint.RunID || + observed.Checkpoint.WorkspaceID != expected.Checkpoint.WorkspaceID { + appendConflict(&preview, Conflict{Kind: ConflictCheckpointBinding, + Reason: "checkpoint Run or Workspace binding does not match"}) + } + if expected.Checkpoint.RootFingerprint != target.Checkpoint.RootFingerprint || + expected.Checkpoint.RootPathSHA256 != target.Checkpoint.RootPathSHA256 || + observed.Checkpoint.RootFingerprint != expected.Checkpoint.RootFingerprint || + observed.Checkpoint.RootPathSHA256 != expected.Checkpoint.RootPathSHA256 { + appendConflict(&preview, Conflict{Kind: ConflictRootDrift, + Reason: "workspace root identity changed"}) + } + if target.Checkpoint.BaseCommit != expected.Checkpoint.BaseCommit || + observed.Checkpoint.BaseCommit != expected.Checkpoint.BaseCommit { + appendConflict(&preview, Conflict{Kind: ConflictCommitDrift, + ExpectedSHA256: expected.Checkpoint.BaseCommit, + CurrentSHA256: observed.Checkpoint.BaseCommit, + TargetSHA256: target.Checkpoint.BaseCommit, + Reason: "Git HEAD changed; restore cannot rewrite commit history"}) + } + if target.Checkpoint.Branch != expected.Checkpoint.Branch || + observed.Checkpoint.Branch != expected.Checkpoint.Branch { + appendConflict(&preview, Conflict{Kind: ConflictBranchDrift, + Reason: "Git branch identity changed"}) + } + if observed.Checkpoint.IndexSHA256 != expected.Checkpoint.IndexSHA256 && + observed.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256 { + appendConflict(&preview, Conflict{Kind: ConflictIndexDrift, + ExpectedSHA256: expected.Checkpoint.IndexSHA256, + CurrentSHA256: observed.Checkpoint.IndexSHA256, + TargetSHA256: target.Checkpoint.IndexSHA256, + Reason: "Git index differs from both expected and target checkpoints"}) + } + + expectedEntries := entryMap(expected.Entries) + targetEntries := entryMap(target.Entries) + observedEntries := entryMap(observed.Entries) + paths := unionPaths(expectedEntries, targetEntries, observedEntries) + changes := make([]Change, 0, len(paths)) + for _, currentPath := range paths { + from, fromOK := expectedEntries[currentPath] + to, toOK := targetEntries[currentPath] + live, liveOK := observedEntries[currentPath] + if !sameWorktreeEntry(from, fromOK, to, toOK) { + change := describeChange(currentPath, from, fromOK, to, toOK) + if !change.Recoverable { + appendConflict(&preview, Conflict{Kind: ConflictUnrecoverable, + Path: currentPath, ExpectedSHA256: entryStateDigest(from, fromOK), + CurrentSHA256: entryStateDigest(live, liveOK), + TargetSHA256: entryStateDigest(to, toOK), + Reason: change.Reason}) + } + changes = append(changes, change) + } + if sameWorktreeEntry(live, liveOK, from, fromOK) || + sameWorktreeEntry(live, liveOK, to, toOK) { + continue + } + appendConflict(&preview, Conflict{Kind: ConflictExternalChange, Path: currentPath, + ExpectedSHA256: entryStateDigest(from, fromOK), + CurrentSHA256: entryStateDigest(live, liveOK), + TargetSHA256: entryStateDigest(to, toOK), + Reason: "live Workspace content differs from both expected and target checkpoints"}) + } + preview.Changes = foldRenames(changes) + if len(preview.Changes) > MaxPreviewChanges { + preview.Changes = preview.Changes[:MaxPreviewChanges] + preview.Truncated = true + preview.RecoveryLevel = RecoveryUnavailable + } + if len(preview.Conflicts) != 0 { + preview.RecoveryLevel = RecoveryUnavailable + } + return preview, nil +} + +// ApplyRestore applies a previously previewed three-way restore. Each path is +// checked again immediately before mutation, so a concurrent writer creates a +// conflict instead of being overwritten. A partially applied restore is safe +// to replay because target values are accepted alongside expected values. +func ApplyRestore(ctx context.Context, workspaceRoot string, expected, target, + observed Snapshot, +) (RestoreResult, error) { + preview, err := PreviewRestore(expected, target, observed) + result := RestoreResult{Preview: preview, AppliedPaths: []string{}, DeletedPaths: []string{}} + if err != nil { + return result, err + } + if len(preview.Conflicts) != 0 { + return result, &ConflictError{Conflicts: preview.Conflicts} + } + rootPath, err := canonicalRoot(workspaceRoot) + if err != nil { + return result, err + } + rootFingerprint, err := workspaceRootFingerprint(rootPath) + if err != nil { + return result, err + } + if rootFingerprint != expected.Checkpoint.RootFingerprint { + conflict := Conflict{Kind: ConflictRootDrift, + Reason: "workspace root identity changed before restore"} + return result, &ConflictError{Conflicts: []Conflict{conflict}} + } + root, err := os.OpenRoot(rootPath) + if err != nil { + return result, err + } + defer root.Close() + + expectedEntries := entryMap(expected.Entries) + targetEntries := entryMap(target.Entries) + blobs := blobMap(target.Blobs) + paths := unionPaths(expectedEntries, targetEntries, nil) + for _, currentPath := range paths { + if err := ctx.Err(); err != nil { + return result, err + } + from, fromOK := expectedEntries[currentPath] + to, toOK := targetEntries[currentPath] + if sameWorktreeEntry(from, fromOK, to, toOK) { + continue + } + live, liveOK, inspectErr := inspectLiveEntry(root, currentPath) + if inspectErr != nil { + return result, inspectErr + } + if sameWorktreeEntry(live, liveOK, to, toOK) { + continue + } + if !sameWorktreeEntry(live, liveOK, from, fromOK) { + conflict := Conflict{Kind: ConflictExternalChange, Path: currentPath, + ExpectedSHA256: entryStateDigest(from, fromOK), + CurrentSHA256: entryStateDigest(live, liveOK), + TargetSHA256: entryStateDigest(to, toOK), + Reason: "Workspace path changed after restore preview"} + return result, &ConflictError{Conflicts: []Conflict{conflict}} + } + if restoreEntryAbsent(to, toOK) { + if !entryCanDelete(from, fromOK) { + conflict := Conflict{Kind: ConflictUnrecoverable, Path: currentPath, + Reason: "checkpoint does not authorize deletion of this path"} + return result, &ConflictError{Conflicts: []Conflict{conflict}} + } + if err := removeRootFile(root, currentPath); err != nil { + return result, err + } + result.DeletedPaths = append(result.DeletedPaths, currentPath) + continue + } + if !to.Recoverable || to.StoragePolicy != StorageStored || to.BlobSHA256 == "" { + conflict := Conflict{Kind: ConflictUnrecoverable, Path: currentPath, + Reason: "target checkpoint content is not available for restore"} + return result, &ConflictError{Conflicts: []Conflict{conflict}} + } + blob, ok := blobs[to.BlobSHA256] + if !ok { + return result, errors.New("target checkpoint blob is missing") + } + if err := atomicRootWrite(root, currentPath, blob.Content, os.FileMode(to.Mode)); err != nil { + return result, err + } + result.AppliedPaths = append(result.AppliedPaths, currentPath) + } + if expected.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256 { + if err := restoreGitIndex(ctx, rootPath, expected, target); err != nil { + return result, err + } + result.IndexRestored = true + } + return result, nil +} + +func workspaceRootFingerprint(root string) (string, error) { + // Use the same device/path identity primitive as Capture; path text alone + // is insufficient after a directory replacement. + return workspaceidentity.Fingerprint(root) +} + +func weakestRecovery(values ...RecoveryLevel) RecoveryLevel { + result := RecoveryComplete + for _, value := range values { + if value == RecoveryUnavailable { + return RecoveryUnavailable + } + if value == RecoveryPartial { + result = RecoveryPartial + } + } + return result +} + +func entryMap(entries []Entry) map[string]Entry { + result := make(map[string]Entry, len(entries)) + for _, entry := range entries { + result[entry.Path] = entry + } + return result +} + +func blobMap(blobs []Blob) map[string]Blob { + result := make(map[string]Blob, len(blobs)) + for _, blob := range blobs { + result[blob.SHA256] = blob + } + return result +} + +func unionPaths(first, second, third map[string]Entry) []string { + seen := make(map[string]struct{}, len(first)+len(second)+len(third)) + for key := range first { + seen[key] = struct{}{} + } + for key := range second { + seen[key] = struct{}{} + } + for key := range third { + seen[key] = struct{}{} + } + values := make([]string, 0, len(seen)) + for key := range seen { + values = append(values, key) + } + sort.Strings(values) + return values +} + +func restoreEntryAbsent(entry Entry, ok bool) bool { + return !ok || entry.State == StateMissing || entry.StoragePolicy == StorageMissing +} + +func entryCanDelete(entry Entry, ok bool) bool { + return !restoreEntryAbsent(entry, ok) && entry.Kind == EntryFile && entry.Recoverable +} + +func sameWorktreeEntry(left Entry, leftOK bool, right Entry, rightOK bool) bool { + leftAbsent := restoreEntryAbsent(left, leftOK) + rightAbsent := restoreEntryAbsent(right, rightOK) + if leftAbsent || rightAbsent { + return leftAbsent == rightAbsent + } + if left.Kind != right.Kind || left.State != right.State || + left.WorktreeSHA256 != right.WorktreeSHA256 { + return false + } + if left.Kind == EntryFile && left.Mode != right.Mode { + return false + } + return true +} + +func entryStateDigest(entry Entry, ok bool) string { + if restoreEntryAbsent(entry, ok) { + return "missing" + } + return entry.WorktreeSHA256 +} + +func validConflictValue(value string) bool { + if value == "" || value == "missing" || value == "non-git" || value == "unborn" { + return true + } + if len(value) != 40 && len(value) != 64 { + return false + } + _, err := hex.DecodeString(value) + return err == nil && value == strings.ToLower(value) +} + +func describeChange(currentPath string, from Entry, fromOK bool, to Entry, + toOK bool, +) Change { + change := Change{Path: currentPath, FromSHA256: entryStateDigest(from, fromOK), + ToSHA256: entryStateDigest(to, toOK), Binary: from.Binary || to.Binary, + Recoverable: true} + switch { + case restoreEntryAbsent(from, fromOK) && !restoreEntryAbsent(to, toOK): + change.Kind = ChangeCreate + change.Recoverable = to.Recoverable && to.StoragePolicy == StorageStored + change.Reason = to.Reason + case !restoreEntryAbsent(from, fromOK) && restoreEntryAbsent(to, toOK): + change.Kind = ChangeDelete + change.Recoverable = entryCanDelete(from, fromOK) + change.Reason = from.Reason + case from.WorktreeSHA256 == to.WorktreeSHA256: + change.Kind = ChangeMetadata + change.Recoverable = to.Recoverable && to.StoragePolicy == StorageStored + change.Reason = to.Reason + default: + change.Kind = ChangeModify + change.Recoverable = to.Recoverable && to.StoragePolicy == StorageStored + change.Reason = to.Reason + } + if !change.Recoverable && change.Reason == "" { + change.Reason = "target checkpoint content is not recoverable" + } + return change +} + +func foldRenames(changes []Change) []Change { + deletes := make(map[string][]int) + creates := make(map[string][]int) + for index, change := range changes { + switch change.Kind { + case ChangeDelete: + if change.FromSHA256 != "missing" { + deletes[change.FromSHA256] = append(deletes[change.FromSHA256], index) + } + case ChangeCreate: + if change.ToSHA256 != "missing" { + creates[change.ToSHA256] = append(creates[change.ToSHA256], index) + } + } + } + removed := make(map[int]struct{}) + for digest, deleteIndexes := range deletes { + createIndexes := creates[digest] + if len(deleteIndexes) != 1 || len(createIndexes) != 1 { + continue + } + deleteIndex, createIndex := deleteIndexes[0], createIndexes[0] + created := changes[createIndex] + deleted := changes[deleteIndex] + created.Kind = ChangeRename + created.PreviousPath = deleted.Path + created.FromSHA256 = deleted.FromSHA256 + created.Recoverable = created.Recoverable && deleted.Recoverable + changes[createIndex] = created + removed[deleteIndex] = struct{}{} + } + result := make([]Change, 0, len(changes)-len(removed)) + for index, change := range changes { + if _, ok := removed[index]; !ok { + result = append(result, change) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].Path == result[j].Path { + return result[i].Kind < result[j].Kind + } + return result[i].Path < result[j].Path + }) + return result +} + +func appendConflict(preview *Preview, conflict Conflict) { + if preview == nil || len(preview.Conflicts) >= MaxConflictItems { + if preview != nil { + preview.Truncated = true + } + return + } + preview.Conflicts = append(preview.Conflicts, conflict) +} + +func inspectLiveEntry(root *os.Root, relative string) (Entry, bool, error) { + if err := inspectExactRootPath(root, relative, true); err != nil { + if errors.Is(err, os.ErrNotExist) { + return Entry{}, false, nil + } + return Entry{}, false, err + } + info, err := root.Lstat(filepath.FromSlash(relative)) + if errors.Is(err, os.ErrNotExist) { + return Entry{}, false, nil + } + if err != nil { + return Entry{}, false, err + } + entry := Entry{Path: relative, Mode: uint32(info.Mode().Perm()), Size: info.Size(), + State: StatePresent} + if info.Mode()&os.ModeSymlink != 0 { + return Entry{}, false, &ConflictError{Conflicts: []Conflict{{ + Kind: ConflictRedirectedPath, Path: relative, + Reason: "workspace path is a symlink or junction"}}} + } + if !info.Mode().IsRegular() { + return Entry{}, false, &ConflictError{Conflicts: []Conflict{{ + Kind: ConflictRedirectedPath, Path: relative, + Reason: "workspace path is not a regular file"}}} + } + file, err := root.Open(filepath.FromSlash(relative)) + if err != nil { + return Entry{}, false, err + } + hash := sha256.New() + _, copyErr := io.Copy(hash, io.LimitReader(file, MaxStoredFileBytes+1)) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + return Entry{}, false, errors.Join(copyErr, closeErr) + } + entry.Kind = EntryFile + entry.WorktreeSHA256 = hex.EncodeToString(hash.Sum(nil)) + return entry, true, nil +} + +func inspectExactRootPath(root *os.Root, relative string, missingFinal bool) error { + components := strings.Split(filepath.ToSlash(relative), "/") + current := root + opened := make([]*os.Root, 0, len(components)) + defer func() { + for _, child := range opened { + _ = child.Close() + } + }() + for index, component := range components { + directory, err := current.Open(".") + if err != nil { + return err + } + entries, err := directory.ReadDir(-1) + closeErr := directory.Close() + if err != nil || closeErr != nil { + return errors.Join(err, closeErr) + } + exact, alias := false, false + for _, entry := range entries { + if entry.Name() == component { + exact = true + break + } + if strings.EqualFold(entry.Name(), component) { + alias = true + } + } + last := index == len(components)-1 + if !exact { + if alias { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictRedirectedPath, + Path: relative, Reason: "workspace path casing changed"}}} + } + if missingFinal && last { + return os.ErrNotExist + } + return os.ErrNotExist + } + info, err := current.Lstat(component) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictRedirectedPath, + Path: relative, Reason: "workspace path contains a symlink or junction"}}} + } + if last { + return nil + } + if !info.IsDir() { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictRedirectedPath, + Path: relative, Reason: "workspace path parent is not a directory"}}} + } + child, err := current.OpenRoot(component) + if err != nil { + return err + } + opened = append(opened, child) + current = child + } + return nil +} + +func atomicRootWrite(root *os.Root, relative string, content []byte, mode os.FileMode) error { + parent := path.Dir(relative) + if parent != "." { + if err := root.MkdirAll(filepath.FromSlash(parent), 0o755); err != nil { + return err + } + if err := inspectExactRootPath(root, parent, false); err != nil { + return err + } + } + if err := inspectExactRootPath(root, relative, true); err != nil && + !errors.Is(err, os.ErrNotExist) { + return err + } + for attempt := 0; attempt < 8; attempt++ { + random := make([]byte, 12) + if _, err := rand.Read(random); err != nil { + return err + } + temporary := path.Join(parent, ".cyberagent-checkpoint-"+hex.EncodeToString(random)) + file, err := root.OpenFile(filepath.FromSlash(temporary), + os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return err + } + writeErr := writeAndSync(file, content) + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + _ = root.Remove(filepath.FromSlash(temporary)) + return errors.Join(writeErr, closeErr) + } + if err := root.Chmod(filepath.FromSlash(temporary), mode.Perm()); err != nil { + _ = root.Remove(filepath.FromSlash(temporary)) + return err + } + if err := root.Rename(filepath.FromSlash(temporary), filepath.FromSlash(relative)); err != nil { + _ = root.Remove(filepath.FromSlash(temporary)) + return err + } + return nil + } + return errors.New("workspace checkpoint temporary file allocation failed") +} + +func removeRootFile(root *os.Root, relative string) error { + if err := inspectExactRootPath(root, relative, false); err != nil { + return err + } + info, err := root.Lstat(filepath.FromSlash(relative)) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictRedirectedPath, + Path: relative, Reason: "restore deletes regular files only"}}} + } + return root.Remove(filepath.FromSlash(relative)) +} + +func writeAndSync(file *os.File, content []byte) error { + if file == nil { + return errors.New("workspace checkpoint staging handle is absent") + } + if _, err := file.Write(content); err != nil { + return err + } + return file.Sync() +} + +func restoreGitIndex(ctx context.Context, root string, expected, target Snapshot) error { + emptyIndexSHA := sha256.Sum256(nil) + emptyIndexDigest := hex.EncodeToString(emptyIndexSHA[:]) + targetPresent := target.Checkpoint.IndexBlobSHA256 != "" || + target.Checkpoint.IndexSHA256 != emptyIndexDigest + targetContent := []byte(nil) + if target.Checkpoint.IndexBlobSHA256 != "" { + targetBlob, ok := blobMap(target.Blobs)[target.Checkpoint.IndexBlobSHA256] + if !ok { + return errors.New("target Git index blob is missing") + } + targetContent = targetBlob.Content + } else if targetPresent { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictUnrecoverable, + Reason: "target Git index blob is unavailable"}}} + } + inventory, err := inspectCaptureInventory(ctx, root) + if err != nil || !inventory.git || inventory.indexPath == "" { + return errors.Join(errors.New("git index path is unavailable"), err) + } + expectedPresent := expected.Checkpoint.IndexBlobSHA256 != "" || + expected.Checkpoint.IndexSHA256 != emptyIndexDigest + return compareAndSwapGitIndex(inventory.indexPath, expected.Checkpoint.IndexSHA256, + expectedPresent, target.Checkpoint.IndexSHA256, targetPresent, targetContent) +} + +func compareAndSwapGitIndex(target, expectedDigest string, expectedPresent bool, + targetDigest string, targetPresent bool, content []byte, +) error { + clean := filepath.Clean(target) + parent := filepath.Dir(clean) + parentInfo, err := os.Lstat(parent) + if err != nil || !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 { + return errors.Join(errors.New("git index parent is redirected or unavailable"), err) + } + resolved, err := filepath.EvalSymlinks(parent) + if err != nil || !sameCapturePath(parent, resolved) { + return errors.Join(errors.New("git index parent is redirected"), err) + } + directory, err := os.OpenRoot(parent) + if err != nil { + return err + } + defer directory.Close() + openedInfo, err := directory.Stat(".") + if err != nil || !os.SameFile(parentInfo, openedInfo) { + return errors.Join(errors.New("git index parent changed during restore"), err) + } + name := filepath.Base(clean) + if name == "." || name == string(filepath.Separator) || + !sameCapturePath(filepath.Join(parent, name), clean) { + return errors.New("git index path is invalid") + } + if err := inspectExactRootPath(directory, name, true); err != nil && + !errors.Is(err, os.ErrNotExist) { + return err + } + mode := os.FileMode(0o600) + if info, statErr := directory.Lstat(name); statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("git index is not a regular file") + } + mode = info.Mode().Perm() + } else if !errors.Is(statErr, os.ErrNotExist) { + return statErr + } + lockName := name + ".lock" + lock, err := directory.OpenFile(lockName, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if errors.Is(err, os.ErrExist) { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictIndexDrift, + ExpectedSHA256: expectedDigest, TargetSHA256: targetDigest, + Reason: "Git index is locked by another operation"}}} + } + if err != nil { + return err + } + lockOwned := true + defer func() { + _ = lock.Close() + if lockOwned { + _ = directory.Remove(lockName) + } + }() + current, currentPresent, err := readGitIndexUnderLock(directory, name) + if err != nil { + return err + } + currentSHA := sha256.Sum256(current) + currentDigest := hex.EncodeToString(currentSHA[:]) + if currentDigest == targetDigest && currentPresent == targetPresent { + return nil + } + if currentDigest != expectedDigest || currentPresent != expectedPresent { + return &ConflictError{Conflicts: []Conflict{{Kind: ConflictIndexDrift, + ExpectedSHA256: expectedDigest, CurrentSHA256: currentDigest, + TargetSHA256: targetDigest, + Reason: "Git index changed after restore preview"}}} + } + if !targetPresent { + return directory.Remove(name) + } + if err := writeAndSync(lock, content); err != nil { + return err + } + if err := lock.Close(); err != nil { + return err + } + if err := directory.Chmod(lockName, mode.Perm()); err != nil { + return err + } + if err := directory.Rename(lockName, name); err != nil { + return err + } + lockOwned = false + return nil +} + +func readGitIndexUnderLock(root *os.Root, name string) ([]byte, bool, error) { + file, err := root.Open(name) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, MaxStoredIndexBytes+1)) + if err != nil { + return nil, false, err + } + if len(content) > MaxStoredIndexBytes { + return nil, false, &ConflictError{Conflicts: []Conflict{{Kind: ConflictIndexDrift, + Reason: "live Git index exceeds the checkpoint limit"}}} + } + return content, true, nil +} diff --git a/internal/workspacecheckpoint/restore_test.go b/internal/workspacecheckpoint/restore_test.go new file mode 100644 index 00000000..9db9a001 --- /dev/null +++ b/internal/workspacecheckpoint/restore_test.go @@ -0,0 +1,231 @@ +package workspacecheckpoint + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRestorePreviewsAndReplaysTextBinaryRenameDeleteUntrackedAndDirtyIndex(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "text.txt"), []byte("alpha\r\nbeta\r\n")) + mustCheckpointWrite(t, filepath.Join(root, "old.bin"), []byte{0, 1, 2, 3}) + mustCheckpointWrite(t, filepath.Join(root, "removed.txt"), []byte("keep me\n")) + runCheckpointGit(t, root, "add", ".") + runCheckpointGit(t, root, "commit", "-m", "baseline") + // The starting point intentionally has a dirty index. Restoring must replay + // the raw index bytes instead of manufacturing an approximation with Git. + mustCheckpointWrite(t, filepath.Join(root, "text.txt"), []byte("staged\r\nvalue\r\n")) + runCheckpointGit(t, root, "add", "text.txt") + before := captureRestoreFixture(t, root, "checkpoint-before", "receipt-before", + time.Date(2026, 8, 19, 3, 0, 0, 0, time.UTC)) + + mustCheckpointWrite(t, filepath.Join(root, "text.txt"), []byte("changed\nvalue\n")) + if err := os.Rename(filepath.Join(root, "old.bin"), filepath.Join(root, "new.bin")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "removed.txt")); err != nil { + t.Fatal(err) + } + mustCheckpointWrite(t, filepath.Join(root, "untracked.dat"), []byte{9, 8, 0, 7}) + runCheckpointGit(t, root, "add", "-A") + after := captureRestoreFixture(t, root, "checkpoint-after", "receipt-after", + time.Date(2026, 8, 19, 3, 1, 0, 0, time.UTC)) + + preview, err := PreviewRestore(after, before, after) + if err != nil { + t.Fatal(err) + } + if len(preview.Conflicts) != 0 || !preview.IndexChanged { + t.Fatalf("unexpected preview: %+v", preview) + } + assertRestoreChange(t, preview.Changes, ChangeRename, "old.bin", "new.bin") + assertRestoreChange(t, preview.Changes, ChangeModify, "text.txt", "") + assertRestoreChange(t, preview.Changes, ChangeCreate, "removed.txt", "") + assertRestoreChange(t, preview.Changes, ChangeDelete, "untracked.dat", "") + + undo, err := ApplyRestore(t.Context(), root, after, before, after) + if err != nil { + t.Fatal(err) + } + if !undo.IndexRestored || len(undo.AppliedPaths) != 3 || len(undo.DeletedPaths) != 2 { + t.Fatalf("unexpected undo result: %+v", undo) + } + observedBefore := captureRestoreFixture(t, root, "checkpoint-observed-before", + "receipt-observed-before", time.Date(2026, 8, 19, 3, 2, 0, 0, time.UTC)) + assertRestoreState(t, observedBefore, before) + + redo, err := ApplyRestore(t.Context(), root, before, after, observedBefore) + if err != nil { + t.Fatal(err) + } + if !redo.IndexRestored { + t.Fatalf("redo did not restore index: %+v", redo) + } + observedAfter := captureRestoreFixture(t, root, "checkpoint-observed-after", + "receipt-observed-after", time.Date(2026, 8, 19, 3, 3, 0, 0, time.UTC)) + assertRestoreState(t, observedAfter, after) + + // Terminal replay is a no-op and remains safe after an event-persistence + // interruption. + replay, err := ApplyRestore(t.Context(), root, before, after, observedAfter) + if err != nil { + t.Fatal(err) + } + if len(replay.AppliedPaths) != 0 || len(replay.DeletedPaths) != 0 { + t.Fatalf("completed restore replay wrote files: %+v", replay) + } +} + +func TestRestoreFailsClosedOnExternalWorktreeAndIndexChanges(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "file.txt"), []byte("one\n")) + runCheckpointGit(t, root, "add", ".") + runCheckpointGit(t, root, "commit", "-m", "baseline") + before := captureRestoreFixture(t, root, "before", "receipt-before", time.Now().UTC()) + mustCheckpointWrite(t, filepath.Join(root, "file.txt"), []byte("two\n")) + after := captureRestoreFixture(t, root, "after", "receipt-after", + time.Now().UTC().Add(time.Second)) + + mustCheckpointWrite(t, filepath.Join(root, "file.txt"), []byte("external\n")) + runCheckpointGit(t, root, "add", "file.txt") + observed := captureRestoreFixture(t, root, "observed", "receipt-observed", + time.Now().UTC().Add(2*time.Second)) + preview, err := PreviewRestore(after, before, observed) + if err != nil { + t.Fatal(err) + } + if !hasRestoreConflict(preview.Conflicts, ConflictExternalChange) || + !hasRestoreConflict(preview.Conflicts, ConflictIndexDrift) { + t.Fatalf("external changes were not reported: %+v", preview.Conflicts) + } + _, err = ApplyRestore(t.Context(), root, after, before, observed) + var conflict *ConflictError + if !errors.As(err, &conflict) { + t.Fatalf("expected conflict error, got %v", err) + } + data, readErr := os.ReadFile(filepath.Join(root, "file.txt")) + if readErr != nil || string(data) != "external\n" { + t.Fatalf("conflicting content was changed: %q err=%v", data, readErr) + } +} + +func TestRestoreRejectsUnrecoverableTargetContent(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "base.txt"), []byte("base\n")) + runCheckpointGit(t, root, "add", ".") + runCheckpointGit(t, root, "commit", "-m", "baseline") + mustCheckpointWrite(t, filepath.Join(root, ".env"), []byte("TOKEN=first\n")) + target := captureRestoreFixture(t, root, "target", "receipt-target", time.Now().UTC()) + mustCheckpointWrite(t, filepath.Join(root, ".env"), []byte("TOKEN=second\n")) + expected := captureRestoreFixture(t, root, "expected", "receipt-expected", + time.Now().UTC().Add(time.Second)) + preview, err := PreviewRestore(expected, target, expected) + if err != nil { + t.Fatal(err) + } + if !hasRestoreConflict(preview.Conflicts, ConflictUnrecoverable) { + t.Fatalf("sensitive target was not rejected: %+v", preview) + } +} + +func TestRestoreUsesGitIndexLockAndRestoresAMissingIndex(t *testing.T) { + root := newCheckpointRepository(t) + mustCheckpointWrite(t, filepath.Join(root, "draft.txt"), []byte("draft\n")) + targetRequest := validCaptureRequest(root, time.Now().UTC()) + targetRequest.ID = "checkpoint-index-missing" + target, err := Capture(t.Context(), targetRequest) + if err != nil { + t.Fatal(err) + } + if target.Checkpoint.IndexBlobSHA256 != "" { + t.Fatalf("target index unexpectedly exists: %+v", target.Checkpoint) + } + runCheckpointGit(t, root, "add", "draft.txt") + expectedRequest := validCaptureRequest(root, time.Now().UTC().Add(time.Second)) + expectedRequest.ID = "checkpoint-index-present" + expected, err := Capture(t.Context(), expectedRequest) + if err != nil { + t.Fatal(err) + } + preview, err := PreviewRestore(expected, target, expected) + if err != nil || len(preview.Conflicts) != 0 || !preview.IndexChanged { + t.Fatalf("missing-index preview=%+v err=%v", preview, err) + } + + indexPath := filepath.Join(root, ".git", "index") + lockPath := indexPath + ".lock" + if err := os.WriteFile(lockPath, []byte("external lock"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ApplyRestore(t.Context(), root, expected, target, expected); err == nil { + t.Fatal("restore ignored an existing Git index lock") + } + if _, err := os.Stat(indexPath); err != nil { + t.Fatalf("locked restore changed the Git index: %v", err) + } + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + + if _, err := ApplyRestore(t.Context(), root, expected, target, expected); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(indexPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing target index was not restored: %v", err) + } + observedRequest := validCaptureRequest(root, time.Now().UTC().Add(2*time.Second)) + observedRequest.ID = "checkpoint-index-observed" + observed, err := Capture(t.Context(), observedRequest) + if err != nil || observed.Checkpoint.IndexSHA256 != target.Checkpoint.IndexSHA256 || + observed.Checkpoint.ManifestSHA256 != target.Checkpoint.ManifestSHA256 { + t.Fatalf("restored missing index=%+v err=%v", observed.Checkpoint, err) + } +} + +func captureRestoreFixture(t *testing.T, root, id, receipt string, at time.Time) Snapshot { + t.Helper() + request := validCaptureRequest(root, at) + request.ID = id + request.TriggerReceiptID = receipt + snapshot, err := Capture(t.Context(), request) + if err != nil { + t.Fatal(err) + } + return snapshot +} + +func assertRestoreChange(t *testing.T, changes []Change, kind ChangeKind, currentPath, + previousPath string, +) { + t.Helper() + for _, change := range changes { + if change.Kind == kind && change.Path == currentPath && + change.PreviousPath == previousPath { + return + } + } + t.Fatalf("missing %s change %s <- %s in %+v", kind, currentPath, previousPath, changes) +} + +func hasRestoreConflict(conflicts []Conflict, kind ConflictKind) bool { + for _, conflict := range conflicts { + if conflict.Kind == kind { + return true + } + } + return false +} + +func assertRestoreState(t *testing.T, observed, expected Snapshot) { + t.Helper() + if observed.Checkpoint.ManifestSHA256 != expected.Checkpoint.ManifestSHA256 || + observed.Checkpoint.IndexSHA256 != expected.Checkpoint.IndexSHA256 || + observed.Checkpoint.BaseCommit != expected.Checkpoint.BaseCommit || + observed.Checkpoint.Branch != expected.Checkpoint.Branch { + t.Fatalf("restored state mismatch:\nobserved=%+v\nexpected=%+v", + observed.Checkpoint, expected.Checkpoint) + } +} diff --git a/internal/workspaceidentity/fingerprint.go b/internal/workspaceidentity/fingerprint.go new file mode 100644 index 00000000..43856110 --- /dev/null +++ b/internal/workspaceidentity/fingerprint.go @@ -0,0 +1,26 @@ +package workspaceidentity + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" +) + +// Fingerprint binds a canonical real directory path to its platform file +// identity. Callers remain responsible for rejecting redirected roots before +// invoking it; this package deliberately has no dependency on Workspace policy. +func Fingerprint(root string) (string, error) { + root = filepath.Clean(root) + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("workspace root identity is unavailable") + } + identity, err := rootIdentity(root, info) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(filepath.ToSlash(root) + "\x00" + identity)) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/workspace/agent_code_root_identity_other.go b/internal/workspaceidentity/identity_other.go similarity index 64% rename from internal/workspace/agent_code_root_identity_other.go rename to internal/workspaceidentity/identity_other.go index 92950079..665a8209 100644 --- a/internal/workspace/agent_code_root_identity_other.go +++ b/internal/workspaceidentity/identity_other.go @@ -1,12 +1,12 @@ //go:build !windows && !linux && !darwin -package workspace +package workspaceidentity import ( "fmt" "os" ) -func agentCodeRootIdentity(_ string, _ os.FileInfo) (string, error) { +func rootIdentity(_ string, _ os.FileInfo) (string, error) { return "", fmt.Errorf("workspace root file identity is unsupported on this platform") } diff --git a/internal/workspace/agent_code_root_identity_unix.go b/internal/workspaceidentity/identity_unix.go similarity index 75% rename from internal/workspace/agent_code_root_identity_unix.go rename to internal/workspaceidentity/identity_unix.go index 52838878..275abc29 100644 --- a/internal/workspace/agent_code_root_identity_unix.go +++ b/internal/workspaceidentity/identity_unix.go @@ -1,6 +1,6 @@ //go:build linux || darwin -package workspace +package workspaceidentity import ( "fmt" @@ -8,7 +8,7 @@ import ( "syscall" ) -func agentCodeRootIdentity(_ string, info os.FileInfo) (string, error) { +func rootIdentity(_ string, info os.FileInfo) (string, error) { stat, ok := info.Sys().(*syscall.Stat_t) if !ok || stat == nil { return "", fmt.Errorf("workspace root file identity is unavailable") diff --git a/internal/workspace/agent_code_root_identity_windows.go b/internal/workspaceidentity/identity_windows.go similarity index 89% rename from internal/workspace/agent_code_root_identity_windows.go rename to internal/workspaceidentity/identity_windows.go index cd925bc0..92a7da79 100644 --- a/internal/workspace/agent_code_root_identity_windows.go +++ b/internal/workspaceidentity/identity_windows.go @@ -1,6 +1,6 @@ //go:build windows -package workspace +package workspaceidentity import ( "fmt" @@ -9,7 +9,7 @@ import ( "golang.org/x/sys/windows" ) -func agentCodeRootIdentity(root string, _ os.FileInfo) (string, error) { +func rootIdentity(root string, _ os.FileInfo) (string, error) { name, err := windows.UTF16PtrFromString(root) if err != nil { return "", err diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 47516f24..45727485 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -28,6 +28,7 @@ function runtimeCapabilitiesData(overrides: Record = {}) { skill_installation_enabled: true, evidence_attachment_enabled: true, verification_evidence_enabled: true, embedded_analyzer_execution_enabled: true, + workspace_checkpoint_control_enabled: true, process_execution_enabled: true, shell_execution_enabled: true, docker_execution_enabled: false, wake_worker: { protocol_version: "run_wake_worker_health.v1", enabled: true, @@ -177,6 +178,7 @@ describe("CyberAgentClient", () => { skill_installation_enabled: true, evidence_attachment_enabled: true, verification_evidence_enabled: true, embedded_analyzer_execution_enabled: true, + workspace_checkpoint_control_enabled: true, process_execution_enabled: true, shell_execution_enabled: true, docker_execution_enabled: false, wake_worker: { protocol_version: "run_wake_worker_health.v1", enabled: true, @@ -199,6 +201,7 @@ describe("CyberAgentClient", () => { runWakeWorkerEnabled: true, verificationEvidenceEnabled: true, embeddedAnalyzerExecutionEnabled: true, + workspaceCheckpointControlEnabled: true, }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 01b6742d..3779b59e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -159,6 +159,7 @@ export interface ClientCapabilities { evidenceAttachmentEnabled?: boolean; verificationEvidenceEnabled?: boolean; embeddedAnalyzerExecutionEnabled?: boolean; + workspaceCheckpointControlEnabled?: boolean; dockerExecutionEnabled?: boolean; agentCodeToolsEnabled?: boolean; } @@ -1105,7 +1106,7 @@ function parseRuntimeCapabilities(value: unknown): RuntimeCapabilitiesView { "execution_permission_control_enabled", "operator_approval_enabled", "danger_full_access_enabled", "debug_maximum_access_enabled", "evidence_attachment_enabled", "verification_evidence_enabled", - "embedded_analyzer_execution_enabled", + "embedded_analyzer_execution_enabled", "workspace_checkpoint_control_enabled", "file_edit_apply_enabled", "file_edit_proposal_enabled", "file_edit_review_enabled", "model_control_enabled", "plan_delivery_control_enabled", "process_execution_enabled", "provider_credential_enabled", "protocol_version", @@ -1243,6 +1244,7 @@ export function clientCapabilitiesFromRuntime(value: RuntimeCapabilitiesView): C evidenceAttachmentEnabled: value.evidence_attachment_enabled, verificationEvidenceEnabled: value.verification_evidence_enabled, embeddedAnalyzerExecutionEnabled: value.embedded_analyzer_execution_enabled, + workspaceCheckpointControlEnabled: value.workspace_checkpoint_control_enabled, dockerExecutionEnabled: value.docker_execution_enabled, agentCodeToolsEnabled: value.agent_code_tools_enabled, }; @@ -3397,6 +3399,7 @@ export class CyberAgentClient { readonly hasEvidenceAttachment: boolean; readonly hasVerificationEvidence: boolean; readonly hasEmbeddedAnalyzerExecution: boolean; + readonly hasWorkspaceCheckpointControl: boolean; constructor( private readonly token: string, @@ -3446,6 +3449,8 @@ export class CyberAgentClient { (capabilities.verificationEvidenceEnabled ?? false); this.hasEmbeddedAnalyzerExecution = controlPresent && (capabilities.embeddedAnalyzerExecutionEnabled ?? false); + this.hasWorkspaceCheckpointControl = controlPresent && + (capabilities.workspaceCheckpointControlEnabled ?? false); } async health(signal?: AbortSignal): Promise { diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 17e29bab..ed658e55 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -1808,6 +1808,130 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/runs/{run_id}/workspace-checkpoints": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Browse the Workspace checkpoint timeline + * @description Returns immutable pre/post Workspace manifests, mutation transactions, the CAS cursor, recovery completeness, and content-store usage without exposing blob content. + */ + get: operations["listWorkspaceCheckpoints"]; + put?: never; + /** + * Create a manual Workspace checkpoint + * @description Captures a bounded content-addressed manifest and exact Git index under an idempotency key; excluded content and incomplete attribution remain explicit. + */ + post: operations["createWorkspaceCheckpoint"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runs/{run_id}/workspace-checkpoints/fork": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fork a checkpoint into an independent Run + * @description Creates an exact branch-backed Git worktree, a distinct Workspace and a new Run. Context is bounded; approvals, credentials, capabilities, leases, processes, terminals, network authority, and execution profiles are reset. + */ + post: operations["forkWorkspaceCheckpoint"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runs/{run_id}/workspace-checkpoints/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Preview a Workspace rewind + * @description Computes a bounded three-way diff against live files and the Git index without writing. External drift is returned as explicit conflicts. + */ + post: operations["previewWorkspaceRewind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runs/{run_id}/workspace-checkpoints/redo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Redo the latest Workspace undo + * @description Restores the original mutation's after checkpoint under the same paused-Run, CAS, permission, and conflict checks as rewind. + */ + post: operations["redoWorkspaceMutation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runs/{run_id}/workspace-checkpoints/rewind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rewind to a Workspace checkpoint + * @description Requires confirm=true, a paused quiescent Run, current operator authorization, and an unchanged preview cursor. Restore is an auditable new write and never uses hard reset or blanket untracked deletion. + */ + post: operations["rewindWorkspaceCheckpoint"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runs/{run_id}/workspace-checkpoints/undo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Undo the current Workspace mutation + * @description Restores the current mutation's before checkpoint with three-way conflict detection and appends a new immutable undo transaction. + */ + post: operations["undoWorkspaceMutation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sandbox/docker/admissions": { parameters: { query?: never; @@ -2505,6 +2629,47 @@ export interface components { /** Format: int32 */ grace_period_millis: number; }; + Change: { + binary: boolean; + from_sha256: string; + kind: string; + path: string; + previous_path?: string; + reason?: string; + recoverable: boolean; + to_sha256: string; + }; + Checkpoint: { + attempt_id?: string; + base_commit: string; + branch: string; + capability_generation?: string; + /** Format: date-time */ + created_at: string; + /** Format: int32 */ + entry_count: number; + id: string; + incomplete_reasons: string[]; + index_blob_sha256?: string; + index_sha256: string; + manifest_sha256: string; + mission_id: string; + parent_checkpoint_id?: string; + phase: string; + protocol_version: string; + recovery_level: string; + requested_by?: string; + root_fingerprint: string; + root_path_sha256: string; + run_id: string; + session_id: string; + /** Format: int64 */ + stored_bytes: number; + title?: string; + trigger: string; + trigger_receipt_id: string; + workspace_id: string; + }; ChildTaskAdmitRequestView: { confirm_admit: boolean; version: string; @@ -2815,6 +2980,14 @@ export interface components { executable: string; working_directory: string; }; + Conflict: { + current_sha256?: string; + expected_sha256?: string; + kind: string; + path?: string; + reason: string; + target_sha256?: string; + }; ContextMemoryExport: { capability_grant: boolean; /** Format: date-time */ @@ -4258,6 +4431,17 @@ export interface components { /** @enum {string} */ version: "plan_delivery_control.v1"; }; + Preview: { + changes: components["schemas"]["Change"][]; + conflicts: components["schemas"]["Conflict"][]; + expected_current_checkpoint_id: string; + index_changed: boolean; + observed_checkpoint_id: string; + protocol_version: string; + recovery_level: string; + target_checkpoint_id: string; + truncated: boolean; + }; PriceEntryView: { /** Format: int64 */ cache_read_per_million_micros: number; @@ -5097,6 +5281,14 @@ export interface components { /** @enum {string} */ surface: "code" | "cyber"; }; + RunState: { + current_checkpoint_id: string; + last_transaction_id?: string; + run_id: string; + /** Format: date-time */ + updated_at: string; + workspace_id: string; + }; RunView: { budget: components["schemas"]["BudgetView"]; config: components["schemas"]["RunConfigView"]; @@ -5257,6 +5449,7 @@ export interface components { skill_installation_enabled: boolean; verification_evidence_enabled: boolean; wake_worker: components["schemas"]["RunWakeWorkerHealthView"]; + workspace_checkpoint_control_enabled: boolean; }; ScopeView: { allowed_targets?: string[]; @@ -5405,6 +5598,14 @@ export interface components { /** @enum {string} */ status: "pending" | "observed" | "resolved"; }; + StorageUsage: { + /** Format: int64 */ + blob_bytes: number; + /** Format: int32 */ + blob_count: number; + /** Format: int32 */ + checkpoint_count: number; + }; SupervisorCheckpointView: { attempt_id?: string; /** Format: int64 */ @@ -5469,6 +5670,30 @@ export interface components { /** Format: int64 */ remaining: number; }; + Transaction: { + after_checkpoint_id?: string; + before_checkpoint_id: string; + /** Format: date-time */ + completed_at?: string; + conflict_json?: string; + /** Format: date-time */ + created_at: string; + error_code?: string; + expected_current_checkpoint_id?: string; + id: string; + kind: string; + operation_key_digest: string; + protocol_version: string; + recovery_level: string; + request_fingerprint: string; + run_id: string; + status: string; + target_checkpoint_id?: string; + trigger_receipt_id: string; + /** Format: date-time */ + updated_at: string; + workspace_id: string; + }; VerificationAssociationControlView: { approval: boolean; /** Format: date-time */ @@ -6050,6 +6275,15 @@ export interface components { /** Format: int64 */ version: number; }; + WorkspaceCheckpointTimeline: { + checkpoints: components["schemas"]["Checkpoint"][]; + current?: components["schemas"]["RunState"]; + protocol_version: string; + run_id: string; + storage_usage: components["schemas"]["StorageUsage"]; + transactions: components["schemas"]["Transaction"][]; + workspace_id: string; + }; WorkspaceExplorerEntryView: { /** @enum {string} */ kind: "directory" | "file" | "blocked"; @@ -6087,6 +6321,15 @@ export interface components { truncated: boolean; workspace_id: string; }; + WorkspaceRestoreResult: { + after?: components["schemas"]["Checkpoint"]; + before: components["schemas"]["Checkpoint"]; + confirmed: boolean; + preview: components["schemas"]["Preview"]; + protocol_version: string; + replayed: boolean; + transaction?: components["schemas"]["Transaction"]; + }; WorkspaceSearchResultView: { content_truncated: boolean; /** Format: int32 */ @@ -6169,6 +6412,78 @@ export interface components { expected_live_fingerprint: string; target_path?: string; }; + workspaceCheckpointCaptureView: { + operation_key: string; + title?: string; + }; + workspaceCheckpointCursorActionView: { + confirm: boolean; + expected_current_checkpoint_id: string; + operation_key: string; + }; + workspaceCheckpointForkContinuityView: { + /** Format: date-time */ + created_at: string; + git_branch?: string; + git_head?: string; + id: string; + kind: string; + run_id: string; + session_id: string; + source_node_id?: string; + workspace_id: string; + }; + workspaceCheckpointForkMissionView: { + id: string; + profile: string; + workspace_id: string; + }; + workspaceCheckpointForkResultView: { + checkpoint: components["schemas"]["Checkpoint"]; + continuity_node: components["schemas"]["workspaceCheckpointForkContinuityView"]; + mission: components["schemas"]["workspaceCheckpointForkMissionView"]; + not_inherited: string[]; + protocol_version: string; + replayed: boolean; + run: components["schemas"]["workspaceCheckpointForkRunView"]; + source_run_id: string; + target: components["schemas"]["Checkpoint"]; + transaction: components["schemas"]["Transaction"]; + workspace: components["schemas"]["workspaceCheckpointForkWorkspaceView"]; + }; + workspaceCheckpointForkRunView: { + /** Format: date-time */ + created_at: string; + id: string; + mission_id: string; + session_id: string; + status: string; + }; + workspaceCheckpointForkView: { + branch: string; + confirm: boolean; + expected_current_checkpoint_id: string; + goal?: string; + operation_key: string; + target_checkpoint_id: string; + workspace_name: string; + }; + workspaceCheckpointForkWorkspaceView: { + /** Format: date-time */ + created_at: string; + id: string; + name: string; + }; + workspaceCheckpointPreviewView: { + expected_current_checkpoint_id: string; + target_checkpoint_id: string; + }; + workspaceCheckpointRewindView: { + confirm: boolean; + expected_current_checkpoint_id: string; + operation_key: string; + target_checkpoint_id: string; + }; }; responses: { /** @description Invalid path, query, method, or request body */ @@ -10265,6 +10580,302 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + listWorkspaceCheckpoints: { + parameters: { + query?: { + /** @description Maximum checkpoints and transactions */ + limit?: number; + }; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["WorkspaceCheckpointTimeline"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + createWorkspaceCheckpoint: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointCaptureView"]; + }; + }; + responses: { + /** @description Resource created or idempotently replayed */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["Checkpoint"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + forkWorkspaceCheckpoint: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointForkView"]; + }; + }; + responses: { + /** @description Resource created or idempotently replayed */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["workspaceCheckpointForkResultView"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + previewWorkspaceRewind: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointPreviewView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["WorkspaceRestoreResult"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + redoWorkspaceMutation: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointCursorActionView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["WorkspaceRestoreResult"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + rewindWorkspaceCheckpoint: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointRewindView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["WorkspaceRestoreResult"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + undoWorkspaceMutation: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["workspaceCheckpointCursorActionView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["WorkspaceRestoreResult"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; admitDockerSandbox: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4e85a52f..fc4f3295 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -137,6 +137,12 @@ export type WorkItemView = components["schemas"]["WorkItemView"]; export type WorkspaceView = components["schemas"]["WorkspaceView"]; export type WorkspaceExplorerView = components["schemas"]["WorkspaceExplorerView"]; export type WorkspaceSearchView = components["schemas"]["WorkspaceSearchView"]; +export type WorkspaceCheckpointView = components["schemas"]["Checkpoint"]; +export type WorkspaceCheckpointTimelineView = components["schemas"]["WorkspaceCheckpointTimeline"]; +export type WorkspaceCheckpointRestoreView = components["schemas"]["WorkspaceRestoreResult"]; +export type WorkspaceCheckpointForkView = components["schemas"]["workspaceCheckpointForkResultView"]; +export type WorkspaceCheckpointChangeView = components["schemas"]["Change"]; +export type WorkspaceCheckpointConflictView = components["schemas"]["Conflict"]; export type RepositoryStateView = components["schemas"]["RepositoryStateView"]; export type RepositoryDiffView = components["schemas"]["RepositoryDiffView"]; export type RepositoryHistoryView = components["schemas"]["RepositoryHistoryView"]; diff --git a/web/src/components/connection-gate.test.tsx b/web/src/components/connection-gate.test.tsx index 85585500..0676d07e 100644 --- a/web/src/components/connection-gate.test.tsx +++ b/web/src/components/connection-gate.test.tsx @@ -91,6 +91,7 @@ describe("ConnectionGate", () => { evidence_attachment_enabled: false, verification_evidence_enabled: false, embedded_analyzer_execution_enabled: false, + workspace_checkpoint_control_enabled: false, user_terminal_enabled: false, agent_terminal_input_default: false, workspace_open_enabled: false, @@ -138,6 +139,7 @@ function runtimeCapabilities() { run_wake_worker_enabled: false, skill_installation_enabled: true, evidence_attachment_enabled: true, process_execution_enabled: true, verification_evidence_enabled: true, embedded_analyzer_execution_enabled: true, + workspace_checkpoint_control_enabled: true, shell_execution_enabled: true, docker_execution_enabled: false, wake_worker: { protocol_version: "run_wake_worker_health.v1", enabled: false, state: "disabled", active: false, poll_interval_ms: 0, concurrency: 1, diff --git a/web/src/components/connection-gate.tsx b/web/src/components/connection-gate.tsx index 3ec9fc5e..75c2d930 100644 --- a/web/src/components/connection-gate.tsx +++ b/web/src/components/connection-gate.tsx @@ -67,6 +67,7 @@ export function ConnectionGate() { evidenceAttachmentEnabled: bootstrap.evidence_attachment_enabled, verificationEvidenceEnabled: bootstrap.verification_evidence_enabled, embeddedAnalyzerExecutionEnabled: bootstrap.embedded_analyzer_execution_enabled, + workspaceCheckpointControlEnabled: bootstrap.workspace_checkpoint_control_enabled, dockerExecutionEnabled: bootstrap.docker_execution_enabled, agentCodeToolsEnabled: bootstrap.agent_code_tools_enabled, }); diff --git a/web/src/components/run-workspace.tsx b/web/src/components/run-workspace.tsx index 32c214b3..b1a412bb 100644 --- a/web/src/components/run-workspace.tsx +++ b/web/src/components/run-workspace.tsx @@ -90,10 +90,11 @@ import { RunActivityTimeline } from "./run-activity-timeline"; import { EmbeddedAnalyzerPanel } from "./embedded-analyzer-panel"; import { DockerSandboxPanel } from "./docker-sandbox-panel"; import { ContextContinuityPanel } from "./context-continuity-panel"; +import { WorkspaceCheckpointPanel } from "./workspace-checkpoint-panel"; export type RunTab = "activity" | "overview" | "journey" | "actions" | "approvals" | "diffs" | "repository" | "files" | "evidence" | "verify" | "handoff" | "receipts" | "agents" | "delegations" | "fanout" | "findings" | "events" | "work" | - "context" | "notes" | "artifacts" | "tools" | "analyzer" | "child-tasks" | "sandbox"; + "context" | "checkpoints" | "notes" | "artifacts" | "tools" | "analyzer" | "child-tasks" | "sandbox"; const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity }> = [ { id: "activity", label: ["活动", "Activity"], icon: MessageSquareText }, @@ -108,6 +109,7 @@ const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity } { id: "verify", label: ["验证", "Verify"], icon: ClipboardCheck }, { id: "handoff", label: ["交接", "Handoff"], icon: BookOpenCheck }, { id: "receipts", label: ["操作收据", "Receipts"], icon: History }, + { id: "checkpoints", label: ["工作区检查点", "Checkpoints"], icon: History }, { id: "agents", label: ["子智能体", "Agents"], icon: GitBranch }, { id: "delegations", label: ["委派", "Delegations"], icon: Network }, { id: "fanout", label: ["并发派发", "Fan-out"], icon: ScanSearch }, @@ -123,7 +125,7 @@ const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity } { id: "sandbox", label: ["沙箱", "Sandbox"], icon: Server }, ]; -const compactTabs = new Set(["activity", "approvals", "diffs", "repository", "files"]); +const compactTabs = new Set(["activity", "approvals", "diffs", "repository", "files", "checkpoints"]); export function RunWorkspaceTabs({ activeTab, ariaLabel, children, items, onSelect }: { activeTab: RunTab; @@ -383,6 +385,8 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: { } {tab === "receipts" && } + {tab === "checkpoints" && } {tab === "agents" && } {tab === "delegations" && } {tab === "fanout" && } diff --git a/web/src/components/workspace-checkpoint-panel.test.tsx b/web/src/components/workspace-checkpoint-panel.test.tsx new file mode 100644 index 00000000..d32217d5 --- /dev/null +++ b/web/src/components/workspace-checkpoint-panel.test.tsx @@ -0,0 +1,187 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { CyberAgentClient } from "../api/client"; +import type { + WorkspaceCheckpointRestoreView, + WorkspaceCheckpointTimelineView, + WorkspaceCheckpointView, +} from "../api/types"; +import { WorkspaceCheckpointPanel } from "./workspace-checkpoint-panel"; + +vi.mock("../lib/locale", () => ({ + useLocale: () => ({ t: (chinese: string) => chinese }), +})); + +function checkpoint(id: string, title: string, createdAt: string, + recovery = "complete"): WorkspaceCheckpointView { + return { + id, + protocol_version: "workspace-checkpoint.v1", + run_id: "run-1", + mission_id: "mission-1", + session_id: "session-1", + workspace_id: "workspace-1", + trigger: "command_batch", + phase: "after", + trigger_receipt_id: `receipt-${id}`, + root_fingerprint: "a".repeat(64), + root_path_sha256: "b".repeat(64), + base_commit: "c".repeat(40), + branch: "codex/test", + index_sha256: "d".repeat(64), + manifest_sha256: "e".repeat(64), + recovery_level: recovery, + incomplete_reasons: recovery === "partial" ? ["filesystem watcher unavailable"] : [], + entry_count: 2, + stored_bytes: 24, + created_at: createdAt, + title, + }; +} + +function timeline(): WorkspaceCheckpointTimelineView { + const before = checkpoint("checkpoint-before", "Before shell", "2026-08-18T00:00:00Z", "partial"); + const current = checkpoint("checkpoint-current", "After shell", "2026-08-18T00:01:00Z"); + return { + protocol_version: "workspace-checkpoint-api.v1", + run_id: "run-1", + workspace_id: "workspace-1", + current: { + run_id: "run-1", + workspace_id: "workspace-1", + current_checkpoint_id: current.id, + last_transaction_id: "transaction-shell", + updated_at: "2026-08-18T00:01:00Z", + }, + checkpoints: [current, before], + transactions: [{ + id: "transaction-shell", + protocol_version: "workspace-checkpoint.v1", + operation_key_digest: "1".repeat(64), + request_fingerprint: "2".repeat(64), + run_id: "run-1", + workspace_id: "workspace-1", + kind: "command_batch", + trigger_receipt_id: "receipt-shell", + before_checkpoint_id: before.id, + after_checkpoint_id: current.id, + status: "completed", + recovery_level: "partial", + created_at: "2026-08-18T00:00:00Z", + updated_at: "2026-08-18T00:01:00Z", + completed_at: "2026-08-18T00:01:00Z", + }], + storage_usage: { blob_bytes: 24, blob_count: 2, checkpoint_count: 2 }, + }; +} + +function preview(): WorkspaceCheckpointRestoreView { + return { + protocol_version: "workspace-checkpoint-api.v1", + confirmed: false, + replayed: false, + before: timeline().checkpoints[0]!, + preview: { + protocol_version: "workspace-checkpoint.v1", + expected_current_checkpoint_id: "checkpoint-current", + target_checkpoint_id: "checkpoint-before", + observed_checkpoint_id: "checkpoint-observed", + recovery_level: "partial", + index_changed: false, + truncated: false, + conflicts: [], + changes: [{ + kind: "modify", + path: "src/parser.go", + from_sha256: "a".repeat(64), + to_sha256: "b".repeat(64), + recoverable: true, + binary: false, + }], + }, + }; +} + +function renderPanel(client: CyberAgentClient, runStatus = "paused") { + return render( + + ); +} + +describe("WorkspaceCheckpointPanel", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("browses provenance, previews impact, and confirms an auditable Rewind", async () => { + const get = vi.fn().mockResolvedValue(timeline()); + const postControl = vi.fn().mockImplementation((path: string) => + Promise.resolve(path.endsWith("/preview") ? preview() : { + ...preview(), confirmed: true, + })); + const client = { get, postControl, hasWorkspaceCheckpointControl: true } as unknown as CyberAgentClient; + vi.stubGlobal("confirm", vi.fn(() => true)); + const user = userEvent.setup(); + renderPanel(client); + + await user.click(await screen.findByRole("button", { name: /Before shell/ })); + expect(screen.getByText("filesystem watcher unavailable")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "预览 Rewind" })); + expect(await screen.findByText("src/parser.go")).toBeInTheDocument(); + expect(screen.getByText(/1 项影响/)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "确认执行 Rewind" })); + + await waitFor(() => expect(postControl).toHaveBeenCalledWith( + "/runs/run-1/workspace-checkpoints/rewind", + expect.objectContaining({ + target_checkpoint_id: "checkpoint-before", + expected_current_checkpoint_id: "checkpoint-current", + confirm: true, + }), expect.any(String), + )); + expect(globalThis.confirm).toHaveBeenCalled(); + }); + + it("keeps restore controls closed without control authority or a paused Run", async () => { + const client = { + get: vi.fn().mockResolvedValue(timeline()), + postControl: vi.fn(), + hasWorkspaceCheckpointControl: false, + } as unknown as CyberAgentClient; + renderPanel(client, "running"); + + expect(await screen.findByText(/当前连接只能浏览时间线/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "立即检查点" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "预览 Undo" })).toBeDisabled(); + expect(client.postControl).not.toHaveBeenCalled(); + }); + + it("forks through a Go-derived worktree path without renderer path input", async () => { + const postControl = vi.fn().mockResolvedValue({ run: { id: "run-fork" } }); + const client = { + get: vi.fn().mockResolvedValue(timeline()), + postControl, + hasWorkspaceCheckpointControl: true, + } as unknown as CyberAgentClient; + vi.stubGlobal("confirm", vi.fn(() => true)); + const user = userEvent.setup(); + renderPanel(client); + + await screen.findByText("不可变时间线"); + await user.type(screen.getByLabelText("新 Workspace 名称"), "parser fork"); + await user.type(screen.getByLabelText("新 Git 分支"), "codex/parser-fork"); + await user.click(screen.getByRole("button", { name: "确认 Fork" })); + + await waitFor(() => expect(postControl).toHaveBeenCalledWith( + "/runs/run-1/workspace-checkpoints/fork", + expect.objectContaining({ + workspace_name: "parser fork", + branch: "codex/parser-fork", + confirm: true, + }), expect.any(String), + )); + const body = postControl.mock.calls[0]?.[1] as Record; + expect(body).not.toHaveProperty("workspace_root"); + }); +}); diff --git a/web/src/components/workspace-checkpoint-panel.tsx b/web/src/components/workspace-checkpoint-panel.tsx new file mode 100644 index 00000000..35c3f3a0 --- /dev/null +++ b/web/src/components/workspace-checkpoint-panel.tsx @@ -0,0 +1,342 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + GitFork, + History, + Plus, + Redo2, + RotateCcw, + Search, + ShieldCheck, + Undo2, +} from "lucide-react"; +import type { CyberAgentClient } from "../api/client"; +import type { + WorkspaceCheckpointForkView, + WorkspaceCheckpointRestoreView, + WorkspaceCheckpointTimelineView, + WorkspaceCheckpointView, +} from "../api/types"; +import { formatBytes, formatDate, shortID } from "../lib/format"; +import { useLocale } from "../lib/locale"; +import { useConnectionStore } from "../state/connection"; +import { ErrorState, LoadingState, StatusBadge } from "./common"; + +type RestoreAction = "rewind" | "undo" | "redo"; + +interface PreviewIntent { + action: RestoreAction; + targetCheckpointID: string; + result: WorkspaceCheckpointRestoreView; +} + +const reversibleKinds = new Set(["file_tool", "command_batch", "git_mutation", "agent_merge"]); + +export function WorkspaceCheckpointPanel({ client, runID, runStatus }: { + client: CyberAgentClient; + runID: string; + runStatus: string; +}) { + const { t } = useLocale(); + const queryClient = useQueryClient(); + const selectRun = useConnectionStore((state) => state.selectRun); + const timelineKey = ["run", runID, "workspace-checkpoints"] as const; + const [selectedID, setSelectedID] = useState(""); + const [title, setTitle] = useState(""); + const [preview, setPreview] = useState(null); + const [forkName, setForkName] = useState(""); + const [forkBranch, setForkBranch] = useState(""); + const [forkGoal, setForkGoal] = useState(""); + + const timeline = useQuery({ + queryKey: timelineKey, + queryFn: ({ signal }) => client.get( + `/runs/${encodeURIComponent(runID)}/workspace-checkpoints`, { limit: 200 }, signal), + enabled: Boolean(runID), + }); + const currentID = timeline.data?.current?.current_checkpoint_id ?? ""; + const checkpoints = timeline.data?.checkpoints ?? []; + + useEffect(() => { + if (selectedID && checkpoints.some((checkpoint) => checkpoint.id === selectedID)) return; + setSelectedID(currentID || checkpoints[0]?.id || ""); + }, [checkpoints, currentID, selectedID]); + + useEffect(() => { + setPreview(null); + }, [currentID, selectedID]); + + const selected = checkpoints.find((checkpoint) => checkpoint.id === selectedID); + const transactions = timeline.data?.transactions ?? []; + const undoSource = transactions.find((transaction) => + transaction.status === "completed" && transaction.after_checkpoint_id === currentID && + reversibleKinds.has(transaction.kind)); + const completedUndo = transactions.find((transaction) => transaction.kind === "undo" && + transaction.status === "completed" && transaction.after_checkpoint_id === currentID); + const redoSource = completedUndo + ? transactions.find((transaction) => transaction.id === completedUndo.trigger_receipt_id) + : undefined; + const checkpointByID = useMemo(() => new Map(checkpoints.map((checkpoint) => + [checkpoint.id, checkpoint])), [checkpoints]); + + const refresh = () => queryClient.invalidateQueries({ queryKey: timelineKey }); + const createCheckpoint = useMutation({ + mutationFn: () => { + const operationKey = `desktop-checkpoint-${globalThis.crypto.randomUUID()}`; + return client.postControl( + `/runs/${encodeURIComponent(runID)}/workspace-checkpoints`, { + operation_key: operationKey, + title: title.trim() || undefined, + }, operationKey); + }, + onSuccess: (checkpoint) => { + setTitle(""); + setSelectedID(checkpoint.id); + setPreview(null); + void refresh(); + }, + }); + + const previewRestore = useMutation({ + mutationFn: ({ action, targetCheckpointID }: { + action: RestoreAction; + targetCheckpointID: string; + }) => client.postControl( + `/runs/${encodeURIComponent(runID)}/workspace-checkpoints/preview`, { + target_checkpoint_id: targetCheckpointID, + expected_current_checkpoint_id: currentID, + }, `desktop-workspace-preview-${globalThis.crypto.randomUUID()}`).then((result) => ({ + action, + targetCheckpointID, + result, + })), + onSuccess: setPreview, + }); + + const applyRestore = useMutation({ + mutationFn: (intent: PreviewIntent) => { + const operationKey = `desktop-workspace-${intent.action}-${globalThis.crypto.randomUUID()}`; + const path = intent.action === "rewind" ? "rewind" : intent.action; + const body = intent.action === "rewind" ? { + target_checkpoint_id: intent.targetCheckpointID, + expected_current_checkpoint_id: intent.result.preview.expected_current_checkpoint_id, + operation_key: operationKey, + confirm: true, + } : { + expected_current_checkpoint_id: intent.result.preview.expected_current_checkpoint_id, + operation_key: operationKey, + confirm: true, + }; + return client.postControl( + `/runs/${encodeURIComponent(runID)}/workspace-checkpoints/${path}`, + body, operationKey); + }, + onSuccess: () => { + setPreview(null); + void refresh(); + void queryClient.invalidateQueries({ queryKey: ["run", runID] }); + }, + }); + + const fork = useMutation({ + mutationFn: () => { + const operationKey = `desktop-workspace-fork-${globalThis.crypto.randomUUID()}`; + return client.postControl( + `/runs/${encodeURIComponent(runID)}/workspace-checkpoints/fork`, { + target_checkpoint_id: selectedID, + expected_current_checkpoint_id: currentID, + operation_key: operationKey, + workspace_name: forkName.trim(), + branch: forkBranch.trim(), + goal: forkGoal.trim() || undefined, + confirm: true, + }, operationKey); + }, + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ["runs"] }); + void queryClient.invalidateQueries({ queryKey: ["sessions"] }); + selectRun(result.run.id); + }, + }); + + const previewAction = (action: RestoreAction) => { + let targetCheckpointID = selectedID; + if (action === "undo") targetCheckpointID = undoSource?.before_checkpoint_id ?? ""; + if (action === "redo") targetCheckpointID = redoSource?.after_checkpoint_id ?? ""; + if (targetCheckpointID) previewRestore.mutate({ action, targetCheckpointID }); + }; + + const confirmRestore = () => { + if (!preview || preview.result.preview.conflicts.length > 0 || + preview.result.preview.expected_current_checkpoint_id !== currentID) return; + const label = restoreActionLabel(t, preview.action); + if (globalThis.confirm(t( + `确认执行${label}?这会作为一次新的、可审计的 Workspace 写入。`, + `Confirm ${label}? This creates a new, auditable Workspace write.`, + ))) applyRestore.mutate(preview); + }; + + const submitFork = (event: FormEvent) => { + event.preventDefault(); + if (!selected || selected.recovery_level === "unavailable" || !forkName.trim() || + !forkBranch.trim()) return; + if (globalThis.confirm(t( + "确认从该检查点创建独立 Run、Git 分支和 worktree?旧权限与进程不会继承。", + "Create an independent Run, Git branch, and worktree from this checkpoint? Old authority and processes are not inherited.", + ))) fork.mutate(); + }; + + if (timeline.isLoading) return ; + if (timeline.isError || !timeline.data) return ; + + const restoreBlocked = !client.hasWorkspaceCheckpointControl || runStatus !== "paused"; + const previewConflicts = preview?.result.preview.conflicts ?? []; + + return
+
+
+ +
+
{ + event.preventDefault(); + if (client.hasWorkspaceCheckpointControl) createCheckpoint.mutate(); + }}> + setTitle(event.target.value)} + placeholder={t("可选:重构前", "Optional: before refactor")} value={title} /> + +
+
+ + + +
+ {!client.hasWorkspaceCheckpointControl &&

{t( + "当前连接只能浏览时间线;创建、预览与恢复需要控制令牌。", + "This connection can browse only; create, preview, and restore require a control token.", + )}

} + {client.hasWorkspaceCheckpointControl && runStatus !== "paused" &&

{t( + "Undo、Redo、Rewind 和 Fork 仅在 Run 已暂停且没有活动执行租约时开放。", + "Undo, redo, rewind, and Fork require a paused Run with no active execution lease.", + )}

} +
+ +
+
+
+ {checkpoints.length === 0 &&

{t("尚无检查点。", "No checkpoints yet.")}

} + {checkpoints.map((checkpoint) => )} +
+ +
+ {!selected ?

{t("选择检查点查看详情。", "Select a checkpoint for details.")}

: <> +
{selected.title || t("Workspace 检查点", "Workspace checkpoint")} + {selected.id}
+
+
{t("来源", "Source")}
{selected.trigger} / {selected.phase}
+
{t("收据", "Receipt")}
{selected.trigger_receipt_id}
+
{t("Attempt", "Attempt")}
{selected.attempt_id ? shortID(selected.attempt_id) : "—"}
+
{t("Capability generation", "Capability generation")}
+
{selected.capability_generation ? shortID(selected.capability_generation) : "—"}
+
{t("Git", "Git")}
{shortID(selected.base_commit)} · {selected.branch || "detached"}
+
{t("清单", "Manifest")}
{selected.entry_count} · {formatBytes(selected.stored_bytes)}
+
+ {selected.incomplete_reasons.length > 0 &&
+
} + } +
+
+ + {(previewRestore.error || applyRestore.error || createCheckpoint.error || fork.error) && +
{humanError(previewRestore.error || applyRestore.error || + createCheckpoint.error || fork.error)}
} + + {preview &&
+
+ {preview.result.preview.index_changed &&

{t( + "Git index 将发生变化;确认时会再次校验完整 index hash。", + "The Git index will change and its complete hash will be rechecked on confirmation.", + )}

} + {previewConflicts.length > 0 &&
+ {t("检测到外部冲突,已停止", "External conflicts detected; restore is blocked")} + {previewConflicts.map((conflict, index) =>

+ {conflict.path || conflict.kind} · {conflict.reason}

)} +
} +
+ {preview.result.preview.changes.map((change) =>
+ {change.path}{change.kind}{change.previous_path ? ` ← ${change.previous_path}` : ""} + + {change.reason && {change.reason}} +
)} + {preview.result.preview.changes.length === 0 &&

{t("目标与当前 Workspace 无差异。", "No Workspace changes are required.")}

} +
+ +
} + +
+
+
+ setForkName(event.target.value)} placeholder={t("Workspace 名称", "Workspace name")} value={forkName} /> + setForkBranch(event.target.value)} placeholder="codex/checkpoint-fork" value={forkBranch} /> + setForkGoal(event.target.value)} placeholder={t("可选新目标", "Optional new goal")} value={forkGoal} /> + +
+
+
; +} + +function restoreActionLabel(t: (chinese: string, english: string) => string, + action: RestoreAction): string { + if (action === "undo") return t("撤销", "Undo"); + if (action === "redo") return t("重做", "Redo"); + return "Rewind"; +} + +function humanError(value: unknown): string { + return value instanceof Error ? value.message : String(value ?? "Unknown error"); +} diff --git a/web/src/lib/desktop-bridge.test.ts b/web/src/lib/desktop-bridge.test.ts index 7bedacaa..ec26cc49 100644 --- a/web/src/lib/desktop-bridge.test.ts +++ b/web/src/lib/desktop-bridge.test.ts @@ -44,6 +44,7 @@ const bootstrap = { evidence_attachment_enabled: false, verification_evidence_enabled: false, embedded_analyzer_execution_enabled: false, + workspace_checkpoint_control_enabled: false, user_terminal_enabled: false, agent_terminal_input_default: false, workspace_open_enabled: false, diff --git a/web/src/lib/desktop-bridge.ts b/web/src/lib/desktop-bridge.ts index f484723e..4c7e5cf6 100644 --- a/web/src/lib/desktop-bridge.ts +++ b/web/src/lib/desktop-bridge.ts @@ -64,6 +64,7 @@ export interface DesktopConnectionBootstrap { evidence_attachment_enabled: boolean; verification_evidence_enabled: boolean; embedded_analyzer_execution_enabled: boolean; + workspace_checkpoint_control_enabled: boolean; user_terminal_enabled: boolean; agent_terminal_input_default: false; workspace_open_enabled: boolean; @@ -700,6 +701,7 @@ function validBootstrap(value: unknown): value is DesktopConnectionBootstrap { "control_enabled", "control_token", "docker_execution_enabled", "file_edit_apply_enabled", "evidence_attachment_enabled", "verification_evidence_enabled", "embedded_analyzer_execution_enabled", + "workspace_checkpoint_control_enabled", "user_terminal_enabled", "agent_terminal_input_default", "file_edit_proposal_enabled", "file_edit_review_enabled", "model_control_enabled", "provider_credential_enabled", "process_execution_enabled", @@ -748,6 +750,7 @@ function validBootstrap(value: unknown): value is DesktopConnectionBootstrap { typeof value.evidence_attachment_enabled === "boolean" && typeof value.verification_evidence_enabled === "boolean" && typeof value.embedded_analyzer_execution_enabled === "boolean" && + typeof value.workspace_checkpoint_control_enabled === "boolean" && typeof value.docker_execution_enabled === "boolean" && typeof value.user_terminal_enabled === "boolean" && value.agent_terminal_input_default === false && @@ -767,7 +770,8 @@ function validBootstrap(value: unknown): value is DesktopConnectionBootstrap { value.run_wake_execution_enabled || value.run_wake_worker_enabled || value.skill_installation_enabled || value.evidence_attachment_enabled || value.verification_evidence_enabled || - value.embedded_analyzer_execution_enabled || value.docker_execution_enabled || + value.embedded_analyzer_execution_enabled || value.workspace_checkpoint_control_enabled || + value.docker_execution_enabled || value.user_terminal_enabled) && (value.control_token === "" || validToken(value.control_token)) && value.control_token !== value.read_token && @@ -796,7 +800,8 @@ function validBootstrap(value: unknown): value is DesktopConnectionBootstrap { value.run_wake_execution_enabled || value.run_wake_worker_enabled || value.skill_installation_enabled || value.evidence_attachment_enabled || value.verification_evidence_enabled || - value.embedded_analyzer_execution_enabled || value.docker_execution_enabled || + value.embedded_analyzer_execution_enabled || value.workspace_checkpoint_control_enabled || + value.docker_execution_enabled || value.user_terminal_enabled) && value.process_execution_enabled === (value.user_terminal_enabled || value.command_runtime_enabled) && diff --git a/web/src/styles.css b/web/src/styles.css index a095f4cc..ce73730e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5178,3 +5178,170 @@ samp { padding: 5px 12px; } } + +.workspace-checkpoint-panel { + display: grid; + gap: 16px; +} + +.checkpoint-safety-boundary, +.checkpoint-toolbar, +.checkpoint-timeline, +.checkpoint-detail, +.checkpoint-preview, +.checkpoint-fork { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} + +.checkpoint-safety-boundary { + display: flex; + gap: 12px; + padding: 14px 16px; + color: var(--text); +} + +.checkpoint-safety-boundary svg { flex: 0 0 auto; color: var(--accent); } +.checkpoint-safety-boundary p { margin: 4px 0 0; color: var(--muted); line-height: 1.5; } + +.checkpoint-toolbar { + display: grid; + gap: 10px; + padding: 12px; +} + +.checkpoint-toolbar form, +.checkpoint-action-row, +.checkpoint-fork form { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.checkpoint-toolbar input, +.checkpoint-fork input { + min-width: 180px; + flex: 1 1 220px; +} + +.checkpoint-layout { + display: grid; + grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.2fr); + gap: 16px; +} + +.checkpoint-timeline, +.checkpoint-detail, +.checkpoint-preview, +.checkpoint-fork { padding: 14px; } + +.checkpoint-timeline > header, +.checkpoint-preview > header, +.checkpoint-fork > header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.checkpoint-timeline > header small { margin-left: auto; color: var(--muted); } + +.checkpoint-timeline > button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px; + border: 1px solid transparent; + border-radius: 10px; + color: var(--text); + text-align: left; + background: transparent; +} + +.checkpoint-timeline > button:hover, +.checkpoint-timeline > button.selected { + border-color: var(--border-strong); + background: var(--surface-subtle); +} + +.checkpoint-timeline > button > span:first-child { + min-width: 0; + display: grid; + gap: 3px; +} + +.checkpoint-timeline small, +.checkpoint-fork small, +.checkpoint-preview small { color: var(--muted); } +.checkpoint-badges { display: flex; align-items: center; gap: 6px; } +.checkpoint-badges em { color: var(--accent); font-size: 11px; font-style: normal; } + +.checkpoint-detail > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.checkpoint-detail > header > div { min-width: 0; display: grid; gap: 4px; } +.checkpoint-detail code { overflow-wrap: anywhere; } +.checkpoint-detail dl { display: grid; gap: 7px; margin: 14px 0; } +.checkpoint-detail dl > div { display: grid; grid-template-columns: 145px 1fr; gap: 10px; } +.checkpoint-detail dt { color: var(--muted); } +.checkpoint-detail dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } + +.checkpoint-incomplete, +.checkpoint-conflicts { + display: flex; + gap: 9px; + padding: 10px; + border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--border)); + border-radius: 10px; + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, var(--surface)); +} + +.checkpoint-incomplete span { display: grid; gap: 4px; } +.checkpoint-incomplete small { color: inherit; } + +.checkpoint-preview > header > div, +.checkpoint-fork > header > div { display: grid; gap: 3px; } +.checkpoint-conflicts { display: block; margin-bottom: 10px; } +.checkpoint-conflicts p { margin: 6px 0 0; } + +.checkpoint-change-list { + display: grid; + gap: 6px; + margin: 12px 0; +} + +.checkpoint-change-list > div { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 9px; +} + +.checkpoint-change-list > div small { grid-column: 1 / -1; color: var(--muted); } +.checkpoint-fork form { align-items: center; } + +@media (max-width: 900px) { + .checkpoint-layout { grid-template-columns: 1fr; } + .checkpoint-change-list > div { grid-template-columns: 1fr auto; } + .checkpoint-change-list > div > span { grid-column: 1 / -1; } +} + +@media (forced-colors: active) { + .checkpoint-safety-boundary, + .checkpoint-toolbar, + .checkpoint-timeline, + .checkpoint-detail, + .checkpoint-preview, + .checkpoint-fork { border-color: CanvasText; background: Canvas; } +}