From 7ad00ab64f12d537ba3a48efcac21342a32dc381 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 17:35:51 -0400 Subject: [PATCH 01/35] docs: describe workspace storage and relay architecture --- docs/ARCHITECTURE.md | 4 + docs/architecture/workspaces-storage-relay.md | 180 ++++++++++++++++++ docs/relay.md | 6 + 3 files changed, 190 insertions(+) create mode 100644 docs/architecture/workspaces-storage-relay.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58e6ef96f3..86b2d90f21 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,9 @@ # codeaf as a resident agent — the finalized architecture +For the current chat relay and the separate multi-root storage proposal, see +[workspace storage and relay architecture](architecture/workspaces-storage-relay.md). +The resident design below is not the current chat storage schema. + This document settles the long-running architecture: one permanent graph, one lease-elected resident role, and any number of surfaces attached to it. It is written as a set of decisions, each with the alternative that was rejected and diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md new file mode 100644 index 0000000000..75c4718d9e --- /dev/null +++ b/docs/architecture/workspaces-storage-relay.md @@ -0,0 +1,180 @@ +# Workspace storage and relay architecture + +This page separates the shipped relay from the proposed workspace lifecycle. +The source baseline is `dev` at `7f5da935c`. The multi-root extension has been +exercised in a separate prototype; this documentation does not ship it or enable +automatic compaction. The resident-graph design in [ARCHITECTURE](../ARCHITECTURE.md) +is a separate design and is not the current chat storage schema. + +## Shipped relay: ownership and encrypted content + +The chat harness owns execution and the selected local folders. Furrow captures +and materializes sealed chat state; `cellsync` transports encrypted objects; +the relay stores those objects and the directory's published heads and leases. +A lease elects a chat's holder. It is not permission to write an arbitrary local +folder. Pairing and the encrypted vault have their existing separate policies. + +```mermaid +sequenceDiagram + participant S as Source harness + participant F as Source Furrow + participant R as Relay + participant G as Receiver Furrow + participant D as Receiver harness + S->>F: Capture chat and portable task data + F-->>S: Sealed head and exportable objects + S->>R: Upload encrypted objects + S->>R: Publish head under ownership fence + D->>R: Confirm Take of displayed holder + R-->>D: Accepted lease and published head + D->>G: Want objects required by head + D->>R: Fetch required encrypted objects + R-->>G: Objects through receiver import + G-->>D: Materialized chat state + D->>D: Restore portable task copies and check readiness +``` + +Publishing uploads objects before advancing the directory head. A stale fence +cannot overwrite the new holder. Fetching may prime from advertised frames, but +Furrow's `Want`/import loop establishes completeness before materialization; +frame listings are hints, not completeness proofs. + +Task worktrees live outside the ordinary sealed chat tree. `taskcopy.Carry` +composes portable commits and uncommitted changes under `.cell/trees` and restores +them at receiver-local paths. Files omitted by the portable representation do +not become transferable merely because a task used them. A successful chat Take +is not a guarantee that every external command or dependency can resume. + +Source seams: [publisher](../../internal/cellsync/publish.go), +[fetcher](../../internal/cellsync/fetch.go), +[native sync adapter](../../internal/cellstore/syncops.go), +[Take](../../internal/syncsetup/take.go), +[portable task copies](../../internal/taskcopy/doc.go), and +[chat file policy](../../internal/chatfiles/chatfiles.go). + +## Multi-root extension: identity is not a path or permission + +The proposed unit is a **working root**, a dynamically selected folder. It need +not be a Git repository, and it is not everything a conversation works on. +One chat can reference several roots. Each execution declares a primary root +and its explicit sibling uses. Every copy URI identifies exactly one physical +copy; a layout groups several copies without combining their identities. + +| Resource | Contract | +| --- | --- | +| Working root | Stable opaque identity; receiver admits each association separately | +| Chat | References many roots; has its own history and execution frontier | +| Execution layout | Primary root plus declared read/write sibling uses | +| Copy | One physical copy, owned by a chat/execution and bound locally | +| Landing destination | Separate explicit permission; never inferred from a source URI | + +The harness prepares private copies for delegated work by default. Cooperating +tasks can share an identical admitted layout; independent work gets separate +copies. Foreground edits retain the explicitly opened project's existing +semantics. A model's brief, an absolute source path, matching names, and matching +Git remotes cannot grant landing authority or merge two root identities. + +```mermaid +flowchart TD + Chat[Chat identity] --> Layout[Execution layout] + Layout --> A[Primary copy URI] + Layout --> B[Sibling copy URI] + A --> RootA[Working root A identity] + B --> RootB[Working root B identity] + A --> LocalA[Private local copy A] + B --> LocalB[Private local copy B] + Layout --> Shared[Cooperating tasks share this layout] + Layout --> Temp[Owned temporary files and logs] +``` + +The registry maps identity to local placement. It remains device-local; relay +carries portable references and bytes, not another device's path authority. +Reopen resolves the recorded identity and validates ownership. An unknown or +incomplete binding refuses rather than silently using a historical path. + +## Receive and restart contract + +The extension must validate the entire required root closure before admission. +Each root is associated independently with an accepted receiver destination. +Reconstruction uses private staging and full history when the receiver lacks a +verified Git base. It does not unbundle into a borrowed original repository. + +```mermaid +flowchart LR + Fetch[Fetch verified sealed state] --> Validate[Validate roots and metadata] + Validate --> Stage[Stage complete private layout] + Stage --> Promote[Promote layout] + Promote --> Admit[Register local copy bindings] + Admit --> Finalize[Finalize checkpoint and clear source landing permission] + Finalize --> Ready[Allow execution] +``` + +A durable operation receipt spans promotion, registry admission and checkpoint +finalization. This is recovery across several stores, not one atomic database +transaction. Retries recognize the recorded operation and expected checkpoint; +they must not duplicate copies or resume against a partly restored layout. + +Git does not represent empty directories or directory permissions. A portable +root therefore needs an explicit versioned directory map in addition to file +content, executable modes, deletions and line-ending metadata. Missing or +unsupported metadata must refuse without discarding the original carrier. +This is not a promise about every filesystem's ACLs, ownership or special files. + +A fork retains completed results and portable history while detaching inherited +active execution authority. Unchanged inputs should not be duplicated as result +artifacts. A later Take must retain that history without reviving old workers. + +## Storage lifecycle and safety + +The proposed lifecycle is `resident → retiring → cold → restoring → resident`. +An owned archive is captured, verified and recorded durably before an eligible +resident copy is removed. Restore verifies exact supported state before use. +Shared content chunks may serve many manifests without merging ownership. + +Original projects, unrelated caches and uncertain writers are protected. +Shell guards and temporary-directory settings are not an operating-system +sandbox: detached writers and remote commands require separate ownership and +quiescence evidence. Uncertain copies stay resident. Multi-root copies currently +stay resident in the prototype; this design does not establish automatic +normal-use reclamation or a long-term storage budget. + +## Compatibility and migration + +This documentation-only change performs **no migration**. A future implementation +needs an explicit rollout before these contracts can become defaults: + +| Boundary | Required behavior | +| --- | --- | +| Legacy singleton checkpoint | Preserve its existing reader and restore behavior; no forced rewrite | +| New multi-root checkpoint/carrier | Version schema, record all required roots, directory metadata and digest binding | +| Early prototype with missing directory metadata | Refuse safely; recapture from intact source if available | +| Missing recorded Git baseline | Preserve full history; refuse changed-output extraction instead of guessing HEAD | +| Mixed or incomplete execution history | Refuse unsupported combinations without dropping records | +| Receiver capabilities | Establish format support before mutation; older clients currently lack a guaranteed early refusal | +| Local path bindings | Re-admit per device; never import remote absolute paths as authority | + +Both ends need supported readers during prototype testing. A capability handshake +and restart/recovery tests are prerequisites to a general rollout. The original +copy must survive a failed upgrade or restore; restoring an old binary is not a +safe rollback if it cannot understand the new records. Do not compact away the +last readable copy while compatibility is uncertain. + +Fast routine key rewrapping, if introduced, must remain separate from full +compromise recovery. Rewrapping an unchanged content key cannot revoke content +already exposed by a compromised key; it cannot silently replace full rotation. + +## Measurements and remaining acceptance + +Separate experiments measured **43.34% lower allocated use** on isolated copies +of a real-worktree corpus and **55.17% lower warm materialization latency** in a +matched relay experiment. The latter reduced HTTP body bytes by **5.33%**; +latency reduction is not byte reduction. These are prototype measurements, +not promised defaults, host-wide free-space reclamation, or four-month usage +results. The new multi-root flow has no matched old/new savings percentage. + +An actual-model prototype sequence preserved supported bytes and metadata across +engine restart, local Take, fork and a second Take. The completed task card still +reported `incomplete`; a cross-machine receiver check was blocked by an offline +receiver. Active cross-device plan recovery, the complete filesystem metadata +matrix, and automatic multi-root compaction remain unverified. Green checks on +this documentation PR do not close those implementation gates. diff --git a/docs/relay.md b/docs/relay.md index eb30455dbc..310ca21896 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -62,3 +62,9 @@ the address every computer syncs through when nobody names another. It sees only time and size of each request, and the network address of each computer; it keeps no request log. The address has a path, and every client keeps it. `codeaf relay` (this page) always serves from the root of its host; behind your own proxy, pass the path through unchanged. + +## Architecture and workspace boundaries + +See [workspace storage and relay architecture](architecture/workspaces-storage-relay.md) +for the shipped transport, the proposed multi-root contract, and the compatibility +requirements. The proposed lifecycle is not enabled by this documentation. From 158eb5e416841e9d47954bcf51de99e5d85d5e5d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 17:37:44 -0400 Subject: [PATCH 02/35] docs: record architecture-only change for PR 1944 --- .../unreleased/1944-workspace-relay-architecture.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/changes/unreleased/1944-workspace-relay-architecture.md diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md new file mode 100644 index 0000000000..992abdef46 --- /dev/null +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: document workspace storage and relay compatibility +pr: 1944 +surface: [docs] +invalidates: [] +--- + +Separate shipped relay behavior from the proposed multi-root lifecycle, including +migration requirements and the limits of prototype measurements. No runtime +behavior, data format, storage policy or migration is changed. From 8c573f6ed54a3face2d633e736fd3faa15cc912c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 18:24:08 -0400 Subject: [PATCH 03/35] feat: own task layouts and transfer multi-root chats safely --- cmd/codeaf/cache.go | 8 +- cmd/codeaf/cache_test.go | 29 + cmd/codeaf/chatv3_initial_idle_test.go | 49 + cmd/codeaf/chatv3_layout.go | 26 +- cmd/codeaf/chatv3_layout_test.go | 3 + cmd/codeaf/synctake.go | 9 + docs/architecture/workspaces-storage-relay.md | 324 +++-- .../1944-workspace-relay-architecture.md | 19 +- internal/blobstore/bounded_frame.go | 76 + internal/blobstore/bounded_frame_test.go | 78 + internal/blobstore/census.go | 301 ++++ internal/blobstore/census_alloc_other.go | 7 + internal/blobstore/census_alloc_unix.go | 16 + internal/blobstore/census_test.go | 294 ++++ internal/blobstore/counting.go | 8 + internal/blobstore/gc.go | 34 +- internal/blobstore/resume.go | 5 + internal/cachedir/cachedir.go | 58 +- internal/cachedir/legacy_test.go | 108 ++ internal/cellstore/engine.go | 23 + internal/cellstore/materialize.go | 8 + internal/cellstore/persist.go | 3 + internal/cellstore/sealwatch.go | 14 + internal/cellstore/sealwatch_test.go | 28 + .../cellstore/workspace_checkpoint_test.go | 73 + internal/cellsync/fetch.go | 16 +- internal/cellsync/frame_hints.go | 88 ++ internal/cellsync/frame_hints_test.go | 135 ++ internal/cellsync/held.go | 4 +- internal/cellsync/held_test.go | 40 + internal/cellsync/publish.go | 26 +- internal/cellsync/trace.go | 115 ++ internal/cellsync/trace_engine.go | 70 + internal/cellsync/trace_store.go | 183 +++ internal/cellsync/trace_summary.go | 159 +++ internal/cellsync/trace_test.go | 288 ++++ internal/directory/frame_hints_test.go | 66 + internal/directory/memory.go | 1 + internal/directory/records.go | 59 +- internal/directory/rules.go | 5 +- internal/e2e/pairjourney_e2e_test.go | 8 +- .../e2e/relay_store_alloc_other_e2e_test.go | 7 + .../e2e/relay_store_alloc_unix_e2e_test.go | 16 + internal/e2e/relay_wan_baseline_e2e_test.go | 458 ++++++ internal/e2e/storage_lifecycle_e2e_test.go | 1064 ++++++++++++++ .../e2e/workspace_ownership_build_e2e_test.go | 339 +++++ internal/e2e/workspace_ownership_e2e_test.go | 416 ++++++ .../workspace_ownership_latency_e2e_test.go | 113 ++ .../e2e/workspace_ownership_relay_e2e_test.go | 362 +++++ internal/executor/environment.go | 67 + internal/executor/environment_test.go | 54 + internal/handoff/handoff.go | 29 +- internal/keys/reader_test.go | 39 + internal/keys/scan.go | 42 +- internal/manual/chat/commands.md | 9 +- internal/manual/chat/how-tasks-run.md | 45 + internal/manual/chat/worker-harness.md | 81 ++ internal/remote/callclass.go | 2 +- internal/remote/client.go | 17 + internal/remote/client_test.go | 27 + internal/remote/server.go | 12 + internal/remote/wire.go | 1 + internal/run/bashworker.go | 20 +- internal/run/enginewire.go | 10 +- internal/run/storage_proof.go | 31 + internal/run/workspace_set_test.go | 101 ++ internal/run/workspace_shell_scope_test.go | 125 ++ internal/session/agent.go | 6 + internal/session/automation_run.go | 5 +- internal/session/bashbelt_worker.go | 6 + internal/session/complexity_test.go | 2 +- internal/session/groundladder.go | 16 + internal/session/jobs.go | 3 + internal/session/land_run_tree.go | 5 + internal/session/land_run_tree_test.go | 26 + internal/session/landing_test.go | 41 +- internal/session/loop.go | 9 + internal/session/metalock_test.go | 3 + internal/session/metatruth.go | 24 +- internal/session/place.go | 31 +- internal/session/placemeta.go | 8 +- internal/session/plandb_work.go | 31 +- internal/session/principal_audit.go | 3 + internal/session/run_tree_changes.go | 2 + internal/session/session.go | 6 + internal/session/stoprun.go | 7 +- internal/session/task.go | 96 +- internal/session/task_branch_history.go | 130 ++ internal/session/task_branch_history_test.go | 53 + internal/session/task_continue.go | 8 + internal/session/task_contract.go | 14 +- internal/session/task_mirror_capture_test.go | 36 + internal/session/task_paths.go | 34 +- internal/session/task_run.go | 228 ++- internal/session/task_run_belt.go | 67 +- internal/session/task_run_continue.go | 10 +- internal/session/task_run_copy.go | 67 +- internal/session/task_run_recover.go | 18 +- internal/session/task_storage_git.go | 261 ++++ internal/session/task_storage_prototype.go | 277 ++++ .../session/task_storage_prototype_test.go | 421 ++++++ internal/session/task_storage_uncertain.go | 117 ++ internal/session/task_store.go | 177 ++- internal/session/task_tree_mirror.go | 9 +- internal/session/taskoutside.go | 84 +- internal/session/taskstands.go | 31 +- internal/session/tools_anchor_workspace.go | 2 +- internal/session/tools_jobs.go | 3 + internal/session/tools_sense.go | 11 +- internal/session/tools_watch.go | 3 + internal/session/workspace_branch.go | 47 + .../workspace_cache_accounting_test.go | 205 +++ internal/session/workspace_chat.go | 129 ++ .../session/workspace_chat_identity_test.go | 204 +++ internal/session/workspace_chat_test.go | 96 ++ internal/session/workspace_identity.go | 235 +++ internal/session/workspace_identity_test.go | 558 ++++++++ .../session/workspace_identity_transfer.go | 210 +++ .../session/workspace_node_ownership_test.go | 261 ++++ internal/session/workspace_ownership.go | 76 + .../session/workspace_ownership_gate_test.go | 23 + .../workspace_ownership_public_test.go | 202 +++ internal/session/workspace_ownership_test.go | 133 ++ internal/session/workspace_policy.go | 37 + internal/session/workspace_policy_test.go | 74 + .../session/workspace_project_alias_test.go | 57 + internal/session/workspace_restore_plan.go | 134 ++ .../session/workspace_restore_plan_test.go | 67 + internal/session/workspace_set.go | 386 +++++ internal/session/workspace_set_prepare.go | 546 +++++++ internal/session/workspace_set_test.go | 827 +++++++++++ internal/session/workspace_transfer.go | 700 +++++++++ .../session/workspace_transfer_preflight.go | 126 ++ .../workspace_transfer_preflight_test.go | 114 ++ internal/session/workspace_transfer_test.go | 339 +++++ internal/session/workspace_write_scope.go | 191 +++ internal/syncsetup/branch.go | 15 +- internal/syncsetup/branch_history.go | 96 ++ internal/syncsetup/branch_history_test.go | 118 ++ .../branch_prototype_disabled_test.go | 118 ++ .../syncsetup/branch_relay_results_test.go | 212 +++ .../syncsetup/multiroot_preflight_test.go | 260 ++++ internal/syncsetup/nested_plain_relay_test.go | 176 +++ internal/syncsetup/take.go | 82 +- .../syncsetup/workspace_chat_identity_test.go | 88 ++ internal/syncsetup/workspace_transfer.go | 91 ++ internal/syncsetup/workspace_transfer_test.go | 200 +++ internal/taskcopy/baseline.go | 32 + internal/taskcopy/cold_prototype.go | 137 ++ internal/taskcopy/cold_prototype_test.go | 123 ++ internal/taskcopy/compose.go | 54 +- .../taskcopy/full_history_secrets_test.go | 404 ++++++ internal/taskcopy/git.go | 7 + internal/taskcopy/history_screen.go | 330 +++++ .../taskcopy/history_screen_bench_test.go | 115 ++ internal/taskcopy/leftout.go | 6 +- internal/taskcopy/nested_plain_test.go | 71 + internal/taskcopy/parents.go | 58 + internal/taskcopy/plain_capture_test.go | 63 + internal/taskcopy/plain_project_test.go | 127 ++ internal/taskcopy/record.go | 12 +- internal/taskcopy/restore.go | 34 +- internal/taskcopy/results.go | 163 +++ internal/taskcopy/results_test.go | 74 + internal/taskcopy/standalone.go | 68 + internal/taskcopy/workspace_carrier.go | 392 +++++ internal/taskcopy/workspace_carrier_test.go | 212 +++ internal/taskcopy/workspace_fence.go | 88 ++ internal/taskcopy/workspace_fence_test.go | 63 + internal/taskcopy/workspace_plan.go | 253 ++++ internal/taskcopy/workspace_restore.go | 1255 +++++++++++++++++ internal/taskcopy/workspace_restore_test.go | 596 ++++++++ internal/taskstorage/backend.go | 111 ++ internal/taskstorage/backend_test.go | 93 ++ internal/taskstorage/check_recovery.go | 84 ++ internal/taskstorage/check_recovery_test.go | 218 +++ internal/taskstorage/lease_process_test.go | 218 +++ internal/taskstorage/lifecycle.go | 694 +++++++++ internal/taskstorage/lifecycle_test.go | 250 ++++ internal/taskstorage/recovery_guard_test.go | 124 ++ internal/tui3/cachecmd.go | 12 +- internal/tui3/cachecmd_test.go | 21 + internal/tui3/contextmodal.go | 3 + internal/tui3/folderact.go | 10 + internal/tui3/folderpick.go | 9 + internal/tui3/folderplace.go | 4 +- internal/tui3/homepanel_continue.go | 42 +- internal/tui3/planroom.go | 20 +- internal/tui3/planroom_test.go | 29 + internal/tui3/workspace_binding_test.go | 86 ++ internal/workspaceidentity/existing.go | 62 + internal/workspaceidentity/existing_test.go | 84 ++ internal/workspaceidentity/migrate.go | 107 ++ .../workspaceidentity/project_alias_test.go | 210 +++ internal/workspaceidentity/registry.go | 537 +++++++ .../registry_benchmark_test.go | 67 + internal/workspaceidentity/registry_test.go | 436 ++++++ relay/hosted/src/directory.js | 2 +- relay/hosted/src/rules.js | 17 +- relay/hosted/test/directory.test.js | 17 + 200 files changed, 24032 insertions(+), 447 deletions(-) create mode 100644 cmd/codeaf/chatv3_initial_idle_test.go create mode 100644 internal/blobstore/bounded_frame.go create mode 100644 internal/blobstore/bounded_frame_test.go create mode 100644 internal/blobstore/census.go create mode 100644 internal/blobstore/census_alloc_other.go create mode 100644 internal/blobstore/census_alloc_unix.go create mode 100644 internal/blobstore/census_test.go create mode 100644 internal/cachedir/legacy_test.go create mode 100644 internal/cellstore/workspace_checkpoint_test.go create mode 100644 internal/cellsync/frame_hints.go create mode 100644 internal/cellsync/frame_hints_test.go create mode 100644 internal/cellsync/trace.go create mode 100644 internal/cellsync/trace_engine.go create mode 100644 internal/cellsync/trace_store.go create mode 100644 internal/cellsync/trace_summary.go create mode 100644 internal/cellsync/trace_test.go create mode 100644 internal/directory/frame_hints_test.go create mode 100644 internal/e2e/relay_store_alloc_other_e2e_test.go create mode 100644 internal/e2e/relay_store_alloc_unix_e2e_test.go create mode 100644 internal/e2e/relay_wan_baseline_e2e_test.go create mode 100644 internal/e2e/storage_lifecycle_e2e_test.go create mode 100644 internal/e2e/workspace_ownership_build_e2e_test.go create mode 100644 internal/e2e/workspace_ownership_e2e_test.go create mode 100644 internal/e2e/workspace_ownership_latency_e2e_test.go create mode 100644 internal/e2e/workspace_ownership_relay_e2e_test.go create mode 100644 internal/executor/environment.go create mode 100644 internal/executor/environment_test.go create mode 100644 internal/keys/reader_test.go create mode 100644 internal/run/storage_proof.go create mode 100644 internal/run/workspace_set_test.go create mode 100644 internal/run/workspace_shell_scope_test.go create mode 100644 internal/session/task_branch_history.go create mode 100644 internal/session/task_branch_history_test.go create mode 100644 internal/session/task_mirror_capture_test.go create mode 100644 internal/session/task_storage_git.go create mode 100644 internal/session/task_storage_prototype.go create mode 100644 internal/session/task_storage_prototype_test.go create mode 100644 internal/session/task_storage_uncertain.go create mode 100644 internal/session/workspace_branch.go create mode 100644 internal/session/workspace_cache_accounting_test.go create mode 100644 internal/session/workspace_chat.go create mode 100644 internal/session/workspace_chat_identity_test.go create mode 100644 internal/session/workspace_chat_test.go create mode 100644 internal/session/workspace_identity.go create mode 100644 internal/session/workspace_identity_test.go create mode 100644 internal/session/workspace_identity_transfer.go create mode 100644 internal/session/workspace_node_ownership_test.go create mode 100644 internal/session/workspace_ownership.go create mode 100644 internal/session/workspace_ownership_gate_test.go create mode 100644 internal/session/workspace_ownership_public_test.go create mode 100644 internal/session/workspace_ownership_test.go create mode 100644 internal/session/workspace_policy.go create mode 100644 internal/session/workspace_policy_test.go create mode 100644 internal/session/workspace_project_alias_test.go create mode 100644 internal/session/workspace_restore_plan.go create mode 100644 internal/session/workspace_restore_plan_test.go create mode 100644 internal/session/workspace_set.go create mode 100644 internal/session/workspace_set_prepare.go create mode 100644 internal/session/workspace_set_test.go create mode 100644 internal/session/workspace_transfer.go create mode 100644 internal/session/workspace_transfer_preflight.go create mode 100644 internal/session/workspace_transfer_preflight_test.go create mode 100644 internal/session/workspace_transfer_test.go create mode 100644 internal/session/workspace_write_scope.go create mode 100644 internal/syncsetup/branch_history.go create mode 100644 internal/syncsetup/branch_history_test.go create mode 100644 internal/syncsetup/branch_prototype_disabled_test.go create mode 100644 internal/syncsetup/branch_relay_results_test.go create mode 100644 internal/syncsetup/multiroot_preflight_test.go create mode 100644 internal/syncsetup/nested_plain_relay_test.go create mode 100644 internal/syncsetup/workspace_chat_identity_test.go create mode 100644 internal/syncsetup/workspace_transfer.go create mode 100644 internal/syncsetup/workspace_transfer_test.go create mode 100644 internal/taskcopy/baseline.go create mode 100644 internal/taskcopy/cold_prototype.go create mode 100644 internal/taskcopy/cold_prototype_test.go create mode 100644 internal/taskcopy/full_history_secrets_test.go create mode 100644 internal/taskcopy/history_screen.go create mode 100644 internal/taskcopy/history_screen_bench_test.go create mode 100644 internal/taskcopy/nested_plain_test.go create mode 100644 internal/taskcopy/parents.go create mode 100644 internal/taskcopy/plain_capture_test.go create mode 100644 internal/taskcopy/plain_project_test.go create mode 100644 internal/taskcopy/results.go create mode 100644 internal/taskcopy/results_test.go create mode 100644 internal/taskcopy/standalone.go create mode 100644 internal/taskcopy/workspace_carrier.go create mode 100644 internal/taskcopy/workspace_carrier_test.go create mode 100644 internal/taskcopy/workspace_fence.go create mode 100644 internal/taskcopy/workspace_fence_test.go create mode 100644 internal/taskcopy/workspace_plan.go create mode 100644 internal/taskcopy/workspace_restore.go create mode 100644 internal/taskcopy/workspace_restore_test.go create mode 100644 internal/taskstorage/backend.go create mode 100644 internal/taskstorage/backend_test.go create mode 100644 internal/taskstorage/check_recovery.go create mode 100644 internal/taskstorage/check_recovery_test.go create mode 100644 internal/taskstorage/lease_process_test.go create mode 100644 internal/taskstorage/lifecycle.go create mode 100644 internal/taskstorage/lifecycle_test.go create mode 100644 internal/taskstorage/recovery_guard_test.go create mode 100644 internal/tui3/workspace_binding_test.go create mode 100644 internal/workspaceidentity/existing.go create mode 100644 internal/workspaceidentity/existing_test.go create mode 100644 internal/workspaceidentity/migrate.go create mode 100644 internal/workspaceidentity/project_alias_test.go create mode 100644 internal/workspaceidentity/registry.go create mode 100644 internal/workspaceidentity/registry_benchmark_test.go create mode 100644 internal/workspaceidentity/registry_test.go diff --git a/cmd/codeaf/cache.go b/cmd/codeaf/cache.go index bb81f898c7..5e9b393814 100644 --- a/cmd/codeaf/cache.go +++ b/cmd/codeaf/cache.go @@ -2,7 +2,7 @@ // that empties it. // // `codeaf cache` answers the question and changes nothing. `codeaf cache clean` -// deletes ~/.codeaf/cache — the shared toolchain caches every task worker fills +// deletes the displayed cache locations — the shared caches task workers fill // (internal/cachedir says what lives there and what never does) — and because a // deletion cannot be undone it is guarded the way destructive command lines are // guarded everywhere a person has already learned: the blast radius is printed @@ -62,12 +62,12 @@ func runCacheWith(args []string, input io.Reader, output io.Writer) error { func showCache(output io.Writer) error { size := cachedir.Size() if size == 0 { - _, err := fmt.Fprintf(output, "the cache is empty · %s\n", cachedir.Root()) + _, err := fmt.Fprintf(output, "the cache is empty · %s\n", strings.Join(cachedir.Locations(), ", ")) return err } _, err := fmt.Fprintf(output, "the cache holds %s · %s\nshared toolchain caches — go modules, builds, npm, pip, cargo. `codeaf cache clean` frees it.\n", - cachedir.Human(size), cachedir.Root()) + cachedir.Human(size), strings.Join(cachedir.Locations(), ", ")) return err } @@ -96,7 +96,7 @@ func cleanCache(args []string, input io.Reader, output io.Writer) error { // stdout means `codeaf cache clean | tee clean.log` puts the question // into the file and leaves the person looking at a blank terminal, // waiting for a word they cannot see. See streams.go. - fmt.Fprintf(aside, "This deletes the shared build cache — %s at %s.\n", cachedir.Human(size), cachedir.Root()) + fmt.Fprintf(aside, "This deletes the shared build cache — %s at %s.\n", cachedir.Human(size), strings.Join(cachedir.Locations(), ", ")) fmt.Fprintln(aside, "Toolchains and modules are re-downloaded cold on the next task. Conversations,") fmt.Fprintln(aside, "settings and credentials live elsewhere and are not touched.") fmt.Fprint(aside, `Type "`+cacheCleanWord+`" to delete it; anything else keeps it: `) diff --git a/cmd/codeaf/cache_test.go b/cmd/codeaf/cache_test.go index 5022dd4cd5..eead746e6f 100644 --- a/cmd/codeaf/cache_test.go +++ b/cmd/codeaf/cache_test.go @@ -156,3 +156,32 @@ func TestBothSurfacesConfirmTheCacheDeletionWithTheSameWord(t *testing.T) { t.Fatalf("a word that is not %q did not keep the cache:\n%s", cacheCleanWord, answer.String()) } } + +func TestCacheQuestionNamesRetainedLegacyLocation(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + path := home.Join("workspace-caches", "legacy", "cache") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "compiler"), []byte("cache"), 0600); err != nil { + t.Fatal(err) + } + var shown strings.Builder + if err := runCacheWith(nil, strings.NewReader(""), &shown); err != nil { + t.Fatal(err) + } + if !strings.Contains(shown.String(), home.Join("workspace-caches")) { + t.Fatalf("cache display omitted affected old location: %s", shown.String()) + } + commentary := captureAside(t) + var output strings.Builder + if err := runCacheWith([]string{"clean"}, strings.NewReader("no\n"), &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(commentary.String(), home.Join("workspace-caches")) { + t.Fatalf("cleanup confirmation omitted affected old location: %s", commentary.String()) + } + if _, err := os.Stat(filepath.Join(path, "compiler")); err != nil { + t.Fatal("confirmation cleaned cache without authorization") + } +} diff --git a/cmd/codeaf/chatv3_initial_idle_test.go b/cmd/codeaf/chatv3_initial_idle_test.go new file mode 100644 index 0000000000..35b71e4c80 --- /dev/null +++ b/cmd/codeaf/chatv3_initial_idle_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestNewChatSealsBackgroundRecordWithoutFirstForegroundTurn(t *testing.T) { + celltest.Available(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + seat, _, watch := v3SeatOf(session.Place{Dir: c.Root, Workspace: t.TempDir()}, nil) + closeClassSeat(t, seat) + if watch == nil { + t.Fatal("new chat has no seal watch") + } + // Observe the tail-seal callback at the real seat-construction seam. This + // proves scheduling only; the public terminal relay test proves transport. + checkpoint := filepath.Join(c.Root, "background-record.json") + sealed := make(chan string, 1) + watch.Quiet = 10 * time.Millisecond + watch.OnTail = func() { + raw, err := os.ReadFile(checkpoint) + if err == nil { + sealed <- string(raw) + } + } + if err := os.WriteFile(checkpoint, []byte(`{"state":"finished"}`), 0600); err != nil { + t.Fatal(err) + } + watch.Wrote() + select { + case got := <-sealed: + if got != `{"state":"finished"}` { + t.Fatalf("sealed stale background record: %s", got) + } + case <-time.After(time.Second): + t.Fatal("background record before the first foreground turn never reached the tail sealer") + } +} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index df2a854b92..cd7e296923 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -487,16 +487,18 @@ func v3MintSessionAs(class cell.Class, bucket, workspace, launchDir string, owne return session.Place{}, err } place := v3PlaceFor(dir, workspace, owned) - // The write is not checked for the reason place.go gives: meta.json is a - // citation and not the record, and a session that would not open because a - // lookup file could not be written would be the wrong trade twice over. - _ = session.SaveMeta(dir, session.Meta{ - ID: id, - Workspace: place.Workspace, - LaunchDir: launchDir, - Owned: owned, - Created: time.Now(), - }) + // Placement policy is durable conversation truth, so a failed creation + // write must not silently open the conversation with legacy task placement. + if err := session.SaveMeta(dir, session.Meta{ + ID: id, + Workspace: place.Workspace, + LaunchDir: launchDir, + Owned: owned, + Created: time.Now(), + WorkspacePolicy: session.DefaultWorkspacePolicy(), + }); err != nil { + return session.Place{}, fmt.Errorf("save new conversation policy: %w", err) + } return place, nil } @@ -830,6 +832,10 @@ func v3SeatWith(place session.Place, say func(string), drives *driveBook) (execu seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) failed(watch, err) watch.OnTail = func() { sealTail(seat, drive) } + // A fresh seat has no foreground turn in flight. Background tasks may be + // the first input; their journal writes need a tail seal even before chat. + // Window attachment reuses the engine's seat rather than constructing one. + watch.Waiting() return executor.Gated(seat, driveGate(drive)), machine, watch } diff --git a/cmd/codeaf/chatv3_layout_test.go b/cmd/codeaf/chatv3_layout_test.go index a35d3d9ffa..ed6998446f 100644 --- a/cmd/codeaf/chatv3_layout_test.go +++ b/cmd/codeaf/chatv3_layout_test.go @@ -154,6 +154,9 @@ func TestAFirstLaunchMintsAFolderThatNamesItself(t *testing.T) { if err != nil { t.Fatal(err) } + if meta.WorkspacePolicy == nil || *meta.WorkspacePolicy != *session.DefaultWorkspacePolicy() { + t.Fatalf("new conversation has no private task policy: %+v", meta.WorkspacePolicy) + } if meta.ID != filepath.Base(found.Place.Dir) { t.Fatalf("the folder is called %s and the session %s", filepath.Base(found.Place.Dir), meta.ID) } diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index adbbac3a89..9be9e1c2af 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -146,6 +146,15 @@ func (t takeover) TakeSeen(ctx context.Context, id string, seen *directory.Lease return surfaceTaken(got, elapsed), nil } +func (t takeover) TakeSeenWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (tui3.Taken, error) { + began := time.Now() + got, err := t.c.TakeSeenWithWorkspaceRoots(ctx, id, seen, roots) + if err != nil { + return tui3.Taken{}, err + } + return surfaceTaken(got, time.Since(began)), nil +} + func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) { began := time.Now() got, err := t.c.OpenBranch(ctx, id) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index 75c4718d9e..fe262ca062 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -1,180 +1,192 @@ # Workspace storage and relay architecture -This page separates the shipped relay from the proposed workspace lifecycle. -The source baseline is `dev` at `7f5da935c`. The multi-root extension has been -exercised in a separate prototype; this documentation does not ship it or enable -automatic compaction. The resident-graph design in [ARCHITECTURE](../ARCHITECTURE.md) -is a separate design and is not the current chat storage schema. - -## Shipped relay: ownership and encrypted content +This implementation separates working-root identity, private task placement, +explicit result application, and encrypted transfer. A working root is a +selected folder, not necessarily a repository or everything a chat works on. +One chat may use several roots. The resident-graph design in +[ARCHITECTURE](../ARCHITECTURE.md) is separate from this chat storage schema. + +## Folders, ownership, and cooperation + +The harness chooses delegated task placement. A model names inputs and relative +layout; its brief cannot grant permission to write an original project. An +execution has one primary root and explicit read/write sibling roots. Root names +are distinct portable directory names; overlapping or unsupported sources are +refused rather than silently renamed. + +```text +profile/ + workspace-identities/ local opaque-URI bindings and ownership + workspace-runtime// owned temporary storage + workspace-caches// explicitly shared project cache +chat/ + .cell/ portable chat truth and task carriers + trees// + app/ private primary copy + library/ private sibling copy + logs/ chat-owned logs +``` -The chat harness owns execution and the selected local folders. Furrow captures -and materializes sealed chat state; `cellsync` transports encrypted objects; -the relay stores those objects and the directory's published heads and leases. -A lease elects a chat's holder. It is not permission to write an arbitrary local -folder. Pairing and the encrypted vault have their existing separate policies. +Paths above describe roles, not portable absolute locations. The local registry +maps identities to the actual device paths. Project caches are project-owned, +so finishing one task does not authorize deleting a cache shared with another. +Original folders and unrelated caches never become task cleanup targets. ```mermaid -sequenceDiagram - participant S as Source harness - participant F as Source Furrow - participant R as Relay - participant G as Receiver Furrow - participant D as Receiver harness - S->>F: Capture chat and portable task data - F-->>S: Sealed head and exportable objects - S->>R: Upload encrypted objects - S->>R: Publish head under ownership fence - D->>R: Confirm Take of displayed holder - R-->>D: Accepted lease and published head - D->>G: Want objects required by head - D->>R: Fetch required encrypted objects - R-->>G: Objects through receiver import - G-->>D: Materialized chat state - D->>D: Restore portable task copies and check readiness +flowchart TD + Chat[Chat identity] --> Layout[Declared execution layout] + Layout --> App[Primary private copy] + Layout --> Library[Sibling private copy] + App --> A[Working root A identity] + Library --> B[Working root B identity] + Layout --> Cooperation[Exact cooperating tasks share the layout] + Chat --> Independent[Independent work gets a separate layout] + Layout --> Runtime[Owned temporary storage and logs] ``` -Publishing uploads objects before advancing the directory head. A stale fence -cannot overwrite the new holder. Fetching may prime from advertised frames, but -Furrow's `Want`/import loop establishes completeness before materialization; -frame listings are hints, not completeness proofs. - -Task worktrees live outside the ordinary sealed chat tree. `taskcopy.Carry` -composes portable commits and uncommitted changes under `.cell/trees` and restores -them at receiver-local paths. Files omitted by the portable representation do -not become transferable merely because a task used them. A successful chat Take -is not a guarantee that every external command or dependency can resume. - -Source seams: [publisher](../../internal/cellsync/publish.go), -[fetcher](../../internal/cellsync/fetch.go), -[native sync adapter](../../internal/cellstore/syncops.go), -[Take](../../internal/syncsetup/take.go), -[portable task copies](../../internal/taskcopy/doc.go), and -[chat file policy](../../internal/chatfiles/chatfiles.go). +Cooperation reuses an already admitted layout; it does not broaden a read-only +root or silently add a sibling. Independent conversations retain independent +execution. Foreground commands keep the semantics of the project the person +explicitly opened. Home/reference tasks keep scratch space rather than copying +an entire home directory. Shell placement and environment variables are not an +operating-system sandbox for detached writers or remote commands. -## Multi-root extension: identity is not a path or permission +Source seams: [placement](../../internal/session/workspace_ownership.go), +[layout preparation](../../internal/session/workspace_set_prepare.go), +[identity registry](../../internal/workspaceidentity), and +[conversation storage](../../internal/session/workspace_chat.go). -The proposed unit is a **working root**, a dynamically selected folder. It need -not be a Git repository, and it is not everything a conversation works on. -One chat can reference several roots. Each execution declares a primary root -and its explicit sibling uses. Every copy URI identifies exactly one physical -copy; a layout groups several copies without combining their identities. +## Identity does not grant write permission | Resource | Contract | | --- | --- | -| Working root | Stable opaque identity; receiver admits each association separately | -| Chat | References many roots; has its own history and execution frontier | -| Execution layout | Primary root plus declared read/write sibling uses | -| Copy | One physical copy, owned by a chat/execution and bound locally | -| Landing destination | Separate explicit permission; never inferred from a source URI | - -The harness prepares private copies for delegated work by default. Cooperating -tasks can share an identical admitted layout; independent work gets separate -copies. Foreground edits retain the explicitly opened project's existing -semantics. A model's brief, an absolute source path, matching names, and matching -Git remotes cannot grant landing authority or merge two root identities. +| Working root | Opaque identity for one selected folder; each receiver association is explicit | +| Chat | Own history and execution frontier; references several roots | +| Execution layout | Primary copy plus explicit read/write sibling uses | +| Copy | One physical copy with local ownership and placement | +| Landing destination | Separate user permission; never inferred from a source identity | -```mermaid -flowchart TD - Chat[Chat identity] --> Layout[Execution layout] - Layout --> A[Primary copy URI] - Layout --> B[Sibling copy URI] - A --> RootA[Working root A identity] - B --> RootB[Working root B identity] - A --> LocalA[Private local copy A] - B --> LocalB[Private local copy B] - Layout --> Shared[Cooperating tasks share this layout] - Layout --> Temp[Owned temporary files and logs] -``` +Equal folder names and Git remotes do not merge identities. URI records are +local authority and are not copied between devices as a path database. Result +application must be explicit for each writable root, with conflicts retained; +receiving a root does not authorize applying changes to it. -The registry maps identity to local placement. It remains device-local; relay -carries portable references and bytes, not another device's path authority. -Reopen resolves the recorded identity and validates ownership. An unknown or -incomplete binding refuses rather than silently using a historical path. +## Relay: fetch, map roots, acquire, restore -## Receive and restart contract - -The extension must validate the entire required root closure before admission. -Each root is associated independently with an accepted receiver destination. -Reconstruction uses private staging and full history when the receiver lacks a -verified Git base. It does not unbundle into a borrowed original repository. +Furrow owns byte capture, cryptographic verification and materialization. +`cellsync` schedules transport. The relay stores encrypted objects and an +authenticated directory of published heads and leases. The harness owns +execution, local folder admission, and result application. A lease elects the +chat holder; it does not authorize writing arbitrary folders. ```mermaid -flowchart LR - Fetch[Fetch verified sealed state] --> Validate[Validate roots and metadata] - Validate --> Stage[Stage complete private layout] - Stage --> Promote[Promote layout] - Promote --> Admit[Register local copy bindings] - Admit --> Finalize[Finalize checkpoint and clear source landing permission] - Finalize --> Ready[Allow execution] +sequenceDiagram + participant S as Source harness + participant F as Furrow + participant R as Relay + participant D as Receiver harness + participant U as User + S->>F: Capture portable chat and task state + F-->>S: Sealed head and exportable objects + S->>R: Upload objects before publishing head + D->>R: Fetch displayed head into private staging + D->>D: Verify carrier and required root closure + D->>U: Choose missing local root folders + U-->>D: Explicit per-root associations + D->>D: Validate staging and final destination + D->>R: Acquire against displayed holder and fence + D->>D: Stage and verify complete private layout + D->>D: Promote, bind identities, finalize checkpoint + D->>D: Permit execution only after readiness checks ``` -A durable operation receipt spans promotion, registry admission and checkpoint -finalization. This is recovery across several stores, not one atomic database -transaction. Retries recognize the recorded operation and expected checkpoint; -they must not duplicate copies or resume against a partly restored layout. - -Git does not represent empty directories or directory permissions. A portable -root therefore needs an explicit versioned directory map in addition to file -content, executable modes, deletions and line-ending metadata. Missing or -unsupported metadata must refuse without discarding the original carrier. -This is not a promise about every filesystem's ACLs, ownership or special files. - -A fork retains completed results and portable history while detaching inherited -active execution authority. Unchanged inputs should not be duplicated as result -artifacts. A later Take must retain that history without reviving old workers. - -## Storage lifecycle and safety - -The proposed lifecycle is `resident → retiring → cold → restoring → resident`. -An owned archive is captured, verified and recorded durably before an eligible -resident copy is removed. Restore verifies exact supported state before use. -Shared content chunks may serve many manifests without merging ownership. - -Original projects, unrelated caches and uncertain writers are protected. -Shell guards and temporary-directory settings are not an operating-system -sandbox: detached writers and remote commands require separate ownership and -quiescence evidence. Uncertain copies stay resident. Multi-root copies currently -stay resident in the prototype; this design does not establish automatic -normal-use reclamation or a long-term storage budget. +The receiver's existing folder picker asks for each missing root. Escape cancels +without taking the lease. A retry retains the holder/fence the person saw, so a +changed holder requires a fresh decision. Bindings overlapping private receiver +storage are rejected before takeover. If staged admission fails, the original +native timeline binding is restored so a valid retry remains possible. + +Publishing uploads before advancing the directory head; stale fences cannot +overwrite the holder. Bounded existing-frame advertisements may prime fetching, +but Furrow's authoritative `Want`/import loop proves completeness. Hints cannot +substitute for verified objects. + +Source seams: [publish](../../internal/cellsync/publish.go), +[fetch](../../internal/cellsync/fetch.go), +[handoff](../../internal/handoff/handoff.go), +[receiver orchestration](../../internal/syncsetup/take.go), and +[task transfer](../../internal/session/workspace_transfer.go). + +## Reopen, restart, and fork + +Task workspaces are carried separately from ordinary sealed chat files. +Portable commits, uncommitted changes, and explicit versioned directory metadata +preserve supported state. Git alone cannot preserve empty directories and +directory permissions. Missing metadata, invalid digest bindings, or an unknown +recorded baseline refuse rather than guessing a replacement. + +Restore uses private staging and full history when no verified Git base is +available. It never unbundles into a borrowed original repository. A durable +receipt spans promotion, registry admission and checkpoint finalization; retries +recognize the same operation rather than creating a second copy. This recovery +protocol spans several stores, not one atomic database transaction. + +A fork retains completed results and portable history, with inherited active +execution detached. It does not duplicate ongoing workers or spending. A later +Take must preserve those results without reviving the original frontier. +Successful chat transfer does not guarantee that arbitrary external commands or +dependencies can resume on another device. + +## Local archives and storage safety + +The local lifecycle is `resident → retiring → cold → restoring → resident`. +An eligible owned copy must be captured, independently verified and recorded +durably before retirement. Restore verifies supported bytes and metadata before +use. Deduplicated compressed chunks may serve several manifests without +combining their ownership. + +The archive implementation is a separate native dependency: +[Furrow #13](https://github.com/Agent-Field/furrow/pull/13). Its local archive +database is not a relay wire format. Release artifacts and the embedded pin must +be updated before ordinary installation can claim archive support. A custom +build used for acceptance is not equivalent to a released dependency. + +Uncertain writers stay resident. Multi-root layout retirement remains excluded +until whole-layout quiescence and recovery are verified. Unsupported ACLs, +ownership, filesystem flags, special files, or other unverified metadata must +retain the resident copy. No four-month automatic reclamation claim follows +from the archive experiment. ## Compatibility and migration -This documentation-only change performs **no migration**. A future implementation -needs an explicit rollout before these contracts can become defaults: - | Boundary | Required behavior | | --- | --- | -| Legacy singleton checkpoint | Preserve its existing reader and restore behavior; no forced rewrite | -| New multi-root checkpoint/carrier | Version schema, record all required roots, directory metadata and digest binding | -| Early prototype with missing directory metadata | Refuse safely; recapture from intact source if available | -| Missing recorded Git baseline | Preserve full history; refuse changed-output extraction instead of guessing HEAD | -| Mixed or incomplete execution history | Refuse unsupported combinations without dropping records | -| Receiver capabilities | Establish format support before mutation; older clients currently lack a guaranteed early refusal | -| Local path bindings | Re-admit per device; never import remote absolute paths as authority | - -Both ends need supported readers during prototype testing. A capability handshake -and restart/recovery tests are prerequisites to a general rollout. The original -copy must survive a failed upgrade or restore; restoring an old binary is not a -safe rollback if it cannot understand the new records. Do not compact away the -last readable copy while compatibility is uncertain. - -Fast routine key rewrapping, if introduced, must remain separate from full -compromise recovery. Rewrapping an unchanged content key cannot revoke content -already exposed by a compromised key; it cannot silently replace full rotation. - -## Measurements and remaining acceptance - -Separate experiments measured **43.34% lower allocated use** on isolated copies -of a real-worktree corpus and **55.17% lower warm materialization latency** in a -matched relay experiment. The latter reduced HTTP body bytes by **5.33%**; -latency reduction is not byte reduction. These are prototype measurements, -not promised defaults, host-wide free-space reclamation, or four-month usage -results. The new multi-root flow has no matched old/new savings percentage. - -An actual-model prototype sequence preserved supported bytes and metadata across -engine restart, local Take, fork and a second Take. The completed task card still -reported `incomplete`; a cross-machine receiver check was blocked by an offline -receiver. Active cross-device plan recovery, the complete filesystem metadata -matrix, and automatic multi-root compaction remain unverified. Green checks on -this documentation PR do not close those implementation gates. +| Legacy conversations | Preserve existing placement and readers; no silent historical rewrite | +| New owned conversations | Persist versioned placement policy so restart does not depend on a shell flag | +| Multi-root carriers | Validate every required root, directory map and digest before execution | +| Mixed or incomplete history | Refuse unsupported combinations without dropping records | +| Receiver paths | Re-admit per device; remote paths never become write authority | +| Older readers | Require supported readers; guaranteed early capability refusal is still unfinished | + +Do not retire the last readable copy while compatibility is uncertain. Installing +an older executable is not a sufficient rollback for records it cannot read. +Fast routine key rewrapping, if introduced separately, cannot replace full +compromise recovery or revoke content exposed by an old key. + +## Validation and measurement limits + +Separate experiments measured **43.34% lower allocated use** inside an isolated +volume containing copies of a real-worktree corpus, and **55.17% lower warm +materialization latency** in a matched transfer test. The transfer experiment +reduced HTTP body bytes by **5.33%**. These are different experiments; latency +reduction is not byte reduction, and isolated-volume reduction is not original +host free-space reclamation. Multi-root normal usage has no matched savings +percentage yet. + +Focused tests cover ordinary owned task admission, exact cooperation, directory +metadata, root mapping, cancellation, stale holders, preflight refusal and retry. +Final real-terminal actual-provider and cross-machine acceptance is still +pending on this implementation candidate. The earlier terminal run used an +incorrect record-card oracle and cannot establish either a full pass or a +confirmed task-status defect. Draft status remains appropriate until the +complete acceptance chain and dependency integration are verified. diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 992abdef46..9b5afb8872 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -1,11 +1,18 @@ --- -kind: internal -title: document workspace storage and relay compatibility +kind: added +title: owned task layouts and explicit multi-root relay mapping pr: 1944 -surface: [docs] +surface: [chat, engine, remote, docs] invalidates: [] --- -Separate shipped relay behavior from the proposed multi-root lifecycle, including -migration requirements and the limits of prototype measurements. No runtime -behavior, data format, storage policy or migration is changed. +Give supported delegated tasks private layouts and reuse the admitted layout for +exact cooperating work. Carry multiple working roots with stable identities, +versioned directory metadata, restart receipts and independent fork history. +Ask for receiver-local folders before acquiring the source lease; cancellation +and invalid mappings preserve the existing holder and allow a valid retry. + +Local archive recovery requires the separately reviewed native dependency. +Uncertain writers and multi-root reclamation remain excluded. Existing +conversations require compatibility safeguards; this change does not silently +rewrite legacy task histories or promise long-term storage savings. diff --git a/internal/blobstore/bounded_frame.go b/internal/blobstore/bounded_frame.go new file mode 100644 index 0000000000..b6dc2b2bd2 --- /dev/null +++ b/internal/blobstore/bounded_frame.go @@ -0,0 +1,76 @@ +package blobstore + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/wireauth" + "io" + "net/http" +) + +// ErrBoundedFrameUnsupported means this adapter cannot enforce an application +// read bound. Callers must degrade rather than use unbounded GetFrame. +var ErrBoundedFrameUnsupported = errors.New("blobstore: bounded frame reads unavailable") + +// BoundedFrameStore is optional; Store and its ordinary behavior stay unchanged. +// maxEncoded excludes one reserved EOF/oversize probe byte. It bounds consumed +// response bytes, not packets already buffered by a transport. +type BoundedFrameStore interface { + GetFrameBounded(context.Context, string, int64) ([]byte, error) +} + +func ReadFrameBounded(ctx context.Context, store Store, id string, maxEncoded int64) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !ValidRID(id) { + return nil, ErrBadRID + } + if maxEncoded <= 0 || maxEncoded >= MaxFrame { + return nil, ErrDamaged + } + bounded, ok := store.(BoundedFrameStore) + if !ok { + return nil, ErrBoundedFrameUnsupported + } + return bounded.GetFrameBounded(ctx, id, maxEncoded) +} + +// GetFrameBounded verifies complete exact-size immutable bytes with at most one +// probe byte beyond maxEncoded. A claimed Content-Length never expands the cap. +func (c *HTTP) GetFrameBounded(ctx context.Context, id string, maxEncoded int64) ([]byte, error) { + if !ValidRID(id) { + return nil, ErrBadRID + } + if maxEncoded <= 0 || maxEncoded >= MaxFrame { + return nil, ErrDamaged + } + ctx, limit := startBudget(ctx, c.deadline, 0) + defer limit.done() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wireauth.Endpoint(c.base, pathFrames+"/"+id), nil) + if err != nil { + return nil, err + } + c.sign(req, nil) + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: bounded frame refused", ErrUnreachable) + } + if resp.ContentLength > maxEncoded { + return nil, ErrDamaged + } + limit.answering(resp.ContentLength) + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxEncoded+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) != maxEncoded || IDOf(raw) != id { + return nil, ErrDamaged + } + return raw, nil +} diff --git a/internal/blobstore/bounded_frame_test.go b/internal/blobstore/bounded_frame_test.go new file mode 100644 index 0000000000..14f73388f3 --- /dev/null +++ b/internal/blobstore/bounded_frame_test.go @@ -0,0 +1,78 @@ +package blobstore_test + +import ( + "bytes" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "io" + "net/http" + "testing" +) + +type boundedTransport func(*http.Request) (*http.Response, error) + +func (f boundedTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type countedFrameBody struct { + io.Reader + consumed int +} + +func (b *countedFrameBody) Read(p []byte) (int, error) { + n, e := b.Reader.Read(p) + b.consumed += n + return n, e +} +func (*countedFrameBody) Close() error { return nil } +func TestBoundedFrameProductionWrappers(t *testing.T) { + frame, err := blobstore.Encode("00000000000000000000000000000000", []blobstore.Object{{RID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Bytes: []byte("AGEO\x01mock")}}) + if err != nil { + t.Fatal(err) + } + for _, suffix := range []string{"", "junk"} { + t.Run(suffix, func(t *testing.T) { + body := &countedFrameBody{Reader: bytes.NewReader(append(append([]byte(nil), frame...), suffix...))} + client := blobstore.NewHTTP("http://fixture.invalid", func(*http.Request, []byte) {}, &http.Client{Transport: boundedTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, ContentLength: -1, Body: body}, nil + })}) + counter := &blobstore.Counters{} + wrapped := &blobstore.Resuming{Inner: blobstore.Counting{Inner: client, C: counter}} + got, err := blobstore.ReadFrameBounded(context.Background(), wrapped, blobstore.IDOf(frame), int64(len(frame))) + if suffix == "" { + if err != nil || !bytes.Equal(got, frame) { + t.Fatalf("exact frame: %v", err) + } + } else if err == nil { + t.Fatal("accepted valid prefix with trailing bytes") + } + if body.consumed > len(frame)+1 { + t.Fatalf("budget exceeded: %d", body.consumed) + } + if counter.Gets.Load() != 1 { + t.Fatal("Counting capability bypassed") + } + }) + } + if _, err := blobstore.ReadFrameBounded(context.Background(), blobstore.NewMemory(), blobstore.IDOf(frame), 32); !errors.Is(err, blobstore.ErrBoundedFrameUnsupported) { + t.Fatalf("unsupported fell through: %v", err) + } +} + +func TestBoundedFrameRejectsDeclaredOversizeBeforeBodyRead(t *testing.T) { + body := &countedFrameBody{Reader: bytes.NewReader([]byte("not read"))} + client := blobstore.NewHTTP("http://fixture.invalid", func(*http.Request, []byte) {}, &http.Client{Transport: boundedTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, ContentLength: 999999, Body: body}, nil + })}) + if _, err := blobstore.ReadFrameBounded(context.Background(), client, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12); err == nil { + t.Fatal("oversize accepted") + } + if body.consumed != 0 { + t.Fatal("declared oversized body read") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := blobstore.ReadFrameBounded(ctx, client, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel lost: %v", err) + } +} diff --git a/internal/blobstore/census.go b/internal/blobstore/census.go new file mode 100644 index 0000000000..1cede892e7 --- /dev/null +++ b/internal/blobstore/census.go @@ -0,0 +1,301 @@ +package blobstore + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// CensusFiles counts observed regular files and their logical on-disk lengths. +type CensusFiles struct{ Count, LogicalBytes, AllocatedBytes int64 } + +// DiskCensus is an observational inventory, never a reachability or deletion +// proof. Frame hints, missing index pointers and duplicate objects establish no +// liveness. All observed frames remain UnclassifiedFrames. +// +// AllocatedBytes sums filesystem-reported blocks once per observed inode where +// supported. It includes visited directories, not unvisited filesystem metadata; +// it does not establish exclusive physical allocation of shared/CoW extents. +// AllocationKnown=false means no portable allocated-size measurement is claimed. +// BytesRead records actual payload/index inspection cost. Locations contains only +// hash-verified, structurally valid frames; it is not a persistent replacement +// index. The inventory uses one bounded frame buffer plus metadata proportional +// to the observed objects, locations and inodes, not their complete payloads. +type DiskCensus struct { + Frames, Pointers, Other CensusFiles + Directories, Inodes int64 + AllocatedBytes int64 + DirectoryAllocatedBytes int64 + AllocationKnown bool + BytesRead int64 + UniqueObjects, ObjectOccurrences int64 + UniquePayloadBytes, RepeatedPayloadBytes int64 + ConflictingOccurrences int64 + VerifiedPointers, FramesWithoutIndexPointers int64 + ObjectsWithoutIndexPointers int64 + UnclassifiedFrames int64 + Locations Locations `json:"-"` + Problems map[string]int64 + Complete bool + // StableSnapshot is always false: other handles/processes and concurrent puts + // are unfenced. A disappearing/changing file is an unresolved observation. + StableSnapshot bool +} + +type censusInode struct{ device, inode uint64 } +type censusAllocation struct { + identity censusInode + bytes int64 + known bool +} +type censusObject struct { + digest [32]byte + size int64 +} +type diskCensusWalk struct { + result DiskCensus + ctx context.Context + root string + inodes map[censusInode]bool + objects map[string]censusObject + frames map[string]bool // true only when a matching pointer was observed + pointers map[string]pointer +} + +// Census reads the store without changing any frame, pointer or index. It does +// not hold Disk's writer mutex or fence any publisher. Cancellation returns a +// partial result; corruption/unknown layout returns ErrDamaged with counts of +// unresolved observations, which can also arise during concurrent publication. +// A successful inventory still cannot authorize reclamation: that needs an +// authenticated complete object closure and a separately fenced epoch. +func (d *Disk) Census(ctx context.Context) (DiskCensus, error) { + walk := diskCensusWalk{ctx: ctx, root: filepath.Dir(d.frames), inodes: map[censusInode]bool{}, objects: map[string]censusObject{}, frames: map[string]bool{}, pointers: map[string]pointer{}} + walk.result = DiskCensus{AllocationKnown: true, Locations: Locations{}, Problems: map[string]int64{}} + if err := ctx.Err(); err != nil { + return walk.result, err + } + if err := filepath.WalkDir(walk.root, walk.visit); err != nil { + return walk.result, err + } + if err := ctx.Err(); err != nil { + return walk.result, err + } + if err := walk.verifyPointers(); err != nil { + return walk.result, err + } + for _, pointed := range walk.frames { + if !pointed { + walk.result.FramesWithoutIndexPointers++ + } + } + walk.result.UnclassifiedFrames = walk.result.Frames.Count + var problems int64 + for _, count := range walk.result.Problems { + problems += count + } + if problems != 0 { + return walk.result, fmt.Errorf("%w: disk census has %d unresolved observations", ErrDamaged, problems) + } + walk.result.Complete = true + return walk.result, nil +} + +func (w *diskCensusWalk) problem(reason string) { w.result.Problems[reason]++ } + +func (w *diskCensusWalk) visit(path string, entry fs.DirEntry, walkErr error) error { + if err := w.ctx.Err(); err != nil { + return err + } + if walkErr != nil { + w.problem("walk-error") + w.result.AllocationKnown = false + return nil + } + info, err := entry.Info() + if err != nil { + w.problem("stat-error") + w.result.AllocationKnown = false + return nil + } + allocated := w.allocate(info) + rel, err := filepath.Rel(w.root, path) + if err != nil { + return err + } + if info.IsDir() { + w.result.Directories++ + w.result.DirectoryAllocatedBytes += allocated + if !censusDirectory(rel) { + w.problem("unknown-directory") + } + return nil + } + if !info.Mode().IsRegular() { + w.result.Other.Count++ + w.result.Other.AllocatedBytes += allocated + w.result.Other.LogicalBytes += info.Size() + w.problem("non-regular-entry") + return nil + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) == 2 && parts[0] == "frames" && ValidRID(parts[1]) { + w.result.Frames.AllocatedBytes += allocated + w.frame(path, parts[1], info) + return nil + } + if len(parts) == 3 && parts[0] == "objects" && len(parts[1]) == 2 && ValidRID(parts[1]+parts[2]) { + w.result.Pointers.AllocatedBytes += allocated + w.index(path, parts[1]+parts[2], info) + return nil + } + w.result.Other.Count++ + w.result.Other.AllocatedBytes += allocated + w.result.Other.LogicalBytes += info.Size() + w.problem("unknown-file") + return nil +} + +func censusDirectory(rel string) bool { + parts := strings.Split(filepath.ToSlash(rel), "/") + return rel == "." || rel == "frames" || rel == "objects" || (len(parts) == 2 && parts[0] == "objects" && isLowerHex(parts[1], 2)) +} + +func (w *diskCensusWalk) allocate(info fs.FileInfo) int64 { + allocation := censusAllocated(info) + if !allocation.known { + w.result.AllocationKnown = false + return 0 + } + if !w.inodes[allocation.identity] { + w.inodes[allocation.identity] = true + w.result.Inodes++ + w.result.AllocatedBytes += allocation.bytes + return allocation.bytes + } + return 0 +} + +func (w *diskCensusWalk) frame(path, id string, info fs.FileInfo) { + w.result.Frames.Count++ + w.result.Frames.LogicalBytes += info.Size() + w.result.UnclassifiedFrames++ + w.frames[id] = false + raw, err := w.read(path, info, MaxFrame) + if err != nil { + w.problem("frame-read") + return + } + if IDOf(raw) != id { + w.problem("frame-hash") + return + } + header, objects, err := Decode(raw) + if err != nil { + w.problem("frame-format") + return + } + start := payloadStart(raw, header) + for n, object := range objects { + ref := header.Objects[n] + w.result.Locations[object.RID] = append(w.result.Locations[object.RID], Location{Frame: id, Off: start + ref.Off, Len: ref.Len}) + w.result.ObjectOccurrences++ + digest := sha256.Sum256(object.Bytes) + if prior, exists := w.objects[object.RID]; exists { + if prior.digest != digest || prior.size != int64(len(object.Bytes)) { + w.result.ConflictingOccurrences++ + w.problem("object-conflict") + } else { + w.result.RepeatedPayloadBytes += int64(len(object.Bytes)) + } + } else { + w.objects[object.RID] = censusObject{digest: digest, size: int64(len(object.Bytes))} + w.result.UniquePayloadBytes += int64(len(object.Bytes)) + w.result.UniqueObjects++ + } + } +} + +func (w *diskCensusWalk) index(path, rid string, info fs.FileInfo) { + w.result.Pointers.Count++ + w.result.Pointers.LogicalBytes += info.Size() + raw, err := w.read(path, info, 256) + if err != nil { + w.problem("pointer-read") + return + } + parsed, err := parsePointer(raw) + if err != nil { + w.problem("pointer-format") + return + } + w.pointers[rid] = parsed +} + +func (w *diskCensusWalk) read(path string, observed fs.FileInfo, limit int64) ([]byte, error) { + if err := w.ctx.Err(); err != nil { + return nil, err + } + if observed.Size() > limit { + return nil, errors.New("census file exceeds format limit") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + current, err := file.Stat() + if err != nil || !current.Mode().IsRegular() || !os.SameFile(observed, current) { + return nil, errors.New("census file changed before inspection") + } + raw, err := io.ReadAll(io.LimitReader(censusReader{ctx: w.ctx, input: file}, limit+1)) + w.result.BytesRead += int64(len(raw)) + if int64(len(raw)) > limit || int64(len(raw)) != observed.Size() { + return nil, errors.New("census file changed or exceeds format limit") + } + return raw, err +} + +type censusReader struct { + ctx context.Context + input io.Reader +} + +func (r censusReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.input.Read(buffer) +} + +func (w *diskCensusWalk) verifyPointers() error { + for rid, index := range w.pointers { + if err := w.ctx.Err(); err != nil { + return err + } + matched := false + for _, location := range w.result.Locations[rid] { + if location.Frame == index.Frame && location.Off == index.Off && location.Len == index.Len { + matched = true + break + } + } + if !matched { + w.problem("pointer-target") + continue + } + w.frames[index.Frame] = true + w.result.VerifiedPointers++ + } + w.result.ObjectsWithoutIndexPointers = w.result.UniqueObjects - w.result.VerifiedPointers + if w.result.ObjectsWithoutIndexPointers != 0 { + w.result.Problems["object-without-pointer"] = w.result.ObjectsWithoutIndexPointers + } + + return nil +} diff --git a/internal/blobstore/census_alloc_other.go b/internal/blobstore/census_alloc_other.go new file mode 100644 index 0000000000..5908fc0397 --- /dev/null +++ b/internal/blobstore/census_alloc_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package blobstore + +import "io/fs" + +func censusAllocated(fs.FileInfo) censusAllocation { return censusAllocation{} } diff --git a/internal/blobstore/census_alloc_unix.go b/internal/blobstore/census_alloc_unix.go new file mode 100644 index 0000000000..427bd0ba7c --- /dev/null +++ b/internal/blobstore/census_alloc_unix.go @@ -0,0 +1,16 @@ +//go:build linux || darwin + +package blobstore + +import ( + "io/fs" + "syscall" +) + +func censusAllocated(info fs.FileInfo) censusAllocation { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return censusAllocation{} + } + return censusAllocation{identity: censusInode{device: uint64(stat.Dev), inode: uint64(stat.Ino)}, bytes: stat.Blocks * 512, known: true} +} diff --git a/internal/blobstore/census_test.go b/internal/blobstore/census_test.go new file mode 100644 index 0000000000..cdf9f7979a --- /dev/null +++ b/internal/blobstore/census_test.go @@ -0,0 +1,294 @@ +package blobstore_test + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +func TestDiskCensusInventoriesEveryLocationWithoutClaimingReachability(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + a, b, c := obj("a", "first"), obj("b", "second"), obj("c", "third") + var frameBytes int64 + for _, objects := range [][]blobstore.Object{{a, b}, {b, c}, {a, c}} { + frame, err := blobstore.Encode(testKey, objects) + if err != nil { + t.Fatal(err) + } + frameBytes += int64(len(frame)) + if _, err := store.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + } + before := tree(t, root) + census, err := store.Census(context.Background()) + if err != nil { + t.Fatal(err) + } + if census.Frames.Count != 3 || census.Pointers.Count != 3 || census.Frames.LogicalBytes != frameBytes { + t.Fatalf("wrong file inventory: %+v", census) + } + if census.UniqueObjects != 3 || census.ObjectOccurrences != 6 || census.VerifiedPointers != 3 { + t.Fatalf("wrong object inventory: %+v", census) + } + payload := int64(len(a.Bytes) + len(b.Bytes) + len(c.Bytes)) + if census.UniquePayloadBytes != payload || census.RepeatedPayloadBytes != payload { + t.Fatalf("wrong payload inventory: %+v", census) + } + for _, object := range []blobstore.Object{a, b, c} { + if len(census.Locations[object.RID]) != 2 { + t.Fatal("missing alternate location") + } + } + if census.FramesWithoutIndexPointers != 1 || census.UnclassifiedFrames != 3 { + t.Fatalf("index observations confused with reachability: %+v", census) + } + if !census.Complete || census.StableSnapshot { + t.Fatal("inventory must state its unfenced snapshot limitation") + } + if census.BytesRead <= frameBytes || census.Pointers.LogicalBytes <= 0 { + t.Fatal("read and pointer costs missing") + } + if census.AllocationKnown { + if census.Inodes != census.Directories+census.Frames.Count+census.Pointers.Count { + t.Fatal("distinct inode inventory mismatch") + } + } + if after := tree(t, root); !reflect.DeepEqual(before, after) { + t.Fatal("read-only census changed store") + } +} + +func TestDiskCensusReportsDamageWithoutFollowingUnknownEntries(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + object := obj("a", "first") + frame, err := blobstore.Encode(testKey, []blobstore.Object{object}) + if err != nil { + t.Fatal(err) + } + id, err := store.PutFrame(context.Background(), frame) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "frames", id), []byte("damaged"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "unclassified.tmp"), []byte("mock pending bytes"), 0600); err != nil { + t.Fatal(err) + } + census, err := store.Census(context.Background()) + if !errors.Is(err, blobstore.ErrDamaged) || census.Complete { + t.Fatalf("damage hidden: %+v %v", census, err) + } + if census.Frames.Count != 1 || census.Other.Count != 1 || census.Pointers.Count != 1 || len(census.Problems) == 0 { + t.Fatalf("unverified bytes disappeared: %+v", census) + } + if census.UnclassifiedFrames != 1 || census.UniqueObjects != 0 { + t.Fatal("damaged frame contents cannot be counted as known objects") + } +} + +func TestDiskCensusCancellationIsPartialAndReadOnly(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + before := tree(t, root) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + census, err := store.Census(ctx) + if !errors.Is(err, context.Canceled) || census.Complete { + t.Fatalf("cancellation hidden: %+v %v", census, err) + } + if after := tree(t, root); !reflect.DeepEqual(before, after) { + t.Fatal("canceled census changed store") + } +} + +func TestDiskCensusReportsMissingAndCorruptPointers(t *testing.T) { + for _, kind := range []string{"missing-pointer", "malformed-pointer", "wrong-target", "missing-frame"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + a, b := obj("a", "first"), obj("b", "second") + frame, err := blobstore.Encode(testKey, []blobstore.Object{a, b}) + if err != nil { + t.Fatal(err) + } + id, err := store.PutFrame(context.Background(), frame) + if err != nil { + t.Fatal(err) + } + pointer := filepath.Join(root, "objects", b.RID[:2], b.RID[2:]) + switch kind { + case "missing-pointer": + if err := os.Remove(pointer); err != nil { + t.Fatal(err) + } + case "malformed-pointer": + if err := os.WriteFile(pointer, []byte("malformed\n"), 0600); err != nil { + t.Fatal(err) + } + case "wrong-target": + raw, err := os.ReadFile(filepath.Join(root, "objects", a.RID[:2], a.RID[2:])) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pointer, raw, 0600); err != nil { + t.Fatal(err) + } + case "missing-frame": + if err := os.Remove(filepath.Join(root, "frames", id)); err != nil { + t.Fatal(err) + } + } + before := tree(t, root) + census, err := store.Census(context.Background()) + if !errors.Is(err, blobstore.ErrDamaged) || census.Complete { + t.Fatalf("unresolved pointer state hidden: %+v %v", census, err) + } + if kind == "missing-pointer" { + if _, err := store.Get(context.Background(), b.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatal("fixture object unexpectedly addressable") + } + if census.ObjectsWithoutIndexPointers != 1 || census.Problems["object-without-pointer"] != 1 || len(census.Locations[b.RID]) != 1 { + t.Fatalf("recoverable frame was confused with available object: %+v", census) + } + } + if after := tree(t, root); !reflect.DeepEqual(before, after) { + t.Fatal("census repaired or removed data") + } + }) + } +} + +func TestDiskCensusDistinguishesConflictingBytesFromRepeatedPayload(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + a := obj("a", "first") + frame, err := blobstore.Encode(testKey, []blobstore.Object{a}) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + conflict, err := blobstore.Encode(testKey, []blobstore.Object{{RID: a.RID, Bytes: []byte("AGEO\x01different")}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "frames", blobstore.IDOf(conflict)), conflict, 0600); err != nil { + t.Fatal(err) + } + census, err := store.Census(context.Background()) + if !errors.Is(err, blobstore.ErrDamaged) || census.ConflictingOccurrences != 1 || census.RepeatedPayloadBytes != 0 { + t.Fatalf("conflict counted as duplicate savings: %+v %v", census, err) + } + if len(census.Locations[a.RID]) != 2 || census.UnclassifiedFrames != 2 { + t.Fatal("alternative bytes must remain inventoried and unclassified") + } +} + +func TestDiskCensusDoesNotFollowUnknownSymlinks(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + external := t.TempDir() + if err := os.WriteFile(filepath.Join(external, "mock.txt"), []byte("external mock data"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, "unexpected-link")); err != nil { + t.Skip("symlink creation unavailable") + } + census, err := store.Census(context.Background()) + if !errors.Is(err, blobstore.ErrDamaged) || census.Other.Count != 1 || census.BytesRead != 0 || census.Problems["non-regular-entry"] != 1 { + t.Fatalf("unknown link followed or hidden: %+v %v", census, err) + } +} + +func BenchmarkDiskCensus(b *testing.B) { + root := b.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + b.Fatal(err) + } + objects := make([]blobstore.Object, 1000) + for n := range objects { + objects[n] = obj(fmt.Sprintf("mock-%04d", n), strings.Repeat("x", 64)+fmt.Sprintf("-%04d", n)) + } + for _, group := range [][]blobstore.Object{objects[:750], objects[250:]} { + frame, err := blobstore.Encode(testKey, group) + if err != nil { + b.Fatal(err) + } + if _, err := store.PutFrame(context.Background(), frame); err != nil { + b.Fatal(err) + } + } + census, err := store.Census(context.Background()) + if err != nil { + b.Fatal(err) + } + b.SetBytes(census.BytesRead) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := store.Census(context.Background()); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(census.Frames.LogicalBytes), "frame-logical-bytes") + b.ReportMetric(float64(census.Pointers.LogicalBytes), "pointer-logical-bytes") + b.ReportMetric(float64(census.Pointers.AllocatedBytes), "pointer-allocated-bytes") + b.ReportMetric(float64(census.DirectoryAllocatedBytes), "directory-allocated-bytes") + b.ReportMetric(float64(census.AllocatedBytes), "reported-allocated-bytes") + b.ReportMetric(float64(census.RepeatedPayloadBytes), "repeated-payload-bytes") +} + +func TestDiskCensusDoesNotReadOversizedFrame(t *testing.T) { + root := t.TempDir() + store, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "frames", strings.Repeat("a", 64)) + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(blobstore.MaxFrame + 1); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + census, err := store.Census(context.Background()) + if !errors.Is(err, blobstore.ErrDamaged) || census.BytesRead != 0 || census.Frames.LogicalBytes != blobstore.MaxFrame+1 || census.Problems["frame-read"] != 1 { + t.Fatalf("oversized frame was read or omitted: %+v %v", census, err) + } +} diff --git a/internal/blobstore/counting.go b/internal/blobstore/counting.go index 81d3b85c94..9b1e75fa2c 100644 --- a/internal/blobstore/counting.go +++ b/internal/blobstore/counting.go @@ -75,3 +75,11 @@ func objectsIn(frame []byte) int64 { } return int64(len(h.Objects)) } + +// GetFrameBounded preserves the optional bounded capability and request counts. +func (c Counting) GetFrameBounded(ctx context.Context, id string, maxEncoded int64) ([]byte, error) { + c.C.Gets.Add(1) + raw, err := ReadFrameBounded(ctx, c.Inner, id, maxEncoded) + c.C.BytesDown.Add(int64(len(raw))) + return raw, err +} diff --git a/internal/blobstore/gc.go b/internal/blobstore/gc.go index 5c1c9ef08e..2558c4c2b6 100644 --- a/internal/blobstore/gc.go +++ b/internal/blobstore/gc.go @@ -2,28 +2,22 @@ package blobstore import "time" -// The garbage-collection rules for append-only frames (contract §22.9). Frames -// are never deleted today; this file is the design as executable rules, so the -// Worker and the Go relay sweep to the same answer when the sweeps land. What -// the sweeps still need is the multi-location index: `objs` keeps one row per -// rid (first location wins), so deleting a frame could strand an object -// another frame also holds. Until that index exists these rules are only -// tested, never run. +// Candidate retention rules for append-only frames. No frame sweep runs today. +// These rules retain named, recent and vault frames, but cannot prove that the +// remaining frames are collectible: directory plans are subset hints, and a +// later publish may reuse objects from an unnamed earlier frame. A multi-location +// index alone does not supply the missing reachability proof. // -// The rules, in one paragraph: a frame is live while any cell record's plan -// names it, or it is younger than a grace window (a publish in flight has -// uploaded frames no record names yet), or it is the vault frame the identity -// record names. A sweep tombstones a collectible frame first and deletes its -// bytes only after a second, longer window, so a take that read a plan just -// before the sweep still finishes: its frame get is answered or 404s into the -// want loop, and both are correct. Rotation (contract §20) is unaffected: the -// retire sweep deletes the whole namespace, and no sweep runs while a rotation -// is frozen or retiring. +// Before any sweep is enabled, clients must provide complete authenticated +// reachability evidence covering retained heads/history, in-flight transfers and +// recovery, with fenced epochs and grace periods. Rotation namespace retirement +// remains a separate operation. Do not turn this candidate set into deletion +// authority or interpret its complement as unreachable storage. -// LiveFrames marks the frames a sweep must keep, by the rule above. `frames` -// is every frame the relay holds with its put time; `plans` is the frames -// field of every live cell record; `vault` is the identity's vault frame, "" -// when there is none; `cutoff` is now minus the grace window. +// LiveFrames returns the minimum set retained by the supplied hints and times. +// It does not prove that frames absent from the result are dead. `frames` names +// stored frames and their put times; `plans` contains available frame hints; +// `vault` is the recorded vault frame, or empty; `cutoff` ends the grace window. func LiveFrames(frames map[FrameID]time.Time, plans [][]string, vault FrameID, cutoff time.Time) map[FrameID]bool { live := make(map[FrameID]bool, len(frames)) for _, plan := range plans { diff --git a/internal/blobstore/resume.go b/internal/blobstore/resume.go index 850d9e426c..4aa3c1cdc0 100644 --- a/internal/blobstore/resume.go +++ b/internal/blobstore/resume.go @@ -126,3 +126,8 @@ func (r *Resuming) Locate(ctx context.Context, rids []string) (map[string]Locati func (r *Resuming) Has(ctx context.Context, rids []string) ([]bool, error) { return r.Inner.Has(ctx, rids) } + +// GetFrameBounded passes through without resuming or widening a bounded read. +func (r *Resuming) GetFrameBounded(ctx context.Context, id string, maxEncoded int64) ([]byte, error) { + return ReadFrameBounded(ctx, r.Inner, id, maxEncoded) +} diff --git a/internal/cachedir/cachedir.go b/internal/cachedir/cachedir.go index aca52a6a0c..c9b99b7b54 100644 --- a/internal/cachedir/cachedir.go +++ b/internal/cachedir/cachedir.go @@ -1,7 +1,8 @@ // Package cachedir owns the one directory under codeaf's state root that holds // nothing but caches: ~/.codeaf/cache, where every task worker's toolchain // caches live (internal/exec's sweCacheRoot puts go-mod, go-build, cargo, npm -// and pip under it). +// and pip under it). The historical workspace-caches prefix remains part of +// this same cache owner until those admitted bindings are no longer used. // // The package exists so that "delete the cache" is one verb with one blast // radius, written down once. Everything in this directory is re-downloadable by @@ -16,6 +17,7 @@ package cachedir import ( + "encoding/hex" "io/fs" "os" "path/filepath" @@ -34,7 +36,42 @@ func Root() string { return home.Join("cache") } // unreadable entry is to promise less rather than fail the question. func Size() int64 { var total int64 - _ = filepath.WalkDir(Root(), func(_ string, entry fs.DirEntry, err error) error { + for _, root := range Locations() { + total += size(root) + } + return total +} + +// Locations lists the exact derived-cache directories covered by Size and +// explicit Clean. Runtime compatibility includes cache leaves only, never temp. +func Locations() []string { + roots := []string{Root()} + legacy := home.Join("workspace-caches") + if _, err := os.Lstat(legacy); err == nil { + roots = append(roots, legacy) + } + runtime := home.Join("workspace-runtime") + info, err := os.Lstat(runtime) + if err != nil || !info.IsDir() { + return roots + } + entries, _ := os.ReadDir(runtime) + for _, entry := range entries { + decoded, err := hex.DecodeString(entry.Name()) + if !entry.IsDir() || err != nil || len(decoded) != 32 { + continue + } + cache := filepath.Join(runtime, entry.Name(), "cache") + if _, err := os.Lstat(cache); err == nil { + roots = append(roots, cache) + } + } + return roots +} + +func size(root string) int64 { + var total int64 + _ = filepath.WalkDir(root, func(_ string, entry fs.DirEntry, err error) error { if err != nil || entry.IsDir() { return nil } @@ -46,7 +83,7 @@ func Size() int64 { return total } -// Clean deletes the cache directory whole and reports how many bytes that +// Clean deletes the cache locations whole and reports how many bytes that // freed. A cache that is already absent is zero bytes freed and no error. // // The chmod walk first is not decoration: Go's module cache is written @@ -56,8 +93,16 @@ func Size() int64 { // that fails is skipped rather than fatal, because the removal itself is about // to say definitively whether the tree could be taken down. func Clean() (int64, error) { - root := Root() freed := Size() + for _, root := range Locations() { + if err := clean(root); err != nil { + return 0, err + } + } + return freed, nil +} + +func clean(root string) error { _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err != nil || !entry.IsDir() { return nil @@ -67,10 +112,7 @@ func Clean() (int64, error) { } return nil }) - if err := os.RemoveAll(root); err != nil { - return 0, err - } - return freed, nil + return os.RemoveAll(root) } // Human is the byte figure as a person reads it: whole bytes under a kilobyte, diff --git a/internal/cachedir/legacy_test.go b/internal/cachedir/legacy_test.go new file mode 100644 index 0000000000..11f05744e9 --- /dev/null +++ b/internal/cachedir/legacy_test.go @@ -0,0 +1,108 @@ +package cachedir_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cachedir" + "github.com/Agent-Field/codeaf/internal/home" +) + +func TestCacheAccountingAndExplicitCleanIncludeLegacyProjectCache(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + caches := []string{filepath.Join(cachedir.Root(), "workspaces", "new", "cache", "compiler"), home.Join("workspace-caches", "legacy", "cache", "compiler")} + for _, path := range caches { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("cache bytes"), 0600); err != nil { + t.Fatal(err) + } + } + protected := []string{"v3/chats/example/transcript.jsonl", "task-storage/vault/archive.sqlite", "credentials.json", "projects/output.txt"} + for _, rel := range protected { + path := home.Join(rel) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("keep exact bytes"), 0600); err != nil { + t.Fatal(err) + } + } + if got := cachedir.Size(); got != 22 { + t.Fatalf("accounted cache=%d; want22 including retained legacy cache", got) + } + freed, err := cachedir.Clean() + if err != nil || freed != 22 { + t.Fatalf("explicit cache clean=(%d,%v)", freed, err) + } + for _, path := range caches { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("cache remains: %v", err) + } + } + for _, rel := range protected { + raw, err := os.ReadFile(home.Join(rel)) + if err != nil || string(raw) != "keep exact bytes" { + t.Fatalf("cache clean touched protected %s: %v", rel, err) + } + } +} + +func TestLegacyRuntimeCacheCleanPreservesTempAndIgnoresLinkedRuntime(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + runtime := home.Join("workspace-runtime") + leaf := filepath.Join(runtime, strings.Repeat("1", 64)) + cache := filepath.Join(leaf, "cache") + for _, dir := range []string{cache, filepath.Join(leaf, "temp")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(cache, "compiler"), []byte("derived"), 0600); err != nil { + t.Fatal(err) + } + temp := filepath.Join(leaf, "temp", "keep") + if err := os.WriteFile(temp, []byte("active scratch"), 0600); err != nil { + t.Fatal(err) + } + locations := cachedir.Locations() + if len(locations) != 2 || locations[1] != cache { + t.Fatalf("cleanup paths do not name exact legacy cache leaf: %v", locations) + } + if got := cachedir.Size(); got != 7 { + t.Fatalf("temporary bytes counted as cache: %d", got) + } + if _, err := cachedir.Clean(); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(temp); err != nil || string(raw) != "active scratch" { + t.Fatal("cache clean reached runtime temporary files") + } + outside := t.TempDir() + outsideCache := filepath.Join(outside, strings.Repeat("2", 64), "cache") + if err := os.MkdirAll(outsideCache, 0700); err != nil { + t.Fatal(err) + } + protected := filepath.Join(outsideCache, "keep") + if err := os.WriteFile(protected, []byte("not ours"), 0600); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(runtime); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, runtime); err != nil { + t.Skip(err) + } + if got := cachedir.Size(); got != 0 { + t.Fatalf("followed substituted runtime directory: %d", got) + } + if _, err := cachedir.Clean(); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(protected); err != nil || string(raw) != "not ours" { + t.Fatal("cache clean followed runtime directory link") + } +} diff --git a/internal/cellstore/engine.go b/internal/cellstore/engine.go index 7a789c90c3..ec4dc6b87c 100644 --- a/internal/cellstore/engine.go +++ b/internal/cellstore/engine.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" ) // dataDirEnv names where the engine keeps its store and catalog. One directory @@ -82,6 +83,23 @@ func (cs Composers) Compose(c cell.Cell) error { return nil } +// ComposeWorkspace supplies the current source project to carriers that need +// its baseline. Other carriers retain their ordinary Compose contract. +func (cs Composers) ComposeWorkspace(c cell.Cell, workspace string) error { + for _, one := range cs { + var err error + if scoped, ok := one.(interface{ ComposeWorkspace(cell.Cell, string) error }); ok { + err = scoped.ComposeWorkspace(c, workspace) + } else { + err = one.Compose(c) + } + if err != nil { + return err + } + } + return nil +} + // LocalDir holds only this cell's private WAL, overlays and transport scratch. // An older engine store there stays in place for compatibility (D1). func (e Engine) LocalDir(c cell.Cell) string { return filepath.Join(e.Root(), c.ID) } @@ -107,6 +125,11 @@ func (e Engine) Seal(ctx context.Context, c cell.Cell, info TurnInfo) (Sealed, e if err != nil { return Sealed{}, fmt.Errorf("seal: encode receipt: %w", err) } + release, err := taskcopy.LockWorkspaceCheckpoint(ctx, c) + if err != nil { + return Sealed{}, fmt.Errorf("seal: workspace checkpoint: %w", err) + } + defer release() if err := e.compose(c, raw, rid, info); err != nil { return Sealed{}, fmt.Errorf("seal: compose: %w", err) } diff --git a/internal/cellstore/materialize.go b/internal/cellstore/materialize.go index f611160e8c..85b9206e35 100644 --- a/internal/cellstore/materialize.go +++ b/internal/cellstore/materialize.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" ) // Capture snapshots the cell's folder as it stands and answers the snapshot @@ -18,6 +19,13 @@ func (e Engine) Capture(ctx context.Context, c cell.Cell) (string, error) { if err := e.recoverCompleted(ctx, c); err != nil { return "", err } + // Recovery can seal independently; acquire after it to avoid nesting this + // process lock while still fencing composition through the native snapshot. + release, err := taskcopy.LockWorkspaceCheckpoint(ctx, c) + if err != nil { + return "", err + } + defer release() if err := e.composeExtra(c); err != nil { return "", err } diff --git a/internal/cellstore/persist.go b/internal/cellstore/persist.go index e67195666e..8bf57b2f95 100644 --- a/internal/cellstore/persist.go +++ b/internal/cellstore/persist.go @@ -55,6 +55,9 @@ func (e Engine) composeExtra(c cell.Cell) error { if e.Compose == nil || e.cellDir(c) == "" { return nil } + if scoped, ok := e.Compose.(interface{ ComposeWorkspace(cell.Cell, string) error }); ok { + return scoped.ComposeWorkspace(c, e.workspace(c)) + } return e.Compose.Compose(c) } diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index a875153ab7..4b635def13 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -41,6 +41,7 @@ type SealWatch struct { Quiet time.Duration mu sync.Mutex idle bool + started bool tail *time.Timer cause string said []string @@ -93,11 +94,23 @@ func (w *SealWatch) say(sentence string) { w.said = append(w.said, sentence) } +// Waiting arms a newly constructed chat that is waiting for its first input. +// It schedules nothing until a write arrives. Once a turn has started or ended, +// calling it again cannot change that turn's state. +func (w *SealWatch) Waiting() { + w.mu.Lock() + defer w.mu.Unlock() + if !w.started { + w.idle = true + } +} + // TurnStarted is the session's word that a turn began: the turn writes the // record itself and every call seals it, so no tail seal is owed. func (w *SealWatch) TurnStarted() { w.mu.Lock() defer w.mu.Unlock() + w.started = true w.idle = false w.stopTail() } @@ -145,6 +158,7 @@ func (w *SealWatch) sealTail() { // forwards, so a watch without a listener costs nothing. func (w *SealWatch) TurnEnded() { w.mu.Lock() + w.started = true w.idle = true w.stopTail() w.mu.Unlock() diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go index 47d6630d6a..36900cf921 100644 --- a/internal/cellstore/sealwatch_test.go +++ b/internal/cellstore/sealwatch_test.go @@ -168,3 +168,31 @@ func TestWithNoticesSendsTheGuardsLineToTheSurface(t *testing.T) { t.Fatalf("guard notice went elsewhere: %q", said) } } + +func TestWaitingArmsOnlyTheInitialIdleChat(t *testing.T) { + tails := make(chan struct{}, 2) + ends := 0 + watch := &SealWatch{Quiet: 10 * time.Millisecond, OnTail: func() { tails <- struct{}{} }, OnTurnEnd: func() { ends++ }} + watch.Waiting() + if ends != 0 { + t.Fatal("initial waiting pretended a foreground turn ended") + } + watch.Wrote() + select { + case <-tails: + case <-time.After(time.Second): + t.Fatal("initial idle write was not sealed") + } + watch.TurnStarted() + watch.Waiting() // Startup notification cannot rearm a running foreground turn. + watch.Wrote() + select { + case <-tails: + t.Fatal("waiting rearmed an active turn") + case <-time.After(50 * time.Millisecond): + } + watch.TurnEnded() + if ends != 1 { + t.Fatal("actual turn end was lost") + } +} diff --git a/internal/cellstore/workspace_checkpoint_test.go b/internal/cellstore/workspace_checkpoint_test.go new file mode 100644 index 0000000000..93e9404266 --- /dev/null +++ b/internal/cellstore/workspace_checkpoint_test.go @@ -0,0 +1,73 @@ +package cellstore_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +type workspaceFenceTransport func(context.Context, cellstore.Target, cellstore.Op) ([]byte, error) + +func (f workspaceFenceTransport) Do(ctx context.Context, target cellstore.Target, op cellstore.Op) ([]byte, error) { + return f(ctx, target, op) +} + +type workspaceFenceComposer func(cell.Cell) error + +func (f workspaceFenceComposer) Compose(c cell.Cell) error { return f(c) } + +func TestWorkspaceCheckpointFenceCoversCompositionAndNativeCapture(t *testing.T) { + for _, capture := range []bool{false, true} { + t.Run(map[bool]string{false: "seal", true: "capture"}[capture], func(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + assertWriterWaits := func(at string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + unlock, err := taskcopy.LockWorkspaceCheckpoint(ctx, c) + if unlock != nil { + unlock() + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("checkpoint writer could change state during %s: %v", at, err) + } + } + composed, captured := false, false + engine := cellstore.Engine{DataRoot: t.TempDir(), Workspace: t.TempDir(), + Compose: workspaceFenceComposer(func(cell.Cell) error { + composed = true + assertWriterWaits("composition") + return nil + }), + Transport: workspaceFenceTransport(func(_ context.Context, _ cellstore.Target, op cellstore.Op) ([]byte, error) { + if op.Verb() == "seal" { + captured = true + assertWriterWaits("native capture") + } + if op.Verb() == "capture-plan" { + return []byte(`{"token":"fixture-plan","full":true,"paths":[]}`), nil + } + return []byte(`{"snapshot":"` + strings.Repeat("a", 64) + `","durable":true}`), nil + }), + } + if capture { + _, err = engine.Capture(context.Background(), c) + } else { + _, err = engine.Seal(context.Background(), c, cellstore.TurnInfo{}) + } + if err != nil || !composed || !captured { + t.Fatalf("capture path not exercised: composed=%v captured=%v error=%v", composed, captured, err) + } + }) + } +} diff --git a/internal/cellsync/fetch.go b/internal/cellsync/fetch.go index 73e5b6d533..d7b3a62b7c 100644 --- a/internal/cellsync/fetch.go +++ b/internal/cellsync/fetch.go @@ -25,8 +25,10 @@ const ImportEvery = 4 // Fetcher brings a head and everything it needs from the store onto this device. type Fetcher struct { - Engine Engine - Store blobstore.Store + // WarmFrameHints opts into bounded latest-frame priming; off by default. + WarmFrameHints bool + Engine Engine + Store blobstore.Store // Dir reads the cell's record for the plan (contract §22.2). Nil means no // priming: the fetch is exactly the want loop (rotation uses it that way). Dir directory.Client @@ -47,6 +49,9 @@ func (f *Fetcher) Fetch(ctx context.Context, c cell.Cell, head string) error { // device's store afterwards and the folder the person works in is not touched. // It is how a device holds a chat's objects without taking the chat over. func (f *Fetcher) Complete(ctx context.Context, c cell.Cell, head string) error { + if err := ctx.Err(); err != nil { + return err + } inbox := f.Inbox(c) if err := os.MkdirAll(inbox, 0o700); err != nil { return err @@ -126,15 +131,20 @@ func (f *Fetcher) prime(ctx context.Context, c cell.Cell, head, inbox string) er if f.Dir == nil { return nil // no directory, no plan: exactly today's want loop } - if err := os.MkdirAll(inbox, 0o700); err != nil { + owned, err := ensureHintInbox(filepath.Dir(inbox)) + if err != nil { return err } + inbox = owned defer os.RemoveAll(inbox) // A device that already exchanged objects with this relay holds most of the // chat: the plan names its whole upload history, so priming it would // download what it has to learn about a one-file change. The want loop // fetches a warm take's delta by rid. if f.Engine.Holds(c) { + if f.WarmFrameHints { + return f.primeWarm(ctx, c, head, inbox) + } return nil } // A device that already holds the head wants nothing, and needs no frames. diff --git a/internal/cellsync/frame_hints.go b/internal/cellsync/frame_hints.go new file mode 100644 index 0000000000..3f918e998a --- /dev/null +++ b/internal/cellsync/frame_hints.go @@ -0,0 +1,88 @@ +package cellsync + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" + "os" +) + +const frameHintBudget = directory.FrameHintBudget + +// latestFrameHints uses actual final uploaded frame sizes, including one reserved +// EOF/oversize probe per frame. Larger uploads simply have no warm optimization. +func latestFrameHints(frames []FrameFile, ids []string) []directory.FrameHint { + if len(frames) == 0 || len(frames) > directory.MaxFrameHints || len(frames) != len(ids) { + return nil + } + out := make([]directory.FrameHint, 0, len(frames)) + remaining := frameHintBudget + for i, f := range frames { + if f.Bytes <= 0 || f.Bytes >= remaining || !blobstore.ValidRID(ids[i]) { + return nil + } + remaining -= f.Bytes + 1 + out = append(out, directory.FrameHint{ID: ids[i], Bytes: f.Bytes}) + } + return out +} +func validWarmHints(hints []directory.FrameHint) bool { + if len(hints) == 0 || len(hints) > directory.MaxFrameHints { + return false + } + remaining := frameHintBudget + seen := map[string]bool{} + for _, hint := range hints { + if hint.Bytes <= 0 || hint.Bytes >= remaining || !blobstore.ValidRID(hint.ID) || seen[hint.ID] { + return false + } + remaining -= hint.Bytes + 1 + seen[hint.ID] = true + } + return true +} +func (f *Fetcher) primeWarm(ctx context.Context, c cell.Cell, head, inbox string) error { + want, err := f.Engine.Want(ctx, c, head) + if err != nil { + return err + } + if len(want) == 0 { + return nil + } + view, err := f.Dir.Cell(ctx, c.ID) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil || view.Cell.Head != head || !validWarmHints(view.Cell.FrameHints) { + return nil + } + for _, hint := range view.Cell.FrameHints { + raw, err := blobstore.ReadFrameBounded(ctx, f.Store, hint.ID, hint.Bytes) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return nil + } + _, objects, err := blobstore.Decode(raw) + if err != nil { + return nil + } + if err := keep(inbox, objects); err != nil { + return err + } + } + _, err = f.Engine.ImportPrimed(ctx, c, head, inbox) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + clearInbox(inbox) + } + return nil +} + +// ensureHintInbox gives every attempt an exclusive directory. No attempt can +// clear another fetcher's primed files while the native import reads them. +func ensureHintInbox(parent string) (string, error) { return os.MkdirTemp(parent, ".frame-prime-") } diff --git a/internal/cellsync/frame_hints_test.go b/internal/cellsync/frame_hints_test.go new file mode 100644 index 0000000000..5c43ce7327 --- /dev/null +++ b/internal/cellsync/frame_hints_test.go @@ -0,0 +1,135 @@ +package cellsync + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestWarmHintsUseProductionAdapterChainAndFallback(t *testing.T) { + for _, mode := range []string{"valid", "stale", "missing", "overflow", "unsupported"} { + t.Run(mode, func(t *testing.T) { + r := newRig(t) + r.pub.WarmFrameHints = true + first := r.publishFirst(map[string]string{"a": "original"}) + server := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return "fixture", "fixture", nil }, func(string) (blobstore.Store, error) { return r.mem, nil })) + defer server.Close() + counters := &blobstore.Counters{} + store := ObserveStore(&blobstore.Resuming{Inner: blobstore.Counting{Inner: blobstore.NewHTTP(server.URL, func(*http.Request, []byte) {}, server.Client()), C: counters}}, func(TraceEvent) {}) + f, c := fetcherFor(t, store) + f.Dir = r.dir.For(devB) + f.WarmFrameHints = true + if err := f.Fetch(context.Background(), c, first); err != nil { + t.Fatal(err) + } + second := r.seal(map[string]string{"a": "updated", "b": "new output"}) + if err := r.pub.Publish(context.Background(), r.drv, second, r.info()); err != nil { + t.Fatal(err) + } + view := r.head(cellID) + if len(view.FrameHints) == 0 { + t.Fatal("opt-in publisher omitted latest small frames") + } + for _, hint := range view.FrameHints { + raw, err := r.mem.GetFrame(context.Background(), hint.ID) + if err != nil || int64(len(raw)) != hint.Bytes { + t.Fatalf("descriptor not exact uploaded frame: %v", err) + } + } + before := counters.Gets.Load() + switch mode { + case "stale": + second = first + case "missing": + _, err := r.dir.For(devA).Publish(context.Background(), cellID, directory.Publish{Fence: r.drv.Fence, OldHead: second, Head: second}) + if err != nil { + t.Fatal(err) + } + case "overflow": + _, err := r.dir.For(devA).Publish(context.Background(), cellID, directory.Publish{Fence: r.drv.Fence, OldHead: second, Head: second, FrameHints: []directory.FrameHint{{ID: view.FrameHints[0].ID, Bytes: math.MaxInt64}}}) + if err != nil { + t.Fatal(err) + } + case "unsupported": + f.Store = r.mem + } + if err := f.Fetch(context.Background(), c, second); err != nil { + t.Fatal(err) + } + want := "updated" + if mode == "stale" { + want = "original" + } + got, err := os.ReadFile(filepath.Join(c.Root, "a")) + if err != nil || string(got) != want { + t.Fatalf("fallback materialization %q: %v", got, err) + } + if mode == "valid" && counters.Gets.Load()-before != 1 { + t.Fatalf("warm delta expected one bounded frame fetch, got %d", counters.Gets.Load()-before) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := f.Fetch(ctx, c, second); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel lost: %v", err) + } + }) + } +} + +type cancelHintStore struct { + blobstore.Store + cancel context.CancelFunc + bounded, unbounded int +} + +func (s *cancelHintStore) GetFrame(context.Context, string) ([]byte, error) { + s.unbounded++ + return nil, errors.New("must not fall back") +} +func (s *cancelHintStore) GetFrameBounded(ctx context.Context, id string, _ int64) ([]byte, error) { + s.bounded++ + raw, err := s.Store.GetFrame(ctx, id) + s.cancel() + return raw, err +} +func TestWarmHintsCancellationRemovesOwnedAttempt(t *testing.T) { + r := newRig(t) + r.pub.WarmFrameHints = true + first := r.publishFirst(map[string]string{"a": "one"}) + f, c := fetcherFor(t, r.mem) + f.Dir = r.dir.For(devB) + if err := f.Fetch(context.Background(), c, first); err != nil { + t.Fatal(err) + } + next := r.seal(map[string]string{"a": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, next, r.info()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := &cancelHintStore{Store: r.mem, cancel: cancel} + f.Store = store + f.WarmFrameHints = true + if err := f.Fetch(ctx, c, next); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel lost: %v", err) + } + if store.bounded != 1 || store.unbounded != 0 { + t.Fatal("bounded capability not preserved") + } + entries, err := os.ReadDir(filepath.Dir(f.Inbox(c))) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() && entry.Name() != c.ID { + t.Fatalf("retained attempt inbox %s", entry.Name()) + } + } +} diff --git a/internal/cellsync/held.go b/internal/cellsync/held.go index f7dc84df14..de031f0064 100644 --- a/internal/cellsync/held.go +++ b/internal/cellsync/held.go @@ -58,7 +58,9 @@ func (p *Publisher) holdings(ctx context.Context, frames []FrameFile) (map[strin return nil, err } for i, h := range have { - held[rids[i]] = h + if h { + held[rids[i]] = true + } } rids = rids[n:] } diff --git a/internal/cellsync/held_test.go b/internal/cellsync/held_test.go index 5c5c6223c4..bce0f4bd3d 100644 --- a/internal/cellsync/held_test.go +++ b/internal/cellsync/held_test.go @@ -2,6 +2,7 @@ package cellsync import ( "context" + "errors" "strings" "testing" @@ -45,3 +46,42 @@ func TestAFreshLedgerDoesNotResendWhatTheStoreHolds(t *testing.T) { t.Fatalf("after the publish the ledger still lacks %d objects (%v)", again.Objects, err) } } + +// A new relay holds no objects. Recording a held subset would add a native +// bookkeeping round trip and an avoidable error before the ordinary upload. +func TestEmptyRelaySkipsHeldObjectBookkeeping(t *testing.T) { + ctx := context.Background() + c := cell.Cell{ID: cellID, Root: t.TempDir()} + engine := NewFakeEngine(t.TempDir()) + checked := &nonemptyPublishedEngine{Engine: engine} + store := blobstore.NewMemory() + publisher := Publisher{Engine: checked, Store: store} + head := engine.Seal(c, map[string]string{"mock.txt": strings.Repeat("mock fresh relay content\n", 8192)}) + exported, err := publisher.Upload(ctx, c, head) + if err != nil { + t.Fatal(err) + } + if exported.Bytes < askFloor { + t.Fatal("fixture does not exercise the holdings question") + } + if checked.emptyRecords != 0 { + t.Fatalf("new relay caused %d empty native bookkeeping calls", checked.emptyRecords) + } + again, err := engine.Export(ctx, c, head) + if err != nil || again.Objects != 0 { + t.Fatalf("uploaded objects not recorded: %d, %v", again.Objects, err) + } +} + +type nonemptyPublishedEngine struct { + Engine + emptyRecords int +} + +func (e *nonemptyPublishedEngine) Published(ctx context.Context, c cell.Cell, paths []string) error { + if len(paths) == 0 { + e.emptyRecords++ + return errors.New("native engine requires at least one published frame") + } + return e.Engine.Published(ctx, c, paths) +} diff --git a/internal/cellsync/publish.go b/internal/cellsync/publish.go index 6366879717..a33587db66 100644 --- a/internal/cellsync/publish.go +++ b/internal/cellsync/publish.go @@ -18,9 +18,11 @@ var ErrSuperseded = fmt.Errorf("cellsync: another device continued this chat: %w // Publisher makes turns durable: objects into the store first, the head in the // directory last. Dir is already bound to this device. type Publisher struct { - Engine Engine - Store blobstore.Store - Dir directory.Client + // WarmFrameHints opts into the bounded existing-frame prototype. + WarmFrameHints bool + Engine Engine + Store blobstore.Store + Dir directory.Client } // Driving is the state of one cell this device is driving. @@ -47,6 +49,7 @@ func (d Driving) ID() string { // PublishInfo is what a publish tells the directory about the cell. type PublishInfo struct { + hints []directory.FrameHint Class, Title string Size uint64 Pending uint32 @@ -67,6 +70,9 @@ func (p *Publisher) publish(ctx context.Context, d *Driving, head string, in Pub if err != nil { return Export{}, err } + if p.WarmFrameHints { + in.hints = latestFrameHints(ex.Frames, frames) + } frames = unionFrames(d.Frames, frames) fence, err := p.advance(ctx, *d, head, in, frames) if err != nil { @@ -112,10 +118,11 @@ func (p *Publisher) uploadPlan(ctx context.Context, c cell.Cell, head string) (E ids := make([]string, 0, len(ex.Frames)) paths := make([]string, len(ex.Frames)) for i, f := range ex.Frames { - id, err := p.put(ctx, f) + id, actualBytes, err := p.put(ctx, f) if err != nil { return Export{}, nil, err } + ex.Frames[i].Bytes = actualBytes ids = append(ids, id) paths[i] = f.Path } @@ -127,12 +134,13 @@ func (p *Publisher) uploadPlan(ctx context.Context, c cell.Cell, head string) (E // put stores one frame and answers the id the store named it, which is what // the plan records: the store's own name for the frame, not a local guess. -func (p *Publisher) put(ctx context.Context, f FrameFile) (string, error) { +func (p *Publisher) put(ctx context.Context, f FrameFile) (string, int64, error) { frame, err := os.ReadFile(f.Path) if err != nil { - return "", err + return "", 0, err } - return p.Store.PutFrame(ctx, frame) + id, err := p.Store.PutFrame(ctx, frame) + return id, int64(len(frame)), err } // advance moves the directory head and answers the fence now held. @@ -152,7 +160,7 @@ func (p *Publisher) move(ctx context.Context, d Driving, head string, in Publish _, err := p.Dir.Publish(ctx, d.ID(), directory.Publish{ Fence: d.Fence, OldHead: d.Head, Head: head, Size: in.Size, Class: in.Class, Title: in.Title, Pending: in.Pending, - Frames: frames, + Frames: frames, FrameHints: in.hints, }) return d.Fence, refusal(err) } @@ -179,7 +187,7 @@ func (p *Publisher) resume(ctx context.Context, d Driving, head string, in Publi func (p *Publisher) create(ctx context.Context, d Driving, head string, in PublishInfo, frames []string) (uint64, error) { v, err := p.Dir.Create(ctx, d.ID(), directory.CellInit{ Head: head, Class: in.Class, Title: in.Title, Size: in.Size, Keys: in.Keys, - Frames: frames, + Frames: frames, FrameHints: in.hints, }) return v.Cell.Lease.Fence, err } diff --git a/internal/cellsync/trace.go b/internal/cellsync/trace.go new file mode 100644 index 0000000000..d2eb77819c --- /dev/null +++ b/internal/cellsync/trace.go @@ -0,0 +1,115 @@ +package cellsync + +import ( + "context" + "encoding/json" + "io" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +// TraceEvent records operation metadata only. RawBytes is unknown at encrypted +// seams; nil is different from a measured zero. HTTP body bytes exclude headers. +type TraceEvent struct { + Timestamp time.Time `json:"ts"` + HTTPStatus int `json:"http_status,omitempty"` + Version int `json:"v"` + Sequence uint64 `json:"sequence"` + Op string `json:"op"` + Layer string `json:"layer"` + Phase string `json:"phase,omitempty"` + Class string `json:"class"` + RID string `json:"rid,omitempty"` + Objects int `json:"objects,omitempty"` + RawBytes *int64 `json:"raw_bytes"` + PayloadBytes int64 `json:"payload_bytes,omitempty"` + SentBytes int64 `json:"sent_body_bytes,omitempty"` + ReceivedBytes int64 `json:"received_body_bytes,omitempty"` + DurationNS int64 `json:"duration_ns"` + Outcome string `json:"outcome"` + Gate string `json:"gate,omitempty"` + HeldLocally *bool `json:"held_locally,omitempty"` + Seed int64 `json:"seed,omitempty"` + LogicalBytes int64 `json:"workload_logical_bytes,omitempty"` + Files int `json:"workload_files,omitempty"` + Attempt int `json:"attempt"` +} + +// Observer is optional. Observers must not inspect payloads or attach paths. +type Observer func(TraceEvent) + +// JSONLTrace writes operation records synchronously only when explicitly used. +// Writer failures are reported separately; they never replace a sync result. +type JSONLTrace struct { + mu sync.Mutex + writer io.Writer + sequence uint64 + err error +} + +func NewJSONLTrace(writer io.Writer) *JSONLTrace { return &JSONLTrace{writer: writer} } +func (t *JSONLTrace) Observe(event TraceEvent) { + t.mu.Lock() + defer t.mu.Unlock() + t.sequence++ + event.Version = 1 + event.Sequence = t.sequence + if event.Timestamp.IsZero() { + event.Timestamp = time.Now() + } + event.Attempt = max(1, event.Attempt) + if event.Class == "" { + event.Class = "unknown" + } + if event.Outcome == "" { + event.Outcome = "ok" + } + if t.err == nil { + t.err = json.NewEncoder(t.writer).Encode(event) + } +} +func (t *JSONLTrace) Err() error { t.mu.Lock(); defer t.mu.Unlock(); return t.err } + +type traceContext struct { + phase string + attempt int +} +type traceKey struct{} + +// TracePhase identifies a fixed benchmark phase, not a user-provided label. +func TracePhase(ctx context.Context, phase string, attempt int) context.Context { + switch phase { + case "publish", "cold", "warm", "calibration": + default: + phase = "unknown" + } + return context.WithValue(ctx, traceKey{}, traceContext{phase: phase, attempt: max(1, attempt)}) +} +func observed(observer Observer, ctx context.Context, event TraceEvent, start time.Time, err error) { + if observer == nil { + return + } + if event.RID != "" && !blobstore.ValidRID(event.RID) { + event.RID = "" + } + event.Version = 1 + event.Timestamp = start + event.DurationNS = time.Since(start).Nanoseconds() + if event.Outcome == "" { + event.Outcome = "ok" + } + if err != nil { + event.Outcome = "error" + } + if (err != nil || event.Outcome == "incomplete") && ctx.Err() != nil { + event.Outcome = "cancelled" + } + state, _ := ctx.Value(traceKey{}).(traceContext) + event.Phase = state.phase + event.Attempt = max(1, state.attempt) + // An optional diagnostic callback cannot change transport behavior by panicking. + defer func() { _ = recover() }() + observer(event) +} diff --git a/internal/cellsync/trace_engine.go b/internal/cellsync/trace_engine.go new file mode 100644 index 0000000000..a29f5995c9 --- /dev/null +++ b/internal/cellsync/trace_engine.go @@ -0,0 +1,70 @@ +package cellsync + +import ( + "context" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// ObserveEngine times native operations without changing the completeness gate. +// Native materialization is not proof of an application's G1/G2 UI readiness. +func ObserveEngine(inner Engine, observer Observer) Engine { + if observer == nil { + return inner + } + return tracedEngine{Engine: inner, observer: observer} +} + +type tracedEngine struct { + Engine + observer Observer +} + +func (e tracedEngine) Import(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + start := time.Now() + out, err := e.Engine.Import(ctx, c, head, inbox) + observed(e.observer, ctx, TraceEvent{Op: "engine.import", Layer: "engine", Class: "unknown", Objects: out}, start, err) + return out, err +} +func (e tracedEngine) ImportPrimed(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + start := time.Now() + out, err := e.Engine.ImportPrimed(ctx, c, head, inbox) + observed(e.observer, ctx, TraceEvent{Op: "engine.importprimed", Layer: "engine", Class: "unknown", Objects: out}, start, err) + return out, err +} +func (e tracedEngine) ImportPartial(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + start := time.Now() + out, err := e.Engine.ImportPartial(ctx, c, head, inbox) + observed(e.observer, ctx, TraceEvent{Op: "engine.importpartial", Layer: "engine", Class: "unknown", Objects: out}, start, err) + return out, err +} +func (e tracedEngine) Want(ctx context.Context, c cell.Cell, head string) ([]string, error) { + start := time.Now() + out, err := e.Engine.Want(ctx, c, head) + held := err == nil && len(out) == 0 + observed(e.observer, ctx, TraceEvent{Op: "engine.want", Layer: "engine", Class: "control", Objects: len(out), HeldLocally: &held}, start, err) + return out, err +} +func (e tracedEngine) Export(ctx context.Context, c cell.Cell, head string) (Export, error) { + start := time.Now() + out, err := e.Engine.Export(ctx, c, head) + observed(e.observer, ctx, TraceEvent{Op: "engine.export", Layer: "engine", Class: "encrypted_frames", Objects: out.Objects, PayloadBytes: out.Bytes}, start, err) + return out, err +} +func (e tracedEngine) Published(ctx context.Context, c cell.Cell, frames []string) error { + start := time.Now() + err := e.Engine.Published(ctx, c, frames) + observed(e.observer, ctx, TraceEvent{Op: "engine.published", Layer: "engine", Class: "control", Objects: len(frames)}, start, err) + return err +} +func (e tracedEngine) Materialize(ctx context.Context, c cell.Cell, head string) error { + start := time.Now() + err := e.Engine.Materialize(ctx, c, head) + gate := "" + if err == nil { + gate = "G3_native_materialized" + } + observed(e.observer, ctx, TraceEvent{Op: "engine.materialize", Layer: "engine", Class: "unknown", Gate: gate}, start, err) + return err +} diff --git a/internal/cellsync/trace_store.go b/internal/cellsync/trace_store.go new file mode 100644 index 0000000000..1ab053a695 --- /dev/null +++ b/internal/cellsync/trace_store.go @@ -0,0 +1,183 @@ +package cellsync + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +// ObserveStore is opt-in. The nil path preserves the exact underlying store. +// Counts at this seam are encrypted payload bytes, not HTTP wire bytes. +func ObserveStore(inner blobstore.Store, observer Observer) blobstore.Store { + if observer == nil { + return inner + } + return tracedStore{Store: inner, observer: observer} +} + +type tracedStore struct { + blobstore.Store + observer Observer +} + +func (s tracedStore) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + start := time.Now() + id, err := s.Store.PutFrame(ctx, frame) + header, _, _ := blobstore.Decode(frame) + observed(s.observer, ctx, TraceEvent{Op: "store.put_frame", Layer: "store", Class: "encrypted_frame", RID: id, Objects: len(header.Objects), PayloadBytes: int64(len(frame))}, start, err) + return id, err +} +func (s tracedStore) Get(ctx context.Context, rid string) ([]byte, error) { + start := time.Now() + out, err := s.Store.Get(ctx, rid) + observed(s.observer, ctx, TraceEvent{Op: "store.get", Layer: "store", Class: "encrypted_object", RID: rid, Objects: 1, PayloadBytes: int64(len(out))}, start, err) + return out, err +} +func (s tracedStore) GetFrame(ctx context.Context, id string) ([]byte, error) { + start := time.Now() + out, err := s.Store.GetFrame(ctx, id) + observed(s.observer, ctx, TraceEvent{Op: "store.get_frame", Layer: "store", Class: "encrypted_frame", RID: id, PayloadBytes: int64(len(out))}, start, err) + return out, err +} +func (s tracedStore) GetMany(ctx context.Context, rids []string) ([]blobstore.Object, error) { + start := time.Now() + out, err := s.Store.GetMany(ctx, rids) + var size int64 + for _, object := range out { + size += int64(len(object.Bytes)) + } + observed(s.observer, ctx, TraceEvent{Op: "store.get_many", Layer: "store", Class: "encrypted_objects", Objects: len(out), PayloadBytes: size}, start, err) + return out, err +} +func (s tracedStore) Has(ctx context.Context, rids []string) ([]bool, error) { + start := time.Now() + out, err := s.Store.Has(ctx, rids) + observed(s.observer, ctx, TraceEvent{Op: "store.has", Layer: "store", Class: "control", Objects: len(rids)}, start, err) + return out, err +} +func (s tracedStore) Locate(ctx context.Context, rids []string) (map[string]blobstore.Location, error) { + start := time.Now() + out, err := s.Store.Locate(ctx, rids) + observed(s.observer, ctx, TraceEvent{Op: "store.locate", Layer: "store", Class: "control", Objects: len(rids)}, start, err) + return out, err +} + +// ObserveHTTP counts request/response BODY bytes separately from decoded object +// bytes. It logs no URL, header, authentication, body, or error text. Requests +// and streaming bodies are not read ahead or retried by this adapter. +func ObserveHTTP(inner http.RoundTripper, observer Observer) http.RoundTripper { + if observer == nil { + return inner + } + if inner == nil { + inner = http.DefaultTransport + } + return tracedHTTP{inner: inner, observer: observer} +} + +type tracedHTTP struct { + inner http.RoundTripper + observer Observer +} +type countedBody struct { + io.ReadCloser + count *atomic.Int64 + finish func(error, bool) +} + +func (b countedBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.count.Add(int64(n)) + if err != nil && b.finish != nil { + b.finish(err, err == io.EOF) + } + return n, err +} +func (b countedBody) Close() error { + err := b.ReadCloser.Close() + if b.finish != nil { + b.finish(err, false) + } + return err +} +func (t tracedHTTP) RoundTrip(request *http.Request) (*http.Response, error) { + start := time.Now() + var sent, received atomic.Int64 + copy := request.Clone(request.Context()) + if copy.Body != nil { + copy.Body = countedBody{ReadCloser: copy.Body, count: &sent} + } + if copy.GetBody != nil { + reopen := copy.GetBody + copy.GetBody = func() (io.ReadCloser, error) { + body, err := reopen() + if err != nil || body == nil { + return body, err + } + return countedBody{ReadCloser: body, count: &sent}, nil + } + } + response, err := t.inner.RoundTrip(copy) + class := "unknown" + switch { + case strings.HasPrefix(request.URL.Path, "/v1/dir/"), request.URL.Path == "/v1/store/has", request.URL.Path == "/v1/store/locate": + class = "control" + case strings.HasPrefix(request.URL.Path, "/v1/store/"): + class = "encrypted_bulk" + case strings.HasPrefix(request.URL.Path, "/calibration/"): + class = "calibration" + } + status := 0 + if response != nil { + status = response.StatusCode + } + var once sync.Once + finish := func(bodyErr error, eof bool) { + once.Do(func() { + if bodyErr == io.EOF { + bodyErr = nil + } + if err != nil { + bodyErr = err + } + outcome := "ok" + if status < 200 || status >= 300 { + outcome = "http_error" + } + complete := eof || response != nil && response.ContentLength >= 0 && received.Load() == response.ContentLength + if bodyErr == nil && !complete { + outcome = "incomplete" + } + observed(t.observer, request.Context(), TraceEvent{Op: "http.request", Layer: "http_body", Class: class, HTTPStatus: status, Outcome: outcome, SentBytes: sent.Load(), ReceivedBytes: received.Load()}, start, bodyErr) + }) + } + if err != nil { + finish(err, false) + } else if response == nil || response.Body == nil { + finish(nil, true) + } else { + response.Body = countedBody{ReadCloser: response.Body, count: &received, finish: finish} + } + return response, err +} + +// CloseIdleConnections preserves the HTTP client's existing cleanup capability. +func (t tracedHTTP) CloseIdleConnections() { + if closer, ok := t.inner.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} + +// GetFrameBounded preserves optional capabilities through the observer adapter. +func (s tracedStore) GetFrameBounded(ctx context.Context, id string, maxEncoded int64) ([]byte, error) { + start := time.Now() + out, err := blobstore.ReadFrameBounded(ctx, s.Store, id, maxEncoded) + observed(s.observer, ctx, TraceEvent{Op: "store.get_frame", Layer: "store", Class: "encrypted_frame", RID: id, PayloadBytes: int64(len(out))}, start, err) + return out, err +} diff --git a/internal/cellsync/trace_summary.go b/internal/cellsync/trace_summary.go new file mode 100644 index 0000000000..2c72ce68bb --- /dev/null +++ b/internal/cellsync/trace_summary.go @@ -0,0 +1,159 @@ +package cellsync + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +// TraceSummary analyzes saved metadata without replaying network operations. +// Seeded workload metadata replays the fixture; opaque IDs are not payloads. +type TraceSummary struct { + Phases map[string]*TracePhaseSummary `json:"phases"` + LogicalBytes int64 `json:"workload_logical_bytes"` + Seed int64 `json:"seed"` + Files int `json:"files"` +} +type TracePhaseSummary struct { + NativeWallNS int64 `json:"native_operation_wall_ns"` + HTTPWallNS int64 `json:"http_operation_wall_ns_not_additive_with_parallel_requests"` + Operations map[string]int `json:"operations"` + HTTPRequests int `json:"http_requests"` + HTTPBodies map[string]TraceBodyBytes `json:"http_body_bytes_by_class"` + StorePayloadUp int64 `json:"encrypted_store_payload_up"` + StorePayloadDown int64 `json:"encrypted_store_payload_down"` + WantCalls int `json:"native_want_calls"` + WantNonempty int `json:"nonempty_native_frontiers"` + GetManyBatches int `json:"get_many_batches"` + GetManyObjects int `json:"get_many_objects"` + Outcomes map[string]int `json:"outcomes"` +} +type TraceBodyBytes struct { + Sent int64 `json:"sent"` + Received int64 `json:"received"` +} + +// ReadTraceSummary streams JSONL. HTTP body and decoded Store payload counts +// are different layers and intentionally never added into a misleading total. +func ReadTraceSummary(reader io.Reader) (TraceSummary, error) { + result := TraceSummary{Phases: map[string]*TracePhaseSummary{}} + var previousSequence uint64 + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 4096), 64<<10) + for scanner.Scan() { + event, err := decodeTraceEvent(scanner.Bytes()) + if err != nil { + return result, err + } + if event.Sequence <= previousSequence { + return result, errors.New("trace sequence is not strictly increasing") + } + previousSequence = event.Sequence + if event.Op == "workload" { + result.Seed = event.Seed + result.Files = event.Files + result.LogicalBytes = event.LogicalBytes + continue + } + phase := result.Phases[event.Phase] + if phase == nil { + phase = &TracePhaseSummary{Operations: map[string]int{}, HTTPBodies: map[string]TraceBodyBytes{}, Outcomes: map[string]int{}} + result.Phases[event.Phase] = phase + } + phase.add(event) + } + if err := scanner.Err(); err != nil { + return result, fmt.Errorf("read operation trace: %w", err) + } + return result, nil +} + +func decodeTraceEvent(line []byte) (TraceEvent, error) { + var event TraceEvent + decoder := json.NewDecoder(bytes.NewReader(line)) + decoder.DisallowUnknownFields() + if decoder.Decode(&event) != nil { + return event, errors.New("invalid operation trace event") + } + var extra any + if !errors.Is(decoder.Decode(&extra), io.EOF) || !validTraceEvent(event) { + return event, errors.New("unsupported operation trace schema or metadata") + } + return event, nil +} + +func validTraceEvent(event TraceEvent) bool { + if event.DurationNS < 0 || event.RawBytes != nil && *event.RawBytes < 0 { + return false + } + if event.Version != 1 || event.Objects < 0 || event.PayloadBytes < 0 || event.SentBytes < 0 || event.ReceivedBytes < 0 || event.LogicalBytes < 0 || event.Files < 0 { + return false + } + switch event.Op { + case "workload", "http.request", "store.put_frame", "store.get", "store.get_frame", "store.get_many", "store.has", "store.locate", "engine.want", "engine.export", "engine.published", "engine.materialize", "engine.import", "engine.importprimed", "engine.importpartial": + default: + return false + } + switch event.Phase { + case "", "unknown", "cold", "warm", "publish", "calibration": + default: + return false + } + switch event.Layer { + case "", "fixture", "store", "engine", "http_body": + default: + return false + } + switch event.Class { + case "unknown", "control", "encrypted_bulk", "calibration", "encrypted_frame", "encrypted_frames", "encrypted_object", "encrypted_objects", "unknown_encrypted_or_control", "mixed_fixture": + default: + return false + } + switch event.Outcome { + case "ok", "error", "cancelled", "incomplete", "http_error": + default: + return false + } + switch event.Gate { + case "", "G3_native_materialized": + default: + return false + } + return event.RID == "" || blobstore.ValidRID(event.RID) +} + +func (phase *TracePhaseSummary) add(event TraceEvent) { + if event.Layer == "engine" { + phase.NativeWallNS += event.DurationNS + } + phase.Operations[event.Op]++ + phase.Outcomes[event.Outcome]++ + switch event.Op { + case "http.request": + phase.HTTPRequests++ + phase.HTTPWallNS += event.DurationNS + body := phase.HTTPBodies[event.Class] + body.Sent += event.SentBytes + body.Received += event.ReceivedBytes + phase.HTTPBodies[event.Class] = body + case "store.put_frame": + phase.StorePayloadUp += event.PayloadBytes + case "store.get", "store.get_frame", "store.get_many": + phase.StorePayloadDown += event.PayloadBytes + } + if event.Op == "engine.want" { + phase.WantCalls++ + if event.Objects > 0 { + phase.WantNonempty++ + } + } + if event.Op == "store.get_many" { + phase.GetManyBatches++ + phase.GetManyObjects += event.Objects + } +} diff --git a/internal/cellsync/trace_test.go b/internal/cellsync/trace_test.go new file mode 100644 index 0000000000..72774af1dd --- /dev/null +++ b/internal/cellsync/trace_test.go @@ -0,0 +1,288 @@ +package cellsync_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellsync" +) + +func TestTraceIsOptInAndPreservesBulkAnswersAndErrors(t *testing.T) { + store := blobstore.NewMemory() + if cellsync.ObserveStore(store, nil) != store { + t.Fatal("nil observer changes store identity/capabilities") + } + var output bytes.Buffer + trace := cellsync.NewJSONLTrace(&output) + observed := cellsync.ObserveStore(store, trace.Observe) + rid := strings.Repeat("1", 64) + frame, err := blobstore.Encode(strings.Repeat("2", 32), []blobstore.Object{{RID: rid, Bytes: append([]byte("AGEO\x01"), []byte("encrypted fixture")...)}}) + if err != nil { + t.Fatal(err) + } + if _, err := observed.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + want, err := store.GetMany(context.Background(), []string{rid}) + if err != nil { + t.Fatal(err) + } + got, err := observed.GetMany(context.Background(), []string{rid}) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatal("tracing changed bulk response") + } + _, before := store.Get(context.Background(), strings.Repeat("3", 64)) + _, after := observed.Get(context.Background(), strings.Repeat("3", 64)) + if !errors.Is(after, before) { + t.Fatal("trace changed error identity") + } + if trace.Err() != nil || strings.Contains(output.String(), "encrypted fixture") { + t.Fatal("trace writes payload or failed") + } + if !strings.Contains(output.String(), `"raw_bytes":null`) { + t.Fatal("unknown raw bytes presented as measured") + } + panicking := cellsync.ObserveStore(store, func(cellsync.TraceEvent) { panic("diagnostic failed") }) + if _, err := panicking.GetMany(context.Background(), []string{rid}); err != nil { + t.Fatal("observer failure changed operation") + } +} + +type failingTraceWriter struct{} + +func (failingTraceWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestTraceWriterFailureAndCancellationDoNotReplaceOperationResults(t *testing.T) { + memory := blobstore.NewMemory() + rid := strings.Repeat("1", 64) + trace := cellsync.NewJSONLTrace(failingTraceWriter{}) + store := cellsync.ObserveStore(memory, trace.Observe) + original := errors.New("same operation error") + memory.FailAfter(0, original) + _, err := store.Get(context.Background(), rid) + if err != original || !errors.Is(trace.Err(), io.ErrClosedPipe) { + t.Fatal("trace writer replaced error or hid diagnostic failure") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var got cellsync.TraceEvent + wrapped := cellsync.ObserveStore(cancelledStore{Store: memory}, func(event cellsync.TraceEvent) { got = event }) + _, err = wrapped.GetMany(cellsync.TracePhase(ctx, "warm", 2), []string{rid}) + if err != context.Canceled || got.Outcome != "cancelled" || got.Attempt != 2 { + t.Fatalf("cancellation/retry trace changed: %+v %v", got, err) + } +} + +type cancelledStore struct{ blobstore.Store } + +func (s cancelledStore) GetMany(ctx context.Context, _ []string) ([]blobstore.Object, error) { + return nil, ctx.Err() +} + +func TestHTTPTraceCountsBodyBytesAndDoesNotReadAhead(t *testing.T) { + const body = "wire fixture body" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _, _ = io.WriteString(w, body) + })) + defer server.Close() + var event cellsync.TraceEvent + calls := 0 + client := &http.Client{Transport: cellsync.ObserveHTTP(http.DefaultTransport, func(got cellsync.TraceEvent) { event = got; calls++ })} + request, _ := http.NewRequest("POST", server.URL+"/private-path-not-to-log", strings.NewReader("request")) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatal("trace read response before caller") + } + got, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || string(got) != body || calls != 1 || event.SentBytes != 7 || event.ReceivedBytes != int64(len(body)) { + t.Fatalf("body trace changed stream/counts: %+v %v", event, err) + } + encoded, _ := json.Marshal(event) + if strings.Contains(string(encoded), "private-path") || strings.Contains(string(encoded), body) { + t.Fatal("wire trace exposed content or path") + } +} + +func TestNilTraceAdaptersPreserveIdentityWithoutAllocations(t *testing.T) { + memory := blobstore.NewMemory() + engine := cellsync.NewFakeEngine(t.TempDir()) + transport := http.DefaultTransport + allocations := testing.AllocsPerRun(100, func() { + if cellsync.ObserveStore(memory, nil) != memory || cellsync.ObserveEngine(engine, nil) != engine || cellsync.ObserveHTTP(transport, nil) != transport { + t.Fatal("nil tracing changes capability") + } + }) + if allocations != 0 { + t.Fatalf("disabled tracing allocates: %v", allocations) + } +} + +type traceRoundTrip func(*http.Request) (*http.Response, error) + +func (f traceRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type traceBrokenBody struct{ err error } + +func (b traceBrokenBody) Read([]byte) (int, error) { return 0, b.err } +func (b traceBrokenBody) Close() error { return nil } + +func TestHTTPTraceDistinguishesServerFailureTruncationAndBodyError(t *testing.T) { + sentinel := errors.New("private body error must not appear in trace") + cases := []struct { + name string + status int + body io.ReadCloser + readAll bool + outcome string + readError error + }{ + {"server", 500, io.NopCloser(strings.NewReader("error reply")), true, "http_error", nil}, + {"early-close", 200, io.NopCloser(strings.NewReader("unread reply")), false, "incomplete", nil}, + {"body-error", 200, traceBrokenBody{sentinel}, true, "error", sentinel}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var event cellsync.TraceEvent + calls := 0 + inner := traceRoundTrip(func(request *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: test.status, Body: test.body, ContentLength: -1, Header: make(http.Header), Request: request}, nil + }) + client := &http.Client{Transport: cellsync.ObserveHTTP(inner, func(got cellsync.TraceEvent) { event = got; calls++ })} + response, err := client.Get("http://fixture.invalid/private-path") + if err != nil { + t.Fatal(err) + } + if test.readAll { + _, err = io.ReadAll(response.Body) + if err != test.readError { + t.Fatalf("trace replaced body error: %v", err) + } + } + response.Body.Close() + if calls != 1 || event.Outcome != test.outcome || event.HTTPStatus != test.status { + t.Fatalf("HTTP result misreported: %+v", event) + } + raw, _ := json.Marshal(event) + if bytes.Contains(raw, []byte(sentinel.Error())) { + t.Fatal("body error text leaked into trace") + } + }) + } +} + +func TestSavedTraceAnalysisSeparatesWirePayloadAndNativeBarriers(t *testing.T) { + var data bytes.Buffer + sink := cellsync.NewJSONLTrace(&data) + events := []cellsync.TraceEvent{ + {Op: "workload", Seed: 42, Files: 2, LogicalBytes: 90}, + {Op: "engine.want", Phase: "warm", Objects: 2, Outcome: "ok"}, + {Op: "http.request", Phase: "warm", Class: "encrypted_bulk", SentBytes: 20, ReceivedBytes: 120, Outcome: "ok"}, + {Op: "store.get_many", Phase: "warm", Objects: 2, PayloadBytes: 90, Outcome: "ok"}, + {Op: "engine.want", Phase: "warm", Objects: 0, Outcome: "ok"}, + {Op: "http.request", Phase: "warm", Class: "control", SentBytes: 10, ReceivedBytes: 5, Outcome: "http_error"}, + } + for _, event := range events { + sink.Observe(event) + } + first, err := cellsync.ReadTraceSummary(bytes.NewReader(data.Bytes())) + if err != nil { + t.Fatal(err) + } + second, err := cellsync.ReadTraceSummary(bytes.NewReader(data.Bytes())) + if err != nil || !reflect.DeepEqual(first, second) { + t.Fatal("offline trace analysis is not repeatable") + } + phase := first.Phases["warm"] + if first.Seed != 42 || first.LogicalBytes != 90 || phase.HTTPRequests != 2 || phase.StorePayloadDown != 90 || phase.HTTPBodies["encrypted_bulk"].Received != 120 || phase.HTTPBodies["control"].Received != 5 || phase.WantCalls != 2 || phase.WantNonempty != 1 || phase.GetManyBatches != 1 || phase.Outcomes["http_error"] != 1 { + t.Fatalf("mixed layers or lost barriers: %+v", first) + } + if _, err := cellsync.ReadTraceSummary(strings.NewReader(`{"v":99}`)); err == nil { + t.Fatal("unsupported trace silently accepted") + } + if _, err := cellsync.ReadTraceSummary(strings.NewReader(`{"v":1`)); err == nil { + t.Fatal("truncated trace silently accepted") + } +} + +func TestTraceAnalysisRejectsUnboundedOrUnknownMetadataAndPreservesReaderCancellation(t *testing.T) { + for _, input := range []string{`{"v":1,"op":"private-custom-label"}`, `{"v":1,"unexpected":"private-data"}`, strings.Repeat(" ", 65<<10)} { + _, err := cellsync.ReadTraceSummary(strings.NewReader(input)) + if err == nil || strings.Contains(err.Error(), "private-") { + t.Fatal("invalid trace accepted or raw metadata echoed") + } + } + _, err := cellsync.ReadTraceSummary(traceBrokenBody{context.Canceled}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("reader cancellation replaced: %v", err) + } +} + +func TestTraceAnalysisRejectsInvalidIdentityNegativeSizeAndRepeatedSequence(t *testing.T) { + negative := int64(-1) + for _, event := range []cellsync.TraceEvent{ + {Op: "engine.want", RID: strings.Repeat("z", 64)}, + {Op: "engine.want", DurationNS: -1}, + {Op: "engine.want", RawBytes: &negative}, + } { + var data bytes.Buffer + sink := cellsync.NewJSONLTrace(&data) + sink.Observe(event) + if _, err := cellsync.ReadTraceSummary(&data); err == nil { + t.Fatal("invalid trace metadata accepted") + } + } + var data bytes.Buffer + sink := cellsync.NewJSONLTrace(&data) + sink.Observe(cellsync.TraceEvent{Op: "engine.want"}) + repeated := append(append([]byte{}, data.Bytes()...), data.Bytes()...) + if _, err := cellsync.ReadTraceSummary(bytes.NewReader(repeated)); err == nil { + t.Fatal("repeated operation sequence counted twice") + } +} + +func TestHTTPTraceCountsReplayedRequestBodyAndPreservesFactoryError(t *testing.T) { + request, _ := http.NewRequest("POST", "http://fixture.invalid/", strings.NewReader("request")) + var event cellsync.TraceEvent + inner := traceRoundTrip(func(r *http.Request) (*http.Response, error) { + _, _ = io.Copy(io.Discard, r.Body) + r.Body.Close() + retry, err := r.GetBody() + if err != nil { + return nil, err + } + _, _ = io.Copy(io.Discard, retry) + retry.Close() + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("reply")), ContentLength: 5, Header: make(http.Header)}, nil + }) + wrapped := cellsync.ObserveHTTP(inner, func(got cellsync.TraceEvent) { event = got }) + response, err := wrapped.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, response.Body) + response.Body.Close() + if event.SentBytes != 14 { + t.Fatalf("transport replay bytes omitted: %+v", event) + } + sentinel := errors.New("same GetBody failure") + request, _ = http.NewRequest("POST", "http://fixture.invalid/", strings.NewReader("request")) + request.GetBody = func() (io.ReadCloser, error) { return nil, sentinel } + _, err = wrapped.RoundTrip(request) + if err != sentinel { + t.Fatalf("GetBody error identity replaced: %v", err) + } +} diff --git a/internal/directory/frame_hints_test.go b/internal/directory/frame_hints_test.go new file mode 100644 index 0000000000..1904060b61 --- /dev/null +++ b/internal/directory/frame_hints_test.go @@ -0,0 +1,66 @@ +package directory + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestFrameHintsReplaceAndListOmission(t *testing.T) { + hint := []FrameHint{{ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Bytes: 42}} + c := Created(CellInit{Head: "first", FrameHints: hint}, "device", 1) + if len(c.FrameHints) != 1 { + t.Fatal("create lost hints") + } + changed, err := PublishTo(c, "device", Publish{Fence: 1, OldHead: "first", Head: "second"}, 2) + if err != nil || len(changed.FrameHints) != 0 { + t.Fatalf("old publisher retained stale hints: %v", err) + } + listing := withoutFrames(Listing{Cells: map[string]Cell{"c": c}}) + if len(listing.Cells["c"].FrameHints) != 0 { + t.Fatal("home list carried frame hints") + } +} + +func TestFrameHintsValidateAndCopyInput(t *testing.T) { + source := []FrameHint{{ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Bytes: 65535}} + c := Created(CellInit{FrameHints: source}, "d", 1) + source[0].Bytes = 1 + if len(c.FrameHints) != 1 || c.FrameHints[0].Bytes != 65535 { + t.Fatal("metadata input aliased") + } + for _, bad := range [][]FrameHint{{{ID: "a", Bytes: 1}}, {{ID: c.FrameHints[0].ID, Bytes: 65536}}, {{ID: c.FrameHints[0].ID, Bytes: 9223372036854775807}}, {c.FrameHints[0], c.FrameHints[0]}} { + if got := Created(CellInit{FrameHints: bad}, "d", 1); len(got.FrameHints) != 0 { + t.Fatal("invalid hint set retained") + } + } +} + +func TestFrameHintReturnedViewsCannotMutateCommittedMetadata(t *testing.T) { + m := NewMemory(func() time.Time { return time.Unix(1, 0) }) + client := m.For("d") + hints := []FrameHint{{ID: strings.Repeat("a", 64), Bytes: 42}} + v, err := client.Create(context.Background(), "c", CellInit{Head: "h", FrameHints: hints}) + if err != nil { + t.Fatal(err) + } + v.Cell.FrameHints[0].Bytes = 1 + read, err := client.Cell(context.Background(), "c") + if err != nil { + t.Fatal(err) + } + if read.Cell.FrameHints[0].Bytes != 42 { + t.Fatal("Create result changed committed hints") + } + read.Cell.FrameHints[0].Bytes = 2 + v, err = client.Publish(context.Background(), "c", Publish{Fence: 1, OldHead: "h", Head: "next", FrameHints: hints}) + if err != nil { + t.Fatal(err) + } + v.Cell.FrameHints[0].Bytes = 3 + final, _ := client.Cell(context.Background(), "c") + if final.Cell.FrameHints[0].Bytes != 42 { + t.Fatal("Publish result changed committed hints") + } +} diff --git a/internal/directory/memory.go b/internal/directory/memory.go index 2a20f6f3f6..498a0ed785 100644 --- a/internal/directory/memory.go +++ b/internal/directory/memory.go @@ -310,6 +310,7 @@ func copyCell(c Cell) Cell { keys[id] = maps.Clone(wrapped) } c.Keys = keys + c.FrameHints = append([]FrameHint(nil), c.FrameHints...) return c } diff --git a/internal/directory/records.go b/internal/directory/records.go index af331f9ac2..315fcf83f0 100644 --- a/internal/directory/records.go +++ b/internal/directory/records.go @@ -85,8 +85,20 @@ type Lease struct { ReleasedAt int64 `json:"released_at,omitempty"` } +// MaxFrameHints and FrameHintBudget bound optional warm-transfer metadata and +// application reads. Each frame reserves one additional EOF probe byte. +const MaxFrameHints = 2 +const FrameHintBudget int64 = 64 << 10 + +// FrameHint describes an existing uploaded frame, never completeness authority. +type FrameHint struct { + ID string `json:"id"` + Bytes int64 `json:"bytes"` +} + // Cell is keyed by cell ULID. It exists only once it has a durable head. type Cell struct { + FrameHints []FrameHint `json:"frame_hints,omitempty"` V uint16 `json:"V"` Head string `json:"head"` // last durable turn id (hex64) DurableAt int64 `json:"durable_at"` // directory ms @@ -117,6 +129,7 @@ type Listing struct { func withoutFrames(l Listing) Listing { for id, c := range l.Cells { c.Frames = nil + c.FrameHints = nil l.Cells[id] = c } return l @@ -130,6 +143,7 @@ type CellView struct { // CellInit creates a cell. The calling device holds the new lease at fence 1. type CellInit struct { + FrameHints []FrameHint `json:"frame_hints,omitempty"` Head string `json:"head"` Class string `json:"class"` Title string `json:"title,omitempty"` @@ -148,12 +162,41 @@ type Beat struct { // Publish moves a cell's durable head under a lease the caller holds. type Publish struct { - Fence uint64 `json:"fence"` - OldHead string `json:"old_head"` - Head string `json:"head"` - Size uint64 `json:"size"` - Class string `json:"class"` - Title string `json:"title,omitempty"` // empty keeps the current title - Pending uint32 `json:"pending"` // turns still not durable after this one - Frames []string `json:"frames,omitempty"` // empty or absent replaces the record's plan with none + FrameHints []FrameHint `json:"frame_hints,omitempty"` + Fence uint64 `json:"fence"` + OldHead string `json:"old_head"` + Head string `json:"head"` + Size uint64 `json:"size"` + Class string `json:"class"` + Title string `json:"title,omitempty"` // empty keeps the current title + Pending uint32 `json:"pending"` // turns still not durable after this one + Frames []string `json:"frames,omitempty"` // empty or absent replaces the record's plan with none +} + +// boundedFrameHints copies optional untrusted metadata. Invalid sets are omitted, +// never a reason to accept a head without the authoritative object closure. +func boundedFrameHints(in []FrameHint) []FrameHint { + if len(in) == 0 || len(in) > MaxFrameHints { + return nil + } + remaining := FrameHintBudget + out := make([]FrameHint, 0, len(in)) + for _, h := range in { + if h.Bytes <= 0 || h.Bytes >= remaining || len(h.ID) != 64 { + return nil + } + for _, c := range h.ID { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return nil + } + } + for _, old := range out { + if old.ID == h.ID { + return nil + } + } + remaining -= h.Bytes + 1 + out = append(out, h) + } + return out } diff --git a/internal/directory/rules.go b/internal/directory/rules.go index cee53a2a35..01b39217aa 100644 --- a/internal/directory/rules.go +++ b/internal/directory/rules.go @@ -62,6 +62,7 @@ func PublishTo(c Cell, device string, p Publish, now int64) (Cell, error) { // the list wholesale: an empty or absent list is a true statement ("the // publisher did not say"), a stale one is not kept. c.Frames = p.Frames + c.FrameHints = boundedFrameHints(p.FrameHints) c.Lease.Pending, c.DurableAt = p.Pending, now c.Lease.Expires = now + ttlMs c.Title = keepIfEmpty(p.Title, c.Title) @@ -87,8 +88,8 @@ func Created(in CellInit, device string, now int64) Cell { return Cell{ V: 1, Head: in.Head, DurableAt: now, Class: in.Class, Size: in.Size, ParentCell: in.ParentCell, Title: in.Title, Keys: in.Keys, OrphanTurns: in.OrphanTurns, - Frames: in.Frames, - Lease: Lease{Device: device, Fence: 1, Expires: now + ttlMs}, + Frames: in.Frames, FrameHints: boundedFrameHints(in.FrameHints), + Lease: Lease{Device: device, Fence: 1, Expires: now + ttlMs}, } } diff --git a/internal/e2e/pairjourney_e2e_test.go b/internal/e2e/pairjourney_e2e_test.go index caeb5c5f38..a7b1999af0 100644 --- a/internal/e2e/pairjourney_e2e_test.go +++ b/internal/e2e/pairjourney_e2e_test.go @@ -140,6 +140,12 @@ func waitPlain(r *rig, within time.Duration, want ...string) (string, bool) { // startRelay runs the local sync service this repository ships and returns its address. func startRelay(t *testing.T) string { + return startRelayAt(t, filepath.Join(t.TempDir(), "store")) +} + +// startRelayAt leaves the owned data directory visible to acceptance tests that +// measure total storage across both computers and the transfer service. +func startRelayAt(t *testing.T, store string) string { t.Helper() bin := filepath.Join(t.TempDir(), "relay") build := exec.Command("go", "build", "-o", bin, "./cmd/relay") @@ -153,7 +159,7 @@ func startRelay(t *testing.T) string { } addr := ln.Addr().String() _ = ln.Close() - cmd := guardedCommand(t, context.Background(), t.TempDir(), nil, bin, "--listen", addr, "--store", filepath.Join(t.TempDir(), "store"), "--quiet") + cmd := guardedCommand(t, context.Background(), t.TempDir(), nil, bin, "--listen", addr, "--store", store, "--quiet") if err := cmd.Start(); err != nil { t.Fatal(err) } diff --git a/internal/e2e/relay_store_alloc_other_e2e_test.go b/internal/e2e/relay_store_alloc_other_e2e_test.go new file mode 100644 index 0000000000..dcc159844f --- /dev/null +++ b/internal/e2e/relay_store_alloc_other_e2e_test.go @@ -0,0 +1,7 @@ +//go:build e2e && !linux && !darwin + +package e2e + +import "os" + +func wanAllocated(os.FileInfo) (int64, bool) { return 0, false } diff --git a/internal/e2e/relay_store_alloc_unix_e2e_test.go b/internal/e2e/relay_store_alloc_unix_e2e_test.go new file mode 100644 index 0000000000..35c70bd19b --- /dev/null +++ b/internal/e2e/relay_store_alloc_unix_e2e_test.go @@ -0,0 +1,16 @@ +//go:build e2e && (linux || darwin) + +package e2e + +import ( + "os" + "syscall" +) + +func wanAllocated(info os.FileInfo) (int64, bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return stat.Blocks * 512, true +} diff --git a/internal/e2e/relay_wan_baseline_e2e_test.go b/internal/e2e/relay_wan_baseline_e2e_test.go new file mode 100644 index 0000000000..0720f57f46 --- /dev/null +++ b/internal/e2e/relay_wan_baseline_e2e_test.go @@ -0,0 +1,458 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// This is a bounded, seeded native Publish/Fetch baseline, not terminal pairing +// or UI readiness acceptance. Authentication uses the existing wire fixture +// seam. Packet shaping is confined to a new user+network namespace per case. +func TestRelayWANBaseline(t *testing.T) { + if os.Getenv("CODEAF_WAN_BASELINE") != "1" { + t.Skip("opt-in bounded WAN baseline") + } + if config := os.Getenv("CODEAF_WAN_CHILD"); config != "" { + wanBaselineChild(t, config) + return + } + if _, err := exec.LookPath("unshare"); err != nil { + t.Skipf("isolated netem unavailable: %v", err) + } + parentNS, err := os.Readlink("/proc/self/ns/net") + if err != nil { + t.Fatal(err) + } + cases := []string{"loopback"} + for _, rtt := range []int{50, 100, 200} { + for _, mbps := range []int{5, 10, 50} { + cases = append(cases, fmt.Sprintf("%d-%d", rtt, mbps)) + } + } + if selected := os.Getenv("CODEAF_WAN_CASES"); selected != "" { + cases = strings.Split(selected, ",") + } + evidence := os.Getenv("CODEAF_WAN_EVIDENCE_DIR") + if evidence == "" { + evidence = t.TempDir() + } + if !filepath.IsAbs(evidence) { + t.Fatal("evidence directory must be absolute") + } + visits := map[string]int{} + for _, config := range cases { + visits[config]++ + label := config + if visits[config] > 1 { + label = fmt.Sprintf("%s-sample-%d", config, visits[config]) + } + t.Run(config, func(t *testing.T) { + command := exec.Command("unshare", "-Urn", os.Args[0], "-test.run=^TestRelayWANBaseline$", "-test.v", "-test.timeout=3m") + command.Env = append(os.Environ(), "CODEAF_WAN_CHILD="+config, "CODEAF_WAN_PARENT_NETNS="+parentNS, "CODEAF_WAN_EVIDENCE_DIR="+filepath.Join(evidence, label)) + output, err := command.CombinedOutput() + t.Log(string(output)) + if err != nil { + t.Fatalf("isolated WAN child: %v", err) + } + }) + } +} + +type wanPhase struct{ HTTPRequests, SentBody, ReceivedBody, Has, GetMany, GetFrames, PutFrames, Objects, Payload int64 } +type wanTrace struct { + mu sync.Mutex + sink *cellsync.JSONLTrace + phases map[string]*wanPhase +} + +func (w *wanTrace) Observe(event cellsync.TraceEvent) { + w.sink.Observe(event) + w.mu.Lock() + defer w.mu.Unlock() + p := w.phases[event.Phase] + if p == nil { + p = &wanPhase{} + w.phases[event.Phase] = p + } + switch event.Op { + case "http.request": + p.HTTPRequests++ + p.SentBody += event.SentBytes + p.ReceivedBody += event.ReceivedBytes + case "store.has": + p.Has++ + case "store.get_many": + p.GetMany++ + p.Objects += int64(event.Objects) + p.Payload += event.PayloadBytes + case "store.get_frame": + p.GetFrames++ + p.Payload += event.PayloadBytes + case "store.put_frame": + p.PutFrames++ + p.Objects += int64(event.Objects) + p.Payload += event.PayloadBytes + } +} + +type wanNativeMode struct { + mu sync.Mutex + DaemonCalls, SpawnCalls int +} +type wanModeTransport struct { + inner cellstore.Transport + mode string + counts *wanNativeMode +} + +func (w wanModeTransport) Do(ctx context.Context, target cellstore.Target, op cellstore.Op) ([]byte, error) { + w.counts.mu.Lock() + if w.mode == "daemon" { + w.counts.DaemonCalls++ + } else { + w.counts.SpawnCalls++ + } + w.counts.mu.Unlock() + return w.inner.Do(ctx, target, op) +} + +func wanBaselineChild(t *testing.T, config string) { + current, err := os.Readlink("/proc/self/ns/net") + if err != nil || current == os.Getenv("CODEAF_WAN_PARENT_NETNS") || os.Getenv("CODEAF_WAN_PARENT_NETNS") == "" { + t.Fatal("refusing network changes outside explicitly isolated namespace") + } + run := func(args ...string) string { + t.Helper() + var command *exec.Cmd + switch args[0] { + case "ip": + command = exec.Command("ip", args[1:]...) + case "tc": + command = exec.Command("tc", args[1:]...) + default: + t.Fatalf("unsupported namespace utility %q", args[0]) + } + out, err := command.CombinedOutput() + if err != nil { + t.Fatalf("namespace command %s failed: %v %s", args[0], err, out) + } + return string(out) + } + run("ip", "link", "set", "lo", "up") + rtt, mbps := 0, 0 + if config != "loopback" { + parts := strings.Split(config, "-") + if len(parts) != 2 { + t.Fatal("invalid matrix case") + } + rtt, _ = strconv.Atoi(parts[0]) + mbps, _ = strconv.Atoi(parts[1]) + if (rtt != 50 && rtt != 100 && rtt != 200) || (mbps != 5 && mbps != 10 && mbps != 50) { + t.Fatal("case outside approved matrix") + } + run("tc", "qdisc", "add", "dev", "lo", "root", "netem", "limit", "1000", "delay", fmt.Sprintf("%dms", rtt/2), "rate", fmt.Sprintf("%dmbit", mbps)) + defer exec.Command("tc", "qdisc", "del", "dev", "lo", "root").Run() + } + t.Setenv(home.EnvVar, t.TempDir()) + evidence := os.Getenv("CODEAF_WAN_EVIDENCE_DIR") + if err := os.MkdirAll(evidence, 0700); err != nil { + t.Fatal(err) + } + file, err := os.Create(filepath.Join(evidence, "operations.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + trace := &wanTrace{sink: cellsync.NewJSONLTrace(file), phases: map[string]*wanPhase{}} + native, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + t.Cleanup(cellstore.StopSpawned) + cleanup := map[string]bool{"namespace_isolated": current != os.Getenv("CODEAF_WAN_PARENT_NETNS")} + newDevice := func(role string) (cellstore.Engine, *wanNativeMode) { + deviceHome := t.TempDir() + socket := filepath.Join(deviceHome, "engine.sock") + daemon := cellstore.Daemon{Binary: native, Socket: socket} + counts := &wanNativeMode{} + selected := cellstore.Fallback{ + Primary: wanModeTransport{inner: daemon, mode: "daemon", counts: counts}, + Secondary: wanModeTransport{inner: cellstore.Spawn{Binary: native, Socket: socket}, mode: "spawn", counts: counts}, + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stopErr := daemon.Stop(ctx) + cleanup[role+"_engine_stopped"] = stopErr == nil + if stopErr == nil { + if _, err := os.Lstat(socket); err == nil { + if err := os.Remove(socket); err != nil { + t.Errorf("remove owned IPC socket: %v", err) + } + } + } + _, socketErr := os.Lstat(socket) + cleanup[role+"_ipc_socket_absent"] = os.IsNotExist(socketErr) + receipt, _ := json.Marshal(cleanup) + if err := os.WriteFile(filepath.Join(evidence, "cleanup.json"), receipt, 0600); err != nil { + t.Errorf("cleanup receipt: %v", err) + } + if stopErr != nil { + t.Errorf("owned engine shutdown: %v", stopErr) + } + }) + return cellstore.Engine{Binary: native, DataRoot: filepath.Join(deviceHome, "stores"), Transport: selected}, counts + } + relayRoot := t.TempDir() + relayDisk, err := blobstore.NewDisk(relayRoot) + if err != nil { + t.Fatal(err) + } + directoryMemory := directory.NewMemory(time.Now) + auth := func(r *http.Request, _ []byte) (string, string, error) { + if r.Header.Get("X-Fixture-Identity") != "seeded-baseline" { + return "", "", wireauth.ErrUnauthorized + } + return "seeded-baseline", "fixture-device", nil + } + storeHandler := blobstore.Handler(auth, func(string) (blobstore.Store, error) { return relayDisk, nil }) + dirHandler := directory.Handler(auth, func(string) (directory.Directory, error) { return directoryMemory, nil }) + calibration := bytes.Repeat([]byte{0x61}, 1<<20) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/calibration/ping": + w.WriteHeader(204) + case r.URL.Path == "/calibration/stream": + _, _ = w.Write(calibration) + case strings.HasPrefix(r.URL.Path, "/v1/dir/"): + dirHandler.ServeHTTP(w, r) + default: + storeHandler.ServeHTTP(w, r) + } + })) + t.Cleanup(server.Close) + newClient := func() *http.Client { + client := &http.Client{Transport: cellsync.ObserveHTTP(&http.Transport{Proxy: nil}, trace.Observe), Timeout: 30 * time.Second} + t.Cleanup(client.CloseIdleConnections) + return client + } + client := newClient() + calibrationCtx := cellsync.TracePhase(context.Background(), "calibration", 1) + var ping []float64 + for i := 0; i < 4; i++ { + started := time.Now() + req, _ := http.NewRequestWithContext(calibrationCtx, "GET", server.URL+"/calibration/ping", nil) + response, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if i > 0 { + ping = append(ping, float64(time.Since(started).Microseconds())/1000) + } + } + started := time.Now() + req, _ := http.NewRequestWithContext(calibrationCtx, "GET", server.URL+"/calibration/stream", nil) + response, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + n, err := io.Copy(io.Discard, response.Body) + response.Body.Close() + if err != nil || n != int64(len(calibration)) { + t.Fatal("calibration stream incomplete") + } + streamSeconds := time.Since(started).Seconds() + sign := func(r *http.Request, _ []byte) { r.Header.Set("X-Fixture-Identity", "seeded-baseline") } + remote := cellsync.ObserveStore(blobstore.NewHTTP(server.URL, sign, newClient()), trace.Observe) + source, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + sourceRevision, _ := exec.Command("git", "rev-parse", "HEAD").Output() + nativeHash := wanFileSHA256(t, native) + harnessHash := wanFileSHA256(t, os.Args[0]) + const seed int64 = 20261025 + const count = 64 + const size = 8192 + random := rand.New(rand.NewSource(seed)) + files := map[string][]byte{} + digest := sha256.New() + for i := 0; i < count; i++ { + name := fmt.Sprintf("mock/module-%03d.bin", i) + body := make([]byte, size) + _, _ = random.Read(body) + files[name] = body + digest.Write([]byte(name)) + digest.Write(body) + path := filepath.Join(source.Root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + } + trace.Observe(cellsync.TraceEvent{Op: "workload", Layer: "fixture", Class: "mixed_fixture", Seed: seed, Files: count, LogicalBytes: count * size, Outcome: "ok"}) + keys := cellstore.SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)} + engine, sourceMode := newDevice("source") + sourceSync := cellsync.ObserveEngine(engine.Sync(keys, cellstore.LedgerName(server.URL, "seeded-fixture")), trace.Observe) + first, err := engine.Seal(context.Background(), source, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + hintsEnabled := os.Getenv("CODEAF_TEST_WARM_FRAME_HINTS") == "1" + publisher := cellsync.Publisher{Engine: sourceSync, Store: remote, Dir: directory.NewHTTP(server.URL, sign, client), WarmFrameHints: hintsEnabled} + driving := cellsync.Driving{Cell: source} + info := cellsync.PublishInfo{Class: string(cell.Sandboxed), Keys: map[string]map[string]string{}} + if err := publisher.Publish(cellsync.TracePhase(context.Background(), "publish", 1), &driving, first.Turn.ID, info); err != nil { + t.Fatal(err) + } + + receiver := cell.Cell{ID: source.ID, Root: t.TempDir()} + receiverEngine, receiverMode := newDevice("receiver") + receiverSync := cellsync.ObserveEngine(receiverEngine.Sync(keys, cellstore.LedgerName(server.URL, "seeded-fixture")), trace.Observe) + receiverClient := newClient() + receiverStore := cellsync.ObserveStore(&blobstore.Resuming{Inner: blobstore.Counting{Inner: blobstore.NewHTTP(server.URL, sign, receiverClient), C: &blobstore.Counters{}}}, trace.Observe) + inbox := filepath.Join(evidence, "inbox") + defer os.RemoveAll(inbox) + fetcher := cellsync.Fetcher{WarmFrameHints: hintsEnabled, Engine: receiverSync, Store: receiverStore, Dir: directory.NewHTTP(server.URL, sign, receiverClient), Inbox: func(cell.Cell) string { return inbox }} + started = time.Now() + if err := fetcher.Fetch(cellsync.TracePhase(context.Background(), "cold", 1), receiver, first.Turn.ID); err != nil { + t.Fatal(err) + } + cold := time.Since(started) + for name, want := range files { + got, err := os.ReadFile(filepath.Join(receiver.Root, name)) + if err != nil || !bytes.Equal(got, want) { + t.Fatal("cold receiver bytes differ") + } + } + files["mock/module-000.bin"] = []byte("seeded one-file replacement\n") + if err := os.WriteFile(filepath.Join(source.Root, "mock/module-000.bin"), files["mock/module-000.bin"], 0600); err != nil { + t.Fatal(err) + } + second, err := engine.Seal(context.Background(), source, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + err = publisher.Publish(cellsync.TracePhase(context.Background(), "publish", 2), &driving, second.Turn.ID, info) + if err != nil { + t.Fatal(err) + } + started = time.Now() + if err := fetcher.Fetch(cellsync.TracePhase(context.Background(), "warm", 1), receiver, second.Turn.ID); err != nil { + t.Fatal(err) + } + warm := time.Since(started) + for name, want := range files { + got, err := os.ReadFile(filepath.Join(receiver.Root, name)) + if err != nil || !bytes.Equal(got, want) { + t.Fatal("warm receiver bytes differ") + } + } + if sourceMode.SpawnCalls != 0 || receiverMode.SpawnCalls != 0 || sourceMode.DaemonCalls == 0 || receiverMode.DaemonCalls == 0 { + t.Fatal("baseline did not stay on the production daemon transport") + } + if err := trace.sink.Err(); err != nil { + t.Fatal(err) + } + if err := file.Sync(); err != nil { + t.Fatal(err) + } + input, err := os.Open(filepath.Join(evidence, "operations.jsonl")) + if err != nil { + t.Fatal(err) + } + analysis, err := cellsync.ReadTraceSummary(input) + input.Close() + if err != nil { + t.Fatal(err) + } + analysisJSON, _ := json.MarshalIndent(analysis, "", " ") + if err := os.WriteFile(filepath.Join(evidence, "analysis.json"), analysisJSON, 0600); err != nil { + t.Fatal(err) + } + storeFootprint := wanStoreFootprint(t, relayRoot) + summary := map[string]any{"warm_frame_hints": hintsEnabled, "relay_backend": "legacy", "relay_footprint": storeFootprint, "native_transport": "production daemon with standalone fallback, isolated per simulated device", "native_modes": map[string]*wanNativeMode{"source": sourceMode, "receiver": receiverMode}, "source_revision": strings.TrimSpace(string(sourceRevision)), "native_sha256": nativeHash, "harness_sha256": harnessHash, "config": config, "requested_rtt_ms": rtt, "shared_rate_mbps": mbps, "one_way_delay_ms": rtt / 2, "ping_ms": ping, "stream_bytes": n, "stream_seconds": streamSeconds, "stream_observed_mbps": float64(n) * 8 / streamSeconds / 1e6, "seed": seed, "files": count, "logical_bytes": count * size, "workload_sha256": hex.EncodeToString(digest.Sum(nil)), "cold_native_g3_ms": float64(cold.Microseconds()) / 1000, "warm_native_g3_ms": float64(warm.Microseconds()) / 1000, "G1": "unobserved", "G2": "unobserved", "phases": trace.phases, "scope": "native protocol fixture; HTTP body bytes exclude headers/TLS; no UI or WAN acceptance gate claim", "transport_setup": "separate calibration/source/receiver connection pools; cold includes receiver TCP setup; no TLS fixture", "namespace_cleanup": "isolated namespace exits; owned engine shutdown checked on test cleanup"} + raw, _ := json.MarshalIndent(summary, "", " ") + if err := os.WriteFile(filepath.Join(evidence, "summary.json"), raw, 0600); err != nil { + t.Fatal(err) + } + t.Log(string(raw)) + if config != "loopback" { + t.Log(run("tc", "-s", "qdisc", "show", "dev", "lo")) + } +} + +func wanFileSHA256(t *testing.T, path string) string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + t.Fatal(err) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +// Fixture paths are private. Receipt contains category totals only. Allocated +// blocks are not exclusive CoW accounting. Includes live SQLite WAL and SHM. +func wanStoreFootprint(t *testing.T, root string) map[string]any { + t.Helper() + var logical, allocated, files, wal, shm int64 + known := true + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + blocks, ok := wanAllocated(info) + known = known && ok + allocated += blocks + if info.Mode().IsRegular() { + files++ + logical += info.Size() + if strings.HasSuffix(path, "-wal") { + wal += info.Size() + } + if strings.HasSuffix(path, "-shm") { + shm += info.Size() + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return map[string]any{"logical_bytes": logical, "allocated_block_bytes": allocated, "allocation_known": known, "regular_files": files, "wal_logical_bytes": wal, "shm_logical_bytes": shm, "scope": "live relay store including directory blocks and SQLite sidecars; not exclusive CoW bytes"} +} diff --git a/internal/e2e/storage_lifecycle_e2e_test.go b/internal/e2e/storage_lifecycle_e2e_test.go new file mode 100644 index 0000000000..4a156dff53 --- /dev/null +++ b/internal/e2e/storage_lifecycle_e2e_test.go @@ -0,0 +1,1064 @@ +//go:build e2e + +package e2e + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +// This acceptance uses the real terminal to create and continue a task. Public +// storage operations independently check that the continued work was retained. +// The task prompt is fictional and the profile configuration is minimal. Raw +// evidence stays private, including any incidental host metadata a tool observes. +func TestStorageLifecycleShellInterruptionKeepsResidentWork(t *testing.T) { + archive := os.Getenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if archive == "" { + t.Skip("set CODEAF_PROTOTYPE_ARCHIVE_BINARY to the task-owned archive executable") + } + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatalf("requires CODEAF_E2E_MODEL=%s", model) + } + home := lifecycleHome(t, model) + ws := newWorkspace(t, "cold-task", false) + env := []string{config.APIKeyEnv + "=" + liveKey(t), "CODEAF_BASE_URL=" + config.DefaultBaseURL, + "CODEAF_PROTOTYPE_TASK_STORAGE=1", "CODEAF_PROTOTYPE_ARCHIVE_BINARY=" + archive, + furrow.BinaryEnvVar + "=" + archive, + "CODEAF_SYNC_URL=off"} + lifecycleDaemonCleanup(t, home, archive) + stopRecoveryEngineOnCleanup(t, env, home, ws) + r := startWithEnv(t, env, "storage_lifecycle", home, ws, 180, 48, "chat", "--model", model) + r.skipSetup(t) + r.lit("/task solo This is a resumable fictional workflow. First read cold-proof.txt if it exists. If it does not exist: use one foreground bash command to write exactly first-stage-copper-heron followed by a newline, then sleep for120 seconds in that same command and wait for it. If it already contains that first line: preserve it and append exactly second-stage-resumed followed by a newline, then finish. Do not delegate, edit other files or run the sleep in the background.") + r.keys("Enter") + path := lifecycleAwaitPartial(t, home, 6*time.Minute) + oldPID := upgradePID(upgradeEngineCommand(t, binary(t), env, home, ws, "--status")) + if oldPID == "" { + t.Fatal("task was not served by a background engine") + } + // Closing the application interrupts unfinished work. Explicit Stop is a + // different user decision: stopped runs intentionally cannot be continued. + r.kill() + upgradeEngineCommand(t, binary(t), env, home, ws, "--stop") + m := taskstorage.Manager{Root: filepath.Join(home, "task-storage"), Trees: filepath.Dir(path), Vault: taskstorage.CLIBackend{Binary: archive, Store: filepath.Join(home, "task-storage", "vault")}} + state, err := m.Inspect(context.Background(), path) + if err != nil || state.Phase != taskstorage.Resident { + t.Fatalf("uncontained shell task was retired: %+v %v", state, err) + } + if data, err := os.ReadFile(filepath.Join(path, "cold-proof.txt")); err != nil || string(data) != "first-stage-copper-heron\n" { + t.Fatal("interrupted shell task did not retain its exact partial work") + } + t.Log("PASS interrupted shell task conservatively kept resident; no unproven writer retirement") + r = startWithEnv(t, env, "storage_lifecycle_reopen", home, ws, 180, 48, "chat", "--model", model) + r.skipSetup(t) + if strings.Contains(r.capture(), "Move this conversation here?") { + r.keys("1", "Enter") + } + // Normal startup picks up interrupted work using its existing continuation + // road. No replacement task or altered execution semantics are introduced. + var b []byte + for until := time.Now().Add(6 * time.Minute); time.Now().Before(until); { + b, _ = os.ReadFile(filepath.Join(path, "cold-proof.txt")) + if string(b) == "first-stage-copper-heron\nsecond-stage-resumed\n" { + break + } + time.Sleep(pollEvery) + } + if string(b) != "first-stage-copper-heron\nsecond-stage-resumed\n" { + t.Fatal("restarted shell workflow did not preserve and continue original work") + } + newPID := upgradePID(upgradeEngineCommand(t, binary(t), env, home, ws, "--status")) + if newPID == "" || newPID == oldPID { + t.Fatal("restart did not create a new background engine") + } + openTasksPlace(t, r) + r.waitFor(2*time.Minute, "completed", fmt.Sprintf("%d done", 1)) + state, err = m.Inspect(context.Background(), path) + if err != nil || state.Phase != taskstorage.Resident { + t.Fatal("continued uncontained shell task lost its conservative residency") + } + models := callModels(t, home) + if len(models) == 0 { + t.Fatal("missing task model receipts") + } + for _, got := range models { + if got != model { + t.Fatalf("unexpected recorded model %q", got) + } + } + lifecycleCompletedCalls(t, home, model) + t.Logf("PASS real terminal shell task interrupted, retained, engine restarted and same task continued with original bytes; no unsafe retirement; %d exact model receipts", len(models)) +} + +func lifecycleAwaitPartial(t *testing.T, home string, within time.Duration) string { + t.Helper() + for deadline := time.Now().Add(within); time.Now().Before(deadline); { + var found string + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && entry.Name() == "cold-proof.txt" && strings.Contains(path, string(filepath.Separator)+"trees"+string(filepath.Separator)) { + if b, err := os.ReadFile(path); err == nil && string(b) == "first-stage-copper-heron\n" { + found = filepath.Dir(path) + } + } + return nil + }) + if found != "" { + return found + } + time.Sleep(pollEvery) + } + t.Fatal("real interrupted workflow never wrote its expected partial file") + return "" +} + +// Live task stages use fictional data in fresh profiles. Real-copy acceptance +// uses the separate offline fixture, zero provider requests and a controlled +// local relay. Full local archives and withheld files remain at source. +func TestStorageLifecycleColdTaskMovesThroughPublicPairAndTake(t *testing.T) { + lifecycleRelayJourney(t, true) +} + +func TestStorageLifecycleOfflineRealCopyMovesThroughPublicPairAndTake(t *testing.T) { + lifecycleRelayJourney(t, false) +} + +func lifecycleRelayJourney(t *testing.T, live bool) { + if live && os.Getenv("CODEAF_E2E_REAL_TASK_CORPUS") != "" { + t.Fatal("real corpus requires the offline fixture journey; live post-start injection is not supported") + } + archive := os.Getenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if archive == "" { + t.Skip("set CODEAF_PROTOTYPE_ARCHIVE_BINARY") + } + if live { + requireTmuxAndKey(t) + } else if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + const model = "deepseek/deepseek-v4.1-flash" + if live && e2eModel != model { + t.Fatalf("requires CODEAF_E2E_MODEL=%s", model) + } + key, base := "unused-offline-fixture-key", config.DefaultBaseURL + var refused atomic.Int64 + if live { + key = liveKey(t) + } else { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "completions") || strings.Contains(r.URL.Path, "responses") { + refused.Add(1) + http.Error(w, "offline fixture must never call a model", 500) + return + } + fmt.Fprint(w, `{"data":[]}`) + })) + defer server.Close() + base = server.URL + t.Cleanup(func() { + if refused.Load() != 0 { + t.Errorf("offline fixture attempted %d model requests", refused.Load()) + } + }) + } + relayStore := filepath.Join(t.TempDir(), "relay-store") + relay := startRelayAt(t, relayStore) + env := []string{config.APIKeyEnv + "=" + key, "CODEAF_BASE_URL=" + base, + "CODEAF_SYNC_URL=" + relay, "CODEAF_SYNC_INTERVAL_MS=1000", + "CODEAF_PROTOTYPE_TASK_STORAGE=1", "CODEAF_PROTOTYPE_ARCHIVE_BINARY=" + archive, + furrow.BinaryEnvVar + "=" + archive} + homeA, homeB := lifecycleHome(t, model), lifecycleHome(t, model) + var fullReal, portableReal map[string]string + wsA, wsB := newWorkspace(t, "cold-source", false), newWorkspace(t, "cold-receiver", false) + lifecycleDaemonCleanup(t, homeA, archive) + lifecycleDaemonCleanup(t, homeB, archive) + t.Cleanup(func() { + // A takeover can start an engine for its new local workspace, beyond + // wsB. Stop only processes belonging to these two fresh profile roots. + for _, home := range []string{homeA, homeB} { + for _, pid := range engineDaemonsUnder(home) { + _ = syscall.Kill(pid, syscall.SIGTERM) + for until := time.Now().Add(10 * time.Second); time.Now().Before(until) && !terminalProcessExited(pid); { + time.Sleep(20 * time.Millisecond) + } + if !terminalProcessExited(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + } + }) + stopRecoveryEngineOnCleanup(t, env, homeA, wsA) + stopRecoveryEngineOnCleanup(t, env, homeB, wsB) + // The application's public pairing command establishes the first identity. + setup := startCommand(t, env, "storage_pair_setup", homeA, wsA, "pair", "--code") + setup.waitFor(20*time.Second, "codeaf pair") + setup.kill() + a := startWithEnv(t, env, "storage_pair_a", homeA, wsA, 180, 48) + a.skipSetup(t) + a.keys("Space", "Space") + a.waitFor(20*time.Second, "Add another device") + b := startCommand(t, env, "storage_pair_b", homeB, wsB, "pair") + if _, asked := waitPlain(b, 10*time.Second, "Press Enter to keep that"); asked { + b.keys("Enter") + } + screen := b.waitFor(20*time.Second, "/p/", "Check number") + link := linkShape.FindString(screen) + if link == "" { + t.Fatal("public pairing command did not show a link") + } + a.keys("M-d") + a.waitFor(10*time.Second, "Paste it here") + a.paste(link) + a.waitFor(20*time.Second, "wants to pair") + a.keys("a") + b.waitFor(25*time.Second, "Paired") + b.kill() + a.kill() + a = startWithEnv(t, env, "storage_cold_source", homeA, wsA, 180, 48, "chat", "--model", model) + a.skipSetup(t) + var source string + var manager taskstorage.Manager + if live { + a.lit("/task solo Create relay-cold-proof.txt containing exactly portable-copper-heron followed by a newline using a simple finite foreground printf command. Do not start background commands, daemons or delegates. Do not edit other files. Do not inspect ps, env, processes, host information or anything outside the assigned directory. Complete normally through plandb.") + a.keys("Enter") + openTasksPlace(t, a) + a.waitFor(6*time.Minute, "completed", fmt.Sprintf("%d done", 1)) + a.keys("Escape") + _ = filepath.WalkDir(homeA, func(path string, entry os.DirEntry, err error) error { + if err == nil && entry.Name() == "relay-cold-proof.txt" && strings.Contains(path, string(filepath.Separator)+"trees"+string(filepath.Separator)) { + source = filepath.Dir(path) + } + return nil + }) + if source == "" { + t.Fatal("completed default task has no resident proof file") + } + manager = taskstorage.Manager{Root: filepath.Join(homeA, "task-storage"), Trees: filepath.Dir(source), Vault: taskstorage.CLIBackend{Binary: archive, Store: filepath.Join(homeA, "task-storage", "vault")}} + resident, err := manager.Inspect(context.Background(), source) + if err != nil || resident.Phase != taskstorage.Resident { + t.Fatal("ordinary shell task must stay conservatively resident") + } + } + a.kill() + upgradeEngineCommand(t, binary(t), env, homeA, wsA, "--stop") + if len(engineDaemonsUnder(homeA)) != 0 { + t.Fatal("owned engine is still writing") + } + stopNative, cancelNative := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelNative() + if err := (cellstore.Daemon{Socket: filepath.Join(homeA, "v3", "engine-"+strings.TrimPrefix(filepath.Base(archive), "furrow-")+".sock"), Binary: archive}).Stop(stopNative); err != nil { + t.Fatal(err) + } + if live { + lifecycleCertifyOfflineFixture(t, homeA) + } else { + manager, source = lifecycleSeedOfflineTask(t, homeA, wsA, archive) + if corpus := os.Getenv("CODEAF_E2E_REAL_TASK_CORPUS"); corpus != "" { + fullReal, portableReal = lifecycleInjectRealCopy(t, corpus, source) + lifecycleSaveManifest(t, "expected-portable", portableReal) + lifecycleSaveManifest(t, "expected-full-local", fullReal) + } + } + manager.Busy = func(string) bool { return false } + ownerForCold := cell.Cell{Root: filepath.Dir(manager.Trees)} + carryForCold := taskcopy.Carry{} + manager.BeforeCapture = func(_ context.Context, path string) error { return carryForCold.PrepareCold(ownerForCold, path) } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { + return carryForCold.MarkCold(ownerForCold, path, snapshot) + } + if err := manager.Park(context.Background(), source); err != nil { + t.Fatal(err) + } + if live { + t.Log("PASS default task remained resident; manually certified offline fixture retired through public Manager; automatic shell retirement is NOT claimed") + } else { + t.Log("PASS explicitly seeded offline fixture retired through public Manager; no worker or model ran; automatic task lifecycle is NOT claimed") + } + if !live { + if err := manager.Ensure(context.Background(), source); err != nil { + t.Fatal(err) + } + if fullReal != nil { + actual := lifecycleContentManifest(t, source) + lifecycleSaveManifest(t, "offline-reopened-full-local", actual) + lifecycleRequireManifest(t, "offline full local reopen", fullReal, actual) + } + if err := manager.Park(context.Background(), source); err != nil { + t.Fatal(err) + } + t.Log("PASS full offline public Manager reopen verified before source application opens; source starts with the complete real cold artifact") + } + a = startWithEnv(t, env, "storage_certified_source", homeA, wsA, 180, 48, "chat", "--model", model) + a.skipSetup(t) + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatal("source was not cold before transfer") + } + lifecycleOpenCompletedCard(t, a) + cardScreen := a.waitFor(20*time.Second, "relay-cold-proof", "m puts it in your message") + if !strings.Contains(cardScreen, "done") { + t.Fatal("completed default task result card did not show its outcome") + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatal("reading completed result card eagerly expanded cold files") + } + t.Log("PASS actual completed default task result card opened and remained cold; this surface has no Work tab") + a.keys("Escape", "Escape") + manager.Busy = func(string) bool { return false } + owner := cell.Cell{Root: filepath.Dir(manager.Trees)} + carry := taskcopy.Carry{} + manager.BeforeCapture = func(_ context.Context, path string) error { return carry.PrepareCold(owner, path) } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { return carry.MarkCold(owner, path, snapshot) } + if err := manager.Park(context.Background(), source); err != nil { + t.Fatal(err) + } + // The existing read tool is a real user's route back to a completed copy; + // showing its record or hovering a task must not expand anything. + if live { + readPrompt := "Read the existing file " + filepath.Join(source, "relay-cold-proof.txt") + " using the read tool and report its exact line. Do not recreate it or edit any file." + a.lit(readPrompt) + a.keys("Enter") + answer, tools := privateMemoryAwaitAnswer(homeA, readPrompt, 2*time.Minute) + if !strings.Contains(answer, "portable-copper-heron") || !strings.Contains(strings.Join(tools, ","), "read") { + t.Fatal("actual read tool did not reopen the completed cold task") + } + } else { + t.Log("PASS offline public Manager exact reopen already verified before source application started; no model integration claimed") + } + postModelCount := len(callModels(t, homeA)) + postReceiverModelCount := len(callModels(t, homeB)) + if err := manager.Park(context.Background(), source); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatal("source did not return to cold storage") + } + // The source remains open so the normal online transfer asks for explicit + // takeover. The receiver has neither this task nor any local archive bytes. + b = startWithEnv(t, env, "storage_cold_receiver", homeB, wsB, 180, 48) + b.skipSetup(t) + b.keys("Space", "Space") + if live { + b.waitFor(45*time.Second, "cold-source") + } else { + b.waitFor(45*time.Second, "Offline real workspace") + } + b.keys("M-m") + b.waitFor(20*time.Second, "Continue this chat here?") + b.keys("1", "Enter") + b.waitFor(90*time.Second, "Moved from") + var copied string + for deadline := time.Now().Add(45 * time.Second); time.Now().Before(deadline); { + _ = filepath.WalkDir(homeB, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && entry.Name() == "relay-cold-proof.txt" && strings.Contains(path, string(filepath.Separator)+"trees"+string(filepath.Separator)) { + if data, err := os.ReadFile(path); err == nil && string(data) == "portable-copper-heron\n" { + copied = path + } + } + return nil + }) + if copied != "" { + break + } + time.Sleep(pollEvery) + } + if copied == "" { + t.Fatal("public takeover did not restore the cold task's exact portable file") + } + if live { + lifecycleOpenCompletedCard(t, b) + b.waitFor(20*time.Second, "relay-cold-proof", "m puts it in your message") + t.Log("PASS actual receiver completed result card reopened transferred task without a model call") + } else { + b.waitFor(20*time.Second, "Manually seeded offline fixture only") + t.Log("PASS actual public takeover reopened the labelled offline conversation; task-history migration is not claimed") + } + lifecycleDiskReport(t, "after_transfer_source_cold", homeA, homeB, wsA, wsB, relayStore) + if portableReal != nil { + if got := len(callModels(t, homeA)); got != postModelCount { + t.Fatal("unexpected model request after real-data injection") + } + if got := len(callModels(t, homeB)); got != postReceiverModelCount { + t.Fatal("unexpected receiver model request after real-data injection") + } + received := lifecycleContentManifest(t, filepath.Dir(copied)) + lifecycleSaveManifest(t, "received-all", received) + // A native fork creates these three local ownership identifiers. They + // are not user payload; permit only their exact paths and ID format. + for _, name := range []string{".furrow/family-id", ".furrow/fork-id", ".furrow/workspace-id"} { + if _, portable := portableReal[name]; portable { + continue + } + if _, exists := received[name]; !exists { + continue + } + path := filepath.Join(filepath.Dir(copied), filepath.FromSlash(name)) + parentInfo, parentErr := os.Lstat(filepath.Dir(path)) + info, err := os.Lstat(path) + if parentErr != nil || !parentInfo.IsDir() || err != nil || !info.Mode().IsRegular() { + t.Fatalf("native ownership identifier is not a local regular file: %s", name) + } + raw, readErr := os.ReadFile(path) + valid := readErr == nil && len(raw) == 33 && raw[32] == '\n' + if valid { + for _, b := range raw[:32] { + if !(b >= '0' && b <= '9' || b >= 'a' && b <= 'f') { + valid = false + break + } + } + } + if !valid { + t.Fatalf("unexpected native ownership identifier at %s", name) + } + delete(received, name) + } + lifecycleSaveManifest(t, "received-portable", received) + lifecycleRequireManifest(t, "portable receiver", portableReal, received) + if err := manager.Ensure(context.Background(), source); err != nil { + t.Fatal(err) + } + preserved := lifecycleContentManifest(t, source) + lifecycleSaveManifest(t, "reopened-full-local", preserved) + lifecycleRequireManifest(t, "full source capsule", fullReal, preserved) + if err := manager.Park(context.Background(), source); err != nil { + t.Fatal(err) + } + t.Logf("PASS complete real workspace after owned Git rebinding: %d local files retained, %d portable files exact at receiver; original copied Git metadata retained outside fixture", len(fullReal), len(portableReal)) + } + lifecycleDiskReport(t, "after_source_reopen_verified_and_reparked", homeA, homeB, wsA, wsB, relayStore) + for _, home := range []string{homeA, homeB} { + for _, got := range callModels(t, home) { + if got != model { + t.Fatalf("unexpected recorded model %q", got) + } + } + } + if live { + lifecycleCompletedCalls(t, homeA, model) + } else { + if len(callModels(t, homeA)) != 0 || len(callModels(t, homeB)) != 0 || refused.Load() != 0 { + t.Fatal("offline fixture made a model request") + } + t.Log("PASS offline fixture ZERO model requests, enforced by local refusing endpoint and both call logs") + } + // Capture the authentic terminal rendering; no reconstructed UI is evidence. + t.Logf("PASS public pair, approval and online takeover restored an archived task on a receiver without source objects.\n%s", b.capture()) + if !live { + lifecycleHandoff(t, homeB, wsB, relay, b.name, archive, filepath.Dir(copied), portableReal) + } +} + +func lifecycleSaveManifest(t *testing.T, name string, manifest map[string]string) { + t.Helper() + gate := os.Getenv("CODEAF_E2E_REAL_RELAY_HANDOFF") + if gate == "" { + return + } + rel, err := filepath.Rel(filepath.Dir(repoRoot(t)), gate) + if err != nil || !filepath.IsLocal(rel) { + t.Fatal("private manifest must stay task owned") + } + if err := os.MkdirAll(gate, 0o700); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gate, name+".json"), raw, 0o600); err != nil { + t.Fatal(err) + } +} + +func lifecycleRequireManifest(t *testing.T, scope string, expected, actual map[string]string) { + t.Helper() + for path, want := range expected { + got, ok := actual[path] + if !ok { + t.Fatalf("%s file absent: %s", scope, path) + } + if got != want { + wantMode, wantBody, _ := strings.Cut(want, ":") + gotMode, gotBody, _ := strings.Cut(got, ":") + if wantMode != gotMode { + t.Fatalf("%s mode/type mismatch at %s: expected %s received %s", scope, path, wantMode, gotMode) + } + if wantBody != gotBody { + t.Fatalf("%s bytes/link-target mismatch at %s", scope, path) + } + } + } + for path := range actual { + if _, ok := expected[path]; !ok { + t.Fatalf("%s unexpected file (including withheld payload): %s", scope, path) + } + } +} + +func lifecycleGit(t *testing.T, directory string, args ...string) []byte { + t.Helper() + argv := append([]string{"-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "user.email=e2e@example.com", "-c", "user.name=e2e", "-c", "commit.gpgsign=false", "-C", directory}, args...) + cmd := exec.Command("git", argv...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("owned fixture Git command %s failed: %v", args[0], err) + } + return out +} + +func lifecycleSeedOfflineTask(t *testing.T, home, workspace, archive string) (taskstorage.Manager, string) { + t.Helper() + metas, err := filepath.Glob(filepath.Join(home, "v3", "projects", "*", "*", cell.MetaPath)) + if err != nil || len(metas) == 0 { + t.Fatal("public blank chat did not create its cell") + } + sort.Strings(metas) + root := filepath.Dir(filepath.Dir(metas[len(metas)-1])) + sid := filepath.Base(root) + source := filepath.Join(root, cell.TreesDir, "1") + if err := os.MkdirAll(source, 0o700); err != nil { + t.Fatal(err) + } + lifecycleGit(t, source, "init", "-q", "-b", "fixture/offline-storage") + if err := os.WriteFile(filepath.Join(source, "relay-cold-proof.txt"), []byte("portable-copper-heron\n"), 0o600); err != nil { + t.Fatal(err) + } + lifecycleGit(t, source, "add", "--", "relay-cold-proof.txt") + lifecycleGit(t, source, "commit", "-q", "-m", "offline fixture marker") + now := time.Now().UTC() + journal := []map[string]any{ + {"type": "session", "version": 1, "id": sid, "cwd": workspace, "model": "deepseek/deepseek-v4.1-flash", "timestamp": now}, + {"type": "message", "role": "user", "content": "Offline real workspace storage fixture. No model or worker ran this fixture.", "timestamp": now}, + {"type": "message", "role": "assistant", "content": "Manually seeded offline fixture only. No model or worker generated this record.", "timestamp": now.Add(time.Nanosecond)}, + } + var lines strings.Builder + for _, line := range journal { + raw, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + lines.Write(raw) + lines.WriteByte('\n') + } + transcript := filepath.Join(root, cell.TranscriptPath) + if err := os.WriteFile(transcript, []byte(lines.String()), 0o600); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(root, "meta.json"), map[string]any{"id": sid, "title": "Offline real workspace storage fixture", "workspace": workspace, "created": now, "lastUserAt": now}) + entry := session.TaskIndexEntry{SessionID: sid, ID: "1", Title: "relay-cold-proof OFFLINE FIXTURE", Status: string(session.TaskDone), EndedAt: now, Outcome: "Manually seeded offline storage fixture. No model or worker ran.", ArtifactURI: "file://" + source} + raw, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(session.TaskIndexPath(transcript), append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + manager := taskstorage.Manager{Root: filepath.Join(home, "task-storage"), Trees: filepath.Dir(source), Vault: taskstorage.CLIBackend{Binary: archive, Store: filepath.Join(home, "task-storage", "vault")}} + return manager, source +} + +func lifecycleOpenCompletedCard(t *testing.T, r *rig) { + t.Helper() + r.lit("/history") + state, _ := r.waitForAny(20*time.Second, "/history", "m puts it in your message", say(t, "tasksFoldShutWord"), say(t, "tasksFoldOpenWord")) + if state == "/history" { + r.keys("Enter") + state, _ = r.waitForAny(20*time.Second, "m puts it in your message", say(t, "tasksFoldShutWord"), say(t, "tasksFoldOpenWord")) + } + // Returning to history preserves its selected child. The command can + // already open that record; only a conversation row has a fold to expand. + if state == "m puts it in your message" { + return + } + if state == say(t, "tasksFoldShutWord") { + r.keys("Right") + r.waitFor(20*time.Second, say(t, "tasksFoldOpenWord")) + } + r.keys("Down", "Enter") + r.waitFor(20*time.Second, "m puts it in your message") +} + +// This seeded UI regression never submits a model request. It checks the actual +// completed-record variant before the live storage journey spends any tokens. +func TestStorageLifecycleCompletedResultCardNavigationNoModel(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + home := lifecycleHome(t, "deepseek/deepseek-v4.1-flash") + ws := newWorkspace(t, "completed-card", false) + bucket := filepath.Join(home, "v3", "projects", strings.ReplaceAll(filepath.Clean(ws), string(filepath.Separator), "-")) + sid := "7100000000000001" + dir := filepath.Join(bucket, sid) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + statesSeedTranscript(t, dir, sid, ws, "inspect the completed mock task") + now := time.Now().Add(-time.Minute) + writeJSON(t, filepath.Join(dir, "meta.json"), map[string]any{"id": sid, "title": "Mock completed conversation", "workspace": ws, "created": now, "lastUserAt": now}) + entry := session.TaskIndexEntry{SessionID: sid, ID: "1", Title: "Mock completed storage fixture", Status: string(session.TaskDone), EndedAt: now, Outcome: "Saved the fictional marker."} + raw, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bucket, "tasks.jsonl"), append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + r := startWithEnv(t, []string{"CODEAF_BASE_URL=http://127.0.0.1:1", config.APIKeyEnv + "=unused-mock-key"}, "storage_card_navigation", home, ws, 180, 48, "chat", "--no-host") + r.skipSetup(t) + for i := 0; i < 2; i++ { + lifecycleOpenCompletedCard(t, r) + screen := r.waitFor(20*time.Second, "Mock completed storage fixture", "m puts it in your message") + if strings.Contains(screen, say(t, "roomTabsWords")) { + t.Fatal("completed record unexpectedly claimed a live room") + } + r.keys("Escape", "Escape") + } + if len(callModels(t, home)) != 0 { + t.Fatal("navigation called a model") + } + t.Log("PASS actual completed result card, Escape twice to conversation, repeat history navigation; zero model calls") +} + +// Certification is an explicit test-operator audit of this finite mock workflow. +// It does not remove or override the product's sticky unknown-writer marker. +func lifecycleCertifyOfflineFixture(t *testing.T, home string) { + t.Helper() + gate := os.Getenv("CODEAF_E2E_REAL_RELAY_HANDOFF") + if gate == "" { + t.Fatal("controlled offline fixture requires an explicit private audit gate") + } + rel, err := filepath.Rel(filepath.Dir(repoRoot(t)), gate) + if err != nil || !filepath.IsLocal(rel) { + t.Fatal("audit gate must be task owned") + } + if err := os.MkdirAll(gate, 0o700); err != nil { + t.Fatal(err) + } + audit := filepath.Join(gate, "offline-audit.json") + certificate := filepath.Join(gate, "offline-certified") + if _, err := os.Lstat(certificate); !os.IsNotExist(err) { + t.Fatal("offline certificate must be fresh") + } + raw, _ := json.Marshal(map[string]string{"home": home, "certificate": certificate, "requirement": "Review every actual worker shell command and confirm finite known fixture operations with no detached writer; all owned engines are stopped. This certifies only this copied fixture."}) + if err := os.WriteFile(audit, raw, 0o600); err != nil { + t.Fatal(err) + } + t.Log("OFFLINE AUDIT READY: private fixture audit receipt; waiting for explicit test-operator certification") + for until := time.Now().Add(5 * time.Minute); time.Now().Before(until); { + if _, err := os.Lstat(certificate); err == nil { + t.Log("controlled offline mock fixture explicitly certified") + return + } + select { + case <-t.Context().Done(): + t.Fatal("offline audit cancelled") + case <-time.After(time.Second): + } + } + t.Fatal("offline fixture was not explicitly certified; refusing retirement") +} + +// Keep the actual successful public-relay fixture alive for a physical receiver. +// The optional gate never sends a chat message or changes product behavior. +func lifecycleHandoff(t *testing.T, home, workspace, relay, session, archive, task string, manifest map[string]string) { + t.Helper() + if t.Failed() { + t.Log("handoff withheld because a prior acceptance assertion failed") + return + } + gate := os.Getenv("CODEAF_E2E_REAL_RELAY_HANDOFF") + if gate == "" { + return + } + owned := filepath.Dir(repoRoot(t)) + rel, err := filepath.Rel(owned, gate) + if err != nil || !filepath.IsLocal(rel) { + t.Fatal("handoff must stay within owned task root") + } + if manifest == nil { + t.Fatal("physical relay handoff requires verified real corpus") + } + if err := os.MkdirAll(gate, 0o700); err != nil { + t.Fatal(err) + } + release := filepath.Join(gate, "release") + if _, err := os.Lstat(release); !os.IsNotExist(err) { + t.Fatal("handoff release must be fresh") + } + manifestPath := filepath.Join(gate, "portable.json") + raw, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, raw, 0o600); err != nil { + t.Fatal(err) + } + count := len(callModels(t, home)) + receipt := map[string]any{"homeB": home, "wsB": workspace, "relayURL": relay, "tmuxSessionB": session, "binary": binary(t), "archive": archive, "receivedTaskRoot": task, "portableManifest": manifestPath, "modelCountBeforeHandoff": count, "releaseFile": release} + raw, err = json.Marshal(receipt) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gate, "ready.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + t.Log("PASS same-host assertions; private physical receiver handoff ready") + deadline := time.NewTimer(45 * time.Minute) + defer deadline.Stop() + poll := time.NewTicker(time.Second) + defer poll.Stop() + progress := time.NewTicker(30 * time.Second) + defer progress.Stop() + for { + select { + case <-t.Context().Done(): + t.Fatal("physical relay handoff context ended") + case <-deadline.C: + t.Fatal("physical relay handoff expired; fixture cleanup proceeding") + case <-progress.C: + t.Log("physical receiver handoff still active; owned fixture retained") + case <-poll.C: + if _, err := os.Lstat(release); err == nil { + if len(callModels(t, home)) != count { + t.Fatal("physical handoff unexpectedly made a model call") + } + t.Log("physical receiver released fixture; cleanup proceeding") + return + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + } + } +} + +func lifecycleInjectRealCopy(t *testing.T, corpus, destination string) (map[string]string, map[string]string) { + t.Helper() + owned := filepath.Dir(repoRoot(t)) + rel, err := filepath.Rel(owned, corpus) + if err != nil || !filepath.IsLocal(rel) { + t.Fatal("real corpus must be an already-owned isolated copy") + } + if info, err := os.Lstat(corpus); err != nil || !info.IsDir() { + t.Fatal("missing owned real corpus") + } + git := func(args ...string) []byte { + argv := append([]string{"-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", destination}, args...) + cmd := exec.Command("git", argv...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("owned fixture git %s failed: %v", args[0], err) + } + return out + } + branch := strings.TrimSpace(string(git("symbolic-ref", "--short", "HEAD"))) + entries, err := os.ReadDir(destination) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(destination, entry.Name())); err != nil { + t.Fatal(err) + } + } + entries, err = os.ReadDir(corpus) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Name() == ".git" { + continue + } // Copied original Git metadata remains outside the test workspace. + cmd := exec.Command("cp", "-a", "--reflink=auto", "--", filepath.Join(corpus, entry.Name()), destination) + if err := cmd.Run(); err != nil { + t.Fatalf("copy owned real workspace entry: %v", err) + } + } + if _, err := os.Lstat(filepath.Join(destination, "relay-cold-proof.txt")); !os.IsNotExist(err) { + t.Fatal("real corpus collides with generated proof marker") + } + if err := os.WriteFile(filepath.Join(destination, "relay-cold-proof.txt"), []byte("portable-copper-heron\n"), 0644); err != nil { + t.Fatal(err) + } + git("init", "-q", "-b", branch) + listing := strings.Split(strings.TrimRight(string(git("ls-files", "--others", "--exclude-standard", "-z")), "\x00"), "\x00") + held := map[string]bool{} + for _, finding := range (keys.Scanner{}).Paths(destination, listing) { + held[finding.Path] = true + } + var cleared []byte + portable := make(map[string]string) + full := lifecycleContentManifest(t, destination) + for _, path := range listing { + if path == "" || held[path] { + continue + } + if value, ok := full[filepath.FromSlash(path)]; ok { + portable[filepath.FromSlash(path)] = value + cleared = append(cleared, []byte(path)...) + cleared = append(cleared, 0) + } + } + cmd := exec.Command("git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", destination, "add", "--pathspec-from-file=-", "--pathspec-file-nul") + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + cmd.Stdin = strings.NewReader(string(cleared)) + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + git("-c", "user.email=e2e@example.com", "-c", "user.name=e2e", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "owned relay fixture baseline") + t.Logf("REAL COPY baseline after Git rebinding: local files=%d portable files=%d withheld-secret-files=%d ignored-or-nonportable=%d; no subsequent model calls", len(full), len(portable), len(held), len(full)-len(portable)-len(held)) + return full, portable +} + +func lifecycleContentManifest(t *testing.T, root string) map[string]string { + t.Helper() + out := make(map[string]string) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return err + } + out[rel] = "link:" + target + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported real corpus node") + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + out[rel] = fmt.Sprintf("%o:%x", info.Mode().Perm(), sha256.Sum256(data)) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func lifecycleCompletedCalls(t *testing.T, home, model string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(home, "logs", "calls.jsonl")) + if err != nil { + t.Fatal(err) + } + completed := 0 + for _, line := range strings.Split(string(raw), "\n") { + var row calllog.Record + if json.Unmarshal([]byte(line), &row) != nil || row.Phase != "" { + continue + } + if row.Model != "" && row.Model != model { + t.Fatalf("unexpected completed model %q", row.Model) + } + if row.Model == model && row.Status >= 200 && row.Status < 300 && row.CompletionTokens > 0 { + completed++ + } + } + if completed == 0 { + t.Fatal("no successful nonempty live model completion receipts") + } + t.Logf("verified %d successful nonempty OpenRouter-model completion receipts", completed) +} + +// Allocated ext4 blocks include catalogs, archive payloads, journals, WAL and +// relay frames. Inodes are counted once for hardlinks. APFS corpus measurements +// use isolated-container accounting instead of this file-block approximation. +func lifecycleAllocated(root string) int64 { + return lifecycleAllocatedMany(root) +} + +func lifecycleAllocatedMany(roots ...string) int64 { + seen := make(map[[2]uint64]bool) + var bytes int64 + for _, root := range roots { + _ = filepath.WalkDir(root, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return nil + } + info, err := os.Lstat(path) + if err != nil { + return nil + } + if s, ok := info.Sys().(*syscall.Stat_t); ok { + key := [2]uint64{uint64(s.Dev), s.Ino} + if !seen[key] { + bytes += s.Blocks * 512 + seen[key] = true + } + } + return nil + }) + } + return bytes +} + +func lifecycleDiskReport(t *testing.T, phase, a, b, wsA, wsB, relay string) { + t.Helper() + rows := map[string]int64{ + "source_profile_including_vault_and_cell_stores": lifecycleAllocated(a), + "source_isolated_login_including_native_fork_stores": lifecycleAllocated(a + ".host"), + "source_project_and_sibling_forks": lifecycleAllocated(filepath.Dir(wsA)), + "receiver_profile_including_vault_and_cell_stores": lifecycleAllocated(b), + "receiver_isolated_login_including_native_fork_stores": lifecycleAllocated(b + ".host"), + "receiver_project_and_sibling_forks": lifecycleAllocated(filepath.Dir(wsB)), + "relay_store": lifecycleAllocated(relay), + "total_hardlink_deduplicated": lifecycleAllocatedMany(a, a+".host", filepath.Dir(wsA), b, b+".host", filepath.Dir(wsB), relay), + } + raw, _ := json.Marshal(rows) + t.Logf("ALLOCATED %s %s", phase, raw) +} + +func lifecycleAwaitCold(t *testing.T, home, archive, previous string, within time.Duration) (taskstorage.Manager, string, taskstorage.State) { + t.Helper() + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + var candidates []string + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err == nil && entry.IsDir() && entry.Name() == "trees" { + candidates = append(candidates, path) + return filepath.SkipDir + } + return nil + }) + for _, trees := range candidates { + m := taskstorage.Manager{Root: filepath.Join(home, "task-storage"), Trees: trees, + Vault: taskstorage.CLIBackend{Binary: archive, Store: filepath.Join(home, "task-storage", "vault")}} + paths, err := m.Paths(context.Background()) + if err != nil { + continue + } + for _, path := range paths { + s, err := m.Inspect(context.Background(), path) + if err == nil && s.Phase == taskstorage.Cold && s.Snapshot != previous { + return m, path, s + } + } + } + time.Sleep(pollEvery) + } + t.Fatal("real task did not reach a new cold state within acceptance deadline") + return taskstorage.Manager{}, "", taskstorage.State{} +} + +func lifecycleHome(t *testing.T, model string) string { + t.Helper() + home := filepath.Join(shortRoot(t), "h") + if err := os.Mkdir(home, 0o700); err != nil { + t.Fatal(err) + } + rows := map[string]any{ + config.KeyIcons: config.IconsPlain, + config.KeyMemoryEnabled: false, + config.KeyTaskMaxLoad: 0, + "tools.approvalMode": "allow", + } + for _, key := range []string{config.KeyChatModel, config.KeyTaskModel, + config.KeyTierWorkerModel, config.KeyTierLowModel, config.KeyTierHighModel, + config.KeyTierReflexModel, config.KeyTierMastermindModel, config.KeyModelFallbacks} { + rows[key] = model + } + var pins []string + for _, role := range textRoles { + pins = append(pins, string(role)+":"+model) + } + rows[config.KeyModelRoles] = strings.Join(pins, ",") + raw, err := json.Marshal(rows) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + return home +} + +func lifecycleDaemonCleanup(t *testing.T, home, archive string) { + t.Helper() + // Register before engine/window cleanup so those writers finish before the + // private Furrow daemon flushes and releases this fixture's store handles. + t.Cleanup(func() { + socket := filepath.Join(home, "v3", "engine-"+strings.TrimPrefix(filepath.Base(archive), "furrow-")+".sock") + if len(socket) >= 100 { + sum := sha256.Sum256([]byte(home + "\x00" + archive)) + base := os.TempDir() + name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) + if len(filepath.Join(base, name, "e.sock")) >= 100 { + base = "/tmp" + } + socket = filepath.Join(base, name, "e.sock") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var pids []int + entries, _ := os.ReadDir("/proc") + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + raw, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) + if err == nil && strings.HasPrefix(string(raw), archive+"\x00serve\x00--socket\x00"+socket+"\x00") { + pids = append(pids, pid) + } + } + if err := (cellstore.Daemon{Socket: socket, Binary: archive}).Stop(ctx); err != nil { + t.Errorf("fixture storage daemon cleanup: %v", err) + } + for _, pid := range pids { + for ctx.Err() == nil && !terminalProcessExited(pid) { + time.Sleep(20 * time.Millisecond) + } + if !terminalProcessExited(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + t.Errorf("fixture storage daemon did not finish graceful shutdown") + } + } + }) +} diff --git a/internal/e2e/workspace_ownership_build_e2e_test.go b/internal/e2e/workspace_ownership_build_e2e_test.go new file mode 100644 index 0000000000..54cb3e2c27 --- /dev/null +++ b/internal/e2e/workspace_ownership_build_e2e_test.go @@ -0,0 +1,339 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +type ownershipBuildUsage struct { + Calls int64 `json:"calls"` + Input int64 `json:"input_tokens"` + Output int64 `json:"output_tokens"` + USD float64 `json:"usd"` +} + +type ownershipBuildStage struct { + Stage int `json:"stage"` + WorkspaceURI string `json:"workspace_uri"` + ChatURI string `json:"chat_uri"` + ProjectURI string `json:"project_uri"` + WorkflowSeconds float64 `json:"workflow_seconds"` + IndependentCheckSeconds float64 `json:"independent_check_seconds"` + TemporaryDirectory string `json:"temporary_directory"` + CacheDirectory string `json:"cache_directory"` + Usage ownershipBuildUsage `json:"cumulative_usage"` +} + +// This acceptance asks a real model to build and extend a small project. The +// primary checkpoints, registered working copies, delivered files and independent +// Python checks decide success; model prose is never treated as evidence. +func TestWorkspaceOwnershipLiveBuildAndExtendProject(t *testing.T) { + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatalf("requires CODEAF_E2E_MODEL=%s", model) + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 required") + } + home := lifecycleHome(t, model) + project := workspaceAt(t, filepath.Join(shortRoot(t), "model-build"), false) + // Tasks intentionally keep branches when the checkout is protected main. + // This build-and-deliver fixture starts on the existing ordinary work branch. + statesWorkBranch(t, project) + env := []string{config.APIKeyEnv + "=" + liveKey(t), "CODEAF_BASE_URL=https://openrouter.ai/api/v1", "CODEAF_PROFILE_DIR=", "CODEAF_SYNC_URL=off", "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1", "CODEAF_PROTOTYPE_TASK_STORAGE=1"} + t.Cleanup(func() { + binaries, _ := filepath.Glob(filepath.Join(home, "bin", "furrow-*")) + for _, binary := range binaries { + lifecycleDaemonCleanup(t, home, binary) + } + }) + stopRecoveryEngineOnCleanup(t, env, home, project) + rig := startWithEnv(t, env, "ownership_build", home, project, 180, 52, "chat", "--model", model, "--one-model", "--yolo") + rig.skipSetup(t) + evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR") + if evidence != "" { + if err := os.MkdirAll(evidence, 0700); err != nil { + t.Fatal(err) + } + // pipe-pane records the actual application stream, including terminal colors. + capturePath := filepath.Join(evidence, "build-live-stream.ansi") + command := "cat > " + shellQuote(capturePath) + if raw, err := exec.Command("tmux", "pipe-pane", "-o", "-t", rig.name, command).CombinedOutput(); err != nil { + t.Fatalf("record live terminal: %v %s", err, raw) + } + } + prompts := []string{ + "/task solo Build a small fictional Python standard-library project in your task working copy. Create mini_tasks.py with class TaskList: add(title) returns monotonically increasing integer IDs starting at 1; an empty title raises ValueError; complete(id) returns None and unknown IDs raise KeyError; list_open() returns a list of (id,title) tuples in insertion order for uncompleted tasks. Do not reset IDs when tasks finish. Create test_mini_tasks.py using unittest with at least four meaningful test methods, and update README.md with usage and the exact test command. Run PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v successfully. Also produce the runtime receipt described below. Do not delegate further, install dependencies, make commits yourself, or alter files outside this project and the assigned runtime scratch/cache. Finish normally with plandb done using your actual assigned task id and claimed agent only after checking the project.", + "/task solo Extend the existing fictional mini_tasks.py project from the previous completed task: add TaskList.pending_count() returning the number of uncompleted tasks. Preserve all existing add/complete/list_open behavior, keep existing tests and add meaningful pending_count tests, and update README.md. Read the existing project first; do not replace it with an unrelated rewrite. Before writing your runtime receipt, verify that $XDG_CACHE_HOME/build-cache-marker contains exactly MOCK_BUILD_CACHE from the first task, and preserve that cache marker. Run PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v successfully. Also produce a fresh runtime receipt described below. Do not delegate further, install dependencies or make commits yourself. Finish normally with plandb done using your actual assigned task id and claimed agent only after checking the extension.", + } + runtimeReceipt := " Runtime receipt: write build-workspace-uri.txt containing the actual CODEAF_WORKSPACE_URI environment value followed by a newline. Write build-runtime.txt containing actual CODEAF_WORKSPACE_URI, TMPDIR and XDG_CACHE_HOME values, one per line. Use mktemp, retain the resulting temporary file, and put its absolute path in build-temp-path.txt. In the first task only, write exactly MOCK_BUILD_CACHE without a newline into $XDG_CACHE_HOME/build-cache-marker. Keep these receipt files as deliverables." + registry, err := workspaceidentity.Open(filepath.Join(home, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + var stages []ownershipBuildStage + var previous string + for index, prompt := range prompts { + stage := index + 1 + began := time.Now() + rig.lit(prompt + runtimeReceipt) + rig.keys("Enter") + copy, run := ownershipAwaitBuiltStage(t, home, project, registry, previous, 6*time.Minute) + elapsed := time.Since(began).Seconds() + checked := time.Now() + output := ownershipCheckPythonProject(t, project, stage) + checkSeconds := time.Since(checked).Seconds() + runtime, err := os.ReadFile(filepath.Join(project, "build-runtime.txt")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(runtime)), "\n") + if len(lines) != 3 || lines[0] != run.URI { + t.Fatalf("stage %d runtime receipt does not name actual owned run", stage) + } + if raw, err := os.ReadFile(filepath.Join(run.Path, "build-workspace-uri.txt")); err != nil || strings.TrimSpace(string(raw)) != run.URI { + t.Fatalf("stage %d owned artifact lost its actual run identity", stage) + } + for _, item := range []struct { + role workspaceidentity.Role + path, owner string + }{{workspaceidentity.Temp, lines[1], run.URI}, {workspaceidentity.Cache, lines[2], run.ProjectURI}} { + record, err := registry.Lookup(item.role, item.path) + if err != nil || record.OwnerURI != item.owner { + t.Fatalf("stage %d actual runtime path is not registered to the expected owner: %+v %v", stage, record, err) + } + relative, err := filepath.Rel(home, item.path) + if err != nil || !filepath.IsLocal(relative) || strings.HasPrefix(item.path, run.Path+string(filepath.Separator)) { + t.Fatalf("stage %d runtime scratch/cache escaped assigned profile", stage) + } + } + temporary, err := os.ReadFile(filepath.Join(project, "build-temp-path.txt")) + if err != nil { + t.Fatal(err) + } + tempPath := strings.TrimSpace(string(temporary)) + if filepath.Dir(tempPath) != lines[1] { + t.Fatal("mktemp escaped the actual worker temporary directory") + } + if info, err := os.Stat(tempPath); err != nil || !info.Mode().IsRegular() { + t.Fatal("actual worker temporary file is unavailable") + } + if marker, err := os.ReadFile(filepath.Join(lines[2], "build-cache-marker")); err != nil || string(marker) != "MOCK_BUILD_CACHE" { + t.Fatal("shared project cache marker was not preserved") + } + if copy.ChatURI != run.OwnerURI || copy.ProjectURI != run.ProjectURI { + t.Fatal("primary checkpoint and registered run ownership disagree") + } + if index > 0 { + first := stages[0] + if run.URI == first.WorkspaceURI || run.OwnerURI != first.ChatURI || run.ProjectURI != first.ProjectURI { + t.Fatal("extension did not get a new run in the original chat and project") + } + if lines[1] == first.TemporaryDirectory || lines[2] != first.CacheDirectory { + t.Fatal("new run must have separate temporary files and reuse the project cache") + } + } + previous = run.URI + usage := ownershipBuildUsageAt(t, home, model) + stages = append(stages, ownershipBuildStage{Stage: stage, WorkspaceURI: run.URI, ChatURI: run.OwnerURI, ProjectURI: run.ProjectURI, WorkflowSeconds: elapsed, IndependentCheckSeconds: checkSeconds, TemporaryDirectory: lines[1], CacheDirectory: lines[2], Usage: usage}) + if evidence != "" { + ownershipBuildEvidence(t, filepath.Join(evidence, fmt.Sprintf("build-stage-%d.ansi", stage)), []byte(captureStyled(rig))) + ownershipBuildEvidence(t, filepath.Join(evidence, fmt.Sprintf("build-stage-%d-python.txt", stage)), []byte(output)) + } + t.Logf("stage %d passed: actual completed primary checkpoint, owned run, delivered project and independent Python checks; workflow %.3fs independent checks %.3fs", stage, elapsed, checkSeconds) + } + models := callModels(t, home) + if len(models) == 0 { + t.Fatal("project workflow has no real model call receipts") + } + for _, got := range models { + if got != model { + t.Fatalf("unexpected actual project model %q", got) + } + } + lifecycleCompletedCalls(t, home, model) + summary := struct { + Model string `json:"model"` + RecordedModelReceipts int `json:"recorded_model_receipts"` + Stages []ownershipBuildStage `json:"stages"` + Usage ownershipBuildUsage `json:"usage"` + Scope string `json:"scope"` + }{model, len(models), stages, ownershipBuildUsageAt(t, home, model), "Two real model tasks build and extend a mock project. Separate run scratch and shared project cache are verified. This test does not claim fork, relay, shell containment or long-term disk reclamation."} + raw, err := json.MarshalIndent(summary, "", " ") + if err != nil { + t.Fatal(err) + } + if evidence != "" { + ownershipBuildEvidence(t, filepath.Join(evidence, "build-project-summary.json"), append(raw, '\n')) + } + t.Logf("PASS actual model project build and extension:\n%s", raw) +} + +func ownershipAwaitBuiltStage(t *testing.T, home, project string, registry *workspaceidentity.Registry, previous string, within time.Duration) (*session.TaskCopyRecord, workspaceidentity.Record) { + t.Helper() + for until := time.Now().Add(within); time.Now().Before(until); { + raw, err := os.ReadFile(filepath.Join(project, "build-workspace-uri.txt")) + uri := strings.TrimSpace(string(raw)) + if err == nil && uri != "" && uri != previous { + run, err := registry.Resolve(uri) + if err == nil && run.Role == workspaceidentity.Run && run.OwnerURI != "" && run.ProjectURI != "" && run.Path != project { + owner, e := registry.Resolve(run.OwnerURI) + origin, p := registry.Resolve(run.ProjectURI) + if e != nil || owner.Role != workspaceidentity.Chat || p != nil || origin.Role != workspaceidentity.Project || origin.Path != project { + t.Fatal("actual project task has invalid chat/project identity") + } + var completed *session.TaskCopyRecord + _ = filepath.WalkDir(home, func(path string, d os.DirEntry, e error) error { + if e != nil || d.IsDir() || d.Name() != "tasks.json" { + return nil + } + data, e := os.ReadFile(path) + if e != nil { + return nil + } + var checkpoint struct { + Runs []struct { + Parent uint64 `json:"parent"` + Node string `json:"node"` + State string `json:"state"` + Copy *session.TaskCopyRecord `json:"copy"` + } `json:"runs"` + } + if json.Unmarshal(data, &checkpoint) != nil { + return nil + } + for _, row := range checkpoint.Runs { + if row.Parent == 0 && row.Node == "" && row.State == "done" && row.Copy != nil && row.Copy.URI == uri { + completed = row.Copy + return filepath.SkipAll + } + } + return nil + }) + ready := completed != nil + for _, name := range []string{"mini_tasks.py", "test_mini_tasks.py", "README.md", "build-runtime.txt", "build-temp-path.txt"} { + if !fileExists(filepath.Join(project, name)) || !fileExists(filepath.Join(run.Path, name)) { + ready = false + } + } + if ready { + return completed, run + } + } + } + time.Sleep(pollEvery) + } + t.Fatal("actual model task did not finish with both a primary checkpoint and delivered project files") + return nil, workspaceidentity.Record{} +} + +func ownershipCheckPythonProject(t *testing.T, project string, stage int) string { + t.Helper() + run := func(args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "python3", args...) + command.Dir = project + command.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("independent Python project check failed: %v\n%s", err, output) + } + return string(output) + } + output := run("-m", "unittest", "-v") + checks := `import pathlib,unittest +from mini_tasks import TaskList +suite=unittest.defaultTestLoader.discover(".",pattern="test_mini_tasks.py") +n=suite.countTestCases() +assert n>=4, n +x=TaskList() +assert x.list_open()==[] +a=x.add("copper"); b=x.add("heron") +assert (a,b)==(1,2) +assert x.list_open()==[(1,"copper"),(2,"heron")] +assert x.complete(a) is None +assert x.list_open()==[(2,"heron")] +assert x.add("third")==3 +try: + x.complete(987654) +except KeyError: + pass +else: + raise AssertionError("unknown task ID accepted") +try: + x.add("") +except ValueError: + pass +else: + raise AssertionError("empty title accepted") +assert "TaskList" in pathlib.Path("README.md").read_text() +` + if stage == 2 { + checks += `y=TaskList() +assert y.pending_count()==0 +first=y.add("one"); second=y.add("two") +assert y.pending_count()==2 +y.complete(first) +assert y.pending_count()==1 +y.complete(second) +assert y.pending_count()==0 +assert "pending_count" in pathlib.Path("README.md").read_text() +` + } + output += run("-c", checks) + return output +} + +func ownershipBuildUsageAt(t *testing.T, home, model string) ownershipBuildUsage { + t.Helper() + raw, err := os.ReadFile(filepath.Join(home, "v3", "usage.jsonl")) + if err != nil { + t.Fatal(err) + } + var total ownershipBuildUsage + for _, line := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var row struct { + Model string `json:"model"` + Calls int64 `json:"calls"` + Input int64 `json:"in"` + Output int64 `json:"out"` + USD float64 `json:"usd"` + } + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatal(err) + } + if row.Model != "" && row.Model != model { + t.Fatalf("usage includes unexpected model %q", row.Model) + } + total.Calls += row.Calls + total.Input += row.Input + total.Output += row.Output + total.USD += row.USD + } + return total +} + +func ownershipBuildEvidence(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/e2e/workspace_ownership_e2e_test.go b/internal/e2e/workspace_ownership_e2e_test.go new file mode 100644 index 0000000000..5d65afd7eb --- /dev/null +++ b/internal/e2e/workspace_ownership_e2e_test.go @@ -0,0 +1,416 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestWorkspaceOwnershipPlainProjectOnRealTerminal(t *testing.T) { + workspaceOwnershipTerminal(t, false, false) +} + +func TestWorkspaceOwnershipLivePlainProject(t *testing.T) { + workspaceOwnershipTerminal(t, true, false) +} + +func TestWorkspaceOwnershipLiveConcurrentChats(t *testing.T) { + workspaceOwnershipTerminal(t, true, true) +} + +func TestWorkspaceOwnershipConcurrentChatsOnRealTerminal(t *testing.T) { + workspaceOwnershipTerminal(t, false, true) +} + +// The original is an ordinary folder, with a prepared brief that must travel +// into the owned copy. Actual worker writes and independent source comparison +// verify placement; an assistant's claim about where it worked is insufficient. +func workspaceOwnershipTerminal(t *testing.T, live, concurrent bool) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + model, key, base := "stub/workspace-ownership", "fixture-workspace-key", "" + if live { + requireTmuxAndKey(t) + model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatalf("requires CODEAF_E2E_MODEL=%s", model) + } + key, base = liveKey(t), "https://openrouter.ai/api/v1" + } + home := lifecycleHome(t, model) + release := filepath.Join(home, "ownership-release") + project := filepath.Join(shortRoot(t), "plain-project") + if err := os.Mkdir(project, 0700); err != nil { + t.Fatal(err) + } + const brief = "Fictional packaging handoff. Keep the exact marker MOCK_COPPER_HERON.\n" + for name, body := range map[string]string{"BRIEF.md": brief, "input.txt": "mock source remains unchanged\n"} { + if err := os.WriteFile(filepath.Join(project, name), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + if !live { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": model, "context_length": 131072, "pricing": map[string]string{"prompt": "0.000001", "completion": "0.000001"}, "supported_parameters": []string{"tools", "tool_choice"}}}}) + case "/key", "/auth/key", "/credits": + fmt.Fprint(w, `{"data":{"limit_remaining":100,"total_credits":100,"total_usage":0}}`) + case "/chat/completions": + var body struct { + Stream bool `json:"stream"` + Messages []struct { + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "fixture decode", 400) + return + } + for _, msg := range body.Messages { + if assignment := freeConsentAssignment.FindStringSubmatch(string(msg.Content)); len(assignment) == 3 { + command := "grep -q MOCK_COPPER_HERON BRIEF.md && printf 'MOCK_COPPER_HERON\\n' > ownership-proof.txt && pwd > ownership-pwd.txt && printf '%s\\n' \"$CODEAF_WORKSPACE_URI\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" > ownership-runtime.txt && mktemp > ownership-temp.txt && printf mock-temp > \"$(cat ownership-temp.txt)\" && printf mock-cache > \"$XDG_CACHE_HOME/ownership-cache\" && mkdir -p " + cell.TaskDropping + " && printf mock-scratch > " + cell.TaskDropping + "/ownership-scratch && printf ready > " + cell.TaskDropping + "/ownership-ready && for ownership_wait in $(seq 1 90); do [ -f " + shellQuote(release) + " ] && break; sleep 1; done && test -f " + shellQuote(release) + " && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'mock prepared brief verified and owned artifact written'" + args, _ := json.Marshal(map[string]string{"command": command}) + freeConsentResponse(w, model, "", map[string]any{"index": 0, "id": "ownership-" + assignment[2], "type": "function", "function": map[string]string{"name": "bash", "arguments": string(args)}}, body.Stream) + return + } + } + freeConsentResponse(w, model, "Acknowledged.", nil, body.Stream) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + base = server.URL + } + env := []string{config.APIKeyEnv + "=" + key, "CODEAF_BASE_URL=" + base, "CODEAF_PROFILE_DIR=", "CODEAF_SYNC_URL=off", "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1"} + // Resolve the unpacked binary at cleanup, after the owned engine stops. + t.Cleanup(func() { + binaries, _ := filepath.Glob(filepath.Join(home, "bin", "furrow-*")) + for _, binary := range binaries { + lifecycleDaemonCleanup(t, home, binary) + } + }) + stopRecoveryEngineOnCleanup(t, env, home, project) + rig := startWithEnv(t, env, "workspace_ownership", home, project, 180, 48, "chat", "--model", model, "--one-model", "--yolo") + rig.skipSetup(t) + baseline := lifecycleContentManifest(t, project) + prompt := "/task solo In this fictional project, inspect the prepared BRIEF.md in your working directory. Verify that it contains MOCK_COPPER_HERON. Write ownership-proof.txt containing exactly MOCK_COPPER_HERON followed by a newline, and ownership-pwd.txt containing the output of pwd. Also write ownership-runtime.txt with CODEAF_WORKSPACE_URI, TMPDIR and XDG_CACHE_HOME environment values, one per line. Use mktemp, record its path in ownership-temp.txt and write exactly mock-temp without a trailing newline into that temporary file. Write exactly mock-cache without a trailing newline to a file ownership-cache inside XDG_CACHE_HOME. Check the proof with grep. Once all these writes and checks are complete, create .codeaf/ownership-ready containing ready. Before finishing, wait up to 90 seconds for the file " + release + " to exist; do not create it yourself. While waiting, leave your shell in the foreground. Then finish normally with plandb done using your actual assigned id and claimed agent. Do not delegate further or modify BRIEF.md or input.txt." + rig.lit(prompt) + rig.keys("Enter") + deadline := time.Now().Add(90 * time.Second) + if live { + deadline = time.Now().Add(5 * time.Minute) + } + var proof string + for time.Now().Before(deadline) { + _ = filepath.WalkDir(home, func(path string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() && d.Name() == "ownership-proof.txt" { + if raw, e := os.ReadFile(path); e == nil && string(raw) == "MOCK_COPPER_HERON\n" && fileExists(filepath.Join(filepath.Dir(path), cell.TaskDropping, "ownership-ready")) { + proof = path + return filepath.SkipAll + } + } + return nil + }) + if proof != "" { + break + } + time.Sleep(pollEvery) + } + if proof == "" { + t.Fatalf("no actual proof under owned profile; original_written=%t; screen=%s", fileExists(filepath.Join(project, "ownership-proof.txt")), rig.capture()) + } + root := filepath.Dir(proof) + pwd, err := os.ReadFile(filepath.Join(root, "ownership-pwd.txt")) + if err != nil || filepath.Clean(strings.TrimSpace(string(pwd))) != root { + t.Fatalf("worker pwd does not match owned proof: %v", err) + } + runtimeRaw, err := os.ReadFile(filepath.Join(root, "ownership-runtime.txt")) + if err != nil { + t.Fatal("worker did not record its runtime environment") + } + runtimeLines := strings.Split(strings.TrimSpace(string(runtimeRaw)), "\n") + if len(runtimeLines) != 3 || runtimeLines[0] == "" { + t.Fatalf("incomplete worker runtime: %q", runtimeRaw) + } + registry, err := workspaceidentity.Open(filepath.Join(home, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + run, err := registry.Resolve(runtimeLines[0]) + if err != nil || run.Role != workspaceidentity.Run || run.Path != root || run.OwnerURI == "" || run.ProjectURI == "" { + t.Fatalf("worker identity does not resolve to its admitted copy: %+v %v", run, err) + } + chat, err := registry.Resolve(run.OwnerURI) + if err != nil || chat.Role != workspaceidentity.Chat { + t.Fatal("run owner is not its registered conversation") + } + projectRecord, err := registry.Resolve(run.ProjectURI) + if err != nil || projectRecord.Role != workspaceidentity.Project || projectRecord.Path != project { + t.Fatal("run project identity does not resolve to the original project") + } + for _, path := range runtimeLines[1:] { + relative, e := filepath.Rel(home, path) + if e != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) || path == root || strings.HasPrefix(path, root+string(filepath.Separator)) { + t.Fatalf("runtime scratch/cache is not outside the deliverable copy under owned profile: %s", path) + } + } + tempRaw, err := os.ReadFile(filepath.Join(root, "ownership-temp.txt")) + tempPath := strings.TrimSpace(string(tempRaw)) + if err != nil || filepath.Dir(tempPath) != runtimeLines[1] { + t.Fatal("actual mktemp file escaped assigned temporary directory") + } + if raw, err := os.ReadFile(tempPath); err != nil || string(raw) != "mock-temp" { + t.Fatal("worker temporary file was not written at the recorded path") + } + if raw, err := os.ReadFile(filepath.Join(runtimeLines[2], "ownership-cache")); err != nil || string(raw) != "mock-cache" { + t.Fatal("worker cache was not written under assigned cache directory") + } + reopened, err := workspaceidentity.Open(filepath.Join(home, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + resolved, err := reopened.Resolve(run.URI) + if err != nil || resolved != run { + t.Fatal("registry reopen changed stable workspace identity") + } + if raw, err := os.ReadFile(filepath.Join(root, "BRIEF.md")); err != nil || string(raw) != brief { + t.Fatal("prepared brief did not survive into owned workspace") + } + actual := lifecycleContentManifest(t, project) + for _, name := range []string{".furrow/family-id", ".furrow/fork-id", ".furrow/workspace-id"} { + if _, existed := baseline[name]; existed { + continue + } + if _, exists := actual[name]; !exists { + continue + } + path := filepath.Join(project, filepath.FromSlash(name)) + info, err := os.Lstat(path) + parent, pe := os.Lstat(filepath.Dir(path)) + raw, re := os.ReadFile(path) + if err != nil || pe != nil || !parent.IsDir() || !info.Mode().IsRegular() || re != nil || len(raw) != 33 || raw[32] != '\n' { + t.Fatalf("invalid generated native ownership marker %s", name) + } + for _, b := range raw[:32] { + if !(b >= '0' && b <= '9' || b >= 'a' && b <= 'f') { + t.Fatalf("invalid generated native ownership ID %s", name) + } + } + delete(actual, name) + } + lifecycleRequireManifest(t, "original while task held", baseline, actual) + for name, want := range map[string]string{"BRIEF.md": brief, "input.txt": "mock source remains unchanged\n"} { + raw, err := os.ReadFile(filepath.Join(project, name)) + if err != nil || string(raw) != want { + t.Fatalf("original %s changed", name) + } + } + if concurrent { + rig.lit("/new") + rig.keys("Enter") + time.Sleep(time.Second) + rig.lit(prompt) + rig.keys("Enter") + var secondRoot string + for until := time.Now().Add(45 * time.Second); secondRoot == ""; { + _ = filepath.WalkDir(home, func(path string, d os.DirEntry, e error) error { + if e != nil || d.IsDir() || d.Name() != "ownership-runtime.txt" || filepath.Dir(path) == root { + return nil + } + if !fileExists(filepath.Join(filepath.Dir(path), cell.TaskDropping, "ownership-ready")) { + return nil + } + raw, e := os.ReadFile(path) + if e != nil { + return nil + } + rows := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(rows) != 3 { + return nil + } + second, e := registry.Resolve(rows[0]) + if e != nil || second.URI == run.URI || second.Path == root || second.OwnerURI == run.OwnerURI || second.ProjectURI != run.ProjectURI { + t.Fatalf("second concurrent chat did not receive distinct owned run/chat linked to same project: %+v %v", second, e) + } + if second.Role != workspaceidentity.Run || second.Path != filepath.Dir(path) { + t.Fatal("second worker identity and actual output directory disagree") + } + secondChat, e := registry.Resolve(second.OwnerURI) + if e != nil || secondChat.Role != workspaceidentity.Chat { + t.Fatal("second run chat owner invalid") + } + secondRoot = second.Path + return filepath.SkipAll + }) + if secondRoot != "" { + break + } + if time.Now().After(until) { + t.Fatalf("second concurrent chat never received its own working copy: %s", rig.capture()) + } + time.Sleep(pollEvery) + } + for _, path := range []string{root, secondRoot} { + if raw, e := os.ReadFile(filepath.Join(path, "ownership-proof.txt")); e != nil || string(raw) != "MOCK_COPPER_HERON\n" { + t.Fatal("concurrent task proof missing") + } + } + if fileExists(filepath.Join(project, "ownership-proof.txt")) { + t.Fatal("concurrent worker output reached project before release") + } + t.Log("PASS two concurrently held chats have different registered run and chat identities, same project identity, and independent actual working copies") + } + if err := os.WriteFile(release, []byte("release\n"), 0600); err != nil { + t.Fatal(err) + } + for until := time.Now().Add(90 * time.Second); !freeConsentDoneNote(home); { + if time.Now().After(until) { + t.Fatalf("owned task did not settle after release: %s", rig.capture()) + } + time.Sleep(pollEvery) + } + for until := time.Now().Add(20 * time.Second); !fileExists(filepath.Join(project, "ownership-proof.txt")); { + if time.Now().After(until) { + break + } + time.Sleep(pollEvery) + } + if fileExists(filepath.Join(project, cell.TaskDropping, "ownership-scratch")) { + t.Fatal("private worker scratch landed in original project") + } + for _, name := range []string{"ownership-proof.txt", "ownership-pwd.txt", "ownership-runtime.txt", "ownership-temp.txt"} { + if !fileExists(filepath.Join(project, name)) { + t.Fatalf("named deliverable %s did not land", name) + } + } + if concurrent { + for until := time.Now().Add(30 * time.Second); ownershipFinishedChats(home) < 2; { + if time.Now().After(until) { + t.Fatal("both concurrent chats must settle before engine restart") + } + time.Sleep(pollEvery) + } + oldPID := upgradePID(upgradeEngineCommand(t, binary(t), env, home, project, "--status")) + if oldPID == "" { + t.Fatal("no owned engine before idle restart") + } + rig.kill() + upgradeEngineCommand(t, binary(t), env, home, project, "--stop") + rig = startWithEnv(t, env, "workspace_ownership_reopen", home, project, 180, 48, "chat", "--model", model, "--one-model", "--yolo") + rig.skipSetup(t) + newPID := upgradePID(upgradeEngineCommand(t, binary(t), env, home, project, "--status")) + if newPID == "" || newPID == oldPID { + t.Fatal("owned engine restart did not create new process") + } + reopenedRegistry, e := workspaceidentity.Open(filepath.Join(home, "workspace-identities")) + if e != nil { + t.Fatal(e) + } + retained, e := reopenedRegistry.Resolve(run.URI) + if e != nil || retained != run { + t.Fatal("actual engine restart changed previously issued workspace identity") + } + if raw, e := os.ReadFile(filepath.Join(retained.Path, "ownership-proof.txt")); e != nil || string(raw) != "MOCK_COPPER_HERON\n" { + t.Fatal("prior workspace artifact unavailable after actual engine restart") + } + t.Log("PASS actual owned engine restarted after delivery; prior run URI still resolves to retained exact artifact; no task resume claim") + } + if live { + models := callModels(t, home) + if len(models) == 0 { + t.Fatal("no recorded live model calls") + } + for _, got := range models { + if got != model { + t.Fatalf("unexpected recorded model %q", got) + } + } + t.Logf("exact live model verified on %d call receipts", len(models)) + usage, err := os.ReadFile(filepath.Join(home, "v3", "usage.jsonl")) + if err != nil { + t.Fatal(err) + } + var totals struct { + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + USD float64 `json:"recorded_usd"` + } + for _, line := range strings.Split(strings.TrimSpace(string(usage)), "\n") { + var row struct { + Input int `json:"in"` + Output int `json:"out"` + USD float64 `json:"usd"` + } + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatal(err) + } + totals.Input += row.Input + totals.Output += row.Output + totals.USD += row.USD + } + summary, err := json.MarshalIndent(map[string]any{"model": model, "call_receipts": len(models), "usage": totals}, "", " ") + if err != nil { + t.Fatal(err) + } + t.Logf("live usage receipts before fixture cleanup: %s", summary) + if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { + for name, data := range map[string][]byte{"ownership-live-usage.json": summary, "ownership-live-ui.ansi": []byte(captureStyled(rig))} { + if err := os.WriteFile(filepath.Join(evidence, name), data, 0600); err != nil { + t.Fatal(err) + } + } + } + } + t.Logf("PASS registered URI resolves to owned copy and survives registry reopen; actual mktemp/cache writes routed outside deliverable copy; prepared brief available in owned worker copy; pwd and actual output agree; original user files byte-identical and startup manifest unchanged while held; named outputs landed after release and private scratch stayed private.\n%s", rig.capture()) +} + +// Each chat's public completion note must exist before the acceptance restarts +// its engine. A single done note could belong only to the first finished chat. +func ownershipFinishedChats(home string) int { + count := 0 + _ = filepath.WalkDir(home, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || d.Name() != "transcript.jsonl" { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + for _, line := range strings.Split(string(raw), "\n") { + var row struct { + Note bool `json:"note"` + Tags []struct { + State string `json:"state"` + } `json:"replyTags"` + } + if json.Unmarshal([]byte(line), &row) != nil || !row.Note { + continue + } + for _, tag := range row.Tags { + if tag.State == "done" { + count++ + return nil + } + } + } + return nil + }) + return count +} diff --git a/internal/e2e/workspace_ownership_latency_e2e_test.go b/internal/e2e/workspace_ownership_latency_e2e_test.go new file mode 100644 index 0000000000..44df3e306d --- /dev/null +++ b/internal/e2e/workspace_ownership_latency_e2e_test.go @@ -0,0 +1,113 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// This compares idle screen response, not provider speed or large registries. +// A caller supplies the saved baseline binary; neither application is installed. +func TestWorkspaceOwnershipIdleTerminalLatency(t *testing.T) { + baseline := os.Getenv("CODEAF_OWNERSHIP_BASELINE_BINARY") + if baseline == "" { + t.Skip("requires a saved workspace-ownership baseline binary") + } + for _, candidate := range []struct{ name, path string }{{"baseline", baseline}, {"candidate", binary(t)}} { + t.Run(candidate.name, func(t *testing.T) { + home := lifecycleHome(t, e2eModel) + project := t.TempDir() + var modelCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + modelCalls.Add(1) + http.Error(w, "model calls forbidden in screen timing", http.StatusForbidden) + return + } + if r.URL.Path == "/models" { + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": e2eModel, "context_length": 131072}}}) + return + } + fmt.Fprint(w, `{"data":{"limit_remaining":100,"total_credits":100,"total_usage":0}}`) + })) + defer server.Close() + env := []string{config.APIKeyEnv + "=fixture-latency", "CODEAF_BASE_URL=" + server.URL, "CODEAF_SYNC_URL=off", "CODEAF_PROFILE_DIR=", "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1"} + t.Cleanup(func() { + binaries, _ := filepath.Glob(filepath.Join(home, "bin", "furrow-*")) + for _, path := range binaries { + lifecycleDaemonCleanup(t, home, path) + } + }) + stopRecoveryEngineOnCleanup(t, env, home, project) + began := time.Now() + rig := startUpgradeOldWindow(t, candidate.path, env, home, project) + rig.skipSetup(t) + // This includes the harness's launch/setup patience; it is kept out + // of keyboard-to-screen samples and is not an app startup benchmark. + results := map[string][]float64{"harness_launch_and_setup_ms": {float64(time.Since(began)) / float64(time.Millisecond)}} + wait := func(name string, began time.Time, ready func(string) bool) { + t.Helper() + for time.Since(began) < 10*time.Second { + if ready(rig.capture()) { + results[name] = append(results[name], float64(time.Since(began))/float64(time.Millisecond)) + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("%s did not render:\n%s", name, rig.capture()) + } + send := func(keys ...string) { + t.Helper() + args := append([]string{"send-keys", "-t", rig.name}, keys...) + if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil { + t.Fatalf("keyboard delivery: %v %s", err, out) + } + } + chat := func(screen string) bool { return strings.Contains(screen, "space space home") } + for trial := 0; trial < 20; trial++ { + began = time.Now() + send("Space", "Space") + wait("home_ms", began, func(screen string) bool { return strings.Contains(screen, "type to search or start something new") }) + began = time.Now() + send("Escape") + wait("home_return_ms", began, chat) + send("-l", "/settings") + wait("command_ms", time.Now(), func(screen string) bool { return strings.Contains(screen, "/settings") }) + began = time.Now() + send("Enter") + wait("settings_ms", began, func(screen string) bool { + return strings.Contains(screen, "categories") && strings.Contains(screen, "type to search all") + }) + began = time.Now() + send("Escape") + wait("settings_return_ms", began, chat) + } + if modelCalls.Load() != 0 { + t.Fatalf("idle navigation made %d model calls", modelCalls.Load()) + } + raw, err := json.MarshalIndent(results, "", " ") + if err != nil { + t.Fatal(err) + } + t.Logf("actual terminal timing, 5ms polling plus command overhead:\n%s", raw) + if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { + if err := os.WriteFile(filepath.Join(evidence, "ownership-latency-"+candidate.name+".json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + }) + } +} diff --git a/internal/e2e/workspace_ownership_relay_e2e_test.go b/internal/e2e/workspace_ownership_relay_e2e_test.go new file mode 100644 index 0000000000..510a843b39 --- /dev/null +++ b/internal/e2e/workspace_ownership_relay_e2e_test.go @@ -0,0 +1,362 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstats" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// A scripted endpoint drives an actual task, including its worker and durable +// checkpoint. Pairing and takeover use the real terminal doors. No checkpoint, +// transcript, primary task, or workspace identity is fabricated by this fixture. +// This is protocol acceptance with fictional data, not live-model acceptance. +func TestWorkspaceOwnershipTaskIdentitySurvivesPublicRelayTake(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + ownershipRelayRequireCurrentBuild(t) + const model = "stub/workspace-uri-relay" + var modelCalls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": model, "context_length": 131072, "pricing": map[string]string{"prompt": "0.000001", "completion": "0.000001"}, "supported_parameters": []string{"tools", "tool_choice"}}}}) + case "/key", "/auth/key", "/credits": + fmt.Fprint(w, `{"data":{"limit_remaining":100,"total_credits":100,"total_usage":0}}`) + case "/chat/completions": + var request struct { + Stream bool `json:"stream"` + Model string `json:"model"` + Messages []struct { + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "fixture decode", 400) + return + } + if request.Model != model { + http.Error(w, "unexpected fixture model", 400) + return + } + modelCalls.Add(1) + for _, message := range request.Messages { + if assignment := freeConsentAssignment.FindStringSubmatch(string(message.Content)); len(assignment) == 3 { + command := `printf 'MOCK_URI_RELAY_HERON\n' > uri-relay-proof.txt && printf '%s\n' "$CODEAF_WORKSPACE_URI" > uri-relay-workspace.txt && printf '%s\n' "$XDG_CACHE_HOME" > uri-relay-cache-path.txt && printf source-private-cache > "$XDG_CACHE_HOME/source-private-cache.bin" && plandb done ` + assignment[1] + ` --agent ` + assignment[2] + ` --result 'uri-relay-proof mock artifact verified'` + arguments, _ := json.Marshal(map[string]string{"command": command}) + freeConsentResponse(w, model, "", map[string]any{"index": 0, "id": "uri-relay-" + assignment[2], "type": "function", "function": map[string]string{"name": "bash", "arguments": string(arguments)}}, request.Stream) + return + } + } + freeConsentResponse(w, model, "The fictional URI relay task is ready.", nil, request.Stream) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + relayStore := filepath.Join(t.TempDir(), "relay-store") + relay := startRelayAt(t, relayStore) + homeA, homeB := lifecycleHome(t, model), lifecycleHome(t, model) + sourceProject := workspaceAt(t, filepath.Join(shortRoot(t), "uri-source"), false) + receiverProject := workspaceAt(t, filepath.Join(shortRoot(t), "uri-receiver"), false) + baseEnv := []string{config.APIKeyEnv + "=fixture-uri-relay-key", "CODEAF_BASE_URL=" + server.URL, "CODEAF_PROFILE_DIR=", "CODEAF_SYNC_URL=" + relay, "CODEAF_SYNC_INTERVAL_MS=1000", "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1", "CODEAF_PROTOTYPE_TASK_STORAGE=1", "CODEAF_PROTOTYPE_ARCHIVE_BINARY=", furrow.BinaryEnvVar + "="} + envFor := func(home string) []string { + return append(append([]string(nil), baseEnv...), "FURROW_DATA_DIR="+filepath.Join(home, "native-store"), "FURROW_REMOTE_DATA_DIR="+filepath.Join(home, "native-remote")) + } + // The test's conservative storage flag preserves completed shell copies. No + // retirement or archive proof is claimed; unrestricted writers stay resident. + t.Cleanup(func() { + for _, home := range []string{homeA, homeB} { + binaries, _ := filepath.Glob(filepath.Join(home, "bin", "furrow-*")) + for _, native := range binaries { + lifecycleDaemonCleanup(t, home, native) + } + } + }) + t.Cleanup(func() { + for _, home := range []string{homeA, homeB} { + for _, pid := range engineDaemonsUnder(home) { + _ = syscall.Kill(pid, syscall.SIGTERM) + for until := time.Now().Add(10 * time.Second); time.Now().Before(until) && !terminalProcessExited(pid); { + time.Sleep(20 * time.Millisecond) + } + if !terminalProcessExited(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + } + }) + stopRecoveryEngineOnCleanup(t, envFor(homeA), homeA, sourceProject) + stopRecoveryEngineOnCleanup(t, envFor(homeB), homeB, receiverProject) + setup := startCommand(t, envFor(homeA), "uri_pair_setup", homeA, sourceProject, "pair", "--code") + setup.waitFor(20*time.Second, "codeaf pair") + setup.kill() + a := startWithEnv(t, envFor(homeA), "uri_pair_source", homeA, sourceProject, 180, 48, "chat", "--model", model, "--one-model", "--yolo") + a.skipSetup(t) + a.keys("Space", "Space") + a.waitFor(20*time.Second, "Add another device") + pairing := startCommand(t, envFor(homeB), "uri_pair_receiver", homeB, receiverProject, "pair") + if _, asked := waitPlain(pairing, 10*time.Second, "Press Enter to keep that"); asked { + pairing.keys("Enter") + } + screen := pairing.waitFor(20*time.Second, "/p/", "Check number") + link := linkShape.FindString(screen) + if link == "" { + t.Fatal("public pairing did not show its link") + } + a.keys("M-d") + a.waitFor(10*time.Second, "Paste it here") + a.paste(link) + a.waitFor(20*time.Second, "wants to pair") + a.keys("a") + pairing.waitFor(25*time.Second, "Paired") + pairing.kill() + a.keys("Escape", "Escape") + a.lit("/task solo Create uri-relay-proof.txt containing exactly MOCK_URI_RELAY_HERON followed by a newline. Write your CODEAF_WORKSPACE_URI into uri-relay-workspace.txt. Write source-private-cache.bin only inside XDG_CACHE_HOME. Finish normally through plandb. Do not delegate or inspect other projects.") + a.keys("Enter") + sourceRun, sourceChat, sourceCache := ownershipRelayActualTask(t, homeA, a) + sourceRegistry, err := workspaceidentity.Open(filepath.Join(homeA, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + sourceProjectRecord, err := sourceRegistry.Resolve(sourceRun.ProjectURI) + if err != nil { + t.Fatal(err) + } + if sourceProjectRecord.Path != sourceProject { + t.Fatal("actual source task was not assigned to its project") + } + b := startWithEnv(t, envFor(homeB), "uri_take_receiver", homeB, receiverProject, 180, 48, "chat", "--model", model, "--one-model", "--yolo") + b.skipSetup(t) + b.keys("Space", "Space") + // A direct /task has no foreground message to title this conversation. + // Select its actual remote activity row, not an elided project-sidebar path. + b.waitFor(60*time.Second, "untitled", "running on") + b.keys("M-m") + b.waitFor(20*time.Second, "Continue this chat here?") + takeBegan := time.Now() + b.keys("1", "Enter") + b.waitFor(90*time.Second, "Moved from") + coldTake := time.Since(takeBegan) + receiverRegistry, err := workspaceidentity.Open(filepath.Join(homeB, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + var receivedRun, receivedChat workspaceidentity.Record + for deadline := time.Now().Add(45 * time.Second); time.Now().Before(deadline); { + receivedRun, err = receiverRegistry.Resolve(sourceRun.URI) + if err == nil { + receivedChat, err = receiverRegistry.Resolve(sourceChat.URI) + } + if err == nil { + break + } + time.Sleep(pollEvery) + } + if err != nil { + t.Fatalf("accepted takeover did not admit the actual source identities: %v; screen=%s", err, b.capture()) + } + if receivedRun.URI != sourceRun.URI || receivedChat.URI != sourceChat.URI || receivedRun.Path == sourceRun.Path || receivedChat.Path == sourceChat.Path || receivedRun.OwnerURI != receivedChat.URI || receivedRun.ProjectURI != sourceRun.ProjectURI { + t.Fatalf("takeover lost stable identities: run=%+v chat=%+v", receivedRun, receivedChat) + } + for _, path := range []string{receivedRun.Path, receivedChat.Path} { + relative, err := filepath.Rel(homeB, path) + if err != nil || !filepath.IsLocal(relative) { + t.Fatal("receiver identity trusted a source-machine binding") + } + } + for name, want := range map[string]string{"uri-relay-proof.txt": "MOCK_URI_RELAY_HERON\n", "uri-relay-workspace.txt": sourceRun.URI + "\n"} { + raw, err := os.ReadFile(filepath.Join(receivedRun.Path, name)) + if err != nil || string(raw) != want { + t.Fatalf("transferred task artifact %s differs: %q %v", name, raw, err) + } + } + if _, err := receiverRegistry.Resolve(sourceCache.URI); err == nil { + t.Fatal("source-private cache identity was imported with task material") + } + _ = filepath.WalkDir(homeB, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && entry.Name() == "source-private-cache.bin" { + t.Errorf("private source cache was copied to receiver: %s", path) + } + return nil + }) + lifecycleOpenCompletedCard(t, b) + b.waitFor(20*time.Second, "uri-relay-proof", "m puts it in your message") + if len(callModels(t, homeB)) != 0 { + t.Fatal("receiver takeover or task-page inspection unexpectedly called the model") + } + for _, home := range []string{homeA, homeB} { + for _, recorded := range callModels(t, home) { + if recorded != model { + t.Fatalf("unexpected recorded fixture model %q", recorded) + } + } + } + coldCounts := ownershipRelayCounters(t, homeB) + warmBefore := ownershipRelayCounters(t, homeA) + // Take the same real chat back onto its already populated device. This + // measures the warm protocol without fabricating a checkpoint or cache hit. + a.keys("Space", "Space") + a.waitFor(30*time.Second, "untitled", "running on") + a.keys("M-m") + a.waitFor(20*time.Second, "Continue this chat here?") + takeBegan = time.Now() + a.keys("1", "Enter") + a.waitFor(90*time.Second, "Moved from") + warmTake := time.Since(takeBegan) + warmAfter := ownershipRelayCounters(t, homeA) + returned, err := sourceRegistry.Resolve(sourceRun.URI) + if err != nil || returned.Path != sourceRun.Path { + t.Fatal("warm return changed the original task workspace identity") + } + if raw, err := os.ReadFile(filepath.Join(returned.Path, "uri-relay-proof.txt")); err != nil || string(raw) != "MOCK_URI_RELAY_HERON\n" { + t.Fatal("warm return lost the actual task output") + } + measurements := map[string]any{ + "cold_confirm_to_moved_ms": float64(coldTake) / float64(time.Millisecond), + "warm_confirm_to_moved_ms": float64(warmTake) / float64(time.Millisecond), + "cold_receiver_blob_payload_bytes_down": coldCounts.BytesDown, + "warm_receiver_blob_payload_bytes_down": warmAfter.BytesDown - warmBefore.BytesDown, + "cold_receiver_blob_gets": coldCounts.Gets, + "warm_receiver_blob_gets": warmAfter.Gets - warmBefore.Gets, + "relay_allocated_bytes": lifecycleAllocated(relayStore), + "source_profile_allocated_bytes": lifecycleAllocated(homeA), + "receiver_profile_allocated_bytes": lifecycleAllocated(homeB), + "total_fixture_allocated_bytes": lifecycleAllocatedMany(homeA, homeA+".host", filepath.Dir(sourceProject), homeB, homeB+".host", filepath.Dir(receiverProject), relayStore), + "scope": "two local isolated profiles; tiny fictional task; byte counters exclude HTTP/TLS overhead; timing includes terminal delivery/polling; allocations include fixture runtime stores", + } + raw, err := json.MarshalIndent(measurements, "", " ") + if err != nil { + t.Fatal(err) + } + t.Logf("cold and warm public transfer measurements:\n%s", raw) + if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { + if err := os.WriteFile(filepath.Join(evidence, "ownership-relay-measurements.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + t.Logf("PASS actual /task produced durable run identity before transfer; public pair/approval/take preserved run/chat/project URIs with receiver-local bindings and exact artifacts; real task card opened without model calls; source-private cache excluded. Scripted protocol calls=%d, paid model calls=0.\n%s", modelCalls.Load(), b.capture()) +} + +func ownershipRelayCounters(t *testing.T, home string) cellstats.Line { + t.Helper() + files, err := filepath.Glob(filepath.Join(home, "v3", "sync", "stats", "*.jsonl")) + if err != nil { + t.Fatal(err) + } + var lines []cellstats.Line + for _, path := range files { + rows, err := cellstats.Read(home, strings.TrimSuffix(filepath.Base(path), ".jsonl")) + if err != nil { + t.Fatal(err) + } + lines = append(lines, rows...) + } + return cellstats.Total(lines) +} + +// ownershipRelayActualTask observes output and the primary checkpoint written by +// the real run. A side index or manually seeded transcript is insufficient. +func ownershipRelayActualTask(t *testing.T, home string, r *rig) (workspaceidentity.Record, workspaceidentity.Record, workspaceidentity.Record) { + t.Helper() + registry, err := workspaceidentity.Open(filepath.Join(home, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + var run, chat, cache workspaceidentity.Record + for deadline := time.Now().Add(90 * time.Second); time.Now().Before(deadline); { + var uri string + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && entry.Name() == "uri-relay-workspace.txt" && strings.Contains(path, string(filepath.Separator)+"trees"+string(filepath.Separator)) { + raw, _ := os.ReadFile(path) + uri = strings.TrimSpace(string(raw)) + return filepath.SkipAll + } + return nil + }) + if uri != "" { + run, err = registry.Resolve(uri) + if err == nil && run.Role == workspaceidentity.Run { + chat, err = registry.Resolve(run.OwnerURI) + if err == nil && ownershipRelayCheckpointDone(home, uri) { + cachePath, readErr := os.ReadFile(filepath.Join(run.Path, "uri-relay-cache-path.txt")) + if readErr != nil { + err = readErr + continue + } + cache, err = registry.Lookup(workspaceidentity.Cache, strings.TrimSpace(string(cachePath))) + if err == nil && cache.OwnerURI == run.ProjectURI && fileExists(filepath.Join(cache.Path, "source-private-cache.bin")) { + return run, chat, cache + } + } + } + } + time.Sleep(pollEvery) + } + t.Fatalf("actual task never produced its finished primary checkpoint and registered artifact: %v; screen=%s", err, r.capture()) + return run, chat, cache +} + +func ownershipRelayCheckpointDone(home, uri string) bool { + found := false + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() || entry.Name() != "tasks.json" { + return nil + } + raw, _ := os.ReadFile(path) + var checkpoint struct { + Runs []struct { + State session.TaskState `json:"state"` + Copy *session.TaskCopyRecord `json:"copy"` + } `json:"runs"` + } + if json.Unmarshal(raw, &checkpoint) == nil { + for _, run := range checkpoint.Runs { + if run.Copy != nil && run.Copy.URI == uri && run.State == session.TaskDone { + found = true + return filepath.SkipAll + } + } + } + return nil + }) + return found +} + +// The application is built by the caller once; the terminal must consume that +// exact committed candidate rather than quietly testing an earlier executable. +func ownershipRelayRequireCurrentBuild(t *testing.T) { + t.Helper() + version := upgradeEngineVersion(t, binary(t), nil, lifecycleHome(t, e2eModel)) + // Go omits vcs rows for linked worktrees; make build's application stamp + // is the supported identity in that environment. + command := exec.Command("git", "rev-parse", "--short", "HEAD") + command.Dir = repoRoot(t) + output, err := command.Output() + if err != nil { + t.Fatal(err) + } + expected := strings.TrimSpace(string(output)) + if !strings.HasPrefix(version, "codeaf "+expected+" built ") || strings.Contains(version, "(dirty)") { + t.Fatalf("relay proof requires a clean application built from current HEAD: application=%s expected=%s", strings.TrimSpace(version), expected) + } + t.Logf("Application artifact=%s; scripted protocol fixture, zero paid model calls", strings.TrimSpace(version)) +} diff --git a/internal/executor/environment.go b/internal/executor/environment.go new file mode 100644 index 0000000000..0ac8a78693 --- /dev/null +++ b/internal/executor/environment.go @@ -0,0 +1,67 @@ +package executor + +import ( + "context" + "os" + "os/exec" + "strings" +) + +// WithEnvironment routes conventional scratch/cache variables per seat. This is +// process configuration, not filesystem containment: a command can override it. +// Unspecified variables, including HOME, retain the caller's environment. +func WithEnvironment(seat Seat, environment []string) Seat { + if len(environment) == 0 { + return seat + } + return environmentSeat{Seat: seat, environment: append([]string(nil), environment...)} +} + +type environmentSeat struct { + Seat + environment []string +} + +func (s environmentSeat) In(dir string) Runner { + return environmentRunner{Runner: s.Seat.In(dir), environment: s.environment} +} +func (s environmentSeat) ForSetup() Seat { return WithEnvironment(ForSetup(s.Seat), s.environment) } +func (s environmentSeat) NoteModelCall(call ModelCall) { NoteModelCall(s.Seat, call) } +func (s environmentSeat) Interrupted() Interrupted { return InterruptedOn(s.Seat) } +func (s environmentSeat) Settle(ctx context.Context) { Settle(ctx, s.Seat) } +func (s environmentSeat) Drain(ctx context.Context) error { return Drain(ctx, s.Seat) } + +type environmentRunner struct { + Runner + environment []string +} + +func (r environmentRunner) Exec(ctx context.Context, request ExecRequest, output func(Chunk)) (ExecResult, error) { + request.Env = mergeEnvironment(request.Env, r.environment) + return r.Runner.Exec(ctx, request, output) +} +func (r environmentRunner) Command(ctx context.Context, request ExecRequest) (*exec.Cmd, error) { + request.Env = mergeEnvironment(request.Env, r.environment) + return r.Runner.Command(ctx, request) +} +func mergeEnvironment(base, extra []string) []string { + if base == nil { + base = os.Environ() + } + result := make([]string, 0, len(base)+len(extra)) + for _, entry := range base { + key, _, _ := strings.Cut(entry, "=") + replaced := false + for _, replacement := range extra { + name, _, _ := strings.Cut(replacement, "=") + if key == name { + replaced = true + break + } + } + if !replaced { + result = append(result, entry) + } + } + return append(result, extra...) +} diff --git a/internal/executor/environment_test.go b/internal/executor/environment_test.go new file mode 100644 index 0000000000..3776670d81 --- /dev/null +++ b/internal/executor/environment_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package executor_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestWorkspaceEnvironmentRoutesRealTemporaryFilesThroughBothRunnerDoors(t *testing.T) { + scratch, cache := t.TempDir(), t.TempDir() + originalHOME := os.Getenv("HOME") + seat := executor.WithEnvironment(executor.Host, []string{"TMPDIR=" + scratch, "XDG_CACHE_HOME=" + cache, "CODEAF_WORKSPACE_URI=codeaf-workspace://example/run"}) + command := `printf '%s\n' "$HOME" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI"; mktemp` + for _, door := range []string{"exec", "command", "setup"} { + runner := seat.In(t.TempDir()) + if door == "setup" { + runner = executor.ForSetup(seat).In(t.TempDir()) + } + request := executor.ExecRequest{Argv: []string{"sh", "-c", command}} + var output []byte + if door == "command" { + cmd, err := runner.Command(context.Background(), request) + if err != nil { + t.Fatal(err) + } + output, err = cmd.Output() + if err != nil { + t.Fatal(err) + } + } else { + result, err := runner.Exec(context.Background(), request, nil) + if err != nil || result.Exit != 0 { + t.Fatalf("%s: %#v %v", door, result, err) + } + output = result.Stdout + } + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) != 4 || lines[0] != originalHOME || lines[1] != cache || lines[2] != "codeaf-workspace://example/run" || filepath.Dir(lines[3]) != scratch { + t.Fatalf("%s environment: %q", door, output) + } + if _, err := os.Stat(lines[3]); err != nil { + t.Fatal(err) + } + } + if os.Getenv("HOME") != originalHOME { + t.Fatal("host environment changed") + } +} diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go index a87c846f0d..c8df262a9f 100644 --- a/internal/handoff/handoff.go +++ b/internal/handoff/handoff.go @@ -66,6 +66,9 @@ type Taker struct { // After hooks run in order once the tree is on disk, for example // vaultsync.Inject. Each may refuse the takeover. After []func(ctx context.Context, c cell.Cell) error + // Preflight validates the authenticated staged tree before acquiring a lease + // or replacing local files. It runs again if the acquired head moved. + Preflight func(context.Context, cell.Cell) error } // Taken is a chat this device now drives. @@ -177,6 +180,12 @@ func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how director } } if err != nil { + // Staged admission may have moved the engine's timeline binding even + // though local files stayed in place. A retry must inspect that original + // root rather than the staging folder we are about to remove. + if t.Preflight != nil && exists(c.Root) && exists(stage.Root) { + err = errors.Join(err, t.Local.Follow(ctx, stage, c)) + } return "", 0, nil, errors.Join(err, os.RemoveAll(stage.Root)) } return head, fence, plan, nil @@ -188,31 +197,41 @@ func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how director // at from, so the fetch writes only the paths the new head changed; with // nothing at from it starts empty. A chat in the person's own folder is only // completed in the store: it is restored where it stands once the lease is -// ours, so a staged restore would be a second whole-tree restore that is made -// and thrown away. It answers the plan of the record the device actually +// ours, except when Preflight needs the staged files to validate admission +// before any local replacement. It answers the plan of the record the device actually // acquired: another device may have published while the fetch ran, and the // next publish replaces the record's plan wholesale, so seeding from the // record read before the claim would carry the stale one (contract §22.2). func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head string, how directory.AcquireOpts) (string, uint64, []string, error) { inPlace := t.InPlace != nil && t.InPlace(cell.Cell{ID: stage.ID, Root: from}) - if !inPlace { + if !inPlace || t.Preflight != nil { if err := freshDir(stage.Root); err != nil { return "", 0, nil, fmt.Errorf("handoff: prepare %s: %w", stage.ID, err) } seedFrom(from, stage.Root) } - if err := t.bring(ctx, stage, inPlace, head); err != nil { + if err := t.bring(ctx, stage, inPlace && t.Preflight == nil, head); err != nil { return "", 0, nil, fmt.Errorf("handoff: fetch %s: %w", short(head), err) } + if t.Preflight != nil { + if err := t.Preflight(ctx, stage); err != nil { + return "", 0, nil, err + } + } got, err := t.Dir.Acquire(ctx, stage.ID, how) if err != nil { return "", 0, nil, fmt.Errorf("handoff: take %s: %w", stage.ID, err) } if got.Cell.Head != head { head = got.Cell.Head - if err := t.bring(ctx, stage, inPlace, head); err != nil { + if err := t.bring(ctx, stage, inPlace && t.Preflight == nil, head); err != nil { return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, fmt.Errorf("handoff: fetch %s: %w", short(head), err)) } + if t.Preflight != nil { + if err := t.Preflight(ctx, stage); err != nil { + return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, err) + } + } } return head, got.Cell.Lease.Fence, got.Cell.Frames, nil } diff --git a/internal/keys/reader_test.go b/internal/keys/reader_test.go new file mode 100644 index 0000000000..dd93559938 --- /dev/null +++ b/internal/keys/reader_test.go @@ -0,0 +1,39 @@ +package keys + +import ( + "errors" + "io" + "strings" + "testing" +) + +type rejectedReader struct{} + +func (rejectedReader) Read([]byte) (int, error) { return 0, errors.New("synthetic read failure") } + +func TestScannerReaderScreensWholeStreamAndNames(t *testing.T) { + for _, test := range []struct{ name, path, text, rule string }{ + {"safe", "note.txt", "safe mock bytes", ""}, + {"dotenv", ".env", "MOCK=value", RuleDotenv}, + {"template", ".env.example", "MOCK=replace-me", ""}, + {"boundary", "note.txt", strings.Repeat("x", (64<<10)-10) + "-----BEGIN PRIVATE KEY-----\nSYNTHETIC", "private-key-file"}, + {"tail", "note.txt", strings.Repeat("safe bytes\n", 200000) + "-----BEGIN PRIVATE KEY-----\nSYNTHETIC", "private-key-file"}, + } { + t.Run(test.name, func(t *testing.T) { + scanner := Scanner{} + found, err := scanner.Reader(test.path, strings.NewReader(test.text)) + if err != nil || found.Rule != test.rule { + t.Fatalf("rule=%q error=%v, want %q", found.Rule, err, test.rule) + } + }) + } + scanner := Scanner{} + if _, err := scanner.Reader("safe.txt", rejectedReader{}); err == nil { + t.Fatal("read failure judged clean") + } + // A caller owns framing: scanning a short object cannot consume a later one. + input := strings.NewReader("safe-----BEGIN PRIVATE KEY-----") + if found, err := scanner.Reader("safe.txt", io.LimitReader(input, 4)); err != nil || found.Rule != "" { + t.Fatalf("limited safe object: %v %v", found, err) + } +} diff --git a/internal/keys/scan.go b/internal/keys/scan.go index 50f8fe52c5..f5ca41a4db 100644 --- a/internal/keys/scan.go +++ b/internal/keys/scan.go @@ -89,6 +89,8 @@ func ScanTree(root string) []Finding { return Scanner{}.Walk(root) } // Ledger remembers the files found clean, so a file that has not changed since // is not read again. type Scanner struct { + readerBuffer []byte + Skip func(rel string) bool Ledger *Ledger // Visit observes screened entries so a caller can reuse this metadata walk. @@ -248,11 +250,33 @@ func contentInFile(abs string) (string, error) { return "", err } defer f.Close() - const overlap, readSize = 128, 64 << 10 - buf := make([]byte, readSize+overlap) - kept := 0 + return contentInReader(f, make([]byte, scanBufferSize)) +} + +const scanOverlap, scanReadSize = 128, 64 << 10 +const scanBufferSize = scanReadSize + scanOverlap + +// Reader screens a named byte stream without a filesystem walk or clean-file +// ledger. It reads the entire stream unless it finds a rule or a read error. +// A framing caller must verify its object length independently. Reader reuses +// bounded scratch memory; use a separate Scanner for concurrent streams. +func (s *Scanner) Reader(rel string, input io.Reader) (Finding, error) { + if !isTemplate(rel) { + if rule, hit := firstRule(nameRules, rel, nil); hit { + return Finding{Path: rel, Rule: rule}, nil + } + } + if len(s.readerBuffer) != scanBufferSize { + s.readerBuffer = make([]byte, scanBufferSize) + } + rule, err := contentInReader(input, s.readerBuffer) + return Finding{Path: rel, Rule: rule}, err +} + +func contentInReader(input io.Reader, buf []byte) (string, error) { + kept, emptyReads := 0, 0 for { - n, err := f.Read(buf[kept : kept+readSize]) + n, err := input.Read(buf[kept : kept+scanReadSize]) end := kept + n if name, hit := firstRule(contentRules, "", buf[:end]); hit { return name, nil @@ -263,7 +287,15 @@ func contentInFile(abs string) (string, error) { if err != nil { return "", err } - kept = copy(buf, buf[max(0, end-overlap):end]) + if n == 0 { + emptyReads++ + } else { + emptyReads = 0 + } + if emptyReads >= 100 { + return "", io.ErrNoProgress + } + kept = copy(buf, buf[max(0, end-scanOverlap):end]) } } diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 0e9087ec55..23427f9216 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1028,12 +1028,17 @@ The `$` is optional, and the amount can be a word — see the next section. ## /cache — the build cache, disk space, and why codeaf is using so much disk -`/cache` prints one line: how big the shared build cache is and where it lives — -`~/.codeaf/cache`. That directory holds the toolchain caches task workers fill as they +`/cache` prints one line: how big the shared build cache is and where it lives. +New caches live under `~/.codeaf/cache`. That directory holds the toolchain caches task workers fill as they build — go modules and build outputs, npm, pip, cargo — shared across sessions so the same module is downloaded once instead of per task. It can quietly grow to hundreds of megabytes; that growth is this cache, not your conversations. +Project caches also live beneath this cache directory and are reused by tasks +using the same working root. Older admitted cache locations remain in use; +`/cache` includes them in its size and lists them before an explicit clean. +Temporary files and saved work are outside the cache cleanup. + With nothing in it, `/cache` answers `the cache is empty · ~/.codeaf/cache`. **The cache is not your conversations.** Conversation history, tasks, settings and diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 326b8f4e13..829bd0c85b 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3390,6 +3390,51 @@ and timelines. Old temporary conversations still follow the usual seven-day retention policy. Previously orphaned timelines are not automatically purged: a missing directory alone does not prove that its history is disposable. +## Experimental compact task copies in an isolated profile + +Compact task storage is unfinished and off unless +`CODEAF_PROTOTYPE_TASK_STORAGE=1`, an explicit `CODEAF_HOME`, and +`CODEAF_PROTOTYPE_ARCHIVE_BINARY` select a separate profile and archive engine. +Ordinary profiles keep their existing cleanup behavior. + +Only controlled file-tool-only cases are eligible. Verification uses isolated +owned copies, trusted Git plumbing and no user hooks or external writers. File +tool names do not prove that an arbitrary custom implementation is contained. +Ordinary planned workers use bash, so their work does not qualify for automatic +retirement. Explicitly certified offline test copies exercise packing and +restoration separately; this is not an automatic normal-use lifecycle. + +Before an uncontained shell, check, automation or custom tool launches, the +prototype durably marks the **whole profile** uncertain: host processes can name +absolute paths outside their launch folder. Every task copy then stays resident. +A clean retry, engine restart or vanished process group never clears this mark; +a detached descendant might still write. There is no automatic expiry or purge. +This prototype does not promise disk reclamation for normal shell workloads. + +For an eligible task, codeaf saves all file contents and supported metadata, +verifies independent restoration and retires the expanded directory. A failure +preserves recovery state and reports `compact storage could not be prepared`. +The person's project and in-place folders are never retired by this mechanism. + +Verification covers file names and types, bytes, modes, owner and group, +modification times, extended attributes and link relationships. Access and +creation timestamps are not retained. Unsupported ACLs, filesystem flags, +sparse files and special nodes keep the copy resident. Exact metadata survival +after power loss has not been tested. + +Explicitly reading a saved task file restores its copy. An existing task room's +`work` tab also restores it where that room is available. Ordinary completed +tasks open a result card, which has no work tab; its record stays compact until +an explicit file read. Listing, hovering and polling do not expand archives. +Interrupted runs restore before their existing continuation; missing or corrupt +archives refuse it. Existing cost-recovery checks can still block continuation. +This does not enable continuing completed runs or programs. + +Linked Git copies retain their administration and index, with an owned lock and +compact backup. Shared project Git objects remain required; user locks survive. +Local archives retain ignored files. Relay keeps its screened transfer rules: +dependencies and secrets do not start travelling because a task was compressed. + ## Can bash tasks search the web and fetch pages? Yes. PlanDB run workers carry `web_search` and `web_fetch` as native tools, diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 610707f1da..7f43378795 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -1,5 +1,49 @@ # The worker harness +## Private task workspaces and saved workspace identities + +New conversations record a versioned private-task policy when they are created. +Existing conversations without that policy keep their previous placement and +history. Earlier private-workspace conversations retain their saved identity +and reopen without requiring the old experiment flag. The explicit +`CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` switch remains a compatibility opt-in. + +Foreground chat editing still uses the project you opened. Delegated tasks get +private copies, and cooperating tasks share the execution's declared layout. +A place mode you explicitly selected still applies. Naming a project does not +grant permission to land changes there: review and apply the retained results +explicitly. Concurrent changes in your own folder must be preserved. + +Saved runs carry a private workspace identity separate from their local path. +Reopening a known identity uses its recorded local location. An unknown identity +stops with an explanation rather than guessing a replacement folder. +Standard temporary files stay outside the deliverable copy; foreground chat +scratch has its own location too. Runs and chats on the same project share its +cache, so each task does not create another copy of that cache. +Your home and account configuration stay where they are. These locations are +not a shell sandbox: commands can still use external paths and other caches. +The experiment does not promise automatic removal of those files. + +A plain folder's private mirror needs its complete recorded Git history to +reopen on another device. Before transfer, the prototype screens that entire +portable history, including deleted historical files and metadata, using the +same secret rules as ordinary capture. If it finds secret material or cannot +complete screening, it refuses the new seal. Your local work and private archive +remain available; it does not strip files or silently send an incomplete copy. +A previous published snapshot can still be available, so a successful takeover +of that older snapshot does not mean the refused work has transferred. Keep +working locally and resolve the reported secret-screening refusal before trying +to move the latest work. There is no bypass that sends the unscreened history. + +The older singleton task-copy route supports one resolved working root for +takeover and chat forks. Several distinct roots on that route refuse before +unbundling task work or detaching the fork's task history. Known authenticated +aliases for the same recorded local root can resolve together. Structured +`workspace_set` tasks use separate per-root receiving associations instead; see +“Private tasks across several working roots” below. When continuing a remote +conversation, choose a local folder for each missing source association in the +folder picker. Mapping those folders grants no permission to apply results. + ## What happens when I type /task On this shipped default road, `/task ` starts a **run** rather than a @@ -1043,3 +1087,40 @@ conversation with nowhere to keep a plan, answers the older road — so a conver the run road cannot serve at all gets exactly the door it always had. A run road that was there and failed does NOT fall back: the task answers `task N did not start: ` and nothing is started on the older engine in its place. + +## Private tasks across several working roots + +In a conversation using private-task policy, a `propose_task` request may +declare a `workspace_set` of 1–64 roots: its primary slot and explicit source +folders, slots and `read`/`write` uses. The harness makes private sibling copies +and gives every delegated worker those routes and each root's scoped instructions. A model cannot +authorize landing into the source folders. These routes are not an operating +system sandbox; unrestricted commands can still reach other paths. +Named shell writes can target every admitted writable copy; read-only copies and +source folders remain protected. A refusal names the writable copies available. + +Restart requires every recorded execution root and its sibling layout. Missing, +relocated or incompletely admitted roots block the whole execution. Failed +preparation keeps its completed copies and ownership receipt; it does not retry +the missing work automatically. + +Relay requires compatible workspace-aware builds at both ends. Each source root needs +its own explicitly admitted receiving association. Complete screened histories +and edits are staged privately before execution can reopen; source landing +permission does not travel. Independent branches keep settled changed outputs +and original history, while inherited active tasks gain no runnable authority. +Visible empty folders and ordinary folder permissions travel in versioned +metadata. Unsafe or incomplete metadata and unsupported permissions refuse; +older prototype V2 snapshots need a new capture before transfer. +Folders must let their owner read, write and enter them. Special permission bits +are unsupported; the prototype refuses them rather than changing permissions. + +This remains a prototype. Mixed older singleton copies and workspace sets refuse +transfer, and active cross-device plan recovery is not established by restoring +files alone. Workspace sets stay resident rather than being partially compacted. +Ordinary profiles retain their existing task behavior. + +Workspace captures and checkpoint writes take a private conversation lock in +every profile, including legacy profiles, so policy activation cannot bypass +the checkpoint fence. This creates local control files during capture and +checkpoint writes; it does not change legacy task placement or rewrite history. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 79dd2790f1..58f1560c49 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -185,7 +185,7 @@ func classify(method string) callClass { MethodAutomationsDelete, MethodAutomationsRunNow, MethodAutomationsStopRun, MethodAutomationsClaim: return classAct - case MethodCompact: + case MethodCompact, MethodOpenPlanTaskWork: return classWork default: return classOrdered diff --git a/internal/remote/client.go b/internal/remote/client.go index 56edc16319..13ca19d87b 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -2253,6 +2253,23 @@ func (a *Agent) PlanTaskWork(id string) (session.PlanTaskWork, bool) { return result.Work, result.OK } +// OpenPlanTaskWork is an explicit storage reopen, never a background query. +// An older engine retains its original work tab through the read-only fallback. +func (a *Agent) OpenPlanTaskWork(id string) (session.PlanTaskWork, bool) { + payload, err := a.c.call(nil, MethodOpenPlanTaskWork, PlanTaskArgs{ID: id}) + if err != nil { + if strings.Contains(err.Error(), "no such method") { + return a.PlanTaskWork(id) + } + return session.PlanTaskWork{Error: err.Error()}, true + } + var result PlanTaskWorkResult + if err := json.Unmarshal(payload, &result); err != nil { + return session.PlanTaskWork{Error: "could not read the saved working copy response"}, true + } + return result.Work, result.OK +} + func (a *Agent) PlanNote(id, text string) error { _, err := a.c.call(nil, MethodPlanNote, PlanTextArgs{ID: id, Text: text}) return err diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index 94c1c99e3f..b077ba206b 100644 --- a/internal/remote/client_test.go +++ b/internal/remote/client_test.go @@ -1014,3 +1014,30 @@ func TestRunSummariesCrossWholeAndDroppedRefreshKeepsNothing(t *testing.T) { } <-dropped } + +func TestExplicitWorkOpenCrossesAndOnlyOldEnginesUseQueryFallback(t *testing.T) { + client, e := newEngine(t) + want := session.PlanTaskWork{Dir: "/work/copy", Read: true, Added: []string{"answer.txt"}} + e.answers[MethodOpenPlanTaskWork] = PlanTaskWorkResult{Work: want, OK: true} + e.answers[MethodPlanTaskWork] = PlanTaskWorkResult{Work: want, OK: true} + got, ok := client.Agent().OpenPlanTaskWork("t-6") + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("explicit work open: %+v %v", got, ok) + } + if len(e.calls(MethodPlanTaskWork)) != 0 { + t.Fatal("opening current storage invoked the polling query") + } + if classify(MethodOpenPlanTaskWork) != classWork { + t.Fatal("archive reopen runs on the ordered interaction lane") + } + e.fails[MethodOpenPlanTaskWork] = `engine: no such method "OpenPlanTaskWork"` + got, ok = client.Agent().OpenPlanTaskWork("t-6") + if !ok || !reflect.DeepEqual(got, want) || len(e.calls(MethodPlanTaskWork)) != 1 { + t.Fatalf("old engine fallback: %+v %v", got, ok) + } + e.fails[MethodOpenPlanTaskWork] = "snapshot object unavailable" + got, ok = client.Agent().OpenPlanTaskWork("t-6") + if !ok || !strings.Contains(got.Error, "snapshot object unavailable") || len(e.calls(MethodPlanTaskWork)) != 1 { + t.Fatalf("archive failure was hidden by a query fallback: %+v %v", got, ok) + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 6aca8764dd..1cde26d233 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2957,6 +2957,18 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { page, found := door.PlanTaskPage(args.ID) return json.Marshal(PlanTaskPageResult{Page: page, OK: found}) + case MethodOpenPlanTaskWork: + args, err := arg[PlanTaskArgs](call) + if err != nil { + return nil, err + } + door, ok := agent.(session.PlanWorkOpener) + if !ok { + return json.Marshal(PlanTaskWorkResult{Work: session.PlanTaskWork{NoDoor: true}}) + } + work, found := door.OpenPlanTaskWork(args.ID) + return json.Marshal(PlanTaskWorkResult{Work: work, OK: found}) + case MethodPlanTaskWork: args, err := arg[PlanTaskArgs](call) if err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 3e2bbb5a1c..9e610dbd26 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -610,6 +610,7 @@ const ( // [MethodPlanSpend]'s reason — an engine that does not know it answers // "no such method", and the tab draws the absence sentence it already // drew for an engine with no door. + MethodOpenPlanTaskWork = "OpenPlanTaskWork" // PlanTaskArgs → PlanTaskWorkResult; explicit storage reopen MethodPlanTaskWork = "PlanTaskWork" // PlanTaskArgs → PlanTaskWorkResult MethodPlanNote = "PlanNote" // PlanTextArgs → nothing MethodPlanPause = "PlanPause" // PlanTaskArgs → nothing diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index b41436cfb3..537b773b94 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -83,12 +83,24 @@ type BashWorker struct { dailyBudgetUSD float64 spendPreauthorized bool // seat carries the cell execution boundary into every worker call. - seat procexec.Seat + seat procexec.Seat + workspaceURI string + workspaceSet *session.WorkspaceSet } // WorkerOption changes how a worker is built; the zero set is the host worker. type WorkerOption func(*BashWorker) +// WithWorkspaceURI carries the admitted identity without deriving it from a path. +func WithWorkspaceURI(uri string) WorkerOption { + return func(w *BashWorker) { w.workspaceURI = uri } +} + +// WithWorkspaceSet carries the prototype's explicitly admitted root routes. +func WithWorkspaceSet(set *session.WorkspaceSet) WorkerOption { + return func(w *BashWorker) { w.workspaceSet = set.Clone() } +} + // WithSeat runs the worker's tool calls on seat. func WithSeat(seat procexec.Seat) WorkerOption { return func(w *BashWorker) { w.seat = seat } @@ -144,6 +156,8 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run } agent, err := session.NewBeltWorker(session.Config{ Workspace: w.workspace, + WorkspaceURI: w.workspaceURI, + WorkspaceSet: w.workspaceSet.Clone(), Model: w.model, Seat: w.seat, WaitForBeltSteps: true, @@ -163,6 +177,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run var receipts workerReceipts defer func() { used := settledWorkerUsage(agent, &receipts) + observeStorageProof(ctx, agent.TaskStorageQuiescent()) rep.USD, rep.TokensIn, rep.TokensOut = used.CostUSD, used.Input, used.Output w.recordSpend(task.ID, used.CostUSD, used.Input, used.Output) }() @@ -181,6 +196,9 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // on the harness's own note, because a round only begins again when the last // one ended on words with no action. brief := session.BeltWorkerBrief(w.store, &task, task.ID == w.store.RootID(), len(past) > 0, WakeClause(runCtx), w.always, w.workspace) + if w.workspaceSet != nil { + brief += "\n\n" + w.workspaceSet.WorkerInstructions() + } // noAction counts replies in a row that carried no tool call. A reply that // did call a tool resets the run to one — its own trailing words are the // first of the new run — and the fourth in a row fails the task. diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 2fcde96c48..590aba03cb 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -24,6 +24,7 @@ import ( type engine struct{} func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummary { + ctx, storage := withStorageProof(ctx) // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf // that lands done is checked against its acceptance, and a check that // does not hold becomes a fix task the run waits on. @@ -40,14 +41,14 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches // a `/task` run the way the manual says it reaches a headless one. - factory := CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ + factory := CrewFactoryWithWorkers(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ Work: spec.WorkModel, Plan: spec.PlanModel, Check: chatCheckSeat(spec.CheckModel), // AND UNDER `--one-model` ONE MODEL IS EVERY SEAT, the probe and the // check's environment rung included ([Seats.One]). One: spec.OneModel, - }, spec.Memory, false, spec.CompleterFor, spec.Sources) + }, spec.Memory, false, spec.CompleterFor, []WorkerOption{WithWorkspaceURI(spec.WorkspaceURI), WithWorkspaceSet(spec.WorkspaceSet)}, spec.Sources) // A `/task` run from interactive chat is never spend-preauthorized: `--yes-spend` // is a headless `codeaf do` flag, so a chat run is held to the daily ceiling // like every interactive turn (crew.go's CrewFactory, #1546). @@ -95,8 +96,9 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar OnWorker: spec.OnWorker, }) return session.RunSummary{ - Outcome: string(outcome), - Result: summary.Result, + StorageSafe: storage.safe() && spec.Delegate == nil, + Outcome: string(outcome), + Result: summary.Result, // WHICH LIMIT FIRED IS A FACT AND NOT A WORD IN THE OUTCOME SENTENCE: // the run's own typed answer crosses the seam here, mapped one for one, // so the session draws the ending out of the fact and never parses the diff --git a/internal/run/storage_proof.go b/internal/run/storage_proof.go new file mode 100644 index 0000000000..96a9c1f54b --- /dev/null +++ b/internal/run/storage_proof.go @@ -0,0 +1,31 @@ +package run + +import ( + "context" + "sync/atomic" +) + +type storageProofKey struct{} + +// storageProof collects facts only from workers after they have closed. The +// supervisor joins every worker before the engine reads this final proof. +type storageProof struct { + observed atomic.Int64 + unsafe atomic.Bool +} + +func withStorageProof(ctx context.Context) (context.Context, *storageProof) { + proof := &storageProof{} + return context.WithValue(ctx, storageProofKey{}, proof), proof +} +func observeStorageProof(ctx context.Context, safe bool) { + proof, _ := ctx.Value(storageProofKey{}).(*storageProof) + if proof == nil { + return + } + if !safe { + proof.unsafe.Store(true) + } + proof.observed.Add(1) +} +func (p *storageProof) safe() bool { return p != nil && p.observed.Load() > 0 && !p.unsafe.Load() } diff --git a/internal/run/workspace_set_test.go b/internal/run/workspace_set_test.go new file mode 100644 index 0000000000..f9047b5b9c --- /dev/null +++ b/internal/run/workspace_set_test.go @@ -0,0 +1,101 @@ +package run_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestDelegatedWorkerKeepsWorkspaceRoutesAndScopedRulesWithoutPrototypeFlag(t *testing.T) { + profile := t.TempDir() + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + chatDir := t.TempDir() + parent := filepath.Join(chatDir, "trees", "1") + app, library := filepath.Join(parent, "app"), filepath.Join(parent, "library") + for _, dir := range []string{app, library} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(app, "AGENTS.md"), []byte("APP_SCOPE_RULE: use app conventions."), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(library, "AGENTS.md"), []byte("LIBRARY_SCOPE_RULE: use library conventions."), 0600); err != nil { + t.Fatal(err) + } + store := runOpenStore(t) + tasks, err := store.AddMany([]plandb.TaskSpec{{ID: "child", ParentID: store.RootID(), Title: "small delegated step", Description: "inspect the dependency"}}) + if err != nil { + t.Fatal(err) + } + task, err := store.Claim(tasks[0].ID, tasks[0].ID) + if err != nil { + t.Fatal(err) + } + set := &session.WorkspaceSet{Version: 1, Primary: "app", Roots: []session.WorkspaceRootUse{ + {Slot: "app", Access: session.WorkspaceWrite, Copy: &session.TaskCopyRecord{Dir: app}}, + {Slot: "library", Access: session.WorkspaceRead, Copy: &session.TaskCopyRecord{Dir: library}}, + }} + registry, err := workspaceidentity.Open(filepath.Join(profile, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, chatDir) + if err != nil { + t.Fatal(err) + } + for i := range set.Roots { + root := &set.Roots[i] + project, err := registry.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + record, err := registry.Register(workspaceidentity.Run, root.Copy.Dir, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}) + if err != nil { + t.Fatal(err) + } + root.Copy.URI = record.URI + root.Copy.ChatURI = chat.URI + root.Copy.ProjectURI = project.URI + } + var requested string + provider := &seat{script: []step{func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + var content strings.Builder + for _, message := range messages { + content.WriteString(messageContent(message)) + } + requested = content.String() + if _, err := store.Done(task.ID, task.ID, "inspected", nil, nil); err != nil { + return nil, err + } + return toolReply(`{"command":"true"}`), nil + }}} + worker := run.NewBashWorker(store, app, "test/model", provider, run.WithWorkspaceSet(set)) + // A caller cannot change a seated worker's admitted routes afterward. + set.Roots[1].Copy.Dir = "/fixture/unadmitted-mutation" + if _, err := worker.Run(runContext(t), *task); err != nil { + t.Fatal(err) + } + for _, want := range []string{library, "APP_SCOPE_RULE", "LIBRARY_SCOPE_RULE", "not an operating-system sandbox"} { + if !strings.Contains(requested, want) { + t.Errorf("delegated request omitted %q", want) + } + } + if strings.Contains(requested, "/fixture/unadmitted-mutation") { + t.Fatal("worker routes alias caller-owned metadata") + } + if !strings.Contains(requested, "UNDER "+library+" AND NOWHERE ELSE") { + t.Fatal("secondary rules lost their local scope") + } +} diff --git a/internal/run/workspace_shell_scope_test.go b/internal/run/workspace_shell_scope_test.go new file mode 100644 index 0000000000..3a06e36e0e --- /dev/null +++ b/internal/run/workspace_shell_scope_test.go @@ -0,0 +1,125 @@ +package run_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestWorkspaceWorkerShellHonorsEachAdmittedWriteRoot(t *testing.T) { + testWorkspaceWorkerShellScope(t, false) +} +func TestWorkspaceWorkerShellProtectsReadOnlyPrimary(t *testing.T) { + testWorkspaceWorkerShellScope(t, true) +} +func testWorkspaceWorkerShellScope(t *testing.T, primaryRead bool) { + profile := t.TempDir() + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + registry, err := workspaceidentity.Open(filepath.Join(profile, "workspace-identities")) + if err != nil { + t.Fatal(err) + } + chatDir := t.TempDir() + chat, err := registry.Register(workspaceidentity.Chat, chatDir) + if err != nil { + t.Fatal(err) + } + base := filepath.Join(chatDir, "trees", "1") + set := &session.WorkspaceSet{Version: 1, Primary: "app"} + dirs := map[string]string{} + originals := map[string]string{} + for _, slot := range []string{"app", "library", "reference"} { + dir := filepath.Join(base, slot) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + dirs[slot] = dir + original := t.TempDir() + originals[slot] = original + project, err := registry.Register(workspaceidentity.Project, original) + if err != nil { + t.Fatal(err) + } + record, err := registry.Register(workspaceidentity.Run, dir, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}) + if err != nil { + t.Fatal(err) + } + access := session.WorkspaceWrite + if slot == "reference" || primaryRead && slot == "app" { + access = session.WorkspaceRead + } + set.Roots = append(set.Roots, session.WorkspaceRootUse{Slot: slot, Access: access, Copy: &session.TaskCopyRecord{URI: record.URI, ChatURI: chat.URI, ProjectURI: project.URI, Dir: dir, Ground: original}}) + } + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(dirs["library"], "escape")); err != nil { + t.Fatal(err) + } + store := runOpenStore(t) + tasks, err := store.AddMany([]plandb.TaskSpec{{ID: "scope", ParentID: store.RootID(), Title: "scope", Description: "write admitted roots only"}}) + if err != nil { + t.Fatal(err) + } + task, err := store.Claim(tasks[0].ID, tasks[0].ID) + if err != nil { + t.Fatal(err) + } + commands := []string{"printf moved > ../library/move-fd.txt 3>&2-", "printf denied >& ../reference/both.txt", "printf denied >| ../reference/clobber.txt", "printf denied &> ../reference/all.txt", "printf denied &>> ../reference/append-all.txt", "printf denied <> ../reference/read-write.txt", "printf fd > ../library/fd.txt 2>&1", "> ../reference/pure.txt", "X=y > ../reference/assignment.txt", "printf denied > " + filepath.Join(originals["app"], "original.txt"), "printf app > app.txt", "printf library > ../library/library.txt", "cd ../library && printf relative > relative.txt", "printf denied > ../reference/denied.txt", "printf denied > " + filepath.Join(outside, "denied.txt"), "printf denied > ../library/escape/denied.txt", "printf denied > mixed.txt > " + filepath.Join(outside, "mixed.txt"), "touch ../library/atomic.txt " + filepath.Join(outside, "atomic.txt")} + script := make([]step, 0, len(commands)+1) + for _, command := range commands { + command := command + script = append(script, func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + + return toolReply(`{"command":` + quoteShellJSON(command) + `}`), nil + }) + } + script = append(script, func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + var observed strings.Builder + for _, message := range messages { + observed.WriteString(messageContent(message)) + } + text := observed.String() + if !strings.Contains(text, "admitted writable copies: "+dirs["library"]) && !strings.Contains(text, "admitted writable copies: "+dirs["app"]+", "+dirs["library"]) { + t.Error("refusal omitted the complete writable-copy scope") + } + _, err := store.Done(task.ID, task.ID, "checked", nil, nil) + return toolReply(`{"command":"true"}`), err + }) + worker := run.NewBashWorker(store, dirs["app"], "test/model", &seat{script: script}, run.WithWorkspaceSet(set)) + if _, err := worker.Run(runContext(t), *task); err != nil { + t.Fatal(err) + } + expected := map[string]string{filepath.Join(dirs["library"], "move-fd.txt"): "moved", filepath.Join(dirs["library"], "fd.txt"): "fd", filepath.Join(dirs["app"], "app.txt"): "app", filepath.Join(dirs["library"], "library.txt"): "library", filepath.Join(dirs["library"], "relative.txt"): "relative"} + if primaryRead { + delete(expected, filepath.Join(dirs["app"], "app.txt")) + } + for path, want := range expected { + raw, err := os.ReadFile(path) + if err != nil || string(raw) != want { + t.Errorf("declared write target %s: %q %v", filepath.Base(path), raw, err) + } + } + if primaryRead { + if _, err := os.Lstat(filepath.Join(dirs["app"], "app.txt")); !os.IsNotExist(err) { + t.Errorf("readonly primary was written: %v", err) + } + } + for _, path := range []string{filepath.Join(dirs["reference"], "both.txt"), filepath.Join(dirs["reference"], "clobber.txt"), filepath.Join(dirs["reference"], "all.txt"), filepath.Join(dirs["reference"], "append-all.txt"), filepath.Join(dirs["reference"], "read-write.txt"), filepath.Join(dirs["reference"], "pure.txt"), filepath.Join(dirs["reference"], "assignment.txt"), filepath.Join(originals["app"], "original.txt"), filepath.Join(dirs["reference"], "denied.txt"), filepath.Join(outside, "denied.txt"), filepath.Join(outside, "mixed.txt"), filepath.Join(dirs["app"], "mixed.txt"), filepath.Join(dirs["library"], "atomic.txt"), filepath.Join(outside, "atomic.txt")} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Errorf("unapproved or mixed command wrote %s: %v", filepath.Base(path), err) + } + } +} + +func quoteShellJSON(value string) string { raw, _ := json.Marshal(value); return string(raw) } diff --git a/internal/session/agent.go b/internal/session/agent.go index a335268320..83ef5dfa51 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -114,6 +114,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { if client == nil { return nil, errors.New("session: completer is required") } + if err := configureOwnedChat(&config); err != nil { + return nil, fmt.Errorf("prepare conversation storage: %w", err) + } if config.newerBuild == nil { config.newerBuild = buildinfo.StaleNotice } @@ -132,6 +135,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { if strings.TrimSpace(system) == "" { system, own = renderSystem(config), true } + if config.WorkspaceURI != "" { + system += "\n\nWORKSPACE STORAGE\nUse mktemp or $TMPDIR for temporary files and $XDG_CACHE_HOME for ordinary caches. These locations are outside the files that a task lands. Keep task deliverables in the assigned working directory. The directory is an assignment, not a shell sandbox; external and remote writes still need an explicit purpose." + } agent := &Agent{ config: config, system: system, diff --git a/internal/session/automation_run.go b/internal/session/automation_run.go index 56091654d9..62b6c8bbf7 100644 --- a/internal/session/automation_run.go +++ b/internal/session/automation_run.go @@ -168,6 +168,9 @@ func (r *automationRunner) Look(ctx context.Context, item automation.Automation) // it replaces did not do. A failing command is evidence, not an error: "is CI // red" is often answered by an exit status. func automationLookCommand(ctx context.Context, workspace, command string) (automation.Sight, error) { + if err := markTaskStorageUncertain(workspace); err != nil { + return automation.Sight{}, err + } shell, args, shellErr := jobShell() if shellErr != nil { return automation.Sight{}, shellErr @@ -837,7 +840,7 @@ func automationWorktree(cfg Config, item automation.Automation) (taskTree, error Folder: item.Workspace, Dir: tree.dir, Branch: tree.branch, Root: tree.root, Home: tree.home, HomeSha: tree.homeSha, Mode: TaskModeWorktree, Cut: time.Now(), }) - return SaveMeta(cfg.Place.Dir, meta) + return saveMetaLocked(cfg.Place.Dir, meta) }) if err != nil { return taskTree{}, fmt.Errorf("record the worktree %s on %s: %w", tree.dir, tree.branch, err) diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index fd41ea7a1e..12fb52d1a5 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -66,6 +66,9 @@ func NewBeltWorker(config Config, completer Completer, task *plandb.Task, storeP // THE BELT IS THE SEAT'S, and InTask is the predicate's other half // ([Config.mayBashBelt]): a run worker is a task, and the switch above is // what said a run may build one. + if err := prepareWorkspaceWriteScope(&config); err != nil { + return nil, err + } config.InTask = true config.bashBelt = true // A RUN WORKER THINKS AT THE WORK SEAT, ONE ACTION PER ROUND. The belt's @@ -94,6 +97,9 @@ func NewBeltWorker(config Config, completer Completer, task *plandb.Task, storeP if err := os.MkdirAll(taskDir, 0o700); err != nil { return nil, err } + if err := configureOwnedWorker(&config, taskDir); err != nil { + return nil, err + } config.SessionFile = filepath.Join(taskDir, workerJournalName()) config.droppings = Place{Dir: taskDir} // AND THE WORKER IS BORN THROUGH THE PUBLIC DOOR, on the seat's provider. A diff --git a/internal/session/complexity_test.go b/internal/session/complexity_test.go index 0477d79691..9572332e0d 100644 --- a/internal/session/complexity_test.go +++ b/internal/session/complexity_test.go @@ -78,7 +78,7 @@ const theCeiling = 15 // refuse the debt it was built to hold; it can only refuse the next one. var complexityDebt = map[string]int{ "decodeTasks": 28, - "taskSegments": 26, + "taskSegments": 22, "TaskGraph.runFrontier": 18, "Agent.workTaskNode": 21, "declaredInvalidations": 21, diff --git a/internal/session/groundladder.go b/internal/session/groundladder.go index 684a2275f2..fd6b18ad3c 100644 --- a/internal/session/groundladder.go +++ b/internal/session/groundladder.go @@ -132,6 +132,7 @@ package session import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -348,6 +349,18 @@ func carveGround(ctx context.Context, order groundOrder) (taskTree, error) { // carveGroundDown is [carveGround] over the rungs it is handed, which is how a // copy that must be a particular kind of world comes back by the same walk. func carveGroundDown(ctx context.Context, ladder []groundRung, order groundOrder) (taskTree, error) { + if err := ensureTaskStorage(ctx, order.place, order.ground); err != nil { + return taskTree{}, fmt.Errorf("restore task ground: %w", err) + } + if manager, enabled := taskStorage(order.place); enabled && taskStorageOwns(order.place, order.dir) { + state, err := manager.Inspect(ctx, order.dir) + if err != nil { + return taskTree{}, err + } + if state.Snapshot != "" { + return taskTree{}, errors.New("this task already has saved work; continue its existing copy") + } + } var climb groundClimb for _, rung := range ladder { reaches, stood := rung.reach(order) @@ -1169,6 +1182,9 @@ func (t taskTree) carryBranchHomeLocked() (string, error) { // releaseLanded retires a completed copy before giving back its directory. // A failed retirement preserves the files Furrow needs to remove its timeline. func (t taskTree) releaseLanded() error { + if retainTaskStorage(t) { + return nil + } // THE SAME DOOR [taskTree.releaseKept] closes: a tree whose ground was never // made has nowhere a worktree could be registered and no repository to ask, // and a command with no directory runs in the process's own directory — so diff --git a/internal/session/jobs.go b/internal/session/jobs.go index dec15ba0d5..1b5a677451 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -854,6 +854,9 @@ func (r *jobRegistry) settled(one *job, code int) bool { // answering the person. The job's lifetime is the session's, and Close is the // only thing that ends it early. func (r *jobRegistry) start(command string) (*job, error) { + if err := markTaskStorageUncertain(r.workspace); err != nil { + return nil, err + } program, shellArgs, shellErr := bare.StreamingShell(command) if shellErr != nil { return nil, shellErr diff --git a/internal/session/land_run_tree.go b/internal/session/land_run_tree.go index 999b59ede1..826b0ce660 100644 --- a/internal/session/land_run_tree.go +++ b/internal/session/land_run_tree.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "slices" "sort" "strings" ) @@ -66,6 +67,10 @@ func LandRunTree(dir, base, title, model string) (branch string, changed []strin } else { changed = saved } + // A worker may have committed native bookkeeping itself. Its commit is + // retained, but those ownership records are never deliverables to lay over + // another workspace's identifiers. + changed = slices.DeleteFunc(changed, harnessWrote) if len(changed) == 0 && !headMoved { return "", nil, runNothingToLand, nil } diff --git a/internal/session/land_run_tree_test.go b/internal/session/land_run_tree_test.go index ad21e1d951..2899cfbee9 100644 --- a/internal/session/land_run_tree_test.go +++ b/internal/session/land_run_tree_test.go @@ -13,6 +13,32 @@ import ( "testing" ) +func TestLandRunTreeLeavesNativeOwnershipMarkersOutOfTheWork(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, ".furrow", "fork-id"), "0123456789abcdef0123456789abcdef\n") + writeFile(t, filepath.Join(repo, ".furrow", "workspace-id"), "fedcba9876543210fedcba9876543210\n") + writeFile(t, filepath.Join(repo, "report.md"), "the requested report\n") + _, changed, refusal, err := LandRunTree(repo, "", "write the report", "") + if err != nil || refusal != "" || !reflect.DeepEqual(changed, []string{"report.md"}) { + t.Fatalf("landing mixed ownership markers into the work: %v, %q, %v", changed, refusal, err) + } +} + +func TestLandRunTreeLeavesCommittedNativeMarkersOutOfItsLedger(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + repo := newTestRepo(t) + base := runTreeHead(repo) + writeFile(t, filepath.Join(repo, ".furrow", "fork-id"), "0123456789abcdef0123456789abcdef\n") + writeFile(t, filepath.Join(repo, "report.md"), "the requested report\n") + mustGit(t, repo, "add", "--all") + mustGit(t, repo, "-c", "user.name=Worker", "-c", "user.email=worker@example.test", "commit", "-m", "worker checkpoint") + _, changed, refusal, err := LandRunTree(repo, base, "write the report", "") + if err != nil || refusal != "" || !reflect.DeepEqual(changed, []string{"report.md"}) { + t.Fatalf("committed markers entered the landing ledger: %v, %q, %v", changed, refusal, err) + } +} + // A RUN'S WORK IS THE TREE'S OWN. Two files the tree says are changed land in // one commit on the branch the copy stands on, and the commit is the paths the // index built, not any list handed in. diff --git a/internal/session/landing_test.go b/internal/session/landing_test.go index 00b18409b4..1414316013 100644 --- a/internal/session/landing_test.go +++ b/internal/session/landing_test.go @@ -411,7 +411,13 @@ func TestEveryAgentThisPackageBuildsSaysWhereItsDroppingsGo(t *testing.T) { fset, files := packageSources(t) var silent []string seen := 0 - for path, file := range files { + for path := range files { + // Resolve local identifiers so a named Config is inspected just as a + // literal passed directly to the constructor is. + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatal(err) + } ast.Inspect(file, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { @@ -430,8 +436,8 @@ func TestEveryAgentThisPackageBuildsSaysWhereItsDroppingsGo(t *testing.T) { // A caller handing over a whole Config — [New], which forwards the // door's own — is not a construction site: the door's Place is the // session's and the question does not arise. - literal, ok := call.Args[0].(*ast.CompositeLit) - if !ok { + literal := constructedConfigLiteral(call.Args[0]) + if literal == nil { return true } seen++ @@ -464,6 +470,35 @@ func TestEveryAgentThisPackageBuildsSaysWhereItsDroppingsGo(t *testing.T) { } } +// constructedConfigLiteral follows only a lexical declaration, not a Config +// forwarded from a parameter or another function's return value. +func constructedConfigLiteral(expression ast.Expr) *ast.CompositeLit { + if literal, ok := expression.(*ast.CompositeLit); ok { + return literal + } + ident, ok := expression.(*ast.Ident) + if !ok || ident.Obj == nil { + return nil + } + switch declaration := ident.Obj.Decl.(type) { + case *ast.AssignStmt: + for i, lhs := range declaration.Lhs { + if name, ok := lhs.(*ast.Ident); ok && name.Obj == ident.Obj && i < len(declaration.Rhs) { + literal, _ := declaration.Rhs[i].(*ast.CompositeLit) + return literal + } + } + case *ast.ValueSpec: + for i, name := range declaration.Names { + if name.Obj == ident.Obj && i < len(declaration.Values) { + literal, _ := declaration.Values[i].(*ast.CompositeLit) + return literal + } + } + } + return nil +} + // ── deliverables ──────────────────────────────────────────────────────────── // A BORROWED session paints into its own artifacts/ and never into the diff --git a/internal/session/loop.go b/internal/session/loop.go index 789b3482e6..b80baad80f 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3609,6 +3609,15 @@ func (a *Agent) executeTool(ctx context.Context, ep *episode, hub *eventHub, cal // owns its process, is recorded like every other. A seat that could not log the // call did not run it, and says so as the tool's own error. func (a *Agent) executeRecorded(ctx context.Context, tool bare.Tool, args json.RawMessage) (text string, isError bool, err error) { + // File-only builtins have no arbitrary process/custom-tool execution road. + // Everything else must conservatively retain the workspace after restart. + switch tool.Name { + case "read", "write", "edit": + default: + if err := markTaskStorageUncertain(a.config.Workspace); err != nil { + return "", true, err + } + } args, moved, mapErr := a.movedToolArguments(tool.Name, args) if mapErr != nil { return "", true, mapErr diff --git a/internal/session/metalock_test.go b/internal/session/metalock_test.go index ee29037ad7..e2f742bef9 100644 --- a/internal/session/metalock_test.go +++ b/internal/session/metalock_test.go @@ -255,6 +255,9 @@ func TestPuttingAConversationAwayFromHomeWhileItsTurnIsSealedKeepsBoth(t *testin func TestAFolderWhoseLockCannotBeTakenStillStamps(t *testing.T) { dir := filepath.Join(t.TempDir(), "0123456789abcdef") seedMetaLockConversation(t, dir) + if err := os.Remove(filepath.Join(dir, placeMetaLock)); err != nil { + t.Fatal(err) + } if err := os.Mkdir(filepath.Join(dir, placeMetaLock), 0o700); err != nil { t.Fatal(err) } diff --git a/internal/session/metatruth.go b/internal/session/metatruth.go index e2fd27816b..6080c0d6c6 100644 --- a/internal/session/metatruth.go +++ b/internal/session/metatruth.go @@ -26,24 +26,28 @@ import ( // .cell/session.json holds it. Every path in it is spelled against a named // base (pathcodec.go), never absolute. type SessionTruth struct { - V uint16 `json:"V"` - LaunchDir string `json:"launch_dir,omitempty"` - Owned bool `json:"owned,omitempty"` - Effort string `json:"effort,omitempty"` - Approval string `json:"approval,omitempty"` - Places []PlaceRef `json:"places,omitempty"` - Trees []StandingTree `json:"trees,omitempty"` - Archived bool `json:"archived,omitempty"` - ArchivedTasks map[string]bool `json:"archived_tasks,omitempty"` + V uint16 `json:"V"` + ChatURI string `json:"chat_uri,omitempty"` + WorkspacePolicy *WorkspacePolicy `json:"workspace_policy,omitempty"` + LaunchDir string `json:"launch_dir,omitempty"` + Owned bool `json:"owned,omitempty"` + Effort string `json:"effort,omitempty"` + Approval string `json:"approval,omitempty"` + Places []PlaceRef `json:"places,omitempty"` + Trees []StandingTree `json:"trees,omitempty"` + Archived bool `json:"archived,omitempty"` + ArchivedTasks map[string]bool `json:"archived_tasks,omitempty"` } const truthVersion = 1 func (m Meta) truth() SessionTruth { - return SessionTruth{truthVersion, m.LaunchDir, m.Owned, m.Effort, m.Approval, m.Places, m.Trees, m.Archived, m.ArchivedTasks} + return SessionTruth{V: truthVersion, ChatURI: m.ChatURI, WorkspacePolicy: m.WorkspacePolicy, LaunchDir: m.LaunchDir, Owned: m.Owned, Effort: m.Effort, Approval: m.Approval, Places: m.Places, Trees: m.Trees, Archived: m.Archived, ArchivedTasks: m.ArchivedTasks} } func (m *Meta) setTruth(t SessionTruth) { + m.ChatURI = t.ChatURI + m.WorkspacePolicy = t.WorkspacePolicy m.LaunchDir, m.Owned, m.Effort, m.Approval = t.LaunchDir, t.Owned, t.Effort, t.Approval m.Places, m.Trees, m.Archived, m.ArchivedTasks = t.Places, t.Trees, t.Archived, t.ArchivedTasks } diff --git a/internal/session/place.go b/internal/session/place.go index 982ab7d4a1..ef06d7715f 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -233,6 +233,9 @@ type Meta struct { // ID is the session's id — the same 16-hex id the transcript header // carries, and the folder's name. ID string `json:"id"` + // ChatURI is stable conversation identity carried in the sealed truth. + ChatURI string `json:"chat_uri,omitempty"` + WorkspacePolicy *WorkspacePolicy `json:"workspace_policy,omitempty"` // Title is what a picker row says. Empty until something names the // session; an empty title marks a session the launch groom may reuse. Title string `json:"title,omitempty"` @@ -418,7 +421,7 @@ func SetArchived(dir string, archived bool) error { return nil } meta.Archived = archived - return SaveMeta(dir, meta) + return saveMetaLocked(dir, meta) }) } @@ -445,7 +448,7 @@ func SetTaskArchived(dir, sessionID, taskID string, archived bool) error { } else { delete(meta.ArchivedTasks, taskID) } - return SaveMeta(dir, meta) + return saveMetaLocked(dir, meta) }) } @@ -454,12 +457,36 @@ func SetTaskArchived(dir, sessionID, taskID string, archived bool) error { // cell layout the truth-only fields go to .cell/ first and meta.json keeps the // summary alone, so no field lives in two files. func SaveMeta(dir string, meta Meta) error { + return withMetaLock(dir, func() error { return saveMetaLocked(dir, meta) }) +} + +// saveMetaLocked preserves immutable identity while a metadata transaction holds the lock. +func saveMetaLocked(dir string, meta Meta) error { if strings.TrimSpace(dir) == "" { return fmt.Errorf("save session meta: no session directory") } if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("save session meta: %w", err) } + current, err := LoadMeta(dir) + if err != nil { + return fmt.Errorf("read session identity: %w", err) + } + if err := validateWorkspacePolicy(meta.WorkspacePolicy); err != nil { + return err + } + if current.WorkspacePolicy != nil { + if meta.WorkspacePolicy != nil && *meta.WorkspacePolicy != *current.WorkspacePolicy { + return fmt.Errorf("save session meta: workspace policy cannot be replaced") + } + meta.WorkspacePolicy = current.WorkspacePolicy + } + if current.ChatURI != "" { + if meta.ChatURI != "" && meta.ChatURI != current.ChatURI { + return fmt.Errorf("save session meta: conversation identity cannot be replaced") + } + meta.ChatURI = current.ChatURI + } // K3: schema paths use slashes even when their source machine is Windows. for _, field := range []*string{&meta.Workspace, &meta.Origin, &meta.FromWorkspace} { if windowsSource(*field) { diff --git a/internal/session/placemeta.go b/internal/session/placemeta.go index dee78f16f5..06ce73f867 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -274,6 +274,9 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { if meta.ID == "" { meta.ID, meta.Owned = snapshot.ID, snapshot.Owned } + if meta.ChatURI == "" { + meta.ChatURI = snapshot.ChatURI + } if meta.Workspace == "" { meta.Workspace = snapshot.Workspace } @@ -290,7 +293,7 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { meta.Trees = snapshot.Trees } patch(&meta) - return SaveMeta(dir, meta) + return saveMetaLocked(dir, meta) }) } @@ -299,6 +302,9 @@ func (a *Agent) updateMeta(dir string, snapshot Meta, patch func(*Meta)) { // comes back whole rather than half-filled. The agent lock is held for the // model, which /model moves. func (a *Agent) fillMetaLocked(meta Meta) Meta { + if !a.config.InTask && a.config.WorkspaceURI != "" { + meta.ChatURI = a.config.WorkspaceURI + } if strings.TrimSpace(meta.ID) == "" { meta.ID = a.config.Place.ID() } diff --git a/internal/session/plandb_work.go b/internal/session/plandb_work.go index f1ce38c361..4db239115e 100644 --- a/internal/session/plandb_work.go +++ b/internal/session/plandb_work.go @@ -15,10 +15,11 @@ package session // copy, so every page of one run reads the same difference; the page says so // rather than pretending a part's share can be told apart. // -// NOTHING HERE WRITES. The copy is read with plumbing commands only, so opening -// a page never moves an index or a branch under a worker that is typing in it. +// Query reads use plumbing commands only. The explicit OpenPlanTaskWork action +// may restore an archived copy before the work tab reads its ordinary difference. import ( + "context" "os" "path/filepath" "strings" @@ -32,6 +33,9 @@ import ( // nothing yet, which [PlanTaskWork.Read] tells apart from a copy nobody could // read. type PlanTaskWork struct { + // Error reports an explicit restoration failure instead of saying the saved + // working copy was never here. Ordinary query reads leave this empty. + Error string `json:"error,omitempty"` // Dir is the run's copy, the directory its workers type in. Dir string // Patch is the unified difference between the commit the copy was cut @@ -68,6 +72,29 @@ type PlanWorkAgent interface { PlanTaskWork(id string) (PlanTaskWork, bool) } +// PlanWorkOpener is the explicit work-tab gesture, separate from background reads. +// Engines that do not implement it keep their ordinary read-only work tab. +type PlanWorkOpener interface { + OpenPlanTaskWork(id string) (PlanTaskWork, bool) +} + +// OpenPlanTaskWork restores a retained run copy only when a person opens its +// work tab. List, hover and polling callers keep using PlanTaskWork. +func (a *Agent) OpenPlanTaskWork(id string) (PlanTaskWork, bool) { + root, ok := a.planTaskRoot(id) + if !ok { + return PlanTaskWork{}, false + } + copied := a.planRunCopy(root) + if copied == nil { + return PlanTaskWork{}, true + } + if err := a.RestoreTaskPath(context.Background(), copied.Dir); err != nil { + return PlanTaskWork{Dir: copied.Dir, Error: "could not restore the saved working copy: " + err.Error()}, true + } + return readPlanWork(copied), true +} + // PlanTaskWork answers the working copy of the run one store task belongs to. // False is a task this conversation's plan does not hold. func (a *Agent) PlanTaskWork(id string) (PlanTaskWork, bool) { diff --git a/internal/session/principal_audit.go b/internal/session/principal_audit.go index faa2487ebd..bd055ab68d 100644 --- a/internal/session/principal_audit.go +++ b/internal/session/principal_audit.go @@ -244,6 +244,9 @@ func (a *Agent) wallBoundedWindow(window time.Duration) time.Duration { // runOneCheck also serves task-baseline callers, whose context already carries // their checking deadline. Session callers supply the smaller wall-sized window. func runOneCheck(ctx context.Context, tree, check string, windows ...time.Duration) CheckRun { + if err := markTaskStorageUncertain(tree); err != nil { + return CheckRun{Command: check, Tail: err.Error()} + } window := sessionCheckWindow if len(windows) > 0 { window = windows[0] diff --git a/internal/session/run_tree_changes.go b/internal/session/run_tree_changes.go index b173e2239f..b7d125d0b6 100644 --- a/internal/session/run_tree_changes.go +++ b/internal/session/run_tree_changes.go @@ -153,6 +153,8 @@ func harnessWrote(path string) bool { switch { case isTaskDropping(path): return true + case path == furrowMarkerDir, strings.HasPrefix(path, furrowMarkerPattern): + return true case path == planStoreFilename, strings.HasPrefix(path, planStoreFilename+"."), strings.HasPrefix(path, planStoreFilename+"-"): diff --git a/internal/session/session.go b/internal/session/session.go index 5ca2181c9f..66c6d54b3f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1233,6 +1233,12 @@ type Config struct { WaitForBeltSteps bool Workspace string // tools root here; all relative paths resolve inside it + // WorkspaceURI selects a previously admitted owned run for worker scratch routing. + WorkspaceURI string + // WorkspaceSet carries identity-backed routes to a prototype task worker. + WorkspaceSet *WorkspaceSet + workspaceWriteRoots []string + workspaceProtectedRoots []string // Seat is the executor this session owns: every tool call runs on it, so // the class the workspace declared and the seal after each call follow the // session and never a path. Nil is a session with no cell, whose calls run diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index f98279d757..3fd4b422ff 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -379,6 +379,10 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { if end.Kept { merge, branch = mergeKept, run.folder.Branch } + } else if run.tree.workspaceSet != nil { + var rootsReport string + merge, branch, changed, rootsReport = a.keepStoppedWorkspaceSet(run) + report += rootsReport } else { // THE WORKER'S OWN COMMITS ARE WORK TOO. The index may be clean when // the stop arrives, so read the branch against its original base before @@ -420,7 +424,8 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { // ([Agent.beltRunEndedAt]). notice := TaskNotice{ ID: run.row, Title: run.title, State: TaskFailed, Stopped: true, - Report: report, Changed: changed, Merge: merge, Branch: branch, EndedAt: a.beltRunEndedAt(run), + WorkspaceSet: cloneWorkspaceSet(run.tree.workspaceSet), + Report: report, Changed: changed, Merge: merge, Branch: branch, EndedAt: a.beltRunEndedAt(run), } g := a.graph() if g == nil { diff --git a/internal/session/task.go b/internal/session/task.go index 9bf6c87f25..5f75033834 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -246,15 +246,19 @@ var taskSchemaJSON = `{"type":"object","properties":{` + // on the same Config.mayDelegate predicate that controls the prompt. var taskSchemaWithoutViaJSON = strings.Replace(taskSchemaJSON, taskViaSchemaJSON, "", 1) +// A model may select private working roots, but cannot grant landing authority. +const workspaceSetTaskSchema = `"workspace_set":{"type":"object","description":"Optional private sibling working roots. Use when a task needs several explicitly selected folders. Each root is copied privately; this does not authorize source writes. Omit ground and where when using this field.","properties":{"primary":{"type":"string"},"roots":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string"},"source_uri":{"type":"string"},"slot":{"type":"string"},"access":{"type":"string","enum":["read","write"]}},"required":["slot","access"],"additionalProperties":false}}},"required":["primary","roots"],"additionalProperties":false}` + // taskArguments is the wire form. type taskArguments struct { - Title string `json:"title"` - Summary string `json:"summary"` - Brief string `json:"brief"` - Deliverable string `json:"deliverable"` - Where string `json:"where"` - Ground string `json:"ground"` - Acceptance string `json:"acceptance"` + Title string `json:"title"` + Summary string `json:"summary"` + Brief string `json:"brief"` + Deliverable string `json:"deliverable"` + Where string `json:"where"` + Ground string `json:"ground"` + WorkspaceSet *WorkspaceSetRequest `json:"workspace_set,omitempty"` + Acceptance string `json:"acceptance"` // Expects is what this brief assumes is already true of the folder the // worker will get, checked before it is allowed to spend anything // (handoffcontract.go). It is optional and the harness never writes one. @@ -287,7 +291,8 @@ type taskArguments struct { // a person may still move afterwards is `model`, and only from inside the node's // own room — see the field itself. type taskSpec struct { - title string + workspaceSet *WorkspaceSetRequest + title string // named says A MODEL ALREADY WROTE THIS TITLE as a name, rather than the // door cutting one out of a sentence it had to hand. It is the whole of what // decides whether the work is named again (taskname.go): a name the shaper @@ -601,6 +606,9 @@ func (a *Agent) taskTools() []bare.Tool { if a.config.mayDelegate() { schema = taskSchemaJSON } + if WorkspaceOwnershipEnabled(a.config.Place) { + schema = strings.Replace(schema, `"properties":{`, `"properties":{`+workspaceSetTaskSchema+`,`, 1) + } return []bare.Tool{bare.StagedTool("propose_task", taskDescription, json.RawMessage(schema), a.stageTask)} } @@ -950,6 +958,11 @@ func proposalBrief(spec taskSpec, via *delegate.Delegate) string { // of its own on the brief, which is not what was asked for, so its failure is // answered as a refusal. func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec taskSpec, elsewhere string) (string, bool, bool) { + stand, err := a.admitWorkspaceProposal(ctx, p.stand, spec) + if err != nil { + return runDidNotStart(p.id, err), true, true + } + if !(bashBeltAsked() || spec.via != "") || chatRunEngine == nil || a.config.InTask { return "", false, false } @@ -976,13 +989,11 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec // before it: in a batch committed at one moment none of the hand-offs // could see a live run beforehand, and the one that opened the run is // decided under the start lock ([Agent.startOrJoinTaskRunVia]). - stand := p.stand - if via != nil { - stand = delegateStand(stand.dir) - } + asked := programAsked(spec) var prior *programOutcome if via != nil { + stand = delegateStand(stand.dir) prior = a.keepProgramAttempt(p.id, a.programAttemptOf()) ctx = withProgramWish(ctx, programWish{thinking: spec.thinking, carry: a.programCarryOf(prior, via.Name)}) } @@ -1004,7 +1015,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec if via != nil { return via.Name + " could not start: " + err.Error(), true, true } - if !errors.Is(err, errRunRoadUnavailable) { + if !singletonRunFallback(err, stand) { return withElsewhere(runDidNotStart(p.id, err), elsewhere), true, true } return "", false, false @@ -1131,14 +1142,15 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { title: strings.TrimSpace(parsed.Title), // THE MODEL GROOMED THIS PROPOSAL AND NAMED IT IN THE SAME BREATH, so the // namer leaves it alone (taskname.go). - named: strings.TrimSpace(parsed.Title) != "", - summary: strings.TrimSpace(parsed.Summary), - brief: strings.TrimSpace(parsed.Brief), - deliverable: strings.TrimSpace(parsed.Deliverable), - where: strings.TrimSpace(parsed.Where), - ground: strings.TrimSpace(parsed.Ground), - acceptance: strings.TrimSpace(parsed.Acceptance), - dependsOn: parsed.DependsOn, + named: strings.TrimSpace(parsed.Title) != "", + workspaceSet: cloneWorkspaceSetRequest(parsed.WorkspaceSet), + summary: strings.TrimSpace(parsed.Summary), + brief: strings.TrimSpace(parsed.Brief), + deliverable: strings.TrimSpace(parsed.Deliverable), + where: strings.TrimSpace(parsed.Where), + ground: strings.TrimSpace(parsed.Ground), + acceptance: strings.TrimSpace(parsed.Acceptance), + dependsOn: parsed.DependsOn, // THE MODEL'S OWN "THIS IS WIDE", carried to [TaskGraph.admit] where the // road is armed. Absent is false, which is the honest default: a model // that has never heard of this argument proposes exactly the task it @@ -1149,6 +1161,9 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { maxSteps: parsed.MaxSteps, noProgress: parsed.NoProgress, } + if problem := validateTaskWorkspaceProposal(spec); problem != "" { + return spec, problem + } if word := strings.TrimSpace(parsed.Effort); word != "" { effort, ok := crewroute.ParseEffort(word) if !ok { @@ -1665,7 +1680,7 @@ func taskWhereNotice(place Place, workspace string, id uint64, where string, mod if strings.EqualFold(where, "in place") { _, redirectedInPlace = whereInsideRepository(where, workspace) } - if where != "" && (mode == TaskModeWorktree || strings.EqualFold(where, "in place") && redirectedInPlace) { + if where != "" && (mode == TaskModeWorktree || mode == TaskModeMirror || strings.EqualFold(where, "in place") && redirectedInPlace) { if trees := place.Trees(); trees != "" { return filepath.Join(trees, strconv.FormatUint(id, 10)) } @@ -1784,3 +1799,40 @@ func (a *Agent) PendingTasks() []uint64 { func (a *Agent) proposalAsk(id uint64, notice TaskNotice) Question { return a.proposalQuestion(id, notice) } + +func validateTaskWorkspaceProposal(spec taskSpec) string { + if spec.workspaceSet != nil { + if !bashBeltAsked() { + return "workspace_set requires the Bash task engine" + } + if spec.via != "" || spec.ground != "" || spec.where != "" { + return "workspace_set cannot be combined with via, ground, or where; declare each selected root explicitly" + } + for _, root := range spec.workspaceSet.Roots { + if root.Landing != "" || root.LandingURI != "" { + return "workspace_set proposals cannot authorize landing; work stays in private copies" + } + } + } + return "" +} + +func (a *Agent) admitWorkspaceProposal(ctx context.Context, stand taskStand, spec taskSpec) (taskStand, error) { + if spec.workspaceSet != nil && !WorkspaceOwnershipEnabled(a.config.Place) { + return stand, errors.New("workspace_set requires private task placement for this conversation") + } + if spec.workspaceSet != nil && (chatRunEngine == nil || a.config.InTask) { + return stand, errors.New("workspace_set requires the linked conversation run engine") + } + if stand.workspaceSet != nil { + request := cloneWorkspaceSetRequest(stand.workspaceSet) + if err := admitWorkspaceSetRequest(ctx, a.config.Place, request); err != nil { + return stand, err + } + stand.workspaceSet = request + } + return stand, nil +} +func singletonRunFallback(err error, stand taskStand) bool { + return stand.workspaceSet == nil && errors.Is(err, errRunRoadUnavailable) +} diff --git a/internal/session/task_branch_history.go b/internal/session/task_branch_history.go new file mode 100644 index 0000000000..910bbde0c1 --- /dev/null +++ b/internal/session/task_branch_history.go @@ -0,0 +1,130 @@ +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// ForkTaskHistory retains inherited tasks as result cards, never as a runnable +// frontier. It runs before the independent conversation is opened. The original +// checkpoint remains exact evidence; retry after partial failure is safe, and a +// completed boundary never rewrites new work admitted to the independent chat. +// Returned task IDs select completed output materialization, not active copies. +func ForkTaskHistory(place Place) ([]taskcopy.ResultSelection, error) { + history := ForkHistoryDir(place) + document, present, err := forkOriginalTasks(place, history) + if err != nil || !present { + return nil, err + } + completed := forkCompletedTasks(document) + marker := filepath.Join(history, "frontier-detached") + if _, err := os.Stat(marker); err == nil { + return completed, nil + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + // Resolve portable result-log paths before replacing the operational frontier. + if codec, sealed := checkpointCodec(place.Tasks()); sealed { + document = document.mapPaths(codec.resolve) + } + for _, node := range document.Nodes { + if node.Result != nil && node.Result.Whole != "" && isFile(node.Result.Whole) { + RecordSealedArtifact(place, Artifact{Path: node.Result.Whole, Session: place.ID(), Title: fmt.Sprintf("Task %d · full result", node.ID), Kind: "document", Created: time.Now()}) + } + } + document = forkHistoryDocument(document) + if err := (&taskStore{path: place.Tasks()}).writeLocked(document); err != nil { + return nil, err + } + if err := os.WriteFile(marker, []byte("history-only\n"), 0600); err != nil { + return nil, err + } + return completed, nil +} + +func forkOriginalTasks(place Place, history string) (taskDocument, bool, error) { + original := filepath.Join(history, "tasks.json") + raw, err := os.ReadFile(original) + if errors.Is(err, os.ErrNotExist) { + raw, err = os.ReadFile(place.Tasks()) + if errors.Is(err, os.ErrNotExist) { + return taskDocument{}, false, nil + } + if err != nil { + return taskDocument{}, false, err + } + if err := os.MkdirAll(history, 0700); err != nil { + return taskDocument{}, false, err + } + if err := os.WriteFile(original, raw, 0600); err != nil { + return taskDocument{}, false, err + } + } else if err != nil { + return taskDocument{}, false, err + } + document, err := decodeTasks(raw) + if err != nil { + return taskDocument{}, false, fmt.Errorf("read independent conversation history: %w", err) + } + return document, true, nil +} + +func forkCompletedTasks(document taskDocument) []taskcopy.ResultSelection { + var completed []taskcopy.ResultSelection + for _, node := range document.Nodes { + if node.State.settled() { + completed = append(completed, taskcopy.ResultSelection{ID: strconv.FormatUint(node.ID, 10), Paths: node.Changed}) + } + } + for _, run := range document.Runs { + if run.State.settled() { + completed = append(completed, taskcopy.ResultSelection{ID: strconv.FormatUint(run.ID, 10), Paths: run.Changed}) + } + } + return completed +} + +func forkHistoryDocument(document taskDocument) taskDocument { + rows := make([]runRecord, 0, len(document.Nodes)+len(document.Runs)) + for _, node := range document.Nodes { + row := runRecord{ID: node.ID, Parent: node.Parent, Title: node.Title, State: node.State, Report: node.Report, CostUSD: node.CostUSD, ElapsedMS: node.ElapsedMS, StartedAt: node.StartedAt, EndedAt: node.EndedAt, Ending: node.Ending, Changed: node.Changed} + if node.Result != nil { + row.Result = node.Result.Text + + } + rows = append(rows, forkHistoryRow(row)) + } + for _, row := range document.Runs { + rows = append(rows, forkHistoryRow(row)) + } + document.Nodes = nil + document.Runs = rows + return document +} + +func forkHistoryRow(row runRecord) runRecord { + if !row.State.settled() { + row.State, row.Stopped = TaskFailed, true + row.Report += "\nNot continued in this independent conversation. Start new work here separately." + } + // Neither worker context, plan-store IDs, continuation proposals nor landing + // authority belong to this conversation. Human-readable results remain. + row.Copy, row.PendingRun, row.CrewState = nil, nil, nil + row.WorkspaceSet = nil + row.Run, row.Node, row.PlanTask = "", "", "" + row.RecoveredCompletion = false + row.Branch, row.Merge = "", "" + return row +} + +// ForkHistoryDir is the chat-relative historical result area for this fork. +// Namespacing by chat ID keeps a fork of a fork independent and retry-safe. +func ForkHistoryDir(place Place) string { + return filepath.Join(place.Dir, ".cell", "fork-history", place.ID()) +} diff --git a/internal/session/task_branch_history_test.go b/internal/session/task_branch_history_test.go new file mode 100644 index 0000000000..7ddc4e905b --- /dev/null +++ b/internal/session/task_branch_history_test.go @@ -0,0 +1,53 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// This is checkpoint API acceptance with fictional rows, not live-task proof. +func TestIndependentForkKeepsTaskResultsWithoutRunnableFrontier(t *testing.T) { + place := newPlace(t, true) + doc := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 4, + Nodes: []taskRecord{{ID: 1, Title: "completed", Brief: "brief", Acceptance: "exists", State: TaskDone, Report: "report", Result: &taskResultRecord{Text: "full answer"}, Worktree: "/foreign/tree", WorkspaceURI: "foreign", ChatURI: "foreign"}, {ID: 2, Title: "queued", Brief: "brief", Acceptance: "exists", State: TaskQueued}}, + Runs: []runRecord{{ID: 3, Title: "active", State: TaskRunning, Report: "partial", Copy: &TaskCopyRecord{Dir: "/foreign/run"}, PendingRun: &PendingRunRecord{}}, {ID: 4, Title: "done", State: TaskDone, Report: "finished", Copy: &TaskCopyRecord{Dir: "/foreign/done"}}}} + raw, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(place.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + complete, err := ForkTaskHistory(place) + if err != nil { + t.Fatal(err) + } + if len(complete) != 2 || complete[0].ID != "1" || complete[1].ID != "4" { + t.Fatalf("completed results selection=%v", complete) + } + got, ok := loadTaskCheckpoint(place.Tasks()) + if !ok { + t.Fatal("fork checkpoint unreadable") + } + if len(got.Nodes) != 0 || len(got.Runs) != 4 || got.Seq != 4 { + t.Fatalf("fork retained executable nodes or lost history: %+v", got) + } + for _, row := range got.Runs { + if !row.State.settled() || row.Copy != nil || row.PendingRun != nil { + t.Fatalf("fork history can resume: %+v", row) + } + } + if got.Runs[0].Report != "report" || got.Runs[0].Result != "full answer" { + t.Fatal("completed result lost") + } + archived, err := os.ReadFile(filepath.Join(ForkHistoryDir(place), "tasks.json")) + if err != nil || string(archived) != string(raw) { + t.Fatalf("original checkpoint lost: %v", err) + } + again, err := ForkTaskHistory(place) + if err != nil || len(again) != 2 { + t.Fatalf("fork boundary not retry-safe: %v %v", again, err) + } +} diff --git a/internal/session/task_continue.go b/internal/session/task_continue.go index 61c42d1915..19cc8e85cb 100644 --- a/internal/session/task_continue.go +++ b/internal/session/task_continue.go @@ -263,6 +263,14 @@ func (n *TaskNode) resumeContinuedTree(place Place, workspace string) (taskTree, return taskTree{}, false } } + if _, enabled := taskStorage(place); enabled { + kept := taskTree{dir: dir, root: root, branch: branch, place: place, ground: ground, mode: mode} + if restored, back, problem := kept.reopenReleased(); back { + return n.ladderRecord(restored), true + } else if problem != "" { + return taskTree{}, false + } + } tree, ok := reattachKeptWorktree(place, root, dir, branch, ground, mode) if !ok { return taskTree{}, false diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 04165e6175..0f6f15ab08 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -503,6 +503,8 @@ type TaskNotice struct { // Where says where the task will work. A proposal carries the resolved task // folder or explicit path; later notices carry the worker's actual directory. Where string + // Workspace identities name legacy task placement without run-copy UI semantics. + WorkspaceURI, ChatURI, ProjectURI, GroundURI, RootURI string // Ground is the repository or folder THE WORK IS ABOUT, absolute, and Mode is // how the task stands on it (taskstands.go resolves both). Where says which // directory the worker types in; these two say which project that directory is @@ -632,7 +634,8 @@ type TaskNotice struct { // RUN's row and nowhere else: a node of the session's own graph already // carries these facts in its own record. Nil is a row whose copy was never // recorded, which is a run that cannot be carried on. - Copy *TaskCopyRecord + Copy *TaskCopyRecord + WorkspaceSet *WorkspaceSet // RecoveredCompletion identifies a completed plan recovered before its final landing receipt. RecoveredCompletion bool // PendingRun keeps an admitted request before it owns a working copy. @@ -989,8 +992,9 @@ func (c *TaskCall) Received() int { // PendingRunRecord is the accepted work needed to rejoin machine admission after // restart. It is cleared once the working copy is recorded, before workers start. type PendingRunRecord struct { - Brief string `json:"brief"` - Ground string `json:"ground"` - Mode TaskMode `json:"mode"` - Asked []string `json:"asked,omitempty"` + WorkspaceSet *WorkspaceSetRequest `json:"workspaceSet,omitempty"` + Brief string `json:"brief"` + Ground string `json:"ground"` + Mode TaskMode `json:"mode"` + Asked []string `json:"asked,omitempty"` } diff --git a/internal/session/task_mirror_capture_test.go b/internal/session/task_mirror_capture_test.go new file mode 100644 index 0000000000..74e2590454 --- /dev/null +++ b/internal/session/task_mirror_capture_test.go @@ -0,0 +1,36 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func TestExplicitPlainMirrorFromGitChatCarriesItsIndependentBaseline(t *testing.T) { + folder, place, parent := familyOnAFolder(t) + writeFile(t, filepath.Join(parent.dir, "result.md"), "completed result\n") + gitOut(t, parent.dir, "add", "result.md") + gitOut(t, parent.dir, "-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "-c", "commit.gpgsign=false", "commit", "-qm", "result") + _ = partOf(t, place, parent, 8, "child holds parent baseline") + otherProject := t.TempDir() + gitOut(t, otherProject, "init", "-q") + gitOut(t, otherProject, "-c", "user.name=fixture", "-c", "user.email=fixture@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "unrelated Git chat") + at := cell.Cell{ID: place.ID(), Root: place.Dir} + if err := (taskcopy.Carry{}).ComposeWorkspace(at, otherProject); err != nil { + t.Fatal(err) + } + results, err := taskcopy.RestoreCompletedResults(at, folder, t.TempDir(), []taskcopy.ResultSelection{{ID: "7", Paths: []string{"result.md"}}}) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(results["7"], "result.md")) + if err != nil || string(body) != "completed result\n" { + t.Fatalf("independent result missing: %q %v", body, err) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatal("plain project was modified") + } +} diff --git a/internal/session/task_paths.go b/internal/session/task_paths.go index a8b60c72a5..f5564d48d0 100644 --- a/internal/session/task_paths.go +++ b/internal/session/task_paths.go @@ -13,16 +13,38 @@ import ( ) func (r *taskRecord) pathFields() []*string { - return []*string{&r.Journal, &r.Beat, &r.OriginJournal, &r.Worktree} + return []*string{&r.Journal, &r.Beat, &r.OriginJournal, &r.Worktree, &r.Ground} } func (r *runRecord) pathFields() []*string { - if r.Copy == nil { - return nil + var fields []*string + if r.Copy != nil { + copied := *r.Copy + r.Copy = &copied + fields = append(fields, &copied.Dir, &copied.Root, &copied.Ground) } - copied := *r.Copy // the pointer is shared with the live graph - r.Copy = &copied - return []*string{&copied.Dir, &copied.Root, &copied.Ground} + r.WorkspaceSet = cloneWorkspaceSet(r.WorkspaceSet) + if r.WorkspaceSet != nil { + for i := range r.WorkspaceSet.Roots { + if copied := r.WorkspaceSet.Roots[i].Copy; copied != nil { + fields = append(fields, &copied.Dir, &copied.Root, &copied.Ground) + } + } + } + if r.PendingRun != nil { + pending := *r.PendingRun + r.PendingRun = &pending + fields = append(fields, &pending.Ground) + if pending.WorkspaceSet != nil { + request := *pending.WorkspaceSet + request.Roots = append([]WorkspaceRootRequest(nil), request.Roots...) + pending.WorkspaceSet = &request + for i := range request.Roots { + fields = append(fields, &request.Roots[i].Source, &request.Roots[i].Landing) + } + } + } + return fields } // clearPaths returns a copy of in with every non-empty path field rewritten by diff --git a/internal/session/task_run.go b/internal/session/task_run.go index d5458f3f5f..554060c30f 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -278,6 +278,9 @@ const ( // brief is assembled once at start (runFrontier's JIT assembly) from spec.brief // plus prerequisites' reports and is not the spec. type TaskNode struct { + // storageBlocked records a writer whose bounded shutdown has not finished. + storageBlocked bool + // Continuation choices preserve the completed attempt until work is reopened. nextModel string nextEffort *string @@ -367,6 +370,8 @@ type TaskNode struct { // guarded by the graph's lock and written once. Ground string Mode TaskMode + // These identities bind restored legacy and nested task worlds to their owners. + WorkspaceURI, ChatURI, ProjectURI, GroundURI, RootURI string // Home is the branch the root checkout was on when this node's branch was // cut. It is the fixed side of the moved-checkout question at landing time; // absence is ordinary for a checkpoint written before this field existed. @@ -2973,6 +2978,9 @@ func (n *TaskNode) leavings() (report string, changed []string, branch, merge st // the node's changes live in that directory, and pretending otherwise would // merge an empty branch and call it done. func (n *TaskNode) workingCopy(place Place, workspace string) (taskTree, error) { + if tree, known, err := n.ownedWorkingCopy(context.Background(), place, workspace); known { + return tree, err + } n.graph.mu.Lock() dir, branch, merge := n.worktree, n.branch, n.merge n.graph.mu.Unlock() @@ -3023,6 +3031,78 @@ func (n *TaskNode) workingCopy(place Place, workspace string) (taskTree, error) return n.ladderRecord(taskTree{dir: dir, root: root, branch: branch, place: place, ground: ground, mode: mode}), nil } +// ownedWorkingCopy resolves recorded identities before touching cached paths. +// Unknown identities fail closed; they never cause a replacement task copy. +func (n *TaskNode) ownedWorkingCopy(ctx context.Context, place Place, workspace string) (taskTree, bool, error) { + n.graph.mu.Lock() + record := &TaskCopyRecord{URI: n.WorkspaceURI, ChatURI: n.ChatURI, ProjectURI: n.ProjectURI, + GroundURI: n.GroundURI, + RootURI: n.RootURI, Dir: n.worktree, Branch: n.branch, Ground: n.Ground, Mode: n.Mode} + merge := n.merge + n.graph.mu.Unlock() + if record.URI == "" { + return taskTree{}, false, nil + } + dir, err := resolveOwnedWorkspace(record, place) + if err != nil { + return taskTree{}, true, fmt.Errorf("resolve its working copy identity: %w", err) + } + ground, root := record.Ground, "" + if record.GroundURI != "" { + ground, err = resolveOwnedGround(record.GroundURI, record) + } else if record.ProjectURI != "" { + ground, err = resolveOwnedProject(record.ProjectURI) + } + if err != nil { + return taskTree{}, true, fmt.Errorf("resolve its task ground: %w", err) + } + root, err = ownedTaskLandingRoot(record, ground, workspace) + if err != nil { + return taskTree{}, true, err + } + if err := ensureTaskStorage(ctx, place, dir); err != nil { + return taskTree{}, true, fmt.Errorf("restore its compact working copy: %w", err) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return taskTree{}, true, fmt.Errorf("its working copy is gone from %s", dir) + } + branch := record.Branch + if mark, released := rememberedRelease(dir); released && branch != "" && strings.TrimSpace(mark.Branch) != "" { + branch = mark.Branch + } + n.graph.mu.Lock() + n.worktree, n.Ground = dir, ground + n.graph.mu.Unlock() + return n.ladderRecord(taskTree{dir: dir, root: root, ground: ground, mode: record.Mode, branch: branch, merge: merge, place: place}), true, nil +} + +func ownedTaskLandingRoot(record *TaskCopyRecord, ground, workspace string) (string, error) { + var root string + var err error + if record.RootURI != "" { + root, err = resolveOwnedGround(record.RootURI, record) + if err != nil { + return "", fmt.Errorf("resolve its landing root: %w", err) + } + } else if record.Branch != "" && record.Mode != TaskModeMirror { + var ok bool + root, ok = repositoryRoot(ground) + if !ok { + root, ok = repositoryRoot(workspace) + } + if !ok { + return "", fmt.Errorf("its recorded task ground is no longer a repository") + } + } + return root, nil +} + +func registerPreparedTaskTree(tree *taskTree, err *error, place Place) { + if *err == nil { + *tree, *err = registerOwnedWorkspace(*tree, place) + } +} + // ladderRecord puts back what the ground ladder wrote down about a node's world // (groundladder.go), on a tree that was rebuilt from the record rather than // carved in this run. @@ -3041,6 +3121,8 @@ func (n *TaskNode) ladderRecord(tree taskTree) taskTree { tree.rung, tree.seal, tree.base, tree.universe = n.Rung, n.Seal, n.Base, n.Universe tree.checkBase = n.CheckBase tree.home, tree.homeSha = n.Home, n.HomeSha + tree.uri, tree.chatURI, tree.projectURI = n.WorkspaceURI, n.ChatURI, n.ProjectURI + tree.groundURI, tree.rootURI = n.GroundURI, n.RootURI return tree } @@ -3394,6 +3476,8 @@ func (n *TaskNode) addSpend(used Usage) { func (n *TaskNode) setTree(tree taskTree) { n.graph.mu.Lock() n.worktree = tree.dir + n.WorkspaceURI, n.ChatURI, n.ProjectURI = tree.uri, tree.chatURI, tree.projectURI + n.GroundURI, n.RootURI = tree.groundURI, tree.rootURI n.branch = tree.branch n.merge = tree.merge // THE RECORD IS WHAT HAPPENED AND NOT WHAT WAS PLANNED. The ground and the @@ -3730,11 +3814,16 @@ func (n *TaskNode) noticeLocked(cost float64) TaskNotice { ID: n.id, Title: n.spec.title, // Reopened task pages need the contract even when no proposal card survives. - Brief: n.spec.brief, - Summary: n.spec.summary, - Acceptance: n.spec.acceptance, - Kind: n.kind, - Where: where, + Brief: n.spec.brief, + Summary: n.spec.summary, + Acceptance: n.spec.acceptance, + Kind: n.kind, + Where: where, + WorkspaceURI: n.WorkspaceURI, + ChatURI: n.ChatURI, + ProjectURI: n.ProjectURI, + GroundURI: n.GroundURI, + RootURI: n.RootURI, // AND WHICH PROJECT THAT DIRECTORY IS A COPY OF, on every update and not // only on the proposal: a row drawn from a checkpoint, a roster replayed // after a resize and a card watching work land all ask the same question, @@ -5157,6 +5246,7 @@ func (a *Agent) runTaskNode(node *TaskNode) { // road where it did not — and this is what answers the two bodies that are // not a worker in a worktree. node.graph.planStopLandedChildren(node.id) + a.parkTaskStorage(node, state, taskLog(listed)) node.graph.complete(node, state) // AND WHATEVER IS LEFT WAITING ON A DECIDER WHO HAS GONE HOME. // [TaskGraph.stopChildren] deliberately leaves settled children alone, and a @@ -5367,10 +5457,18 @@ func (a *Agent) openTaskWorld(ctx context.Context, node *TaskNode, log io.Writer // checkout this node's branch comes off, which for a part is its parent's // worktree. place := a.familyPlace(node) - tree, resumed := node.resumeTree(place, a.config.Workspace) - var err error - if !resumed { - tree, err = prepareTaskTreeForNode(ctx, place, a.config.Workspace, a.journalID(), node) + tree, known, err := node.ownedWorkingCopy(ctx, place, a.config.Workspace) + if !known { + if restoreErr := a.ensureNodeStorage(ctx, node); restoreErr != nil { + node.end(TaskEndingError) + node.finish("could not restore its compact working copy: "+restoreErr.Error(), nil, "", "") + return taskTree{}, false + } + var resumed bool + tree, resumed = node.resumeTree(place, a.config.Workspace) + if !resumed { + tree, err = prepareTaskTreeForNode(ctx, place, a.config.Workspace, a.journalID(), node) + } } if err != nil { node.end(TaskEndingError) @@ -5606,6 +5704,7 @@ func (a *Agent) workTaskNode(ctx context.Context, node *TaskNode, listed *job) T return } _ = child.Close() + rememberTaskWriters(node, child) a.foldTaskUsage(node, child) // The fold moved this child's whole tally onto the node, so the bill // stops naming it — a price read now comes off the frozen figure, and a @@ -6219,6 +6318,9 @@ func (n *TaskNode) landingFacts(merge string) TaskFacts { // resumeTree reuses the durable working copy after a process interruption, and // after a person asked to continue a settled node ([Agent.ContinueTask]). func (n *TaskNode) resumeTree(place Place, workspace string) (taskTree, bool) { + if tree, known, err := n.ownedWorkingCopy(context.Background(), place, workspace); known { + return tree, err == nil + } n.graph.mu.Lock() dir, branch, merge, interrupted, continuing := n.worktree, n.branch, n.merge, n.interrupted, n.continuing n.graph.mu.Unlock() @@ -6367,6 +6469,9 @@ func (t taskTree) releaseKept() { // releaseKeptLocked is the same cleanup for a caller already holding the root // lock, which is the conflict arm inside [taskTree.comeHome]. func (t taskTree) releaseKeptLocked() { + if retainTaskStorage(t) { + return + } left := leftBehind(t.dir) rememberLeftBehind(t.dir, left) if t.ownRepository() { @@ -6488,6 +6593,9 @@ func branchIsThere(repo, name string) bool { // and worktree repair binds that pointer to the retained directory. A failed // registration therefore cannot erase or partly relocate the user's leavings. func (t taskTree) reopenReleased() (taskTree, bool, string) { + if err := ensureTaskStorage(context.Background(), t.place, t.dir); err != nil { + return t, false, "its compact working copy could not be restored: " + err.Error() + } mark, released := t.releaseIdentity() if !released { return t, false, "" @@ -7583,7 +7691,8 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, workerSeat = effort.RoleWork } - child, err := a.newChildAgent(Config{ + workspaceURI, logOwnerURI := node.workspaceIdentity() + childConfig := Config{ // Search authority follows the work without enabling memory writes. ConversationHistory: parent.conversationHistory(), // AND THE CONVERSATION'S MEMORY, READ-ONLY. A worker has no brain of its @@ -7630,6 +7739,7 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // answer is settled here, once, while the family's is in hand. ownSpace: standingInOwnSpace(family, dir), Workspace: dir, + WorkspaceURI: workspaceURI, Model: model, APIKey: parent.APIKey, BaseURL: parent.BaseURL, @@ -7829,13 +7939,35 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // asks the node it was armed on. This line is only the person's yes // travelling with the work. Divide: parent.Divide, - }) + } + if childConfig.WorkspaceURI != "" { + if err := os.MkdirAll(filepath.Dir(journal), 0700); err != nil { + return nil, err + } + } + var logOwners []string + if logOwnerURI != "" { + logOwners = []string{logOwnerURI} + } + if err := configureOwnedWorker(&childConfig, filepath.Dir(journal), logOwners...); err != nil { + return nil, err + } + child, err := a.newChildAgent(childConfig) if err != nil { return nil, err } return child, nil } +func (n *TaskNode) workspaceIdentity() (string, string) { + n.graph.mu.Lock() + defer n.graph.mu.Unlock() + if n.WorkspaceURI != "" { + return n.WorkspaceURI, n.ChatURI + } + return n.ProjectURI, n.ChatURI +} + // seatTaskModelLocked answers the model a task worker may actually start on, // given the model it was admitted with: A TASK WITHOUT TOOLS CANNOT START. The // catalog's supported-parameter row is the same capability fact the picker @@ -8061,6 +8193,9 @@ func taskLog(listed *job) io.Writer { // taskTree is where one node works and how its work comes home. type taskTree struct { + workspaceSet *WorkspaceSet + // Stable local identity survives a path relocation; it grants no cleanup authority. + uri, chatURI, projectURI, groundURI, rootURI string // dir is the node's workspace: a worktree, or the person's own directory // when there is no repository. dir string @@ -8196,8 +8331,21 @@ func prepareTaskTree(place Place, workspace, session string, id uint64, title st // its parent froze the family tree at, which the ladder cuts from instead of // sealing a tree that has moved since (task_divide_wip.go). Empty for everything // that is not a part, which is almost every task. -func prepareTaskTreeAt(ctx context.Context, place Place, workspace, session string, id uint64, title, where, frozen string) (taskTree, error) { +func prepareTaskTreeAt(ctx context.Context, place Place, workspace, session string, id uint64, title, where, frozen string) (tree taskTree, err error) { + defer registerPreparedTaskTree(&tree, &err, place) + if err := ensureTaskStorage(ctx, place, workspace); err != nil { + return taskTree{}, err + } where = strings.TrimSpace(where) + if WorkspaceOwnershipEnabled(place) { + ground, err := ownershipTaskGround(where, workspace) + if err != nil { + return taskTree{}, err + } + if root, ok := repositoryRoot(ground); !ok || !hasCommit(root) { + return prepareTaskTreeOn(ctx, place, workspace, session, id, title, taskStand{dir: ground, mode: ownershipPlainMode(ground)}) + } + } if root, ok := whereInsideRepository(where, workspace); where != "" && ok { return cutTaskWorktree(ctx, place, root, session, id, title, frozen) } @@ -8391,8 +8539,22 @@ func cutWorktreeFrom(place Place, root, dir, branch string, mode os.FileMode, fr // that admits a node does not resolve one — a subharness run, a design, a graph // scripted by a test — and the tree they get is the tree they always got, with // its ground filled in from the repository it was actually cut from. -func prepareTaskTreeOn(ctx context.Context, place Place, workspace, session string, id uint64, title string, stand taskStand) (taskTree, error) { +func prepareTaskTreeOn(ctx context.Context, place Place, workspace, session string, id uint64, title string, stand taskStand) (tree taskTree, err error) { + if stand.workspaceSet != nil { + return prepareWorkspaceSet(ctx, place, id, title, *stand.workspaceSet) + } + return prepareSingletonTaskTreeOn(ctx, place, workspace, session, id, title, stand) +} + +func prepareSingletonTaskTreeOn(ctx context.Context, place Place, workspace, session string, id uint64, title string, stand taskStand) (tree taskTree, err error) { + defer registerPreparedTaskTree(&tree, &err, place) + if err := ensureTaskStorage(ctx, place, stand.dir); err != nil { + return taskTree{}, err + } ground := canonicalPath(strings.TrimSpace(stand.dir)) + if WorkspaceOwnershipEnabled(place) && stand.mode == TaskModeFolder { + stand.mode = ownershipPlainMode(ground) + } if ground == "" { // A PART REACHES THIS ROAD AND ITS FREEZE HAS TO TRAVEL WITH IT. A part // admitted by `divide_work` carries no ground of its own (task_divide.go @@ -8422,6 +8584,9 @@ func prepareTaskTreeOn(ctx context.Context, place Place, workspace, session stri // read. Copying the world in would be the opposite of what was asked. return taskTree{dir: dir, merge: mergeInPlace, ground: ground, mode: stand.mode}, nil case TaskModeMirror: + if err := ensureOwnedMirrorGround(place, ground); err != nil { + return taskTree{}, err + } dir, mode := taskOwnFolder(place, workspace, session, id) tree, err := carveGround(ctx, groundOrder{ place: place, @@ -8448,6 +8613,10 @@ func prepareTaskTreeOn(ctx context.Context, place Place, workspace, session stri } root, ok := repositoryRoot(ground) if !ok || !hasCommit(root) { + if WorkspaceOwnershipEnabled(place) { + stand.mode = TaskModeMirror + return prepareTaskTreeOn(ctx, place, workspace, session, id, title, stand) + } // The ground turned out to have no history to branch from between the // proposal and this moment, or never had one. The honest answer is the one // the old road gives: work in it and say so, rather than claim a branch @@ -8471,7 +8640,7 @@ func mirrorGround(ground, dir string) string { walk = func(relative string) string { entries, err := os.ReadDir(filepath.Join(ground, filepath.FromSlash(relative))) if err != nil { - return "" + return "this folder could not be copied for the task: " + err.Error() } for _, entry := range entries { child := entry.Name() @@ -8487,12 +8656,20 @@ func mirrorGround(ground, dir string) string { source := filepath.Join(ground, filepath.FromSlash(child)) target := filepath.Join(dir, filepath.FromSlash(child)) if entry.IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { + info, err := entry.Info() + if err != nil { + return "this folder could not be copied for the task: " + err.Error() + } + if err := os.MkdirAll(target, 0o700); err != nil { return "this folder could not be copied for the task: " + err.Error() } if problem := walk(child); problem != "" { return problem } + mode := info.Mode() & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky) + if err := os.Chmod(target, mode); err != nil { + return "this folder could not be copied for the task: " + err.Error() + } continue } if err := copyPath(source, target); err != nil { @@ -9641,3 +9818,24 @@ func mergePaths(kept, added []string) []string { } return kept } + +func ownershipTaskGround(where, workspace string) (string, error) { + if where == "" || strings.EqualFold(where, "in place") { + return workspace, nil + } + ground, err := resolveTaskWhere(where, workspace) + if err != nil { + return "", fmt.Errorf("task workspace: %w", err) + } + return ground, nil +} + +func ensureOwnedMirrorGround(place Place, ground string) error { + if !WorkspaceOwnershipEnabled(place) { + return nil + } + if err := os.MkdirAll(ground, 0o755); err != nil { + return fmt.Errorf("task project: %w", err) + } + return nil +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index e951168668..1c84504968 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -127,6 +127,9 @@ type RunSpec struct { // commits: the run's own working copy, or the folder itself for a program // that edits files (programfolder.go). Workspace string + // WorkspaceURI is the durable identity of an admitted private working copy. + WorkspaceURI string + WorkspaceSet *WorkspaceSet // Title and Brief are the run's own words: the title names the root row, // and the brief is the assignment the root worker reads. Title string @@ -277,8 +280,11 @@ const ( // the engine's outcome word, the root's result, the run's size, and the limit // that ended it when one did. type RunSummary struct { - Outcome string - Result string + // StorageSafe means every worker closed its jobs and no owned process group + // remained alive when the run returned. Missing proof keeps copies resident. + StorageSafe bool + Outcome string + Result string // Limit is empty on every run that did not end on a bound its person set. Limit RunLimit // Program is how a delegated run's program ended when it did not finish, @@ -570,6 +576,12 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri if engine == nil || g == nil || g.planPath() == "" { return false, errRunRoadUnavailable } + if via == nil { + stand, err = a.ownedRunPlacement(ctx, stand) + if err != nil { + return false, err + } + } path := g.planPath() storeID := strconv.FormatUint(id, 10) dependencies := make([]plandb.Dependency, 0, len(dependsOn)) @@ -677,6 +689,9 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T } else { tree, err = beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), id, title, stand) if err != nil { + if tree.workspaceSet != nil { + a.publishRunRow(g, TaskNotice{ID: id, Title: title, State: TaskFailed, Report: err.Error(), Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), EndedAt: a.taskClockNow()}) + } discardUnstartedRunStore(store) return false, err } @@ -692,7 +707,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T born := a.taskClockNow() run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, - workspace: tree.dir, ground: ground, tree: tree, admission: admission, + workspace: tree.dir, ground: ground, tree: tree, stand: stand, admission: admission, cut: cut, born: born, over: make(chan struct{}), delegate: via, folder: folder, asked: asked, crew: crew, } @@ -709,7 +724,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T // ([TaskNotice.Program], [Agent.publishRunRow]). a.publishRunRow(g, TaskNotice{ ID: id, Title: title, State: TaskRunning, StartedAt: born, - Copy: runCopyOf(tree), Program: programName(via), + Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), Program: programName(via), // THE ROOT'S ROW NAMES THE STORE'S ROOT, which is this same number: the // store was seeded under `storeID` a few lines up, so the row the person // was answered with and the task the store drives are one identity said @@ -769,7 +784,7 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG ID: id, Title: title, State: TaskQueued, Program: programName(via), PlanTask: planStoreID(storeID), Crew: run.crewDecision(), CrewState: a.initialCrewRecord(run), Model: run.crewWorker(), Waiting: waitingMachineBusy, - PendingRun: &PendingRunRecord{Brief: brief, Ground: stand.dir, Mode: stand.mode, Asked: asked}, + PendingRun: &PendingRunRecord{WorkspaceSet: stand.workspaceSet, Brief: brief, Ground: stand.dir, Mode: stand.mode, Asked: asked}, }) go a.driveBeltRun(runCtx, engine, run, RunSpec{}) return false, nil @@ -902,7 +917,7 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl a.beltMu.Unlock() return nil, refusal } - if canonicalPath(stand.dir) != live.ground { + if !stand.sharesWorkspace(live) { a.beltMu.Unlock() return nil, standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} } @@ -1077,13 +1092,15 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { admission = newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, a.graph().lanes) } return RunSpec{ - Store: run.store, - Workspace: run.workspace, - Title: run.title, - Brief: brief, - Slots: a.config.TaskParallel, - CostUSD: cost, - Elapsed: wallLeft, + Store: run.store, + Workspace: run.workspace, + WorkspaceURI: run.tree.workerWorkspaceURI(), + Title: run.title, + Brief: brief, + WorkspaceSet: cloneWorkspaceSet(run.tree.workspaceSet), + Slots: a.config.TaskParallel, + CostUSD: cost, + Elapsed: wallLeft, // The step cap a node of this session's own tree carries, so a run // worker and a node worker stop at the same figure. StepsPerTask: taskMaxSteps, @@ -1832,7 +1849,7 @@ func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { if g := a.graph(); g != nil { a.publishRunRow(g, TaskNotice{ ID: run.row, Title: run.title, State: TaskRunning, StartedAt: run.born, - Copy: runCopyOf(tree), Program: programName(run.delegate), + Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), Program: programName(run.delegate), PlanTask: planStoreID(run.root), Crew: run.crewDecision(), Model: run.crewWorker(), }) a.publishJoinedRunRows(g, run) @@ -1909,7 +1926,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun fold := &beltFold{agent: a} spec.OnSpend = fold.total spec.OnCharge = fold.charge - summary := engine.Start(ctx, spec) + summary := startRunWithTaskStorageProof(ctx, engine, spec) // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is // taken now, before the landing, the summary refresh and the note — which // can take a quarter of a minute between them and are not the work. @@ -1925,6 +1942,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun closing := run.closing a.beltMu.Unlock() defer a.releaseBeltRun(run) + defer a.parkBeltRunStorage(run, summary) // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. fold.total(summary.USD) @@ -2092,6 +2110,9 @@ func (a *Agent) releaseBeltRun(run *beltRun) { // branch the conversation's note names is the ground's, and a merge that would // not go in answers with the sentence that names the kept branch and the files. func (a *Agent) landBeltRun(ctx context.Context, engine RunEngine, run *beltRun) RunLanding { + if run.tree.workspaceSet != nil { + return a.landWorkspaceSet(ctx, engine, run) + } landing, err := engine.Land(ctx, run.store, run.workspace, run.tree.checkBase, run.root) if err != nil { if stopped, _ := a.beltRunStopped(run); stopped { @@ -2116,7 +2137,7 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { if run.tree.dir == "" { return landing } - merge, said, _, _ := run.tree.comeHome(run.title, nil, a.signsGitWork()) + merge, said, _, _ := run.tree.comeHome(run.title, landing.Changed, a.signsGitWork()) if landing.Refused != "" { // NOTHING TO LAND IS STILL AN ENDING: the copy was given back above, and // the sentence the engine answered is the whole account. @@ -2660,6 +2681,9 @@ func carryRunIdentity(notice, kept TaskNotice) TaskNotice { if notice.State == TaskQueued && notice.PendingRun == nil { notice.PendingRun = kept.PendingRun } + if notice.WorkspaceSet == nil { + notice.WorkspaceSet = cloneWorkspaceSet(kept.WorkspaceSet) + } if notice.Copy == nil && kept.Copy != nil { notice.Copy = kept.Copy } @@ -2688,3 +2712,14 @@ func (a *Agent) beltWorkerLifetime(run *beltRun, id string, on bool) { delete(run.workers, id) } } + +func (tree taskTree) workerWorkspaceURI() string { + if tree.uri == "" && tree.mode == TaskModeInPlace { + return tree.projectURI + } + return tree.uri +} + +func (stand taskStand) sharesWorkspace(live *beltRun) bool { + return canonicalPath(stand.dir) == live.ground && sameWorkspaceSetRequest(stand.workspaceSet, live.stand.workspaceSet) +} diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index 2dea3b2f5b..189e2da648 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -95,7 +95,7 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { // record exists for: the road that MAKES a copy clears the directory it is // handed, so reaching for it here would delete the work this door is meant // to resume (task_run_copy.go says it at length). - tree, err := runCopyTree(kept.Copy, a.config.Place) + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) if err != nil { return "", err } @@ -115,7 +115,7 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { runCtx, cut := context.WithCancel(context.WithoutCancel(ctx)) run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: row, title: kept.Title, - workspace: tree.dir, ground: tree.ground, tree: tree, cut: cut, crew: crew, recoveredCrew: kept.CrewState, + workspace: tree.dir, ground: tree.ground, tree: tree, stand: taskStand{dir: tree.ground, mode: tree.mode, workspaceSet: workspaceSetRequestOf(tree.workspaceSet)}, cut: cut, crew: crew, recoveredCrew: kept.CrewState, born: a.taskClockNow(), over: make(chan struct{}), joined: recoveredJoinedRows(g, row), } @@ -123,9 +123,13 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { // THE ROW GOES BACK TO RUNNING AND KEEPS THE COPY IT NAMED. Publishing // without it would make the run one nobody can carry on a second time, which // is the defect this door exists on the other side of. + primaryCopy := kept.Copy + if tree.workspaceSet != nil { + primaryCopy = runCopyOf(tree) + } a.publishRunRow(g, TaskNotice{ ID: row, Title: kept.Title, State: TaskRunning, - StartedAt: kept.StartedAt, Parent: kept.Parent, Copy: kept.Copy, + StartedAt: kept.StartedAt, Parent: kept.Parent, Copy: primaryCopy, WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), }) // THE SAME SPEC THE RUN WOULD HAVE HAD ([Agent.beltRunSpec]). A continued diff --git a/internal/session/task_run_copy.go b/internal/session/task_run_copy.go index 823a44a19c..328c16656f 100644 --- a/internal/session/task_run_copy.go +++ b/internal/session/task_run_copy.go @@ -21,6 +21,7 @@ package session // reader that ADOPTS what it names. The reader refuses rather than repairs. import ( + "context" "fmt" "os" "strings" @@ -34,6 +35,12 @@ import ( // whose branch genuinely was never recorded and genuinely cannot be resumed. // [runCopyTree] says so out loud rather than guessing ([errNoRunCopy]). type TaskCopyRecord struct { + // URI is local workspace identity; paths remain the legacy fallback only when absent. + URI string `json:"uri,omitempty"` + ChatURI string `json:"chat_uri,omitempty"` + ProjectURI string `json:"project_uri,omitempty"` + GroundURI string `json:"ground_uri,omitempty"` + RootURI string `json:"root_uri,omitempty"` // Dir is the directory the workers typed in and Branch is the branch their // work is on. Dir alone is not enough: a directory whose branch is unknown // can be read but not landed. @@ -82,6 +89,7 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { return nil } return &TaskCopyRecord{ + URI: tree.uri, ChatURI: tree.chatURI, ProjectURI: tree.projectURI, GroundURI: tree.groundURI, RootURI: tree.rootURI, Dir: tree.dir, Branch: tree.branch, Root: tree.root, @@ -136,7 +144,7 @@ func runCannotContinue(record *TaskCopyRecord, program string) string { if program = strings.TrimSpace(program); program != "" { return programNotCarriedOn(program) } - if record == nil || strings.TrimSpace(record.Dir) == "" { + if record == nil || (strings.TrimSpace(record.Dir) == "" && record.URI == "") { return errNoRunCopy.Error() } return "" @@ -154,10 +162,26 @@ func runCannotContinue(record *TaskCopyRecord, program string) string { // no longer on disk. A tree invented in either case would merge an empty branch // and call it done. func runCopyTree(record *TaskCopyRecord, place Place) (taskTree, error) { - if record == nil || strings.TrimSpace(record.Dir) == "" { + if record == nil || (strings.TrimSpace(record.Dir) == "" && record.URI == "") { return taskTree{}, errNoRunCopy } dir := strings.TrimSpace(record.Dir) + if record.URI != "" { + var err error + dir, err = resolveOwnedWorkspace(record, place) + if err != nil { + return taskTree{}, fmt.Errorf("resolve its working copy identity: %w", err) + } + } + + root, ground, err := runCopyLandingPaths(record) + if err != nil { + return taskTree{}, err + } + + if err := ensureTaskStorage(context.Background(), place, dir); err != nil { + return taskTree{}, fmt.Errorf("restore its compact working copy: %w", err) + } if info, err := os.Stat(dir); err != nil || !info.IsDir() { // THE BRANCH IS NAMED EVEN THOUGH THE FOLDER IS GONE, because it is the // one thing left that a person can act on: the work is on it. @@ -167,13 +191,14 @@ func runCopyTree(record *TaskCopyRecord, place Place) (taskTree, error) { return taskTree{}, fmt.Errorf("its working copy is gone from %s, so there is nothing left to carry on in", dir) } return taskTree{ + uri: record.URI, chatURI: record.ChatURI, projectURI: record.ProjectURI, groundURI: record.GroundURI, rootURI: record.RootURI, dir: dir, - root: record.Root, + root: root, branch: record.Branch, home: record.Home, homeSha: record.HomeSha, checkBase: record.CheckBase, - ground: record.Ground, + ground: ground, mode: record.Mode, rung: record.Rung, seal: record.Seal, @@ -184,3 +209,37 @@ func runCopyTree(record *TaskCopyRecord, place Place) (taskTree, error) { bashBelt: true, }, nil } + +func runCopyLandingPaths(record *TaskCopyRecord) (string, string, error) { + root, ground := record.Root, record.Ground + if record.URI != "" { + if record.GroundURI != "" { + resolved, err := resolveOwnedGround(record.GroundURI, record) + if err != nil { + return "", "", fmt.Errorf("resolve its ground identity: %w", err) + } + ground = resolved + } else if record.ProjectURI != "" && ground != "" { + resolved, err := resolveOwnedProject(record.ProjectURI) + if err != nil { + return "", "", fmt.Errorf("resolve its project identity: %w", err) + } + ground = resolved + } + if record.RootURI != "" { + resolved, err := resolveOwnedGround(record.RootURI, record) + if err != nil { + return "", "", fmt.Errorf("resolve its landing identity: %w", err) + } + root = resolved + } else if record.ProjectURI != "" && root != "" { + resolved, err := resolveOwnedProject(record.ProjectURI) + if err != nil { + return "", "", fmt.Errorf("resolve its project identity: %w", err) + } + root = resolved + } + } + + return root, ground, nil +} diff --git a/internal/session/task_run_recover.go b/internal/session/task_run_recover.go index 7ced018988..c507a6d86a 100644 --- a/internal/session/task_run_recover.go +++ b/internal/session/task_run_recover.go @@ -128,6 +128,22 @@ func (a *Agent) resumePendingRun(g *TaskGraph, kept TaskNotice) { return } pending := kept.PendingRun + if pending.WorkspaceSet != nil { + copied := *pending + request := *pending.WorkspaceSet + if err := validateWorkspaceSetRequest(&request); err != nil { + a.interruptUnrecoverableRun(g, kept, err.Error()) + return + } + copied.WorkspaceSet = &request + for _, root := range request.Roots { + if root.Slot == request.Primary { + copied.Ground = root.Source + } + } + pending = &copied + kept.PendingRun = pending + } if reason := invalidPendingRun(pending); reason != "" { a.interruptUnrecoverableRun(g, kept, reason) return @@ -149,7 +165,7 @@ func (a *Agent) resumePendingRun(g *TaskGraph, kept TaskNotice) { ctx, cut := context.WithCancel(a.rootContext()) run := &beltRun{plan: plan, store: reopened, root: reopened.RootID(), row: id, title: kept.Title, brief: pending.Brief, crew: crew, recoveredCrew: kept.CrewState, ground: canonicalPath(pending.Ground), - stand: taskStand{dir: pending.Ground, mode: pending.Mode}, pending: true, + stand: taskStand{dir: pending.Ground, mode: pending.Mode, workspaceSet: pending.WorkspaceSet}, pending: true, delegate: via, asked: pending.Asked, cut: cut, born: a.taskClockNow(), over: make(chan struct{}), joined: recoveredJoinedRows(g, id), admission: newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, g.lanes), diff --git a/internal/session/task_storage_git.go b/internal/session/task_storage_git.go new file mode 100644 index 0000000000..de66f99614 --- /dev/null +++ b/internal/session/task_storage_git.go @@ -0,0 +1,261 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +const taskStorageGitLock = "codeaf prototype compact task storage" + +type taskGitPin struct { + Root string `json:"root"` + Admin string `json:"admin"` + Snapshot string `json:"snapshot"` + Head string `json:"head"` + Index string `json:"index"` + Owned bool `json:"owned"` +} + +func taskGitPinPath(manager taskstorage.Manager, path string) string { + sum := sha256.Sum256([]byte(filepath.Clean(path))) + return filepath.Join(manager.Root, "git-pins", hex.EncodeToString(sum[:])+".json") +} +func readTaskGitPin(manager taskstorage.Manager, path string) (taskGitPin, error) { + var pin taskGitPin + raw, e := os.ReadFile(taskGitPinPath(manager, path)) + if e != nil { + return pin, e + } + e = json.Unmarshal(raw, &pin) + return pin, e +} +func writeTaskGitPin(manager taskstorage.Manager, path string, pin taskGitPin) error { + name := taskGitPinPath(manager, path) + if e := os.MkdirAll(filepath.Dir(name), 0700); e != nil { + return e + } + raw, e := json.Marshal(pin) + if e != nil { + return e + } + f, e := os.CreateTemp(filepath.Dir(name), ".pin-") + if e != nil { + return e + } + defer os.Remove(f.Name()) + if e = f.Chmod(0600); e == nil { + _, e = f.Write(raw) + } + if e == nil { + e = f.Sync() + } + closed := f.Close() + if e == nil { + e = closed + } + if e != nil { + return e + } + if e = os.Rename(f.Name(), name); e != nil { + return e + } + dir, e := os.Open(filepath.Dir(name)) + if e != nil { + return e + } + defer dir.Close() + return dir.Sync() +} +func taskIndexDigest(admin string) (string, error) { + body, e := os.ReadFile(filepath.Join(admin, "index")) + if errors.Is(e, os.ErrNotExist) { + return "absent", nil + } + if e != nil { + return "", e + } + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]), nil +} + +// pinTaskGit keeps Git's external administration alive while a linked working +// directory is cold. Its compact backup preserves the index if normal pruning +// or an administrative repair removes it; shared repository objects stay shared. +func pinTaskGit(ctx context.Context, manager taskstorage.Manager, place Place, path string) (retErr error) { + info, e := os.Lstat(filepath.Join(path, ".git")) + if e != nil { + return e + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return errors.New("task Git pointer is not a regular file") + } + root, ok := repositoryRoot(path) + if !ok { + return errors.New("task Git repository is unavailable") + } + defer lockGitRoot(place, root)() + admin, common, e := taskGitAdministration(path) + if e != nil { + return e + } + return captureTaskGitPin(ctx, manager, path, admin, common) +} + +func taskGitAdministration(path string) (string, string, error) { + admin, e := git(path, "rev-parse", "--absolute-git-dir") + if e != nil { + return "", "", e + } + admin = canonicalPath(strings.TrimSpace(admin)) + common, e := git(path, "rev-parse", "--git-common-dir") + if e != nil { + return "", "", e + } + common = strings.TrimSpace(common) + if !filepath.IsAbs(common) { + common = filepath.Join(path, common) + } + common = canonicalPath(common) + rel, e := filepath.Rel(filepath.Join(common, "worktrees"), admin) + if e != nil || !filepath.IsLocal(rel) || filepath.Dir(rel) != "." { + return "", "", errors.New("task Git administration is outside its shared repository") + } + return admin, common, nil +} + +func captureTaskGitPin(ctx context.Context, manager taskstorage.Manager, path, admin, common string) (retErr error) { + acquired := false + defer func() { + if retErr == nil || !acquired { + return + } + if lock, err := os.ReadFile(filepath.Join(admin, "locked")); err == nil && strings.TrimSpace(string(lock)) == taskStorageGitLock { + _, _ = git(path, "worktree", "unlock", path) + } + }() + pin, e := priorTaskGitPin(manager, path, admin, common) + if e != nil { + return e + } + if lock, e := os.ReadFile(filepath.Join(admin, "locked")); errors.Is(e, os.ErrNotExist) { + if _, e = git(path, "worktree", "lock", "--reason", taskStorageGitLock, path); e != nil { + return e + } + pin.Owned = true + acquired = true + } else if e != nil { + return e + } else if strings.TrimSpace(string(lock)) != taskStorageGitLock { + pin.Owned = false + } + head, e := git(path, "rev-parse", "HEAD") + if e != nil { + return e + } + pin.Head = strings.TrimSpace(head) + pin.Index, e = taskIndexDigest(admin) + if e != nil { + return e + } + pin.Snapshot, e = manager.Vault.Capture(ctx, admin) + if e != nil { + return e + } + return writeTaskGitPin(manager, path, pin) +} + +func priorTaskGitPin(manager taskstorage.Manager, path, admin, common string) (taskGitPin, error) { + pin := taskGitPin{Root: common, Admin: admin} + if prior, e := readTaskGitPin(manager, path); e == nil { + if prior.Admin != admin || prior.Root != common { + return pin, errors.New("task Git administration changed since its last archive") + } + pin.Owned = prior.Owned + } else if !errors.Is(e, os.ErrNotExist) { + return pin, e + } + return pin, nil +} + +func restoreTaskGit(ctx context.Context, manager taskstorage.Manager, path string) error { + pin, e := readTaskGitPin(manager, path) + if errors.Is(e, os.ErrNotExist) { + return nil + } + if e != nil { + return e + } + rel, e := filepath.Rel(filepath.Join(pin.Root, "worktrees"), pin.Admin) + if e != nil || !filepath.IsAbs(pin.Root) || !filepath.IsLocal(rel) || filepath.Dir(rel) != "." || pin.Snapshot == "" { + return errors.New("invalid task Git administration receipt") + } + // A local task archive depends on the shared repository's objects. Missing + // repositories are refused rather than replaced by a different project's Git. + if info, e := os.Stat(filepath.Join(pin.Root, "objects")); e != nil || !info.IsDir() { + return errors.New("the shared Git repository needed by this task is unavailable") + } + if _, e = os.Lstat(pin.Admin); errors.Is(e, os.ErrNotExist) { + if e = manager.Vault.Restore(ctx, pin.Snapshot, pin.Admin); e != nil { + return e + } + } else if e != nil { + return e + } + if e := verifyTaskGitPin(path, pin); e != nil { + return e + } + return releaseTaskGitPin(manager, path, pin) +} + +func verifyTaskGitPin(path string, pin taskGitPin) error { + admin, e := git(path, "rev-parse", "--absolute-git-dir") + if e != nil { + return e + } + if canonicalPath(strings.TrimSpace(admin)) != pin.Admin { + return errors.New("restored task points at a different Git administration folder") + } + head, e := git(path, "rev-parse", "HEAD") + if e != nil { + return e + } + index, e := taskIndexDigest(pin.Admin) + if e != nil { + return e + } + if strings.TrimSpace(head) != pin.Head || index != pin.Index { + return errors.New("task Git commit or staged index changed while its files were archived") + } + return nil +} + +func releaseTaskGitPin(manager taskstorage.Manager, path string, pin taskGitPin) error { + if pin.Owned { + lock, e := os.ReadFile(filepath.Join(pin.Admin, "locked")) + if errors.Is(e, os.ErrNotExist) { + return os.Remove(taskGitPinPath(manager, path)) + } + if e != nil { + return e + } + if strings.TrimSpace(string(lock)) != taskStorageGitLock { + return fmt.Errorf("task Git lock is now owned by another writer") + } + if _, e = git(path, "worktree", "unlock", path); e != nil { + return e + } + } + return os.Remove(taskGitPinPath(manager, path)) +} diff --git a/internal/session/task_storage_prototype.go b/internal/session/task_storage_prototype.go new file mode 100644 index 0000000000..6df0c7a790 --- /dev/null +++ b/internal/session/task_storage_prototype.go @@ -0,0 +1,277 @@ +package session + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +// taskStorage owns only this session's task copies, never the caller's project. +func taskStorage(place Place) (taskstorage.Manager, bool) { + if strings.TrimSpace(place.Dir) == "" { + return taskstorage.Manager{}, false + } + manager, enabled := taskstorage.Prototype(place.Trees()) + if enabled { + manager.AfterRestore = func(ctx context.Context, path string) error { return restoreTaskGit(ctx, manager, path) } + } + return manager, enabled +} + +func ensureTaskStorage(ctx context.Context, place Place, dir string) error { + manager, enabled := taskStorage(place) + if !enabled || strings.TrimSpace(dir) == "" || !taskStorageOwns(place, dir) { + return nil + } + return manager.Ensure(ctx, dir) +} + +func (a *Agent) ensureNodeStorage(ctx context.Context, node *TaskNode) error { + node.graph.mu.Lock() + dir := node.worktree + node.graph.mu.Unlock() + return ensureTaskStorage(ctx, a.familyPlace(node), dir) +} + +// retainTaskStorage postpones the old checkout removal until the full archive +// has independently proved it can restore this owned task directory. +func taskStorageOwns(place Place, dir string) bool { + rel, err := filepath.Rel(place.Trees(), dir) + return err == nil && rel != "." && filepath.IsLocal(rel) +} + +func retainTaskStorage(tree taskTree) bool { + _, enabled := taskStorage(tree.place) + if !enabled || tree.merge == mergeInPlace { + return false + } + return taskStorageOwns(tree.place, tree.dir) +} + +// rememberTaskWriters runs after child.Close: shutdown is bounded and may have +// sent SIGKILL without joining the process yet, which is not proof of quiescence. +func rememberTaskWriters(node *TaskNode, child *Agent) { + if _, enabled := taskStorage(child.familyPlace(node)); !enabled { + return + } + blocked := false + for _, job := range child.jobs.all() { + if job.running() || job.group.Alive() { + blocked = true + break + } + } + if blocked { + node.graph.mu.Lock() + node.storageBlocked = true + node.graph.mu.Unlock() + } +} + +// parkTaskStorage runs after executor teardown but before the completed state is +// announced, so a user cannot reopen the node during the archive transition. +func (a *Agent) parkTaskStorage(node *TaskNode, state TaskState, log io.Writer) { + if !state.settled() { + return + } + manager, enabled := taskStorage(a.familyPlace(node)) + if !enabled { + return + } + node.graph.mu.Lock() + dir, blocked, ground := node.worktree, node.storageBlocked, node.Ground + inPlace := node.merge == mergeInPlace + node.graph.mu.Unlock() + if dir == "" || inPlace { + return + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + return + } + manager.Busy = func(path string) bool { return blocked || node.graph.taskStorageBusy(node, path) } + place := a.familyPlace(node) + c := cell.Cell{ID: place.ID(), Root: place.Dir} + carry := taskcopy.Carry{Project: ground} + manager.BeforeCapture = func(ctx context.Context, path string) error { + if err := carry.PrepareCold(c, path); err != nil { + return err + } + return pinTaskGit(ctx, manager, place, path) + } + manager.BeforeRetire = func(ctx context.Context, snapshot, path string) error { + return carry.MarkCold(c, path, snapshot) + } + if err := manager.Park(context.Background(), dir); err != nil { + releaseTaskGitAfterFailedPark(manager, dir) + note := fmt.Sprintf("compact storage could not be prepared; its recovery state was retained: %v", err) + fmt.Fprintln(log, note) + node.graph.mu.Lock() + node.landLocked(withReport(node.landed, note)) + node.graph.mu.Unlock() + } +} + +func (g *TaskGraph) taskStorageBusy(finishing *TaskNode, path string) bool { + if taskStorageHasUncertainWriter() { + return true + } + g.mu.Lock() + defer g.mu.Unlock() + for _, other := range g.nodes { + if other == finishing { + if other.storageBlocked { + return true + } + continue + } + if other.state.settled() && !other.storageBlocked { + continue + } + if finishing != nil && other.parent == finishing.id { + return true + } + for _, used := range []string{other.worktree, other.Ground} { + if used == "" { + continue + } + if taskStorageOverlaps(path, used) { + return true + } + } + } + return false +} + +// TaskStorageQuiescent answers only after this agent has closed every call road. +// A settled job row alone is insufficient while its process group still lives. +func (a *Agent) TaskStorageQuiescent() bool { + if taskStorageHasUncertainWriter() { + return false + } + a.mu.Lock() + closed := a.closed + a.mu.Unlock() + if !closed { + return false + } + for _, job := range a.jobs.all() { + if job.running() || job.group.Alive() { + return false + } + } + return true +} + +func (a *Agent) parkBeltRunStorage(run *beltRun, summary RunSummary) { + // Multi-root execution copies remain resident until the per-root carrier + // and ownership lifecycle are ready. Projecting the set onto its primary + // copy would compact only part of a still-related workspace. + if run.tree.workspaceSet != nil { + return + } + + manager, enabled := taskStorage(a.config.Place) + if !enabled || !taskStorageOwns(a.config.Place, run.tree.dir) || run.tree.merge == mergeInPlace { + return + } + if _, err := os.Stat(run.tree.dir); os.IsNotExist(err) { + return + } + manager.Busy = func(path string) bool { return a.beltRunStorageBusy(run, summary, path) } + c := cell.Cell{ID: a.config.Place.ID(), Root: a.config.Place.Dir} + carry := taskcopy.Carry{Project: run.tree.ground} + manager.BeforeCapture = func(ctx context.Context, path string) error { + if err := carry.PrepareCold(c, path); err != nil { + return err + } + return pinTaskGit(ctx, manager, a.config.Place, path) + } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { return carry.MarkCold(c, path, snapshot) } + if err := manager.Park(context.Background(), run.tree.dir); err != nil { + releaseTaskGitAfterFailedPark(manager, run.tree.dir) + note := "compact storage could not be prepared; its recovery state was retained: " + err.Error() + if g := a.tasker(); g != nil { + g.planNote(note) + if kept, ok := runRowOf(g, run.row); ok && kept.State.settled() { + kept.Report = withReport(kept.Report, note) + a.publishRunRow(g, kept) + } + } + _, _ = run.store.AddNote(run.root, run.root, note) + } +} + +func (a *Agent) beltRunStorageBusy(run *beltRun, summary RunSummary, path string) bool { + a.beltMu.Lock() + workers := len(run.workers) + a.beltMu.Unlock() + if !summary.StorageSafe || workers != 0 { + return true + } + if g := a.tasker(); g != nil && g.taskStorageBusy(nil, path) { + return true + } + for _, job := range a.jobs.all() { + if !job.running() && !job.group.Alive() { + continue + } + if taskStorageOverlaps(path, job.dir) { + return true + } + } + return false +} + +// A failed initial capture has no cold transition to protect. Give back only +// the lock this attempt acquired; interrupted retirement keeps its pin. +func releaseTaskGitAfterFailedPark(manager taskstorage.Manager, path string) { + state, err := manager.Inspect(context.Background(), path) + if err == nil && state.Phase == taskstorage.Resident { + _ = restoreTaskGit(context.Background(), manager, path) + } +} + +func taskStorageOverlaps(a, b string) bool { + a, b = canonicalPath(a), canonicalPath(b) + for _, pair := range [][2]string{{a, b}, {b, a}} { + rel, err := filepath.Rel(pair[0], pair[1]) + if err == nil && (rel == "." || filepath.IsLocal(rel)) { + return true + } + } + return false +} + +// RestoreTaskPath prepares an explicitly requested task file for normal reading. +// Listing, hovering and rendering never call this filesystem-changing door. +func (a *Agent) RestoreTaskPath(ctx context.Context, path string) error { + place := a.familyPlace(nil) + manager, enabled := taskStorage(place) + if !enabled || !taskStorageOwns(place, path) { + return nil + } + rel, err := filepath.Rel(place.Trees(), path) + if err != nil { + return err + } + first := strings.Split(rel, string(filepath.Separator))[0] + return manager.Ensure(ctx, filepath.Join(place.Trees(), first)) +} + +// An external program is another uncontained writer even if its top-level +// process returns cleanly. Mark before handing execution to its engine. +func startRunWithTaskStorageProof(ctx context.Context, engine RunEngine, spec RunSpec) RunSummary { + if spec.Delegate != nil { + if err := markTaskStorageUncertain(spec.Workspace); err != nil { + return RunSummary{Outcome: "could not run", Result: "could not record program writer uncertainty: " + err.Error()} + } + } + return engine.Start(ctx, spec) +} diff --git a/internal/session/task_storage_prototype_test.go b/internal/session/task_storage_prototype_test.go new file mode 100644 index 0000000000..a2f9fae0f2 --- /dev/null +++ b/internal/session/task_storage_prototype_test.go @@ -0,0 +1,421 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +// An archive failure must not turn successful task retirement into data loss. +// This drives the same task executor a person starts through the conversation. +func TestTaskStorageFailureKeepsFinishedCopyForRecovery(t *testing.T) { + repo := newGoModuleRepo(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "missing-archive")) + completer := &routedCompleter{ + parent: []step{proposeCall("Add greeting", "write greet.go"), finalText("handed off")}, + child: nodeLane(4, func(_, wrote bool) *ai.Response { + if !wrote { + return writeResponse("greet", "greet.go", "package greet\nfunc Greet() string { return \"hi\" }\n") + } + return pricedResponse("greet.go is ready", 0.01) + }), + } + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: t.TempDir(), Workspace: repo} + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + c.TaskAudit = false + }) + notice := landedNode(t, agent, agent.graph()) + if notice.State != TaskDone { + t.Fatalf("task did not finish: %+v", notice) + } + node := agent.graph().node(1) + node.graph.mu.Lock() + dir := node.worktree + node.graph.mu.Unlock() + if _, err := os.Stat(filepath.Join(dir, "greet.go")); err != nil { + t.Fatalf("archive failure removed the full working copy: %v", err) + } + if !strings.Contains(notice.Report, "compact storage") { + t.Fatalf("archive failure hidden from report: %q", notice.Report) + } +} + +func TestDefaultRunWithoutWriterProofKeepsItsFullWorkingCopy(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "missing-archive")) + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("finished") + double.real = true + double.work = func(dir string) { writeFile(t, filepath.Join(dir, "proof.txt"), "finished bytes\n") } + registerBeltRunEngine(t, double) + place := Place{Dir: t.TempDir()} + agent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{finalText("finished")}}, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = place + c.SessionFile = place.Transcript() + c.AskConsent = false + }) + id, _, _, err := agent.StartTask(context.Background(), "write proof.txt", false) + if err != nil { + t.Fatal(err) + } + endBeltRun(t, agent, double) + row, ok := runRowOf(agent.graph(), id) + if !ok || row.Copy == nil { + t.Fatalf("run has no saved working copy: %+v", row) + } + if b, e := os.ReadFile(filepath.Join(row.Copy.Dir, "proof.txt")); e != nil || string(b) != "finished bytes\n" { + t.Fatalf("uncertain writer teardown lost task bytes: %q %v", b, e) + } + if !strings.Contains(row.Report, "compact storage") { + t.Fatalf("retained-copy reason was hidden: %q", row.Report) + } +} + +func TestColdLinkedRunKeepsStagedAndUnstagedWorkAcrossPruneAndContinue(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive binary is not configured") + } + for _, name := range []string{"ordinary prune", "missing administration repair", "existing user lock"} { + t.Run(name, func(t *testing.T) { + repair := name == "missing administration repair" + prelocked := name == "existing user lock" + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", binary) + agent, g, repo, double := continueAgent(t) + tree, e := cutWorktreeAt(agent.config.Place, repo, filepath.Join(agent.config.Place.Trees(), "7"), "task/cold-index", 0700) + if e != nil { + t.Fatal(e) + } + writeFile(t, filepath.Join(tree.dir, ".gitignore"), "ignored/\n") + mustGit(t, tree.dir, "add", ".gitignore") + mustGit(t, tree.dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "task input policy") + writeFile(t, filepath.Join(tree.dir, "shared.txt"), "staged version\n") + mustGit(t, tree.dir, "add", "shared.txt") + writeFile(t, filepath.Join(tree.dir, "shared.txt"), "unstaged version\n") + writeFile(t, filepath.Join(tree.dir, "untracked.txt"), "untracked bytes\n") + writeFile(t, filepath.Join(tree.dir, "ignored", "cache.txt"), "ignored bytes\n") + head := strings.TrimSpace(gitOut(t, tree.dir, "rev-parse", "HEAD")) + staged := gitOut(t, tree.dir, "show", ":shared.txt") + status := gitOut(t, tree.dir, "status", "--porcelain=v1", "--untracked-files=all") + if prelocked { + mustGit(t, tree.dir, "worktree", "lock", "--reason", "the user is keeping this", tree.dir) + } + keepInterruptedRun(t, agent, g, 7, "continue preserved state", runCopyOf(tree)) + manager, _ := taskStorage(agent.config.Place) + manager.Busy = func(string) bool { return false } + c := cell.Cell{ID: agent.config.Place.ID(), Root: agent.config.Place.Dir} + carry := taskcopy.Carry{} + manager.BeforeCapture = func(ctx context.Context, path string) error { + if e := carry.PrepareCold(c, path); e != nil { + return e + } + return pinTaskGit(ctx, manager, agent.config.Place, path) + } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { return carry.MarkCold(c, path, snapshot) } + if e := manager.Park(context.Background(), tree.dir); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(tree.dir); !os.IsNotExist(e) { + t.Fatalf("working tree was not cold: %v", e) + } + pin, e := readTaskGitPin(manager, tree.dir) + if e != nil { + t.Fatal(e) + } + mustGit(t, repo, "worktree", "prune", "--expire", "now") + if _, e := os.Stat(pin.Admin); e != nil { + t.Fatalf("normal pruning removed pinned Git state: %v", e) + } + if repair { + if e := os.RemoveAll(pin.Admin); e != nil { + t.Fatal(e) + } + } + if _, e := agent.ContinueRun(context.Background(), 7); e != nil { + t.Fatal(e) + } + beltRunWaitFor(t, "the restored run to start", double.didRun) + if got := strings.TrimSpace(gitOut(t, tree.dir, "rev-parse", "HEAD")); got != head { + t.Fatalf("commit changed: %q != %q", got, head) + } + if got := gitOut(t, tree.dir, "show", ":shared.txt"); got != staged { + t.Fatalf("staged bytes changed: %q != %q", got, staged) + } + if got := gitOut(t, tree.dir, "status", "--porcelain=v1", "--untracked-files=all"); got != status { + t.Fatalf("staging state changed:\n%s\nwant:\n%s", got, status) + } + for path, want := range map[string]string{"shared.txt": "unstaged version\n", "untracked.txt": "untracked bytes\n", "ignored/cache.txt": "ignored bytes\n"} { + got, e := os.ReadFile(filepath.Join(tree.dir, path)) + if e != nil || string(got) != want { + t.Fatalf("%s lost: %q %v", path, got, e) + } + } + lock, e := os.ReadFile(filepath.Join(pin.Admin, "locked")) + if prelocked { + if e != nil || strings.TrimSpace(string(lock)) != "the user is keeping this" { + t.Fatalf("the user's Git lock changed: %q %v", lock, e) + } + } else if !os.IsNotExist(e) { + t.Fatalf("our archive lock was not released: %v", e) + } + endBeltRun(t, agent, double) + }) + } +} + +func TestTaskStorageQuiescenceWaitsForClosedBackgroundJobs(t *testing.T) { + agent, _ := jobsAgent(t) + text, failed := runTool(t, agent, "bash", `{"command":"sleep 30","background":true}`) + if failed { + t.Fatalf("background launch: %s", text) + } + if agent.TaskStorageQuiescent() { + t.Fatal("a live worker was called safe to archive") + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + waitFor(t, "the closed worker's processes to be reaped", agent.TaskStorageQuiescent) +} + +func TestExplicitReadRestoresACompletedColdTaskFile(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive binary is not configured") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + place := Place{Dir: t.TempDir()} + repo := newTestRepo(t) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace = repo; c.Place = place }) + tree, e := cutWorktreeAt(place, repo, filepath.Join(place.Trees(), "completed"), "task/closed-answer", 0700) + if e != nil { + t.Fatal(e) + } + writeFile(t, filepath.Join(tree.dir, "answer.txt"), "a completed task's retained answer\n") + manager, _ := taskStorage(place) + manager.Busy = func(string) bool { return false } + carry := taskcopy.Carry{} + c := cell.Cell{ID: place.ID(), Root: place.Dir} + manager.BeforeCapture = func(ctx context.Context, path string) error { + if e := carry.PrepareCold(c, path); e != nil { + return e + } + return pinTaskGit(ctx, manager, place, path) + } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { return carry.MarkCold(c, path, snapshot) } + if e := manager.Park(context.Background(), tree.dir); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(tree.dir); !os.IsNotExist(e) { + t.Fatalf("copy was not cold: %v", e) + } + args, e := json.Marshal(map[string]string{"path": filepath.Join(tree.dir, "answer.txt")}) + if e != nil { + t.Fatal(e) + } + text, failed := runTool(t, agent, "read", string(args)) + if failed || !strings.Contains(text, "a completed task's retained answer") { + t.Fatalf("reading an archived task failed: %q error=%v", text, failed) + } +} + +func TestActiveAncestorWorkerPreventsColdRetirement(t *testing.T) { + root := t.TempDir() + trees := filepath.Join(root, "trees") + dir := filepath.Join(trees, "child") + writeFile(t, filepath.Join(dir, "answer.txt"), "still mutable by its parent\n") + graph := newTaskGraph() + graph.nodes[1] = &TaskNode{id: 1, graph: graph, state: TaskRunning, worktree: trees} + manager := taskstorage.Manager{ + Root: filepath.Join(root, "storage"), Trees: trees, + Vault: taskstorage.CLIBackend{Binary: filepath.Join(root, "must-not-run"), Store: filepath.Join(root, "vault")}, + Busy: func(path string) bool { return graph.taskStorageBusy(nil, path) }, + } + if e := manager.Park(context.Background(), dir); e == nil || !strings.Contains(e.Error(), "writer") { + t.Fatalf("active ancestor was not refused before capture: %v", e) + } + if b, e := os.ReadFile(filepath.Join(dir, "answer.txt")); e != nil || string(b) != "still mutable by its parent\n" { + t.Fatalf("ancestor-owned bytes changed: %q %v", b, e) + } +} + +func TestDefaultCompletedRunArchivesAndExplicitWorkTabRestoresIt(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive binary is not configured") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("finished") + double.summary.StorageSafe = true + double.real = true + double.work = func(dir string) { writeFile(t, filepath.Join(dir, "proof.txt"), "automatically archived result\n") } + registerBeltRunEngine(t, double) + place := Place{Dir: t.TempDir()} + agent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{finalText("finished")}}, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = place + c.SessionFile = place.Transcript() + c.AskConsent = false + }) + id, _, _, e := agent.StartTask(context.Background(), "write proof.txt", false) + if e != nil { + t.Fatal(e) + } + endBeltRun(t, agent, double) + row, ok := runRowOf(agent.graph(), id) + if !ok || row.Copy == nil || row.State != TaskDone { + t.Fatalf("completed run record: %+v", row) + } + if _, e := os.Stat(row.Copy.Dir); !os.IsNotExist(e) { + t.Fatalf("normal completion did not archive the working copy: %v", e) + } + key := planStoreID(strconv.FormatUint(id, 10)) + _, _ = agent.PlanTaskWork(key) + if _, e := os.Stat(row.Copy.Dir); !os.IsNotExist(e) { + t.Fatalf("background work query expanded the cold copy: %v", e) + } + work, ok := agent.OpenPlanTaskWork(key) + if !ok || !work.Read || work.Error != "" { + t.Fatalf("explicit work reopen: %+v %v", work, ok) + } + if b, e := os.ReadFile(filepath.Join(row.Copy.Dir, "proof.txt")); e != nil || string(b) != "automatically archived result\n" { + t.Fatalf("completed task bytes lost: %q %v", b, e) + } + after, _ := runRowOf(agent.graph(), id) + if after.State != TaskDone { + t.Fatalf("opening a completed copy restarted its execution: %+v", after) + } +} + +func TestPreviousShellAttemptCannotBeRetiredByACleanContinuation(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive binary is not configured") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + place := Place{Dir: t.TempDir()} + dir := filepath.Join(place.Trees(), "one") + writeFile(t, filepath.Join(dir, "answer.txt"), "must remain resident\n") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace = dir; c.Place = place }) + if text, failed := runTool(t, agent, "bash", `{"command":"printf shell-finished"}`); failed { + t.Fatal(text) + } + graph := agent.graph() + node := &TaskNode{id: 1, graph: graph, state: TaskFailed, worktree: dir, storageBlocked: true, done: make(chan struct{})} + graph.nodes[1] = node + graph.order = []uint64{1} + graph.seq = 1 + graph.run = func(n *TaskNode) { graph.complete(n, TaskDone) } + if e := agent.ContinueTask(1, "only file work now"); e != nil { + t.Fatal(e) + } + waitDoneNode(t, node) + manager, _ := taskStorage(place) + manager.Busy = func(path string) bool { return graph.taskStorageBusy(nil, path) } + if e := manager.Park(context.Background(), dir); e == nil || !strings.Contains(e.Error(), "writer") { + t.Fatalf("a clean retry erased shell uncertainty: %v", e) + } + if b, e := os.ReadFile(filepath.Join(dir, "answer.txt")); e != nil || string(b) != "must remain resident\n" { + t.Fatalf("uncertain task bytes changed: %q %v", b, e) + } +} + +func TestSettledShellChildPreventsParentRetirementAfterRestart(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive binary is not configured") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + place := Place{Dir: t.TempDir()} + parent := filepath.Join(place.Trees(), "parent") + child := filepath.Join(parent, "child") + writeFile(t, filepath.Join(child, "answer.txt"), "late writer could still change this\n") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace = child; c.Place = place }) + if text, failed := runTool(t, agent, "bash", `{"command":"printf shell-finished"}`); failed { + t.Fatal(text) + } + if e := agent.Close(); e != nil { + t.Fatal(e) + } + // A fresh graph has no old in-memory blocked bit. Retirement must still see + // the device-local launch uncertainty that deliberately survives restart. + restarted, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace = parent; c.Place = place }) + manager, _ := taskStorage(place) + manager.Busy = func(path string) bool { return restarted.graph().taskStorageBusy(nil, path) } + if e := manager.Park(context.Background(), parent); e == nil || !strings.Contains(e.Error(), "writer") { + t.Fatalf("a restart forgot the settled child shell: %v", e) + } + if _, e := os.Stat(filepath.Join(child, "answer.txt")); e != nil { + t.Fatalf("parent retirement removed uncertain child: %v", e) + } +} + +func TestUncontainedShellBlocksEvenUnrelatedTaskCopiesInItsProfile(t *testing.T) { + if env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") == "" { + t.Skip("prototype archive binary is not configured") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + agent, _ := jobsAgent(t) + if text, failed := runTool(t, agent, "bash", `{"command":"printf finished"}`); failed { + t.Fatal(text) + } + if e := agent.Close(); e != nil { + t.Fatal(e) + } + if agent.TaskStorageQuiescent() { + t.Fatal("a closed shell's missing process group was treated as escaped-writer proof") + } + place := Place{Dir: t.TempDir()} + dir := filepath.Join(place.Trees(), "unrelated") + writeFile(t, filepath.Join(dir, "answer.txt"), "host-wide access remains uncertain\n") + restarted, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace = dir; c.Place = place }) + manager, _ := taskStorage(place) + manager.Busy = func(path string) bool { return restarted.graph().taskStorageBusy(nil, path) } + if e := manager.Park(context.Background(), dir); e == nil || !strings.Contains(e.Error(), "writer") { + t.Fatalf("an unrelated path bypassed uncontained host-writer uncertainty: %v", e) + } + if _, e := os.Stat(filepath.Join(dir, "answer.txt")); e != nil { + t.Fatal(e) + } +} + +func TestShellDoesNotRunWhenItsUncertaintyCannotBeRecorded(t *testing.T) { + profile := t.TempDir() + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + writeFile(t, filepath.Join(profile, "task-storage", "uncertain-writers"), "not a directory") + agent, dir := jobsAgent(t) + text, failed := runTool(t, agent, "bash", `{"command":"printf must-not-run > started.txt"}`) + if !failed || !strings.Contains(text, "writer uncertainty") { + t.Fatalf("an unrecorded shell was allowed: %q %v", text, failed) + } + if _, e := os.Stat(filepath.Join(dir, "started.txt")); !os.IsNotExist(e) { + t.Fatalf("the shell ran before its durable guard: %v", e) + } +} diff --git a/internal/session/task_storage_uncertain.go b/internal/session/task_storage_uncertain.go new file mode 100644 index 0000000000..38a7e4bd55 --- /dev/null +++ b/internal/session/task_storage_uncertain.go @@ -0,0 +1,117 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" +) + +type taskStorageUncertainty struct { + Version int `json:"version"` + Workspace string `json:"workspace"` +} + +func taskStorageUncertaintyRoot() string { + if env.Get("CODEAF_PROTOTYPE_TASK_STORAGE") != "1" || strings.TrimSpace(env.Get(home.EnvVar)) == "" { + return "" + } + return home.Join("task-storage", "uncertain-writers") +} + +// markTaskStorageUncertain is written before launching an uncontained shell or +// custom tool. Process-group disappearance cannot prove that a daemon did not +// escape, so this local receipt intentionally never expires or clears on retry. +func markTaskStorageUncertain(workspace string) error { + root := taskStorageUncertaintyRoot() + if root == "" { + return nil + } + workspace = canonicalPath(workspace) + if workspace == "" || !filepath.IsAbs(workspace) { + return errors.New("cannot record the tool's workspace for safe compact storage") + } + if e := os.MkdirAll(root, 0700); e != nil { + return e + } + // A host-bound process can name absolute paths outside its launch folder. + // Without containment, one launch makes this entire test profile uncertain. + name := filepath.Join(root, "uncontained.json") + f, e := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if errors.Is(e, os.ErrExist) { + return syncTaskStorageUncertainty(name) + } + if e != nil { + return e + } + raw, e := json.Marshal(taskStorageUncertainty{Version: 1, Workspace: workspace}) + if e == nil { + _, e = f.Write(raw) + } + if e == nil { + e = f.Sync() + } + closed := f.Close() + if e == nil { + e = closed + } + if e != nil { + return e + } + return syncTaskStorageUncertainty(name) +} + +// Every launcher makes the presence durable itself, including a launcher that +// races the first creator before its write or directory sync has finished. +func syncTaskStorageUncertainty(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + err = f.Sync() + closed := f.Close() + if err != nil { + return err + } + if closed != nil { + return closed + } + for dir := filepath.Dir(name); ; dir = filepath.Dir(dir) { + if err := syncTaskStorageDirectory(dir); err != nil { + return err + } + if filepath.Dir(dir) == dir { + return nil + } + } +} + +func syncTaskStorageDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func taskStorageHasUncertainWriter() bool { + root := taskStorageUncertaintyRoot() + if root == "" { + return false + } + entries, e := os.ReadDir(root) + if errors.Is(e, os.ErrNotExist) { + return false + } + if e != nil { + return true + } + // Presence is the proof: malformed or unfamiliar entries must also retain + // copies, and reading their contents cannot make retirement safer. + return len(entries) != 0 +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 1a877858e7..51fcc8d0f7 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -66,9 +66,12 @@ package session // model reads one grammar for one kind of news. import ( + "context" "encoding/json" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" "log" "os" "path/filepath" @@ -83,11 +86,13 @@ import ( const ( // taskFileVersion is the schema of .tasks.json. A file carrying any - // other version is IGNORED rather than migrated, for state.go's reason: the + // unsupported version is IGNORED rather than migrated, for state.go's reason: the // graph is a record of work that has already happened, and guessing at an // older shape risks rehydrating a report into a field that no longer means // what it meant — which here would mean briefing a dependent with it. taskFileVersion = 1 + // Set-bearing documents require V2 so older singleton readers fail closed. + taskWorkspaceSetFileVersion = 2 // taskDocumentType is the type tag. A file that is JSON, parses, and is not // this is not this store's file. @@ -142,10 +147,13 @@ func (c Config) checkpointFile() string { // the reports its prerequisites left, which is the same thing it would have done // had nothing died. type taskRecord struct { - Restart *taskRestartRecord `json:"restart,omitempty"` - ID uint64 `json:"id"` - Title string `json:"title"` - Summary string `json:"summary,omitempty"` + // StorageBlocked is conservative launch uncertainty; a clean later attempt + // does not establish that an older writer or detached descendant has exited. + StorageBlocked bool `json:"storage_blocked,omitempty"` + Restart *taskRestartRecord `json:"restart,omitempty"` + ID uint64 `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` // Request is the person's own message, kept because a node that resumes // without it would be re-opened on the model's paraphrase alone — the one // part of what it was told that nothing downstream could reconstruct @@ -320,10 +328,15 @@ type taskRecord struct { // back to the report exactly as it did then. Result *taskResultRecord `json:"result,omitempty"` - Changed []string `json:"changed,omitempty"` - Branch string `json:"branch,omitempty"` - Worktree string `json:"worktree,omitempty"` - Merge string `json:"merge,omitempty"` + Changed []string `json:"changed,omitempty"` + Branch string `json:"branch,omitempty"` + Worktree string `json:"worktree,omitempty"` + WorkspaceURI string `json:"workspace_uri,omitempty"` + ChatURI string `json:"chat_uri,omitempty"` + ProjectURI string `json:"project_uri,omitempty"` + GroundURI string `json:"ground_uri,omitempty"` + RootURI string `json:"root_uri,omitempty"` + Merge string `json:"merge,omitempty"` // Wrote is what a node's own hands have written SO FAR, kept while it runs // rather than only when it lands ([TaskNode.noteWrote]). @@ -755,8 +768,9 @@ type runRecord struct { // saved before this field existed decodes with nil, and a run with no copy // recorded is one that cannot be carried on — which [runCopyTree] says out // loud rather than repairing. - Copy *TaskCopyRecord `json:"copy,omitempty"` - PendingRun *PendingRunRecord `json:"pendingRun,omitempty"` + WorkspaceSet *WorkspaceSet `json:"workspaceSet,omitempty"` + Copy *TaskCopyRecord `json:"copy,omitempty"` + PendingRun *PendingRunRecord `json:"pendingRun,omitempty"` // PlanTask is WHICH TASK OF THE PLAN STORE THIS ROW IS // ([TaskNotice.PlanTask]), carried across a restart for the same reason the @@ -807,11 +821,12 @@ type runRecord struct { // with no runs in it and resumes precisely as it always did, and this code's own // files are still version 1 documents that older builds can read. type taskDocument struct { - Type string `json:"type"` - Version int `json:"version"` - Seq uint64 `json:"seq"` - Nodes []taskRecord `json:"nodes"` - Runs []runRecord `json:"runs,omitempty"` + WorkspaceTransfer *taskcopy.WorkspacePlan `json:"workspace_transfer,omitempty"` + Type string `json:"type"` + Version int `json:"version"` + Seq uint64 `json:"seq"` + Nodes []taskRecord `json:"nodes"` + Runs []runRecord `json:"runs,omitempty"` } // taskStore is the file and the lock that serializes writes to it. @@ -903,14 +918,26 @@ func (s *taskStore) writeLocked(document taskDocument) error { if s.duringWrite != nil { s.duringWrite() } - document = filterDeletedDocument(document, taskDeletions(filepath.Join(filepath.Dir(s.path), placeTranscript))) - if codec, sealed := checkpointCodec(s.path); sealed { - document = document.mapPaths(codec.encode) + root := filepath.Dir(s.path) + if filepath.Base(root) == cellStateDir { + root = filepath.Dir(root) + } + if err := workspaceFinalizationPending(Place{Dir: root}); err != nil { + return err + } + release, err := taskcopy.LockWorkspaceCheckpoint(context.Background(), cell.Cell{Root: root}) + if err != nil { + return err + } + defer release() + if err := workspaceFinalizationPending(Place{Dir: root}); err != nil { + return err } - encoded, err := json.MarshalIndent(document, "", " ") + encoded, err := encodeTaskCheckpoint(document, s.path) if err != nil { return err } + if directory := filepath.Dir(s.path); directory != "" && directory != "." { if err := os.MkdirAll(directory, 0o755); err != nil { return err @@ -1081,6 +1108,9 @@ func (g *TaskGraph) documentLocked() taskDocument { // a record. for _, notice := range g.runRowsLocked() { document.Runs = append(document.Runs, runRowRecord(notice)) + if notice.WorkspaceSet != nil || notice.PendingRun != nil && notice.PendingRun.WorkspaceSet != nil { + document.Version = taskWorkspaceSetFileVersion + } } return document } @@ -1117,6 +1147,7 @@ func runRowRecord(notice TaskNotice) runRecord { StartedAt: notice.StartedAt, EndedAt: notice.EndedAt, Copy: notice.Copy, + WorkspaceSet: cloneWorkspaceSet(notice.WorkspaceSet), PendingRun: notice.PendingRun, Ending: notice.Ending, Branch: notice.Branch, @@ -1165,6 +1196,7 @@ func runRowNotice(record runRecord) TaskNotice { StartedAt: record.StartedAt, EndedAt: record.EndedAt, Copy: record.Copy, + WorkspaceSet: cloneWorkspaceSet(record.WorkspaceSet), PendingRun: record.PendingRun, Ending: record.Ending, Branch: record.Branch, @@ -1287,6 +1319,11 @@ func (n *TaskNode) recordLocked() taskRecord { Wrote: wrote, Branch: n.branch, Worktree: n.worktree, + WorkspaceURI: n.WorkspaceURI, + ChatURI: n.ChatURI, + ProjectURI: n.ProjectURI, + GroundURI: n.GroundURI, + RootURI: n.RootURI, Merge: n.merge, Journal: n.journal, Beat: beat, @@ -1309,6 +1346,7 @@ func (n *TaskNode) recordLocked() taskRecord { NotedState: n.notedState, Attempt: n.attempt, Interrupted: n.interrupted, + StorageBlocked: n.storageBlocked, Decider: n.decider, Clashing: n.clashing, Shifted: n.shifted, @@ -1399,8 +1437,8 @@ func decodeTasks(content []byte) (taskDocument, error) { if document.Type != taskDocumentType { return taskDocument{}, fmt.Errorf("type is %q, want %q", document.Type, taskDocumentType) } - if document.Version != taskFileVersion { - return taskDocument{}, fmt.Errorf("version %d, want %d", document.Version, taskFileVersion) + if err := validateTaskDocumentVersion(document); err != nil { + return taskDocument{}, err } seen := make(map[uint64]bool, len(document.Nodes)) var highest uint64 @@ -1747,6 +1785,10 @@ func (a *Agent) recoverTasks() { document, found := loadTaskCheckpoint(a.config.checkpointFile()) if !found { a.setAsideRefusedCheckpoint() + } + var recoveredPreparation bool + document, recoveredPreparation = recoverWorkspaceSetPreparations(a.config.Place, document) + if !found && !recoveredPreparation { return } if len(document.Nodes) == 0 && len(document.Runs) == 0 { @@ -2186,30 +2228,36 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { ending: record.Ending, // The ending already persists a person's cancellation. Rebuild the // runtime flag so restored results retain the same routing protection. - stopped: record.Ending == TaskEndingStopped, - kind: record.Kind, - claim: record.Claim, - checkedOn: record.CheckedOn, - produced: resultFromRecord(record.Result), - changed: record.Changed, - wrote: record.Wrote, - branch: record.Branch, - worktree: record.Worktree, - merge: record.Merge, - journal: record.Journal, - elapsed: time.Duration(record.ElapsedMS) * time.Millisecond, - started: record.StartedAt, - ended: record.EndedAt, - cost: record.CostUSD, - input: record.Input, - output: record.Output, - cacheRead: record.CacheRead, - cacheWrite: record.CacheWrite, - noted: record.Noted, - notedRead: record.Noted, - notedState: record.NotedState, - attempt: record.Attempt, - interrupted: record.Interrupted, + stopped: record.Ending == TaskEndingStopped, + kind: record.Kind, + claim: record.Claim, + checkedOn: record.CheckedOn, + produced: resultFromRecord(record.Result), + changed: record.Changed, + wrote: record.Wrote, + branch: record.Branch, + worktree: record.Worktree, + WorkspaceURI: record.WorkspaceURI, + ChatURI: record.ChatURI, + ProjectURI: record.ProjectURI, + GroundURI: record.GroundURI, + RootURI: record.RootURI, + merge: record.Merge, + journal: record.Journal, + elapsed: time.Duration(record.ElapsedMS) * time.Millisecond, + started: record.StartedAt, + ended: record.EndedAt, + cost: record.CostUSD, + input: record.Input, + output: record.Output, + cacheRead: record.CacheRead, + cacheWrite: record.CacheWrite, + noted: record.Noted, + notedRead: record.Noted, + notedState: record.NotedState, + attempt: record.Attempt, + interrupted: record.Interrupted, + storageBlocked: record.StorageBlocked, // AND WHO WAS HOLDING ITS DECISION, which is read back exactly as it was // written and then put right by the floor a few lines above this node's // arrival ([TaskGraph.handBackOnLoad], called out of [TaskGraph.rehydrate]). @@ -2466,3 +2514,40 @@ func LoadLandedForJudge(tasksPath string) ([]TaskLanding, error) { } return landed, nil } + +func encodeTaskCheckpoint(document taskDocument, path string) ([]byte, error) { + root := filepath.Dir(path) + if filepath.Base(root) == cellStateDir { + root = filepath.Dir(root) + } + document = filterDeletedDocument(document, taskDeletions(filepath.Join(filepath.Dir(path), placeTranscript))) + projection, err := workspaceTransferProjection(document, Place{Dir: root}) + if err != nil { + return nil, err + } + document.WorkspaceTransfer = projection + if projection != nil { + document.Version = taskWorkspaceSetFileVersion + } else if document.Version == taskWorkspaceSetFileVersion { + document.Version = taskFileVersion + } + + if codec, sealed := checkpointCodec(path); sealed { + document = document.mapPaths(codec.encode) + } + return json.MarshalIndent(document, "", " ") +} + +func validateTaskDocumentVersion(document taskDocument) error { + if document.Version != taskFileVersion && document.Version != taskWorkspaceSetFileVersion { + return fmt.Errorf("version %d, want %d", document.Version, taskFileVersion) + } + if document.Version == taskFileVersion { + for _, run := range document.Runs { + if run.WorkspaceSet != nil || run.PendingRun != nil && run.PendingRun.WorkspaceSet != nil { + return errors.New("workspace-set checkpoint requires version 2; version 1 would silently discard required roots in older builds") + } + } + } + return nil +} diff --git a/internal/session/task_tree_mirror.go b/internal/session/task_tree_mirror.go index d81f38c1ab..afc5f6a557 100644 --- a/internal/session/task_tree_mirror.go +++ b/internal/session/task_tree_mirror.go @@ -52,7 +52,11 @@ package session // own machinery where they live. Parts of such a parent go on sharing the // directory, which is what "here" means. -import "strings" +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/taskcopy" +) // openFamilyTree makes a mirrored family tree into the repository its parts cut // their worktrees off, and hands the tree back either way. @@ -142,6 +146,9 @@ func openFamilyRepository(dir string) string { "-m", familyTreeCommitMessage)...); err != nil { return familyTreeProblem(out, err) } + if err := taskcopy.MarkIndependentBaseline(dir); err != nil { + return familyTreeProblem("", err) + } return "" } diff --git a/internal/session/taskoutside.go b/internal/session/taskoutside.go index d38d8fbcb4..28897be1e4 100644 --- a/internal/session/taskoutside.go +++ b/internal/session/taskoutside.go @@ -126,10 +126,10 @@ func (g taskGroundGuard) PreAction(_ context.Context, _ *episode, _ *eventHub, c return call, toolResult{}, true } path, shown, ok := g.agent.mutatingPath(call) - if !ok || insideGround(ground, path) { + if !ok || g.agent.taskMayWrite(path) { return call, toolResult{}, true } - return call, toolResult{text: outsideGroundRefusal(shown, ground), isError: true}, false + return call, toolResult{text: g.agent.taskOutsideRefusal(shown), isError: true}, false } var parsed struct { Command string `json:"command"` @@ -158,8 +158,8 @@ func (g taskGroundGuard) PreAction(_ context.Context, _ *episode, _ *eventHub, c if ground == "" { return call, toolResult{}, true } - if why := refusedOutsideGround(ground, parsed.Command); why != "" { - return call, toolResult{text: why, isError: true}, false + if aim := outsideScopeAim(ground, parsed.Command, g.agent.taskMayWrite); aim != "" { + return call, toolResult{text: g.agent.taskOutsideRefusal(aim), isError: true}, false } return call, toolResult{}, true } @@ -463,15 +463,28 @@ const taskCredentialInstead = "Say in your report what needed it; anything that // check that read the segments independently would have seen a `git checkout -b` // with no path in it at all. func refusedOutsideGround(ground, command string) string { + aim := outsideScopeAim(ground, command, func(path string) bool { return insideGround(ground, path) }) + if aim == "" { + return "" + } + return outsideGroundRefusal(aim, ground) +} + +func outsideScopeAim(ground, command string, allowed func(string) bool) string { cwd := ground for _, segment := range taskSegments(command) { words, assignments := taskSegmentHead(stripRedirections(segment)) + for index, word := range segment { + if index+1 < len(segment) && fileWriteRedirection(word, segment[index+1]) { + target := resolvePath(cwd, segment[index+1]) + if !allowed(target) { + return "writing to " + target + } + } + } if len(words) == 0 { continue } - if target := redirectTarget(cwd, segment); target != "" && !insideGround(ground, target) { - return outsideGroundRefusal("writing to "+target, ground) - } program := filepath.Base(words[0]) rest := words[1:] switch { @@ -479,13 +492,13 @@ func refusedOutsideGround(ground, command string) string { cwd = resolveCd(cwd, rest) case program == "git": dir, verb, after := gitAim(cwd, assignments, rest) - if verb == "" || insideGround(ground, dir) || gitOnlyReads(verb, after) { + if verb == "" || allowed(dir) || gitOnlyReads(verb, after) { continue } - return outsideGroundRefusal("git "+verb+" in "+dir, ground) + return "git " + verb + " in " + dir default: - if aimed := writeAimedOutside(ground, cwd, program, rest); aimed != "" { - return outsideGroundRefusal(aimed, ground) + if aimed := writeAimedOutsideScope(cwd, program, rest, allowed); aimed != "" { + return aimed } } } @@ -678,10 +691,14 @@ var writesItsLastOperand = map[string]bool{ // target from the diff it is fed and writes relative to the directory it is in, // so a patch run after a `cd` is aimed by the `cd` alone. func writeAimedOutside(ground, cwd, program string, rest []string) string { + return writeAimedOutsideScope(cwd, program, rest, func(path string) bool { return insideGround(ground, path) }) +} + +func writeAimedOutsideScope(cwd, program string, rest []string, allowed func(string) bool) string { operands := commandOperands(rest) switch { case program == "patch": - if !insideGround(ground, cwd) { + if !allowed(cwd) { return "patching in " + cwd } case program == "sed" || program == "perl": @@ -697,7 +714,7 @@ func writeAimedOutside(ground, cwd, program string, rest []string) string { } for _, operand := range operands { path := resolvePath(cwd, operand) - if !insideGround(ground, path) { + if !allowed(path) { return path } } @@ -739,7 +756,7 @@ func stripRedirections(segment []string) []string { out := make([]string, 0, len(segment)) for index := 0; index < len(segment); index++ { word := segment[index] - if word != "<" && !strings.HasPrefix(word, ">") { + if word != "<" && word != "<>" && !strings.HasPrefix(word, ">") { out = append(out, word) continue } @@ -997,17 +1014,11 @@ func taskSegments(command string) [][]string { continue } word.WriteRune(letter) - case '>': + case '>', '<': flushWord() - operator := ">" - for index+1 < len(runes) && (runes[index+1] == '>' || runes[index+1] == '&') { - index++ - operator += string(runes[index]) - } + operator, last := taskRedirectOperator(runes, index) + index = last words = append(words, operator) - case '<': - flushWord() - words = append(words, "<") default: word.WriteRune(letter) } @@ -1051,3 +1062,30 @@ func environmentAssignment(word string) (string, string, bool) { } return word[:at], word[at+1:], true } + +// Numeric descriptor duplication/closure does not open a filesystem target. +// Bash's >& also accepts a filename, unlike its numeric 2>&1 spelling. +func fileWriteRedirection(operator, target string) bool { + switch operator { + case ">", ">>", ">|", "<>": + return true + case ">&": + return target != "-" && !isDigits(strings.TrimSuffix(target, "-")) + } + return false +} + +func taskRedirectOperator(runes []rune, index int) (string, int) { + operator := string(runes[index]) + if operator == "<" { + if index+1 < len(runes) && runes[index+1] == '>' { + return "<>", index + 1 + } + return operator, index + } + for index+1 < len(runes) && strings.ContainsRune(">&|", runes[index+1]) { + index++ + operator += string(runes[index]) + } + return operator, index +} diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 28a8ddd0f1..0d6e578dc0 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -56,6 +56,7 @@ package session // paragraph is the signpost between them. import ( + "context" "encoding/json" "errors" "os" @@ -75,6 +76,7 @@ import ( // not a stand with a guess in it as well, because the guess would be what // actually ran the moment a caller forgot to look. type taskStand struct { + workspaceSet *WorkspaceSetRequest // dir is the absolute path the work stands in: a repository root, or a // folder. dir string @@ -152,6 +154,21 @@ const taskGroundPathsRead = 400 // card. A second reading of the same evidence somewhere else would be a second // answer to the one question this file exists to have one answer to. func (a *Agent) resolveTaskGround(spec taskSpec) taskStand { + if spec.workspaceSet != nil { + request := cloneWorkspaceSetRequest(spec.workspaceSet) + if err := validateWorkspaceSetRequest(request); err != nil { + return taskStand{refusal: err.Error()} + } + if _, err := validateWorkspaceSetSources(context.Background(), a.config.Place, *request, ""); err != nil { + return taskStand{refusal: err.Error()} + } + for _, root := range request.Roots { + if root.Slot == request.Primary { + return taskStand{dir: root.Source, mode: TaskModeMirror, workspaceSet: request} + } + } + return taskStand{refusal: "workspace_set primary is missing"} + } workspace := canonicalPath(strings.TrimSpace(a.config.Workspace)) if program, err := a.delegateFor(spec.via); err == nil { return programGround(spec, workspace, program) @@ -172,6 +189,9 @@ func (a *Agent) resolveTaskGround(spec taskSpec) taskStand { // the placement a model asked for, the ladder, the brief's last word, and the // mode. func (a *Agent) ordinaryTaskGround(spec taskSpec, workspace string) taskStand { + if stand, handled := a.ownershipPlacement(spec, workspace); handled { + return stand + } redirect := "" // A MODEL'S PLACEMENT IS EVIDENCE, NOT AUTHORITY, INSIDE A REPOSITORY. A // branch is the repository's isolation boundary even when `where` asked for @@ -217,7 +237,7 @@ func (a *Agent) ordinaryTaskGround(spec taskSpec, workspace string) taskStand { } else if reground != "" { stand.dir, stand.rung = reground, taskGroundSaid } - stand.mode, redirect = groundModeAfterPlacement(stand, spec, workspace, redirect, a.config.Place.Trees()) + stand.mode, redirect = groundModeAfterPlacement(stand, spec, workspace, redirect, a.config.Place.Trees(), WorkspaceOwnershipEnabled(a.config.Place)) stand.redirect = redirect return stand } @@ -225,7 +245,7 @@ func (a *Agent) ordinaryTaskGround(spec taskSpec, workspace string) taskStand { // groundModeAfterPlacement is the mode phase of [Agent.resolveTaskGround]. It // keeps the person's referred-place word authoritative while letting a model's // declined `in place` request become ordinary evidence for [groundMode]. -func groundModeAfterPlacement(stand taskStand, spec taskSpec, workspace, redirect, trees string) (TaskMode, string) { +func groundModeAfterPlacement(stand taskStand, spec taskSpec, workspace, redirect, trees string, owned bool) (TaskMode, string) { // A MODE THE PERSON SAID IS NOT RECOMPUTED. Every ordinary rung leaves this // blank; a referred place may carry the person's own word about how work // happens there ([PlaceRef.Mode]), and that word is obeyed rather than read. @@ -245,7 +265,7 @@ func groundModeAfterPlacement(stand taskStand, spec taskSpec, workspace, redirec // reference, exactly as it would on every other ladder rung. modeStand.rung = "" } - return groundMode(modeStand, spec, workspace, trees), redirect + return groundMode(modeStand, spec, workspace, trees, owned), redirect } // whereInsideRepository reports the committed repository a model-authored @@ -894,9 +914,12 @@ func groundRoot(dir string) string { // looks like — this design came to place work that was going somewhere WRONG, // and it may not quietly take a worktree away from work that was already going // somewhere right. -func groundMode(stand taskStand, spec taskSpec, workspace string, trees string) TaskMode { +func groundMode(stand taskStand, spec taskSpec, workspace string, trees string, owned bool) TaskMode { _, isRepo := repositoryRoot(stand.dir) writes := groundNamesWorkUnder(stand.dir, spec, trees) + if owned && !isRepo { + return ownershipPlainMode(stand.dir) + } switch { case isRepo && (stand.rung == taskGroundStandingIn || writes): return TaskModeWorktree diff --git a/internal/session/tools_anchor_workspace.go b/internal/session/tools_anchor_workspace.go index 522874e503..3f4fe05f5e 100644 --- a/internal/session/tools_anchor_workspace.go +++ b/internal/session/tools_anchor_workspace.go @@ -137,7 +137,7 @@ func (a *Agent) anchorMetaLocked(dir, workspace string) error { meta = a.fillMetaLocked(meta) } meta.Workspace, meta.Owned = workspace, false - if err := SaveMeta(dir, meta); err != nil { + if err := saveMetaLocked(dir, meta); err != nil { return fmt.Errorf("save conversation place: %w", err) } return nil diff --git a/internal/session/tools_jobs.go b/internal/session/tools_jobs.go index ba97d32255..98a12021b3 100644 --- a/internal/session/tools_jobs.go +++ b/internal/session/tools_jobs.go @@ -188,6 +188,9 @@ func (a *Agent) backgroundBash(inner bare.Tool) bare.Tool { Description: inner.Description + backgroundSentence + timeoutSentence(a.config.BashBackgroundAfterSeconds), Schema: schemaWithBackground(inner.Schema), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + if err := markTaskStorageUncertain(a.config.Workspace); err != nil { + return "could not record shell writer uncertainty: " + err.Error(), true, nil + } var parsed struct { Command string `json:"command"` Background bool `json:"background"` diff --git a/internal/session/tools_sense.go b/internal/session/tools_sense.go index 098e035c7f..7a09f691d1 100644 --- a/internal/session/tools_sense.go +++ b/internal/session/tools_sense.go @@ -237,6 +237,9 @@ func (a *Agent) senseRead(inner bare.Tool) bare.Tool { return inner.Execute(ctx, args) } absolute := resolveInWorkspace(parsed.Path, a.config.Workspace) + if err := a.RestoreTaskPath(ctx, absolute); err != nil { + return "could not restore the saved task file: " + err.Error(), true, nil + } // A file that is not there, or is a directory, is bare's sentence // and not one of ours. A name ending in .mp3 is a claim about a file // that does not exist yet, and "Error reading file: no such file" is @@ -247,7 +250,13 @@ func (a *Agent) senseRead(inner bare.Tool) bare.Tool { } shown := filepath.ToSlash(parsed.Path) - switch entry := sniffSense(absolute); entry.kind { + entry := sniffSense(absolute) + if entry.kind != senseNone { + if err := markTaskStorageUncertain(a.config.Workspace); err != nil { + return "could not record reader process uncertainty: " + err.Error(), true, nil + } + } + switch entry.kind { case sensePDF: return a.pdfSense(shown, absolute, parsed.Offset, parsed.Limit) case senseImage: diff --git a/internal/session/tools_watch.go b/internal/session/tools_watch.go index f16a1b2abb..4c1c03867f 100644 --- a/internal/session/tools_watch.go +++ b/internal/session/tools_watch.go @@ -653,6 +653,9 @@ func (r *jobRegistry) watchTick(ctx context.Context, watched *job, spec watchSpe // and the failure line is both part of that output (a command that starts // failing IS news) and the identity the failure streak counts. func (r *jobRegistry) runTick(ctx context.Context, spec watchSpec) (string, string) { + if err := markTaskStorageUncertain(r.workspace); err != nil { + return "", "(could not record watch writer uncertainty: " + err.Error() + ")" + } // Two bounds, and the tighter one wins: never longer than the interval, so // ticks cannot pile up on each other, and never longer than a minute, so a // watch on an hourly timer cannot sit inside one hung run for an hour. diff --git a/internal/session/workspace_branch.go b/internal/session/workspace_branch.go new file mode 100644 index 0000000000..a1968cd268 --- /dev/null +++ b/internal/session/workspace_branch.go @@ -0,0 +1,47 @@ +package session + +import ( + "fmt" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// ForkChatWorkspaceIdentity establishes a new independent owner at the fork's +// own folder. It never rebinds the source chat's identity or imports its runs. +func ForkChatWorkspaceIdentity(place Place) error { + if !WorkspaceOwnershipEnabled(place) { + return nil + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + chat, err := registry.Register(workspaceidentity.Chat, canonicalPath(place.Dir)) + if err != nil { + return err + } + return withMetaLock(place.Dir, func() error { + meta, err := LoadMeta(place.Dir) + if err != nil { + return err + } + if meta.ID != "" && meta.ID != place.ID() { + return fmt.Errorf("independent conversation metadata belongs to another chat") + } + meta.ID, meta.ChatURI = place.ID(), chat.URI + meta.Owned = true + meta.Workspace = filepath.Join(place.Dir, "work") + // Forking is the one authorized identity split. Ordinary SaveMeta must + // continue refusing replacement of an existing chat identity. + truth := layoutOf(place.Dir).metaTruth(place.Dir) + if truth == "" { + return fmt.Errorf("independent conversation requires a sealed cell") + } + if err := writeTruth(place.Dir, truth, meta); err != nil { + return err + } + return writeJSONAtomic(place.MetaPath(), meta.derived()) + }) +} diff --git a/internal/session/workspace_cache_accounting_test.go b/internal/session/workspace_cache_accounting_test.go new file mode 100644 index 0000000000..b7046eb91e --- /dev/null +++ b/internal/session/workspace_cache_accounting_test.go @@ -0,0 +1,205 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/cachedir" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestAdmittedLegacyProjectCacheKeepsItsIdentityAndBytes(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + project, err := registry.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(project.URI)) + legacy := home.Join("workspace-caches", hex.EncodeToString(digest[:]), "cache") + if err := os.MkdirAll(legacy, 0700); err != nil { + t.Fatal(err) + } + admitted, err := registry.Register(workspaceidentity.Cache, legacy, workspaceidentity.Links{OwnerURI: project.URI}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacy, "compiler"), []byte("legacy cache"), 0600); err != nil { + t.Fatal(err) + } + selected, err := ownedProjectCache(registry, project.URI) + if err != nil || selected != legacy { + t.Fatalf("legacy binding moved: %q %v", selected, err) + } + if got := observedWorkerCache(t, project.Path, project.URI); got != legacy { + t.Fatalf("actual worker ignored retained cache: %s", got) + } + current, err := registry.Lookup(workspaceidentity.Cache, selected) + if err != nil || current.URI != admitted.URI { + t.Fatalf("cache identity replaced: %+v %v", current, err) + } + if got := cachedir.Size(); got != 12 { + t.Fatalf("legacy cache is invisible: %d", got) + } + if _, err := os.Stat(filepath.Join(cachedir.Root(), "workspaces")); !os.IsNotExist(err) { + t.Fatal("legacy project acquired a second cache pool") + } + if _, err := cachedir.Clean(); err != nil { + t.Fatal(err) + } + selected, err = ownedProjectCache(registry, project.URI) + if err != nil || selected != legacy { + t.Fatalf("clean lost admitted binding: %q %v", selected, err) + } + current, err = registry.Lookup(workspaceidentity.Cache, selected) + if err != nil || current.URI != admitted.URI { + t.Fatal("clean replaced cache URI") + } +} + +func observedWorkerCache(t *testing.T, workspace, uri string) string { + t.Helper() + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "cache proof", "1", "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + script := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("cache", "bash", `{"command":"printf '%s\\n' \"$XDG_CACHE_HOME\" > cache-path.txt"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + worker, err := NewBeltWorker(Config{Workspace: workspace, WorkspaceURI: uri, Model: "test/model"}, script, store.Task("1"), store.Path(), store.RootID()) + if err != nil { + t.Fatal(err) + } + drainTurn(t, worker, "Observe the assigned cache") + worker.Close() + raw, err := os.ReadFile(filepath.Join(workspace, "cache-path.txt")) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(raw)) +} + +func TestLegacyRuntimeCacheKeepsIdentityWithoutCleaningRunTemp(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + run, err := registry.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI}) + if err != nil { + t.Fatal(err) + } + newRun, err := registry.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI}) + if err != nil { + t.Fatal(err) + } + newCache := observedWorkerCache(t, newRun.Path, newRun.URI) + if rel, err := filepath.Rel(cachedir.Root(), newCache); err != nil || !filepath.IsLocal(rel) { + t.Fatalf("new rootless worker cache outside cache owner: %s", newCache) + } + legacy := filepath.Join(workspaceRuntimeRoot(run.URI), "cache") + if err := os.MkdirAll(legacy, 0700); err != nil { + t.Fatal(err) + } + cache, err := registry.Register(workspaceidentity.Cache, legacy, workspaceidentity.Links{OwnerURI: run.URI}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacy, "compiler"), []byte("cache"), 0600); err != nil { + t.Fatal(err) + } + if got := observedWorkerCache(t, run.Path, run.URI); got != legacy { + t.Fatalf("runtime cache moved: %s", got) + } + scratch := filepath.Join(workspaceRuntimeRoot(run.URI), "temp", "keep") + if err := os.WriteFile(scratch, []byte("pending scratch"), 0600); err != nil { + t.Fatal(err) + } + if got := cachedir.Size(); got != 5 { + t.Fatalf("cache accounting included scratch or missed cache: %d", got) + } + if _, err := cachedir.Clean(); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(scratch); err != nil || string(raw) != "pending scratch" { + t.Fatal("cache cleaning touched scratch") + } + if got := observedWorkerCache(t, run.Path, run.URI); got != legacy { + t.Fatal("clean changed legacy cache binding") + } + actual, err := registry.Lookup(workspaceidentity.Cache, legacy) + if err != nil || actual.URI != cache.URI { + t.Fatal("clean replaced legacy runtime cache identity") + } +} + +func TestOwnedChatProfileCacheUsesCacheOwnerAndRetainsLegacyBinding(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprint(legacy), func(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + old := home.Join("workspace-caches", "profile", "cache") + var admitted workspaceidentity.Record + if legacy { + if err := os.MkdirAll(old, 0700); err != nil { + t.Fatal(err) + } + admitted, err = registry.Register(workspaceidentity.Cache, old) + if err != nil { + t.Fatal(err) + } + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = t.TempDir() + config.Place = Place{Dir: t.TempDir(), Owned: true} + }) + result, err := agent.config.seat().In(agent.config.Workspace).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", `printf '%s\n' "$XDG_CACHE_HOME"`}}, nil) + if err != nil || result.Exit != 0 { + t.Fatalf("foreground cache process: %v", err) + } + path := strings.TrimSpace(string(result.Stdout)) + if legacy { + record, err := registry.Lookup(workspaceidentity.Cache, path) + if err != nil || record.URI != admitted.URI || path != old { + t.Fatal("foreground chat replaced legacy profile cache") + } + } else if rel, err := filepath.Rel(cachedir.Root(), path); err != nil || !filepath.IsLocal(rel) { + t.Fatalf("new foreground cache outside cache owner: %s", path) + } + if err := os.WriteFile(filepath.Join(path, "compiler"), []byte("cache"), 0600); err != nil { + t.Fatal(err) + } + if got := cachedir.Size(); got != 5 { + t.Fatalf("foreground cache invisible or other assets counted: %d", got) + } + }) + } +} diff --git a/internal/session/workspace_chat.go b/internal/session/workspace_chat.go new file mode 100644 index 0000000000..595c5ae53c --- /dev/null +++ b/internal/session/workspace_chat.go @@ -0,0 +1,129 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// configureOwnedChat gives foreground scratch a home without changing the +// borrowed project where the person intentionally reads and edits their files. +func configureOwnedChat(config *Config) error { + if config.InTask || config.Place.Dir == "" { + return nil + } + meta, err := LoadMeta(config.Place.Dir) + if err != nil { + return err + } + if err := validateWorkspacePolicy(meta.WorkspacePolicy); err != nil { + return err + } + if !WorkspaceOwnershipEnabled(config.Place) { + return nil + } + if err := os.MkdirAll(config.Place.Dir, 0700); err != nil { + return err + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + var chat workspaceidentity.Record + err = withMetaLock(config.Place.Dir, func() error { + meta, err := LoadMeta(config.Place.Dir) + if err != nil { + return err + } + if err := validateWorkspacePolicy(meta.WorkspacePolicy); err != nil { + return err + } + if meta.WorkspacePolicy == nil { + meta.WorkspacePolicy = DefaultWorkspacePolicy() + } + if meta.ChatURI != "" { + chat, err = registry.Resolve(meta.ChatURI) + if err != nil { + return fmt.Errorf("resolve conversation identity: %w", err) + } + if chat.Role != workspaceidentity.Chat || chat.Path != canonicalPath(config.Place.Dir) { + return fmt.Errorf("conversation identity is not bound to this chat") + } + return saveMetaLocked(config.Place.Dir, meta) + } + chat, err = registry.Register(workspaceidentity.Chat, canonicalPath(config.Place.Dir)) + if err != nil { + return err + } + meta.ChatURI = chat.URI + if meta.ID == "" { + meta.ID = config.Place.ID() + } + if meta.Workspace == "" { + meta.Workspace = config.Workspace + } + return saveMetaLocked(config.Place.Dir, meta) + }) + if err != nil { + return err + } + config.WorkspaceURI = chat.URI + scratch := filepath.Join(workspaceRuntimeRoot(chat.URI), "temp") + var cache string + cacheOwner := "" + if !config.Place.Owned && ownershipPlainMode(config.Workspace) != TaskModeReference { + project, err := registry.Register(workspaceidentity.Project, groundRoot(config.Workspace)) + if err != nil { + return err + } + cache, err = ownedProjectCache(registry, project.URI) + if err != nil { + return err + } + cacheOwner = project.URI + } else { + cache, err = ownedCache(registry, "", home.Join("workspace-caches", "profile", "cache")) + if err != nil { + return err + } + } + for _, entry := range []struct { + path string + role workspaceidentity.Role + owner string + }{{scratch, workspaceidentity.Temp, chat.URI}, {cache, workspaceidentity.Cache, cacheOwner}, {config.Place.Logs(), workspaceidentity.Log, chat.URI}} { + if err := os.MkdirAll(entry.path, 0700); err != nil { + return err + } + if _, err := registry.Register(entry.role, canonicalPath(entry.path), workspaceidentity.Links{OwnerURI: entry.owner}); err != nil { + return fmt.Errorf("record conversation storage: %w", err) + } + } + config.Seat = executor.WithEnvironment(config.seat(), []string{"TMPDIR=" + scratch, "TMP=" + scratch, "TEMP=" + scratch, "XDG_CACHE_HOME=" + cache, "CODEAF_WORKSPACE_URI=" + chat.URI}) + return nil +} + +// AdoptChatWorkspaceIdentity binds sealed conversation identity after an accepted +// takeover. Ordinary session opening only resolves known identities. +func AdoptChatWorkspaceIdentity(place Place) error { + if !WorkspaceOwnershipEnabled(place) { + return nil + } + meta, err := LoadMeta(place.Dir) + if err != nil || meta.ChatURI == "" { + return err + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + _, err = registry.Adopt(workspaceidentity.Record{URI: meta.ChatURI, Role: workspaceidentity.Chat}, canonicalPath(place.Dir), workspaceidentity.Links{}) + if err != nil { + return fmt.Errorf("adopt conversation identity: %w", err) + } + return nil +} diff --git a/internal/session/workspace_chat_identity_test.go b/internal/session/workspace_chat_identity_test.go new file mode 100644 index 0000000000..cba30865b3 --- /dev/null +++ b/internal/session/workspace_chat_identity_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "context" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +func TestOwnershipChatIdentitySurvivesMetadataRefresh(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + place := Place{Dir: filepath.Join(home.Dir(), "chats", "0123456789abcdef"), Workspace: project} + stale := Meta{ID: place.ID(), Workspace: project, Title: "Mock handoff"} + if err := SaveMeta(place.Dir, stale); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = project + config.Place = place + config.SessionFile = place.Transcript() + }) + current, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(current) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if fields["chat_uri"] != agent.config.WorkspaceURI { + t.Fatalf("chat without tasks did not persist its identity: %s", raw) + } + if err := SaveMeta(place.Dir, stale); err != nil { + t.Fatal(err) + } + if !agent.SetConversationEffort("high") { + t.Fatal("conversation effort change refused") + } + agent.SettleWrites() + current, err = LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + raw, err = json.Marshal(current) + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if fields["chat_uri"] != agent.config.WorkspaceURI { + t.Fatal("stale save or session metadata refresh erased chat identity") + } +} + +func TestOwnershipUnknownChatIdentityCannotMintReplacement(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + place := Place{Dir: filepath.Join(t.TempDir(), "0123456789abcdef"), Workspace: project} + meta := Meta{ID: place.ID(), Workspace: project, ChatURI: "codeaf-workspace://foreign/unknown"} + if err := SaveMeta(place.Dir, meta); err != nil { + t.Fatal(err) + } + config := Config{Workspace: project, Place: place, Model: "test/model", System: "SYSTEM"} + if agent, err := newAgent(config, &scriptedCompleter{}); err == nil { + agent.Close() + t.Fatal("unknown carried chat identity silently minted a replacement") + } + current, err := LoadMeta(place.Dir) + if err != nil || current.ChatURI != meta.ChatURI { + t.Fatal("refused chat identity altered carried metadata") + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Lookup(workspaceidentity.Chat, place.Dir); err == nil { + t.Fatal("unknown chat identity created a different path binding") + } +} + +func TestOwnershipConflictingMetadataIdentityIsRefused(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + place := Place{Dir: filepath.Join(t.TempDir(), "0123456789abcdef"), Workspace: project} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Workspace = project; config.Place = place }) + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + meta.ChatURI = "codeaf-workspace://foreign/replacement" + if err := SaveMeta(place.Dir, meta); err == nil { + t.Fatal("metadata save replaced established conversation identity") + } + current, err := LoadMeta(place.Dir) + if err != nil || current.ChatURI != agent.config.WorkspaceURI { + t.Fatal("refused metadata save changed chat identity") + } +} + +func TestOwnershipSealedTasklessChatCarriesIdentity(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(furrow.BinaryEnvVar, os.Getenv("CODEAF_TEST_FURROW")) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("requires native cell engine: %v", err) + } + // Stop native writers before TempDir cleanup removes their state folders. + defer cellstore.StopSpawned() + source, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, "input.txt"), []byte("mock taskless project\n"), 0600); err != nil { + t.Fatal(err) + } + place := Place{Dir: source.Root, Workspace: project} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = project + config.Place = place + config.SessionFile = place.Transcript() + }) + expected := agent.config.WorkspaceURI + if err := agent.Close(); err != nil { + t.Fatal(err) + } + agent.SettleWrites() + sourceMeta, err := LoadMeta(source.Root) + if err != nil || sourceMeta.ChatURI != expected { + t.Fatal("source chat identity missing before seal") + } + engine := cellstore.Engine{Binary: bin, DataRoot: t.TempDir(), Workspace: project, Transport: cellstore.Spawn{Binary: bin}} + sealed, err := engine.Seal(context.Background(), source, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + targetRoot := filepath.Join(t.TempDir(), source.ID) + if err := os.MkdirAll(filepath.Join(targetRoot, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(source.Root, cell.MetaPath)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetRoot, cell.MetaPath), raw, 0600); err != nil { + t.Fatal(err) + } + target, err := cell.OpenAt(targetRoot, source.ID) + if err != nil { + t.Fatal(err) + } + targetProject := t.TempDir() + receiver := engine + receiver.Workspace = targetProject + if _, err := receiver.Seal(context.Background(), target, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + if err := receiver.Restore(context.Background(), target, sealed.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(targetRoot, placeMeta)); !os.IsNotExist(err) { + t.Fatal("derived metadata travelled with sealed tree") + } + carried, err := LoadMetaIn(targetRoot, targetProject) + if err != nil || carried.ChatURI != expected { + t.Fatalf("taskless sealed chat lost identity: %+v %v", carried, err) + } + t.Setenv(home.EnvVar, t.TempDir()) + if err := AdoptChatWorkspaceIdentity(Place{Dir: targetRoot, Workspace: targetProject}); err != nil { + t.Fatal(err) + } + opened, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = targetProject + config.Place = Place{Dir: targetRoot, Workspace: targetProject} + config.SessionFile = config.Place.Transcript() + }) + defer func() { + _ = opened.Close() + opened.SettleWrites() + }() + if opened.config.WorkspaceURI != expected { + t.Fatal("receiver chat minted a new identity after accepted adoption") + } + if _, err := os.Stat(opened.config.Place.Tasks()); !os.IsNotExist(err) { + t.Fatal("taskless fixture unexpectedly carries task copies") + } +} diff --git a/internal/session/workspace_chat_test.go b/internal/session/workspace_chat_test.go new file mode 100644 index 0000000000..b3b5fdbab8 --- /dev/null +++ b/internal/session/workspace_chat_test.go @@ -0,0 +1,96 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestOwnershipChatRoutesScratchWithoutMovingTheProject(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = project + c.Place = Place{Dir: t.TempDir(), Workspace: project} + }) + if a.config.Workspace != project { + t.Fatal("a borrowed project was moved") + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Resolve(a.config.WorkspaceURI) + if err != nil || chat.Role != workspaceidentity.Chat { + t.Fatalf("chat identity missing: %v", err) + } + result, err := a.config.seat().In(project).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", "mktemp"}}, nil) + if err != nil { + t.Fatal(err) + } + scratch := filepath.Join(workspaceRuntimeRoot(chat.URI), "temp") + path := strings.TrimSpace(string(result.Stdout)) + if result.Exit != 0 || filepath.Dir(path) != scratch { + t.Fatalf("temporary file escaped chat scratch: %q, exit %d", path, result.Exit) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +func TestOwnershipChatsShareProjectCacheAndKeepSeparateTemporaryFiles(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + var values [2][]string + for i := range values { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = project + c.Place = Place{Dir: t.TempDir(), Workspace: project} + }) + result, err := a.config.seat().In(project).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", "printf '%s\\n' \"$TMPDIR\" \"$XDG_CACHE_HOME\""}}, nil) + if err != nil || result.Exit != 0 { + t.Fatalf("chat process: %+v, %v", result, err) + } + values[i] = strings.Split(strings.TrimSpace(string(result.Stdout)), "\n") + } + if len(values[0]) != 2 || len(values[1]) != 2 || values[0][0] == values[1][0] || values[0][1] != values[1][1] { + t.Fatalf("chat scratch must be separate and project cache shared: %v", values) + } +} + +func TestOwnershipPrototypeChatReopensWithoutProcessFlag(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + project := t.TempDir() + place := Place{Dir: t.TempDir(), Workspace: project} + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, canonicalPath(place.Dir)) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), ChatURI: chat.URI, Workspace: project}); err != nil { + t.Fatal(err) + } + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace, c.Place = project, place }) + if a.config.WorkspaceURI != chat.URI || a.config.Workspace != project { + t.Fatal("reopen lost existing identity or changed foreground workspace") + } + meta, err := LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + if meta.WorkspacePolicy == nil || *meta.WorkspacePolicy != *DefaultWorkspacePolicy() { + t.Fatalf("prototype chat has no persistent policy: %+v", meta.WorkspacePolicy) + } +} diff --git a/internal/session/workspace_identity.go b/internal/session/workspace_identity.go new file mode 100644 index 0000000000..a15b0fe558 --- /dev/null +++ b/internal/session/workspace_identity.go @@ -0,0 +1,235 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cachedir" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// registerOwnedWorkspace names only copies beneath the conversation's tree root. +// A project reference never becomes an owned run just because its path is known. +func registerOwnedWorkspace(tree taskTree, place Place) (taskTree, error) { + if !WorkspaceOwnershipEnabled(place) { + return tree, nil + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return tree, err + } + + projectURI := "" + project := tree.ground + if project == "" { + project = tree.root + } + owned := tree.mode != TaskModeInPlace && tree.mode != TaskModeFolder && ownedTreePath(tree.dir, place) + var chat workspaceidentity.Record + if strings.TrimSpace(place.Dir) != "" { + chat, err = registry.Register(workspaceidentity.Chat, canonicalPath(place.Dir)) + if err != nil { + return tree, fmt.Errorf("record task conversation: %w", err) + } + } + if project != "" { + if owned && ownedTreePath(project, place) { + parent, err := registry.Lookup(workspaceidentity.Run, canonicalPath(project)) + if err != nil { + return tree, fmt.Errorf("find task's admitted parent copy: %w", err) + } + if parent.OwnerURI != chat.URI { + return tree, errors.New("task ground belongs to another conversation") + } + projectURI = parent.ProjectURI + tree.groundURI = parent.URI + } else { + record, err := registry.Register(workspaceidentity.Project, canonicalPath(project)) + if err != nil { + return tree, fmt.Errorf("record task project: %w", err) + } + projectURI, tree.groundURI = record.URI, record.URI + } + } + tree.projectURI, tree.chatURI = projectURI, chat.URI + if !owned { + return tree, nil + } + if tree.root != "" { + if canonicalPath(tree.root) == canonicalPath(project) { + tree.rootURI = tree.groundURI + } else { + tree.rootURI = projectURI + } + } + run, err := registry.Register(workspaceidentity.Run, canonicalPath(tree.dir), workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: projectURI}) + if err != nil { + return tree, fmt.Errorf("record task working copy: %w", err) + } + tree.uri, tree.chatURI, tree.projectURI = run.URI, chat.URI, run.ProjectURI + + // Temporary files are run-owned; conventional caches are shared by project. + if _, err := ownedRuntimePaths(registry, run); err != nil { + return tree, err + } + + return tree, nil +} + +func ownedTreePath(path string, place Place) bool { + trees := place.Trees() + if trees == "" || path == "" { + return false + } + relative, err := filepath.Rel(trees, canonicalPath(path)) + return err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + +// resolveOwnedWorkspace never falls back from a supplied URI to a stale path. +// Foreign relay identities require explicit receiver adoption. +func resolveOwnedWorkspace(record *TaskCopyRecord, place Place) (string, error) { + if !WorkspaceOwnershipEnabled(place) { + return "", errors.New("private task placement must be enabled to reopen this identity") + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return "", err + } + run, err := registry.Resolve(record.URI) + if err != nil { + return "", err + } + if run.Role != workspaceidentity.Run || record.ChatURI == "" || run.OwnerURI != record.ChatURI || run.ProjectURI != record.ProjectURI { + return "", errors.New("working copy identity does not match its recorded ownership") + } + chat, err := registry.Resolve(record.ChatURI) + if err != nil { + return "", err + } + if chat.Role != workspaceidentity.Chat || chat.Path != canonicalPath(place.Dir) { + return "", errors.New("working copy belongs to another conversation") + } + if !ownedTreePath(run.Path, place) { + return "", errors.New("working copy binding is outside its conversation trees") + } + return run.Path, nil +} + +// configureOwnedWorker routes scratch for admitted copies or explicit in-place +// projects at the runner seam. Project configuration grants no copy ownership. +// It preserves HOME and the existing seat's policy/recording capabilities. +func configureOwnedWorker(config *Config, taskDir string, logOwnerURI ...string) error { + if config.WorkspaceURI == "" { + return nil + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + run, err := registry.Resolve(config.WorkspaceURI) + if err != nil { + return err + } + if (run.Role != workspaceidentity.Run && run.Role != workspaceidentity.Project) || run.Path != canonicalPath(config.Workspace) { + return errors.New("worker directory does not match its admitted workspace identity") + } + if len(logOwnerURI) > 1 { + return errors.New("worker log directory has more than one owner") + } + logOwner := run.URI + if len(logOwnerURI) == 1 && logOwnerURI[0] != "" { + chat, err := registry.Resolve(logOwnerURI[0]) + if err != nil { + return err + } + if chat.Role != workspaceidentity.Chat || run.Role == workspaceidentity.Run && run.OwnerURI != chat.URI { + return errors.New("worker log directory belongs to another conversation") + } + logOwner = chat.URI + } + if _, err := registry.Register(workspaceidentity.Log, canonicalPath(taskDir), workspaceidentity.Links{OwnerURI: logOwner}); err != nil { + return err + } + + paths, err := ownedRuntimePaths(registry, run) + if err != nil { + return err + } + scratch, cache := paths[0], paths[1] + + config.Seat = executor.WithEnvironment(config.seat(), []string{"TMPDIR=" + scratch, "TMP=" + scratch, "TEMP=" + scratch, "XDG_CACHE_HOME=" + cache, "CODEAF_WORKSPACE_URI=" + run.URI}) + return nil +} + +// workspaceRuntimeRoot uses the full URI: imported namespaces can reuse the +// same resource suffix without ever sharing an unrelated run's scratch files. +func workspaceRuntimeRoot(uri string) string { + digest := sha256.Sum256([]byte(uri)) + return home.Join("workspace-runtime", hex.EncodeToString(digest[:])) +} + +func ownedRuntimePaths(registry *workspaceidentity.Registry, run workspaceidentity.Record) ([2]string, error) { + scratch := filepath.Join(workspaceRuntimeRoot(run.URI), "temp") + if err := os.MkdirAll(scratch, 0700); err != nil { + return [2]string{}, err + } + if _, err := registry.Register(workspaceidentity.Temp, scratch, workspaceidentity.Links{OwnerURI: run.URI}); err != nil { + return [2]string{}, err + } + var cache string + var err error + if run.ProjectURI != "" { + cache, err = ownedProjectCache(registry, run.ProjectURI) + } else { + cache, err = ownedCache(registry, run.URI, filepath.Join(workspaceRuntimeRoot(run.URI), "cache")) + } + return [2]string{scratch, cache}, err +} + +// ownedProjectCache is project-owned shared storage, never a run's cleanup target. +func ownedProjectCache(registry *workspaceidentity.Registry, projectURI string) (string, error) { + project, err := registry.Resolve(projectURI) + if err != nil { + return "", err + } + if project.Role != workspaceidentity.Project { + return "", errors.New("cache project identity does not name a project") + } + // A transfer may admit a second name for a project already used here. + // Cache ownership follows its canonical local identity, not that alias. + project, err = registry.Lookup(workspaceidentity.Project, project.Path) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(project.URI)) + legacy := home.Join("workspace-caches", hex.EncodeToString(digest[:]), "cache") + return ownedCache(registry, project.URI, legacy) +} + +// ownedCache retains an admitted historical binding, including after its files +// were explicitly cleaned. New bindings use the existing shared cache owner. +func ownedCache(registry *workspaceidentity.Registry, ownerURI, legacy string) (string, error) { + digest := sha256.Sum256([]byte(ownerURI)) + cache := filepath.Join(cachedir.Root(), "workspaces", hex.EncodeToString(digest[:]), "cache") + prior, err := registry.LookupRecorded(workspaceidentity.Cache, legacy) + if err == nil { + if prior.OwnerURI != ownerURI { + return "", errors.New("cache belongs to another workspace identity") + } + cache = prior.Path + } else if !errors.Is(err, workspaceidentity.ErrUnknown) { + return "", err + } + if err := os.MkdirAll(cache, 0700); err != nil { + return "", err + } + _, err = registry.Register(workspaceidentity.Cache, cache, workspaceidentity.Links{OwnerURI: ownerURI}) + return cache, err +} diff --git a/internal/session/workspace_identity_test.go b/internal/session/workspace_identity_test.go new file mode 100644 index 0000000000..706cdf1ba6 --- /dev/null +++ b/internal/session/workspace_identity_test.go @@ -0,0 +1,558 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/cachedir" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestOwnedRunIdentityReopensItsReboundCopy(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + place := Place{Dir: filepath.Join(home.Dir(), "chats", "example")} + original := filepath.Join(place.Trees(), "7") + if err := os.MkdirAll(original, 0700); err != nil { + t.Fatal(err) + } + project := t.TempDir() + owned, err := registerOwnedWorkspace(taskTree{dir: original, ground: project, mode: TaskModeMirror}, place) + if err != nil { + t.Fatal(err) + } + recorded := runCopyOf(owned) + if recorded.URI == "" || recorded.ChatURI == "" || recorded.ProjectURI == "" { + t.Fatalf("ownership missing: %#v", recorded) + } + moved := filepath.Join(place.Trees(), "relocated") + if err := os.Rename(original, moved); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Bind(recorded.URI, moved); err != nil { + t.Fatal(err) + } + reopened, err := runCopyTree(recorded, place) + if err != nil { + t.Fatal(err) + } + if reopened.dir != moved || runCopyOf(reopened).URI != recorded.URI { + t.Fatalf("reopen used stale binding: %#v", reopened) + } +} + +func TestUnknownWorkspaceIdentityCannotFallBackToExistingPath(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + place := Place{Dir: t.TempDir()} + record := &TaskCopyRecord{Dir: t.TempDir(), URI: "codeaf-workspace://foreign/unknown", Mode: TaskModeMirror} + if _, err := runCopyTree(record, place); err == nil { + t.Fatal("unknown identity silently used its path fallback") + } +} + +func TestOwnedIdentityRefusesAnotherConversationAndKeepsLegacyCopies(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + place := Place{Dir: filepath.Join(home.Dir(), "chats", "one")} + path := filepath.Join(place.Trees(), "7") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + owned, err := registerOwnedWorkspace(taskTree{dir: path, ground: t.TempDir(), mode: TaskModeMirror}, place) + if err != nil { + t.Fatal(err) + } + notice := TaskNotice{ID: 7, State: TaskRunning, Copy: runCopyOf(owned)} + restored := runRowNotice(runRowRecord(notice)).Copy + if restored.URI != owned.uri || restored.ChatURI != owned.chatURI || restored.ProjectURI != owned.projectURI { + t.Fatalf("row lost ownership: %#v", restored) + } + if _, err := runCopyTree(restored, Place{Dir: t.TempDir()}); err == nil { + t.Fatal("another conversation acquired run") + } + legacy, err := runCopyTree(&TaskCopyRecord{Dir: path, Mode: TaskModeMirror}, place) + if err != nil || legacy.dir != path { + t.Fatalf("legacy copy no longer opens: %#v %v", legacy, err) + } +} + +func TestInPlaceProjectNeverBecomesOwnedRun(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + project := t.TempDir() + tree, err := registerOwnedWorkspace(taskTree{dir: project, ground: project, mode: TaskModeInPlace}, Place{Dir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if tree.uri != "" || tree.chatURI == "" || tree.projectURI == "" { + t.Fatalf("project was mislabeled as owned run: %#v", tree) + } +} + +func TestOneConversationCanOwnRunsForDifferentProjects(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + place := Place{Dir: filepath.Join(home.Dir(), "chats", "multi")} + var first taskTree + for _, id := range []string{"one", "two"} { + path := filepath.Join(place.Trees(), id) + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: path, ground: t.TempDir(), mode: TaskModeMirror}, place) + if err != nil { + t.Fatal(err) + } + if first.uri == "" { + first = tree + continue + } + if tree.chatURI != first.chatURI || tree.projectURI == first.projectURI { + t.Fatalf("conversation ownership changed between projects: %#v %#v", first, tree) + } + } +} + +func TestOwnedBeltWorkerUsesRealScratchWithoutChangingHome(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) + place := Place{Dir: filepath.Join(home.Dir(), "chats", "worker")} + workspace := filepath.Join(place.Trees(), "7") + if err := os.MkdirAll(workspace, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: workspace, ground: t.TempDir(), mode: TaskModeMirror}, place) + if err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(place.Dir, planStoreFilename), "scratch proof", planRootID, "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + script := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("scratch", "bash", `{"command":"printf '%s\n' \"$HOME\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" \"$CODEAF_WORKSPACE_URI\" > runtime.txt; mktemp >> runtime.txt"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + agent, err := NewBeltWorker(Config{Workspace: workspace, WorkspaceURI: tree.uri, Model: "test/model"}, script, store.Task(planRootID), store.Path(), store.RootID()) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + drainTurn(t, agent, "Check the temporary directory") + raw, err := os.ReadFile(filepath.Join(workspace, "runtime.txt")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 5 { + t.Fatalf("worker environment missing: %q", raw) + } + scratch, cache := lines[1], lines[2] + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + scratchRecord, err := registry.Lookup(workspaceidentity.Temp, scratch) + if err != nil || scratchRecord.OwnerURI != tree.uri { + t.Fatalf("temporary directory has wrong owner: %#v %v", scratchRecord, err) + } + cacheRecord, err := registry.Lookup(workspaceidentity.Cache, cache) + if err != nil || cacheRecord.OwnerURI != tree.projectURI { + t.Fatalf("shared cache has wrong owner: %#v %v", cacheRecord, err) + } + if lines[0] != os.Getenv("HOME") || lines[3] != tree.uri || filepath.Dir(lines[4]) != scratch { + t.Fatalf("worker runtime escaped assigned scratch: %q", raw) + } + if _, err := os.Stat(lines[4]); err != nil { + t.Fatal(err) + } +} + +func TestAcceptedTakeAdoptsOnlyRestoredRunIdentities(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + sourceCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + source := Place{Dir: sourceCell.Root, Workspace: project} + if err := SaveMeta(source.Dir, Meta{Workspace: project}); err != nil { + t.Fatal(err) + } + path := filepath.Join(source.Trees(), "7") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + owned, err := registerOwnedWorkspace(taskTree{dir: path, ground: project, root: project, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + omittedPath := filepath.Join(source.Trees(), "8") + if err := os.MkdirAll(omittedPath, 0700); err != nil { + t.Fatal(err) + } + omitted, err := registerOwnedWorkspace(taskTree{dir: omittedPath, ground: project, root: project, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 8, Runs: []runRecord{{ID: 7, Title: "carried task", State: TaskInterrupted, Copy: runCopyOf(owned)}, {ID: 8, Title: "not restored", State: TaskInterrupted, Copy: runCopyOf(omitted)}}} + if err := newTaskStore(source.Tasks()).writeLocked(document); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(source.Tasks()) + if err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, t.TempDir()) + receiverCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + receiverProject := t.TempDir() + receiver := Place{Dir: receiverCell.Root, Workspace: receiverProject} + if err := SaveMeta(receiver.Dir, Meta{Workspace: receiverProject}); err != nil { + t.Fatal(err) + } + restored := filepath.Join(receiver.Trees(), "7") + if err := os.MkdirAll(restored, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(receiver.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + incoming := readCheckpoint(t, receiver.Tasks()).Runs[0].Copy + if _, err := runCopyTree(incoming, receiver); err == nil { + t.Fatal("receiver resolved foreign copy before accepted take") + } + if err := AdoptTaskWorkspaceIdentities(receiver, receiverProject, []string{"7"}); err != nil { + t.Fatal(err) + } + received := readCheckpoint(t, receiver.Tasks()) + if _, err := runCopyTree(received.Runs[1].Copy, receiver); err == nil { + t.Fatal("omitted task copy gained a local identity") + } + adopted := received.Runs[0].Copy + reopened, err := runCopyTree(adopted, receiver) + if err != nil { + t.Fatal(err) + } + if reopened.uri != owned.uri || reopened.dir != restored || reopened.root != receiverProject || reopened.ground != receiverProject { + t.Fatalf("receiver used source bindings: %#v", reopened) + } +} + +func TestRunsShareProjectCacheButKeepSeparateTemporaryFiles(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) + place := Place{Dir: filepath.Join(home.Dir(), "chats", "shared-cache")} + firstProject, secondProject := t.TempDir(), t.TempDir() + var tempPaths, cachePaths []string + for index, project := range []string{firstProject, firstProject, secondProject} { + id := fmt.Sprint(index + 1) + workspace := filepath.Join(place.Trees(), id) + if err := os.MkdirAll(workspace, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: workspace, ground: project, mode: TaskModeMirror}, place) + if err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(place.Dir, "runs", id, planStoreFilename), "cache proof", id, "", "") + if err != nil { + t.Fatal(err) + } + script := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + arguments, _ := json.Marshal(map[string]string{"command": `printf '%s\n' "$TMPDIR" "$XDG_CACHE_HOME" > paths.txt`}) + return toolResponse("paths", "bash", string(arguments)), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + agent, err := NewBeltWorker(Config{Workspace: workspace, WorkspaceURI: tree.uri, Model: "test/model"}, script, store.Task(id), store.Path(), store.RootID()) + if err != nil { + store.Close() + t.Fatal(err) + } + drainTurn(t, agent, "Check the assigned cache") + agent.Close() + store.Close() + raw, err := os.ReadFile(filepath.Join(workspace, "paths.txt")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 2 { + t.Fatalf("worker paths: %q", raw) + } + tempPaths = append(tempPaths, lines[0]) + cachePaths = append(cachePaths, lines[1]) + } + if tempPaths[0] == tempPaths[1] || tempPaths[1] == tempPaths[2] { + t.Fatalf("runs shared temporary files: %#v", tempPaths) + } + if cachePaths[0] != cachePaths[1] || cachePaths[0] == cachePaths[2] { + t.Fatalf("cache scope does not follow project: %#v", cachePaths) + } + if rel, err := filepath.Rel(cachedir.Root(), cachePaths[0]); err != nil || !filepath.IsLocal(rel) { + t.Fatalf("actual worker cache is outside existing cache owner: %q", cachePaths[0]) + } + marker := filepath.Join(cachePaths[0], "shared-proof.txt") + if err := os.WriteFile(marker, []byte("reused cache bytes"), 0600); err != nil { + t.Fatal(err) + } + if got := cachedir.Size(); got != int64(len("reused cache bytes")) { + t.Fatalf("actual worker cache is invisible to cache accounting: %d", got) + } + raw, err := os.ReadFile(filepath.Join(cachePaths[1], "shared-proof.txt")) + if err != nil || string(raw) != "reused cache bytes" { + t.Fatalf("project cache was not actually shared: %q %v", raw, err) + } +} + +func TestAcceptedTakeKeepsNestedTaskLandingInItsParentCopy(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + sourceCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + source := Place{Dir: sourceCell.Root, Workspace: project} + if err := SaveMeta(source.Dir, Meta{Workspace: project}); err != nil { + t.Fatal(err) + } + parentPath, childPath := filepath.Join(source.Trees(), "7"), filepath.Join(source.Trees(), "8") + for _, path := range []string{parentPath, childPath} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + parent, err := registerOwnedWorkspace(taskTree{dir: parentPath, ground: project, root: project, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + child, err := registerOwnedWorkspace(taskTree{dir: childPath, ground: parentPath, root: parentPath, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + if child.projectURI != parent.projectURI || child.groundURI != parent.uri || child.rootURI != parent.uri { + t.Fatalf("nested task lost its parent: %#v", child) + } + // Reversed rows exercise dependency order rather than depending on checkpoint ordering. + document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 8, Runs: []runRecord{{ID: 8, Title: "child", State: TaskInterrupted, Copy: runCopyOf(child)}, {ID: 7, Title: "parent", State: TaskInterrupted, Copy: runCopyOf(parent)}}} + if err := newTaskStore(source.Tasks()).writeLocked(document); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(source.Tasks()) + if err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, t.TempDir()) + receiverCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + receiverProject := t.TempDir() + receiver := Place{Dir: receiverCell.Root, Workspace: receiverProject} + if err := SaveMeta(receiver.Dir, Meta{Workspace: receiverProject}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"7", "8"} { + if err := os.MkdirAll(filepath.Join(receiver.Trees(), id), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(receiver.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if err := AdoptTaskWorkspaceIdentities(receiver, receiverProject, []string{"7", "8"}); err != nil { + t.Fatal(err) + } + record := readCheckpoint(t, receiver.Tasks()).Runs[0].Copy + reopened, err := runCopyTree(record, receiver) + if err != nil { + t.Fatal(err) + } + parentOnReceiver := filepath.Join(receiver.Trees(), "7") + if reopened.ground != parentOnReceiver || reopened.root != parentOnReceiver || reopened.projectURI != parent.projectURI { + t.Fatalf("nested task would land in original project: %#v", reopened) + } +} + +func TestOwnedCopyCannotLandInAnotherConversationsKnownRun(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + project := t.TempDir() + places := []Place{{Dir: filepath.Join(home.Dir(), "chats", "one")}, {Dir: filepath.Join(home.Dir(), "chats", "two")}} + var trees []taskTree + for _, place := range places { + path := filepath.Join(place.Trees(), "7") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: path, ground: project, root: project, mode: TaskModeWorktree}, place) + if err != nil { + t.Fatal(err) + } + trees = append(trees, tree) + } + record := runCopyOf(trees[0]) + record.GroundURI, record.RootURI = trees[1].uri, trees[1].uri + if _, err := runCopyTree(record, places[0]); err == nil { + t.Fatal("task adopted another conversation's known landing target") + } +} + +func TestExplicitInPlaceWorkerRoutesScratchWithoutOwningTheProject(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) + project := t.TempDir() + place := Place{Dir: t.TempDir()} + tree, err := registerOwnedWorkspace(taskTree{dir: project, ground: project, mode: TaskModeInPlace}, place) + if err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(place.Dir, planStoreFilename), "in-place runtime", planRootID, "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + script := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + arguments, _ := json.Marshal(map[string]string{"command": `pwd > placement.txt; printf '%s\n' "$TMPDIR" "$XDG_CACHE_HOME" "$CODEAF_WORKSPACE_URI" >> placement.txt; mktemp >> placement.txt`}) + return toolResponse("runtime", "bash", string(arguments)), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + agent, err := NewBeltWorker(Config{Workspace: project, WorkspaceURI: tree.projectURI, Model: "test/model"}, script, store.Task(planRootID), store.Path(), store.RootID()) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + drainTurn(t, agent, "Check explicit in-place runtime") + raw, err := os.ReadFile(filepath.Join(project, "placement.txt")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 5 || canonicalPath(lines[0]) != project || lines[3] != tree.projectURI || filepath.Dir(lines[4]) != lines[1] { + t.Fatalf("in-place worker changed placement: %q", raw) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Lookup(workspaceidentity.Run, project); err == nil { + t.Fatal("explicit project became an owned run") + } + for _, binding := range []struct { + role workspaceidentity.Role + path string + }{{workspaceidentity.Temp, lines[1]}, {workspaceidentity.Cache, lines[2]}} { + record, err := registry.Lookup(binding.role, binding.path) + if err != nil || record.OwnerURI != tree.projectURI { + t.Fatalf("project runtime has wrong owner: %#v %v", record, err) + } + } +} + +func TestAcceptedTakeAdoptsLegacyNodeIdentitiesWithoutRunRows(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, t.TempDir()) + sourceCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + source := Place{Dir: sourceCell.Root, Workspace: project} + if err := SaveMeta(source.Dir, Meta{Workspace: project}); err != nil { + t.Fatal(err) + } + parentPath, childPath := filepath.Join(source.Trees(), "7"), filepath.Join(source.Trees(), "9") + for _, path := range []string{parentPath, childPath} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + parent, err := registerOwnedWorkspace(taskTree{dir: parentPath, ground: project, root: project, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + child, err := registerOwnedWorkspace(taskTree{dir: childPath, ground: parentPath, root: parentPath, mode: TaskModeWorktree}, source) + if err != nil { + t.Fatal(err) + } + recordOf := func(id uint64, tree taskTree) taskRecord { + return taskRecord{ID: id, Title: "legacy task", Brief: "Preserve this exact brief: /source/reference stays quoted", Acceptance: "files preserved", State: TaskFailed, Mode: tree.mode, Ground: tree.ground, Worktree: tree.dir, WorkspaceURI: tree.uri, ChatURI: tree.chatURI, ProjectURI: tree.projectURI, GroundURI: tree.groundURI, RootURI: tree.rootURI} + } + childRecord := recordOf(9, child) + childRecord.Parent = 7 + document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 9, Nodes: []taskRecord{recordOf(7, parent), childRecord}} + if err := newTaskStore(source.Tasks()).writeLocked(document); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(source.Tasks()) + if err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, t.TempDir()) + receiverCell, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + receiverProject := t.TempDir() + receiver := Place{Dir: receiverCell.Root, Workspace: receiverProject} + if err := SaveMeta(receiver.Dir, Meta{Workspace: receiverProject}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"7", "9"} { + if err := os.MkdirAll(filepath.Join(receiver.Trees(), id), 0700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(receiver.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if err := AdoptTaskWorkspaceIdentities(receiver, receiverProject, []string{"7", "9"}); err != nil { + t.Fatal(err) + } + adopted := readCheckpoint(t, receiver.Tasks()) + if len(adopted.Runs) != 0 { + t.Fatal("legacy task received run-copy semantics") + } + node := adopted.Nodes[1] + if node.WorkspaceURI != child.uri || node.Worktree != filepath.Join(receiver.Trees(), "9") || node.Ground != filepath.Join(receiver.Trees(), "7") || node.Brief != childRecord.Brief { + t.Fatalf("legacy transfer lost structured placement: %#v", node) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if binding, err := registry.Resolve(node.WorkspaceURI); err != nil || binding.Path != node.Worktree { + t.Fatalf("legacy identity was not admitted: %#v %v", binding, err) + } +} diff --git a/internal/session/workspace_identity_transfer.go b/internal/session/workspace_identity_transfer.go new file mode 100644 index 0000000000..760cf6e0e5 --- /dev/null +++ b/internal/session/workspace_identity_transfer.go @@ -0,0 +1,210 @@ +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// AdoptTaskWorkspaceIdentities is called only after an accepted takeover has +// restored the named task copies, before any receiver agent opens the chat. +// It imports structured identity fields, never arbitrary paths from transcripts. +// A copy omitted by the restorer remains unbound and cannot silently reopen. +func AdoptTaskWorkspaceIdentities(place Place, project string, restoredIDs []string) error { + if !WorkspaceOwnershipEnabled(place) || len(restoredIDs) == 0 { + return nil + } + restored := make(map[string]bool, len(restoredIDs)) + for _, id := range restoredIDs { + if !filepath.IsLocal(id) || filepath.Base(id) != id || id == "." { + return errors.New("restored task identity leaves its conversation") + } + restored[id] = true + } + raw, err := os.ReadFile(place.Tasks()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + document, err := decodeTasks(raw) + if err != nil { + return err + } + if codec, sealed := checkpointCodec(place.Tasks()); sealed { + document = document.mapPaths(codec.resolve) + } + project = canonicalPath(project) + var incomingCopies []*TaskCopyRecord + for index := range document.Runs { + incomingCopies = append(incomingCopies, document.Runs[index].Copy) + } + // Legacy nodes keep the same identity fields directly rather than in a run + // row. Adapt only those typed fields; their briefs and reports stay verbatim. + type nodeCopy struct { + index int + copy *TaskCopyRecord + } + var nodeCopies []nodeCopy + for index := range document.Nodes { + node := &document.Nodes[index] + if node.WorkspaceURI == "" { + continue + } + copy := &TaskCopyRecord{URI: node.WorkspaceURI, ChatURI: node.ChatURI, ProjectURI: node.ProjectURI, GroundURI: node.GroundURI, RootURI: node.RootURI, Dir: node.Worktree, Ground: node.Ground, Mode: node.Mode} + nodeCopies = append(nodeCopies, nodeCopy{index, copy}) + incomingCopies = append(incomingCopies, copy) + } + var selected []*TaskCopyRecord + for _, copy := range incomingCopies { + if copy == nil || copy.URI == "" { + continue + } + if copy.Mode == TaskModeInPlace || copy.Mode == TaskModeFolder { + return errors.New("an in-place project cannot be imported as an owned task copy") + } + name := filepath.Base(copy.Dir) + if !restored[name] { + continue + } + actual := filepath.Join(place.Trees(), name) + if canonicalPath(copy.Dir) != actual || copy.ChatURI == "" || copy.ProjectURI == "" { + return errors.New("restored task has no matching structured workspace identity") + } + + if info, err := os.Stat(actual); err != nil || !info.IsDir() { + return fmt.Errorf("restored task %s directory is unavailable", name) + } + selected = append(selected, copy) + } + if len(selected) == 0 { + return nil + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + + // Every target is derived from a successfully restored copy or this take's + // project. Historical projects and missing parents are never guessed. + targets := make(map[string]string, len(selected)*2) + for _, copy := range selected { + actual := filepath.Join(place.Trees(), filepath.Base(copy.Dir)) + if previous, exists := targets[copy.URI]; exists && previous != actual { + return errors.New("one workspace identity names several restored copies") + } + targets[copy.URI] = actual + targets[copy.ProjectURI] = project + } + for _, copy := range selected { + if copy.GroundURI == "" { + copy.GroundURI = copy.ProjectURI + } + expected, known := targets[copy.GroundURI] + if !known || canonicalPath(copy.Ground) != expected { + return errors.New("restored task belongs to an unavailable project or parent; its identity was not rebound") + } + if copy.Root != "" { + if copy.RootURI == "" { + copy.RootURI = copy.ProjectURI + } + expected, known := targets[copy.RootURI] + if !known || canonicalPath(copy.Root) != expected { + return errors.New("restored task landing target was not restored") + } + } + } + for _, copy := range selected { + if _, err := registry.Adopt(workspaceidentity.Record{URI: copy.ProjectURI, Role: workspaceidentity.Project}, project, workspaceidentity.Links{}); err != nil { + return fmt.Errorf("adopt task project: %w", err) + } + if _, err := registry.Adopt(workspaceidentity.Record{URI: copy.ChatURI, Role: workspaceidentity.Chat}, canonicalPath(place.Dir), workspaceidentity.Links{}); err != nil { + return fmt.Errorf("adopt task conversation: %w", err) + } + } + adopted := make(map[string]bool, len(selected)*2) + for _, copy := range selected { + adopted[copy.ProjectURI] = true + } + pending := append([]*TaskCopyRecord(nil), selected...) + for len(pending) > 0 { + remaining := pending[:0] + progressed := false + for _, copy := range pending { + if !adopted[copy.GroundURI] || copy.RootURI != "" && !adopted[copy.RootURI] { + remaining = append(remaining, copy) + continue + } + actual := targets[copy.URI] + if _, err := registry.Adopt(workspaceidentity.Record{URI: copy.URI, Role: workspaceidentity.Run, OwnerURI: copy.ChatURI, ProjectURI: copy.ProjectURI}, actual, workspaceidentity.Links{OwnerURI: copy.ChatURI, ProjectURI: copy.ProjectURI}); err != nil { + return fmt.Errorf("adopt restored task copy: %w", err) + } + copy.Dir, copy.Ground = actual, targets[copy.GroundURI] + if strings.TrimSpace(copy.Root) != "" { + copy.Root = targets[copy.RootURI] + } + adopted[copy.URI] = true + progressed = true + } + if !progressed { + return errors.New("restored task parent identities contain a cycle or missing parent") + } + pending = remaining + } + + for _, entry := range nodeCopies { + node := &document.Nodes[entry.index] + node.Worktree, node.Ground = entry.copy.Dir, entry.copy.Ground + node.GroundURI, node.RootURI = entry.copy.GroundURI, entry.copy.RootURI + } + return newTaskStore(place.Tasks()).writeLocked(document) +} + +func resolveOwnedProject(uri string) (string, error) { + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return "", err + } + project, err := registry.Resolve(uri) + if err != nil { + return "", err + } + if project.Role != workspaceidentity.Project { + return "", errors.New("working copy project identity does not name a project") + } + return project.Path, nil +} + +// resolveOwnedGround resolves an exact project or parent-run landing target. +func resolveOwnedGround(uri string, record *TaskCopyRecord) (string, error) { + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return "", err + } + ground, err := registry.Resolve(uri) + if err != nil { + return "", err + } + if record == nil { + return "", errors.New("task landing has no recorded owner") + } + switch ground.Role { + case workspaceidentity.Project: + if ground.URI != record.ProjectURI { + return "", errors.New("task landing belongs to another project") + } + case workspaceidentity.Run: + if ground.OwnerURI != record.ChatURI || ground.ProjectURI != record.ProjectURI { + return "", errors.New("task landing belongs to another conversation or project") + } + default: + return "", errors.New("task ground identity does not name a project or parent run") + } + return ground.Path, nil +} diff --git a/internal/session/workspace_node_ownership_test.go b/internal/session/workspace_node_ownership_test.go new file mode 100644 index 0000000000..9c858099ad --- /dev/null +++ b/internal/session/workspace_node_ownership_test.go @@ -0,0 +1,261 @@ +package session + +import ( + "context" + "encoding/json" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func ownedLegacyNode(t *testing.T) (*Agent, *TaskNode, taskTree) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := Place{Dir: filepath.Join(home.Dir(), "chats", "example")} + project := t.TempDir() + path := filepath.Join(place.Trees(), "1") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(path, "proof.txt"), "retained task bytes\n") + tree, err := registerOwnedWorkspace(taskTree{dir: path, ground: project, mode: TaskModeMirror, merge: mergeInPlace, place: place}, place) + if err != nil { + t.Fatal(err) + } + agent, node := unverifiedNode(t, func(config *Config) { config.Workspace = project; config.Place = place }) + node.setTree(tree) + return agent, node, tree +} + +func TestLegacyOwnedTaskPublishesWorkspaceIdentity(t *testing.T) { + agent, node, tree := ownedLegacyNode(t) + lane, stop := agent.WatchTaskUpdates() + defer stop() + select { + case event := <-lane: + if event.Task == nil || event.Task.ID != node.id { + t.Fatal("missing public task notice") + } + raw, err := json.Marshal(event.Task) + if err != nil { + t.Fatal(err) + } + var record map[string]any + if err := json.Unmarshal(raw, &record); err != nil { + t.Fatal(err) + } + if record["WorkspaceURI"] != tree.uri || record["ChatURI"] != tree.chatURI || record["ProjectURI"] != tree.projectURI { + t.Fatalf("public legacy task notice dropped its workspace identity: %s", raw) + } + if event.Task.Copy != nil { + t.Fatal("legacy identity must not enable run-copy UI semantics") + } + case <-time.After(time.Second): + t.Fatal("public task roster did not arrive") + } +} + +func TestLegacyOwnedTaskCheckpointReopensReboundCopy(t *testing.T) { + agent, node, tree := ownedLegacyNode(t) + raw, err := json.Marshal(node.graph.document()) + if err != nil { + t.Fatal(err) + } + var checkpoint taskDocument + if err := json.Unmarshal(raw, &checkpoint); err != nil { + t.Fatal(err) + } + if len(checkpoint.Nodes) != 1 { + t.Fatal("missing checkpoint task") + } + moved := filepath.Join(agent.config.Place.Trees(), "rebound") + if err := os.Rename(tree.dir, moved); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Bind(tree.uri, moved); err != nil { + t.Fatal(err) + } + restored := restoreNode(node.graph, checkpoint.Nodes[0]) + reopened, err := restored.workingCopy(agent.config.Place, agent.config.Workspace) + if err != nil || reopened.dir != moved || reopened.uri != tree.uri { + t.Fatalf("checkpoint reopening lost identity binding: %+v %v", reopened, err) + } + if raw, err := os.ReadFile(filepath.Join(reopened.dir, "proof.txt")); err != nil || string(raw) != "retained task bytes\n" { + t.Fatal("checkpoint reopened different task bytes") + } +} + +func TestLegacyOwnedTaskUnknownIdentityRefusesFreshWorld(t *testing.T) { + agent, node, tree := ownedLegacyNode(t) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + // A checkpoint carrying an unknown identity must not use its still-existing + // old directory, nor prepare a replacement from the project. + raw, err := json.Marshal(node.graph.document()) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + nodes := document["nodes"].([]any) + record := nodes[0].(map[string]any) + record["workspace_uri"] = "codeaf-workspace://foreign/unknown" + record["chat_uri"] = tree.chatURI + record["project_uri"] = tree.projectURI + raw, err = json.Marshal(document) + if err != nil { + t.Fatal(err) + } + var checkpoint taskDocument + if err = json.Unmarshal(raw, &checkpoint); err != nil { + t.Fatal(err) + } + restored := restoreNode(node.graph, checkpoint.Nodes[0]) + if _, err := restored.workingCopy(agent.config.Place, agent.config.Workspace); err == nil { + t.Fatal("unknown task URI silently fell back to existing path") + } + if _, opened := agent.openTaskWorld(context.Background(), restored, io.Discard); opened { + t.Fatal("unknown task URI prepared a replacement world") + } + if raw, err := os.ReadFile(filepath.Join(tree.dir, "proof.txt")); err != nil || string(raw) != "retained task bytes\n" { + t.Fatal("refused task identity changed retained bytes") + } + if _, err := registry.Resolve(tree.uri); err != nil { + t.Fatal("unknown identity damaged existing workspace registration") + } +} + +func TestLegacyOwnedWorkerUsesItsAdmittedRuntime(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + script := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("runtime", "bash", `{"command":"printf '%s\\n' \"$CODEAF_WORKSPACE_URI\" \"$TMPDIR\" \"$XDG_CACHE_HOME\" > runtime.txt; mktemp >> runtime.txt"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + place := Place{Dir: filepath.Join(home.Dir(), "chats", "active-worker")} + project := t.TempDir() + agent, _ := newTestAgent(t, script, func(config *Config) { config.Place = place; config.Workspace = project }) + graph := stubbedGraph(agent, func(*TaskNode) {}) + id := graph.reserve() + graph.admit(id, taskSpec{title: "Workspace runtime", brief: "Record the workspace environment", acceptance: "runtime recorded", model: "test/model", depth: 1}) + node := graph.node(id) + path := filepath.Join(place.Trees(), "1") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: path, ground: project, mode: TaskModeMirror, merge: mergeInPlace, place: place}, place) + if err != nil { + t.Fatal(err) + } + node.setTree(tree) + worker, err := agent.newTaskAgent(context.Background(), tree.dir, node, "") + if err != nil { + t.Fatalf("legacy worker construction: %v", err) + } + defer worker.Close() + drainTurn(t, worker, "Record the workspace environment") + raw, err := os.ReadFile(filepath.Join(tree.dir, "runtime.txt")) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 4 || lines[0] != tree.uri || filepath.Dir(lines[3]) != lines[1] { + t.Fatalf("legacy worker did not receive admitted workspace and temporary directory: %q", raw) + } + for _, path := range lines[1:3] { + if !withinDir(home.Dir(), path) || withinDir(tree.dir, path) { + t.Fatalf("legacy worker scratch/cache path outside owned runtime: %s", path) + } + } + if _, err := os.Stat(lines[3]); err != nil { + t.Fatal("legacy worker did not create its actual temporary file") + } +} + +func TestLegacyNestedTaskKeepsItsParentGroundIdentity(t *testing.T) { + agent, node, parent := ownedLegacyNode(t) + childPath := filepath.Join(agent.config.Place.Trees(), "child") + if err := os.MkdirAll(childPath, 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(childPath, "proof.txt"), "child bytes\n") + child, err := registerOwnedWorkspace(taskTree{dir: childPath, root: parent.dir, ground: parent.dir, branch: "task/child", mode: TaskModeWorktree}, agent.config.Place) + if err != nil { + t.Fatal(err) + } + node.setTree(child) + raw, err := json.Marshal(node.graph.document()) + if err != nil { + t.Fatal(err) + } + var checkpoint taskDocument + if err = json.Unmarshal(raw, &checkpoint); err != nil { + t.Fatal(err) + } + moved := filepath.Join(agent.config.Place.Trees(), "parent-rebound") + if err := os.Rename(parent.dir, moved); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Bind(parent.uri, moved); err != nil { + t.Fatal(err) + } + restored := restoreNode(node.graph, checkpoint.Nodes[0]) + copy, err := restored.workingCopy(agent.config.Place, agent.config.Workspace) + if err != nil || copy.ground != moved || copy.root != moved || copy.projectURI != parent.projectURI || copy.groundURI != parent.uri { + t.Fatalf("nested task lost parent landing identity after checkpoint: %+v %v", copy, err) + } +} + +func TestLegacyOwnedWorkersCanShareTheirConversationJournalDirectory(t *testing.T) { + agent, node, first := ownedLegacyNode(t) + worker, err := agent.newTaskAgent(context.Background(), first.dir, node, "") + if err != nil { + t.Fatal(err) + } + if err := worker.Close(); err != nil { + t.Fatal(err) + } + graph := stubbedGraph(agent, func(*TaskNode) {}) + id := graph.reserve() + graph.admit(id, taskSpec{title: "Second workspace", brief: "second task", acceptance: "done", model: "test/model", depth: 1}) + second := graph.node(id) + path := filepath.Join(agent.config.Place.Trees(), "second") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + tree, err := registerOwnedWorkspace(taskTree{dir: path, ground: first.ground, mode: TaskModeMirror, merge: mergeInPlace, place: agent.config.Place}, agent.config.Place) + if err != nil { + t.Fatal(err) + } + second.setTree(tree) + next, err := agent.newTaskAgent(context.Background(), tree.dir, second, "") + if err != nil { + t.Fatalf("second legacy task cannot use its conversation's shared journal directory: %v", err) + } + defer next.Close() + if filepath.Dir(agent.TaskJournal(node.id)) != filepath.Dir(agent.TaskJournal(second.id)) { + t.Fatal("legacy task journal placement changed") + } +} diff --git a/internal/session/workspace_ownership.go b/internal/session/workspace_ownership.go new file mode 100644 index 0000000000..8aa173f650 --- /dev/null +++ b/internal/session/workspace_ownership.go @@ -0,0 +1,76 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" +) + +// WorkspaceOwnershipPrototypeEnabled retains the explicit legacy experiment +// switch. Production placement reads each conversation's persisted policy. +func WorkspaceOwnershipPrototypeEnabled() bool { + return env.Get("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP") == "1" && filepath.IsAbs(strings.TrimSpace(env.Get(home.EnvVar))) +} + +// ownedRunPlacement puts ordinary Bash tasks on the same durable layout road +// as explicitly selected roots. A placement names input, never landing consent. +// Reference tasks retain their empty scratch and borrowed context; in particular +// opening a conversation at home must not copy the person's home directory. +func (a *Agent) ownedRunPlacement(ctx context.Context, stand taskStand) (taskStand, error) { + if !WorkspaceOwnershipEnabled(a.config.Place) || !bashBeltAsked() || stand.workspaceSet != nil || stand.ask != "" || stand.refusal != "" || stand.dir == "" || stand.mode == TaskModeReference || stand.mode == TaskModeInPlace { + return stand, nil + } + if place, found := a.knownPlace(stand.dir); found && TaskMode(place.Mode) == TaskModeInPlace { + stand.mode = TaskModeInPlace + return stand, nil + } + if ownershipPlainMode(stand.dir) == TaskModeReference { + return stand, nil + } + request := &WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: canonicalPath(stand.dir), Slot: "work", Access: WorkspaceWrite}}} + if err := admitWorkspaceSetRequest(ctx, a.config.Place, request); err != nil { + return stand, err + } + stand.workspaceSet = request + return stand, nil +} + +// ownershipPlacement reads a model's placement as the project to copy, never as +// the person's permission to write that project in place. A saved place mode +// remains the person's own choice through the existing place-selection door. +func (a *Agent) ownershipPlacement(spec taskSpec, workspace string) (taskStand, bool) { + if !WorkspaceOwnershipEnabled(a.config.Place) || strings.TrimSpace(spec.where) == "" { + return taskStand{}, false + } + dir := workspace + if !strings.EqualFold(strings.TrimSpace(spec.where), "in place") { + var err error + dir, err = resolveTaskWhere(spec.where, workspace) + if err != nil { + return taskStand{refusal: "this task names a folder it cannot work in: " + spec.where}, true + } + } + dir = groundRoot(dir) + if place, found := a.knownPlace(dir); found && place.Mode != "" { + return placeStand(place), true + } + mode := ownershipPlainMode(dir) + if root, ok := repositoryRoot(dir); ok && hasCommit(root) { + dir, mode = root, TaskModeWorktree + } + return taskStand{dir: dir, mode: mode, rung: taskGroundNamed, + redirect: "The task works in its own copy of " + dir + "; its results come back through the task's normal landing."}, true +} + +// Home is a reference, not a project snapshot. A general task must not copy a +// person's unrelated files merely because the conversation was opened there. +func ownershipPlainMode(dir string) TaskMode { + if personHome, err := os.UserHomeDir(); err == nil && canonicalPath(personHome) == canonicalPath(dir) { + return TaskModeReference + } + return TaskModeMirror +} diff --git a/internal/session/workspace_ownership_gate_test.go b/internal/session/workspace_ownership_gate_test.go new file mode 100644 index 0000000000..64bd536e3e --- /dev/null +++ b/internal/session/workspace_ownership_gate_test.go @@ -0,0 +1,23 @@ +package session_test + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestOwnershipPrototypeRequiresOptInAndAbsoluteProfile(t *testing.T) { + absolute := t.TempDir() + for _, check := range []struct { + name, flag, profile string + want bool + }{{"disabled", "0", absolute, false}, {"unset", "", absolute, false}, {"missing profile", "1", "", false}, {"relative profile", "1", "relative-profile", false}, {"explicit profile", "1", absolute, true}} { + t.Run(check.name, func(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", check.flag) + t.Setenv("CODEAF_HOME", check.profile) + if got := session.WorkspaceOwnershipPrototypeEnabled(); got != check.want { + t.Fatalf("prototype enabled=%v want %v", got, check.want) + } + }) + } +} diff --git a/internal/session/workspace_ownership_public_test.go b/internal/session/workspace_ownership_public_test.go new file mode 100644 index 0000000000..1f40f571f0 --- /dev/null +++ b/internal/session/workspace_ownership_public_test.go @@ -0,0 +1,202 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func TestOwnershipStartTaskUsesPrivateWorkspaceSetWithoutPrototypeFlag(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_TASK_BELT", "bash") + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, "input.txt"), []byte("input\n"), 0600); err != nil { + t.Fatal(err) + } + place := Place{Dir: t.TempDir(), Workspace: project} + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), Workspace: project, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { + t.Fatal(err) + } + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = project, place + c.SessionFile = place.Transcript() + }) + if _, _, _, err := agent.StartTask(context.Background(), "write result.txt", true); err != nil { + t.Fatal(err) + } + <-engine.entered + ended := false + t.Cleanup(func() { + if !ended { + endBeltRun(t, agent, engine) + } + }) + engine.mu.Lock() + spec := engine.spec + engine.mu.Unlock() + if spec.WorkspaceSet == nil || len(spec.WorkspaceSet.Roots) != 1 { + t.Fatal("ordinary task did not receive an owned workspace set") + } + root := spec.WorkspaceSet.Roots[0] + if root.Access != WorkspaceWrite || root.Landing != "" || root.Copy == nil || root.Copy.Dir == project { + t.Fatalf("ordinary task did not retain writes privately: %+v", root) + } + if _, _, _, err := agent.StartTask(context.Background(), "also write another.txt", true); err != nil { + t.Fatalf("cooperator: %v", err) + } + plan, _, err := taskcopy.ReadWorkspacePlan(cell.Cell{Root: place.Dir}) + if err != nil || plan == nil { + t.Fatalf("ordinary tasks have no transferable closure: %v", err) + } + if err := os.WriteFile(filepath.Join(spec.Workspace, "result.txt"), []byte("private result\n"), 0600); err != nil { + t.Fatal(err) + } + endBeltRun(t, agent, engine) + ended = true + if _, err := os.Stat(filepath.Join(project, "result.txt")); !os.IsNotExist(err) { + t.Fatalf("ordinary task landed without permission: %v", err) + } + next := newBeltRunDouble("done") + registerBeltRunEngine(t, next) + other := t.TempDir() + request := WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: project, Slot: "app", Access: WorkspaceWrite}, {Source: other, Slot: "library", Access: WorkspaceRead}}} + if _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "use both roots", request); err != nil { + t.Fatal(err) + } + <-next.entered + t.Cleanup(func() { endBeltRun(t, agent, next) }) + if _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "cooperate on exact roots", request); err != nil { + t.Fatal(err) + } + request.Roots[1].Access = WorkspaceWrite + if _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "broaden library access", request); err == nil { + t.Fatal("cooperator broadened read authority") + } + plan, _, err = taskcopy.ReadWorkspacePlan(cell.Cell{Root: place.Dir}) + if err != nil || plan == nil || len(plan.Groups) < 2 { + t.Fatalf("ordinary and multiroot history cannot share a checkpoint: %+v %v", plan, err) + } +} + +func TestOwnershipStartTaskReopensPendingWorkspaceSet(t *testing.T) { + littleMemoryHost(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + project := t.TempDir() + place := Place{Dir: t.TempDir(), Workspace: project} + engine := newBeltRunDouble("must remain queued") + registerBeltRunEngine(t, engine) + profile := t.TempDir() + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = project, place, place.Transcript() + c.ProfileDir, c.TaskMinFreeMB = profile, 1<<40 + }) + return agent + } + first := open() + id, _, _, err := first.StartTask(context.Background(), "queued private output", true) + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + second := open() + defer second.Close() + raw, err := os.ReadFile(place.Tasks()) + if err != nil { + t.Fatal(err) + } + var checkpoint struct { + Runs []struct { + ID uint64 `json:"id"` + Pending *struct { + WorkspaceSet *WorkspaceSetRequest `json:"workspaceSet"` + } `json:"pendingRun"` + } `json:"runs"` + } + if err := json.Unmarshal(raw, &checkpoint); err != nil { + t.Fatal(err) + } + found := false + for _, row := range checkpoint.Runs { + if row.ID == id && row.Pending != nil && row.Pending.WorkspaceSet != nil { + set := row.Pending.WorkspaceSet + found = len(set.Roots) == 1 && set.Primary == "work" && set.Roots[0].SourceURI != "" && set.Roots[0].Landing == "" && set.Roots[0].LandingURI == "" + } + } + if !found { + t.Fatalf("reopened ordinary task lost pending owned layout: %s", raw) + } + if engine.didRun() { + t.Fatal("held task ran before admission") + } + entries, err := os.ReadDir(place.Trees()) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatal("held task carved its copy before admission") + } +} + +func TestOwnershipStartTaskPreservesExplicitInPlaceAndHomeScratch(t *testing.T) { + for _, choice := range []string{"in place", "home"} { + t.Run(choice, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + project := t.TempDir() + if choice == "home" { + t.Setenv("HOME", project) + } + if err := os.WriteFile(filepath.Join(project, "personal.txt"), []byte("borrowed\n"), 0600); err != nil { + t.Fatal(err) + } + place := Place{Dir: t.TempDir(), Workspace: project} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place = project, place }) + if choice == "in place" { + if _, err := agent.ReferPlace(project, PlaceSaid); err != nil { + t.Fatal(err) + } + if err := agent.SetPlaceMode(project, "in place"); err != nil { + t.Fatal(err) + } + } + if _, _, _, err := agent.StartTask(context.Background(), "write output.txt", true); err != nil { + t.Fatal(err) + } + <-engine.entered + t.Cleanup(func() { endBeltRun(t, agent, engine) }) + engine.mu.Lock() + spec := engine.spec + engine.mu.Unlock() + if spec.WorkspaceSet != nil { + t.Fatal("explicit in-place or home reference was converted into a snapshot") + } + if choice == "in place" && spec.Workspace != project { + t.Fatalf("explicit placement moved to %s", spec.Workspace) + } + if choice == "home" { + if spec.Workspace == project { + t.Fatal("home task works in the person's home") + } + if _, err := os.Stat(filepath.Join(spec.Workspace, "personal.txt")); !os.IsNotExist(err) { + t.Fatalf("home was copied into task scratch: %v", err) + } + } + }) + } +} diff --git a/internal/session/workspace_ownership_test.go b/internal/session/workspace_ownership_test.go new file mode 100644 index 0000000000..0cca37f689 --- /dev/null +++ b/internal/session/workspace_ownership_test.go @@ -0,0 +1,133 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// A model's folder argument names the project, not permission to edit it while +// the task is running. The prepared brief must reach the worker's own copy. +func TestOwnershipProposalCopiesPlainProjectBeforeWorkerWrites(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + writeFile(t, filepath.Join(project, "BRIEF.md"), "prepared input\n") + place := Place{Dir: t.TempDir(), Workspace: project} + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = t.TempDir() + c.Place = place + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + world, release := heldTaskWorld(t, a, "result.txt", "worker output\n") + args, _ := json.Marshal(taskArguments{Title: "copy the project", Summary: "use the prepared input", + Ground: project, Where: project, Brief: "Read BRIEF.md and write result.txt", + Deliverable: "result.txt", Acceptance: "result.txt has the result"}) + answer, bad, err := a.proposeTask(context.Background(), args) + if err != nil || bad { + t.Fatalf("proposal: %s, %v", answer, err) + } + tree := <-world + if !withinDir(place.Trees(), tree.dir) || tree.mode != TaskModeMirror { + t.Fatalf("worker did not get an owned mirror: %+v", tree) + } + if got := readFile(t, filepath.Join(tree.dir, "BRIEF.md")); got != "prepared input\n" { + t.Fatalf("worker input = %q", got) + } + if _, err := os.Stat(filepath.Join(project, "result.txt")); !os.IsNotExist(err) { + t.Fatalf("worker wrote original while running: %v", err) + } + if tree.uri == "" || tree.chatURI == "" || tree.projectURI == "" { + t.Fatalf("task copy has no durable ownership identity: %+v", runCopyOf(tree)) + } + release() + waitDoneNode(t, a.graph().node(1)) +} + +func TestOwnershipCooperatingTasksShareTheAdmittedRunIdentity(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + project := t.TempDir() + writeFile(t, filepath.Join(project, "BRIEF.md"), "shared project\n") + place := Place{Dir: t.TempDir(), Workspace: project} + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = project, place + }) + stand := a.resolveTaskGround(taskSpec{ground: project, where: project, deliverable: "result.txt"}) + if err := a.startKnownTaskRun(context.Background(), 1, "shared work", "brief", nil, stand, ""); err != nil { + t.Fatal(err) + } + <-double.entered + t.Cleanup(func() { endBeltRun(t, a, double) }) + if err := a.startKnownTaskRun(context.Background(), 2, "cooperating work", "brief", nil, stand, ""); err != nil { + t.Fatal(err) + } + root, ok := runRowOf(a.graph(), 1) + child, joined := runRowOf(a.graph(), 2) + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if !ok || root.Copy == nil || root.Copy.URI == "" || spec.WorkspaceURI != root.Copy.URI { + t.Fatalf("worker did not receive the recorded run identity: %#v / %q", root.Copy, spec.WorkspaceURI) + } + if !joined || child.Parent != 1 || spec.Workspace != root.Copy.Dir { + t.Fatalf("cooperating task did not join the existing workspace: %#v", child) + } +} + +func TestOwnershipExplicitPersonInPlaceChoiceIsStillHonoured(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + project := t.TempDir() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = t.TempDir() + c.Place = Place{Dir: t.TempDir()} + }) + if _, err := a.ReferPlace(project, PlaceSaid); err != nil { + t.Fatal(err) + } + if err := a.SetPlaceMode(project, "in place"); err != nil { + t.Fatal(err) + } + stand := a.resolveTaskGround(taskSpec{ground: project, where: project, deliverable: "result.txt"}) + if stand.mode != TaskModeInPlace || stand.dir != canonicalPath(project) { + t.Fatalf("person's explicit placement was ignored: %+v", stand) + } + tree, err := prepareTaskTreeOn(context.Background(), a.config.Place, a.config.Workspace, a.journalID(), 1, "in place", stand) + if err != nil || tree.dir != canonicalPath(project) || tree.uri != "" { + t.Fatalf("borrowed project was mislabeled an owned run: %+v, %v", tree, err) + } +} + +func TestOwnershipTaskFromHomeDoesNotCopyUnrelatedPersonalFiles(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + personHome := t.TempDir() + t.Setenv("HOME", personHome) + writeFile(t, filepath.Join(personHome, "unrelated.txt"), "mock unrelated material\n") + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = personHome + c.Place = Place{Dir: t.TempDir()} + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + world, release := heldTaskWorld(t, a, "reply.txt", "mock reply\n") + args, _ := json.Marshal(taskArguments{Title: "write a short reply", Summary: "compose a reply", + Brief: "Write reply.txt with a fictional short reply", Deliverable: "reply.txt", Acceptance: "the reply exists"}) + answer, bad, err := a.proposeTask(context.Background(), args) + if err != nil || bad { + t.Fatalf("proposal: %s, %v", answer, err) + } + tree := <-world + if _, err := os.Stat(filepath.Join(tree.dir, "unrelated.txt")); !os.IsNotExist(err) { + t.Fatalf("home-only task copied unrelated personal material: %v", err) + } + release() + waitDoneNode(t, a.graph().node(1)) +} diff --git a/internal/session/workspace_policy.go b/internal/session/workspace_policy.go new file mode 100644 index 0000000000..e6480bb168 --- /dev/null +++ b/internal/session/workspace_policy.go @@ -0,0 +1,37 @@ +package session + +import "fmt" + +// WorkspacePolicy records a conversation's task placement contract. Its absence +// preserves legacy placement; new conversation creation explicitly selects v1. +type WorkspacePolicy struct { + Version int `json:"version"` + Mode string `json:"mode"` +} + +// DefaultWorkspacePolicy selects private delegated task copies. It does not +// relocate foreground editing, grant landing consent, or contain shell programs. +func DefaultWorkspacePolicy() *WorkspacePolicy { + return &WorkspacePolicy{Version: 1, Mode: "private-tasks"} +} + +func validateWorkspacePolicy(policy *WorkspacePolicy) error { + if policy != nil && (policy.Version != 1 || policy.Mode != "private-tasks") { + return fmt.Errorf("unsupported conversation workspace policy") + } + return nil +} + +// WorkspaceOwnershipEnabled reports this conversation's task placement policy. +func WorkspaceOwnershipEnabled(place Place) bool { + if place.Dir != "" { + meta, err := LoadMeta(place.Dir) + if err != nil || validateWorkspacePolicy(meta.WorkspacePolicy) != nil { + return false + } + if meta.WorkspacePolicy != nil || meta.ChatURI != "" { + return true + } + } + return WorkspaceOwnershipPrototypeEnabled() +} diff --git a/internal/session/workspace_policy_test.go b/internal/session/workspace_policy_test.go new file mode 100644 index 0000000000..de54a62095 --- /dev/null +++ b/internal/session/workspace_policy_test.go @@ -0,0 +1,74 @@ +package session_test + +import ( + "encoding/json" + "github.com/Agent-Field/codeaf/internal/session" + "os" + "path/filepath" + "testing" +) + +func TestWorkspacePolicySurvivesMetadataUpdates(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".cell"), 0700); err != nil { + t.Fatal(err) + } + var meta session.Meta + if err := json.Unmarshal([]byte(`{"id":"policy-chat","workspace_policy":{"version":1,"mode":"private-tasks"}}`), &meta); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, session.Meta{ID: "policy-chat", Title: "later metadata stamp"}); err != nil { + t.Fatal(err) + } + got, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if string(fields["workspace_policy"]) != `{"version":1,"mode":"private-tasks"}` { + t.Fatalf("policy did not survive metadata roundtrip: %s", raw) + } +} + +func TestWorkspacePolicyActivatesOnlyRecordedConversations(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + for _, check := range []struct { + name string + policy *session.WorkspacePolicy + chat string + want bool + }{ + {"new conversation", session.DefaultWorkspacePolicy(), "", true}, + {"legacy conversation", nil, "", false}, + {"prototype identity", nil, "codeaf-workspace://test/chat", true}, + } { + t.Run(check.name, func(t *testing.T) { + place := session.Place{Dir: t.TempDir()} + if err := session.SaveMeta(place.Dir, session.Meta{ID: "policy-chat", WorkspacePolicy: check.policy, ChatURI: check.chat}); err != nil { + t.Fatal(err) + } + if got := session.WorkspaceOwnershipEnabled(place); got != check.want { + t.Fatalf("recorded conversation enabled=%v want %v", got, check.want) + } + }) + } +} + +func TestWorkspacePolicyRejectsUnsupportedVersion(t *testing.T) { + dir := t.TempDir() + err := session.SaveMeta(dir, session.Meta{ID: "future-policy", WorkspacePolicy: &session.WorkspacePolicy{Version: 2, Mode: "private-tasks"}}) + if err == nil { + t.Fatal("unsupported policy was silently admitted") + } +} diff --git a/internal/session/workspace_project_alias_test.go b/internal/session/workspace_project_alias_test.go new file mode 100644 index 0000000000..6e35b2056e --- /dev/null +++ b/internal/session/workspace_project_alias_test.go @@ -0,0 +1,57 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestTransferredProjectIdentityReusesExistingLocalCache(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + receiver, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + local, err := receiver.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + before, err := ownedProjectCache(receiver, local.URI) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(before, "mock-compiler-cache"), []byte("reusable mock bytes"), 0600); err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(incoming, local.Path, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + after, err := ownedProjectCache(receiver, incoming.URI) + if err != nil { + t.Fatal(err) + } + if got := observedWorkerCache(t, local.Path, incoming.URI); got != before { + t.Fatalf("actual worker did not reuse aliased cache: %s", got) + } + if after != before { + t.Fatal("equivalent transferred project created a second local cache") + } + if raw, err := os.ReadFile(filepath.Join(after, "mock-compiler-cache")); err != nil || string(raw) != "reusable mock bytes" { + t.Fatal("transferred project did not reuse exact existing cache bytes") + } + record, err := receiver.Lookup(workspaceidentity.Cache, after) + if err != nil || record.OwnerURI != local.URI { + t.Fatal("cache stopped belonging to the canonical local project") + } +} diff --git a/internal/session/workspace_restore_plan.go b/internal/session/workspace_restore_plan.go new file mode 100644 index 0000000000..7717667894 --- /dev/null +++ b/internal/session/workspace_restore_plan.go @@ -0,0 +1,134 @@ +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// TaskCopyRestoreParents resolves typed incoming ground identities to tree IDs. +// It grants no path authority: every target must be another carried copy in the +// same conversation and project. Legacy records without GroundURI stay legacy. +func TaskCopyRestoreParents(place Place) (map[string]string, error) { + return restoreParentsFromCheckpoint(place, place.Tasks()) +} + +// ForkTaskRestoreParents uses the original frontier while a fork is detaching +// it. A retry may already have replaced the operational checkpoint with history. +func ForkTaskRestoreParents(place Place) (map[string]string, error) { + original := filepath.Join(ForkHistoryDir(place), "tasks.json") + if _, err := os.Stat(original); errors.Is(err, os.ErrNotExist) { + return TaskCopyRestoreParents(place) + } else if err != nil { + return nil, err + } + return restoreParentsFromCheckpoint(place, original) +} + +func restoreParentsFromCheckpoint(place Place, path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + document, err := decodeTasks(raw) + if err != nil { + return nil, err + } + if codec, sealed := checkpointCodec(place.Tasks()); sealed { + document = document.mapPaths(codec.resolve) + } + return incomingRestoreParents(place, document) +} + +func incomingRestoreParents(place Place, document taskDocument) (map[string]string, error) { + copies, err := restoreCopiesByIdentity(place, document) + if err != nil { + return nil, err + } + available := map[string]bool{} + at := cell.Cell{ID: place.ID(), Root: place.Dir} + for _, name := range taskcopy.Carried(at) { + available[name] = true + } + parents := map[string]string{} + for _, copy := range copies { + name := filepath.Base(copy.Dir) + if !available[name] || copy.GroundURI == "" || copy.GroundURI == copy.ProjectURI { + continue + } + parent := copies[copy.GroundURI] + if parent == nil { + return nil, errors.New("task ground identity names a missing parent") + } + if parent.ChatURI != copy.ChatURI || parent.ProjectURI != copy.ProjectURI { + return nil, errors.New("task parent belongs to another conversation or project") + } + if canonicalPath(copy.Ground) != canonicalPath(parent.Dir) { + return nil, errors.New("task parent identity does not match its owned ground") + } + parents[name] = filepath.Base(parent.Dir) + } + if err := taskcopy.ValidateParentBindings(at, parents); err != nil { + return nil, err + } + return parents, nil +} + +func restoreCopiesByIdentity(place Place, document taskDocument) (map[string]*TaskCopyRecord, error) { + records := incomingTaskCopyRecords(document) + meta, err := LoadMeta(place.Dir) + if err != nil { + return nil, err + } + byURI := map[string]*TaskCopyRecord{} + names := map[string]string{} + for _, record := range records { + if record == nil || record.URI == "" || record.GroundURI == "" { + continue + } + if err := validateIncomingRestoreCopy(place, record, meta.ChatURI); err != nil { + return nil, err + } + if previous := byURI[record.URI]; previous != nil && (canonicalPath(previous.Dir) != canonicalPath(record.Dir) || previous.ChatURI != record.ChatURI || previous.ProjectURI != record.ProjectURI || previous.GroundURI != record.GroundURI) { + return nil, errors.New("one incoming identity names conflicting task copies") + } + name := filepath.Base(record.Dir) + if uri := names[name]; uri != "" && uri != record.URI { + return nil, errors.New("several incoming identities name one task folder") + } + names[name], byURI[record.URI] = record.URI, record + } + return byURI, nil +} + +func validateIncomingRestoreCopy(place Place, copy *TaskCopyRecord, expectedChatURI string) error { + name := filepath.Base(copy.Dir) + if copy.ChatURI == "" || copy.ProjectURI == "" || name == "." || canonicalPath(copy.Dir) != canonicalPath(filepath.Join(place.Trees(), name)) { + return errors.New("incoming task identity has no owned conversation folder") + } + if copy.Mode == TaskModeInPlace || copy.Mode == TaskModeFolder { + return errors.New("in-place project cannot be restored as an owned copy") + } + if expectedChatURI != "" && expectedChatURI != copy.ChatURI { + return fmt.Errorf("incoming task belongs to another conversation") + } + return nil +} + +func incomingTaskCopyRecords(document taskDocument) []*TaskCopyRecord { + var records []*TaskCopyRecord + for _, run := range document.Runs { + records = append(records, run.Copy) + } + for _, node := range document.Nodes { + records = append(records, &TaskCopyRecord{URI: node.WorkspaceURI, ChatURI: node.ChatURI, ProjectURI: node.ProjectURI, GroundURI: node.GroundURI, Dir: node.Worktree, Ground: node.Ground, Mode: node.Mode}) + } + return records +} diff --git a/internal/session/workspace_restore_plan_test.go b/internal/session/workspace_restore_plan_test.go new file mode 100644 index 0000000000..60636dce1c --- /dev/null +++ b/internal/session/workspace_restore_plan_test.go @@ -0,0 +1,67 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestRestoreParentPlanUsesStructuredIdentityAndOwnedTreeNames(t *testing.T) { + place := newPlace(t, true) + chat := "codeaf-workspace://11111111111111111111111111111111/22222222222222222222222222222222" + project := "codeaf-workspace://11111111111111111111111111111111/33333333333333333333333333333333" + parent := "codeaf-workspace://11111111111111111111111111111111/44444444444444444444444444444444" + child := "codeaf-workspace://11111111111111111111111111111111/55555555555555555555555555555555" + parentDir, childDir := filepath.Join(place.Trees(), "1"), filepath.Join(place.Trees(), "2") + original := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 2, Runs: []runRecord{{ID: 1, Title: "parent", State: TaskDone, Copy: &TaskCopyRecord{URI: parent, ChatURI: chat, ProjectURI: project, GroundURI: project, Dir: parentDir, Ground: t.TempDir(), Mode: TaskModeMirror}}, {ID: 2, Title: "child", State: TaskDone, Copy: &TaskCopyRecord{URI: child, ChatURI: chat, ProjectURI: project, GroundURI: parent, Dir: childDir, Ground: parentDir, Mode: TaskModeWorktree}}}} + for _, id := range []string{"1", "2"} { + dir := filepath.Join(place.Dir, ".cell", "trees", id) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "tree.json"), []byte(`{"head":"fixture"}`), 0600); err != nil { + t.Fatal(err) + } + } + save := func(document taskDocument) { + t.Helper() + raw, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(place.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + } + save(original) + parents, err := TaskCopyRestoreParents(place) + if err != nil || !reflect.DeepEqual(parents, map[string]string{"2": "1"}) { + t.Fatalf("nested ground not mapped: %v %v", parents, err) + } + for _, bad := range []string{"missing", "cross-chat", "cross-project", "path", "cycle"} { + t.Run(bad, func(t *testing.T) { + raw, _ := json.Marshal(original) + var document taskDocument + _ = json.Unmarshal(raw, &document) + switch bad { + case "missing": + document.Runs[1].Copy.GroundURI = "codeaf-workspace://11111111111111111111111111111111/66666666666666666666666666666666" + case "cross-chat": + document.Runs[1].Copy.ChatURI = child + case "cross-project": + document.Runs[1].Copy.ProjectURI = child + case "path": + document.Runs[1].Copy.Ground = t.TempDir() + case "cycle": + document.Runs[0].Copy.GroundURI = child + document.Runs[0].Copy.Ground = childDir + } + save(document) + if _, err := TaskCopyRestoreParents(place); err == nil { + t.Fatal("unsafe restoration topology admitted") + } + }) + } +} diff --git a/internal/session/workspace_set.go b/internal/session/workspace_set.go new file mode 100644 index 0000000000..879c2ac7ab --- /dev/null +++ b/internal/session/workspace_set.go @@ -0,0 +1,386 @@ +package session + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" + "path/filepath" + "reflect" + "strconv" + "strings" +) + +// WorkspaceAccess describes intended use, not an operating-system sandbox. +type WorkspaceAccess string + +const workspaceSetRootLimit = 64 + +const ( + WorkspaceRead WorkspaceAccess = "read" + WorkspaceWrite WorkspaceAccess = "write" +) + +// WorkspaceRootRequest explicitly selects one source and its relative layout. +// Slots preserve supported ASCII spelling; case-fold aliases, Unicode and +// reserved filesystem names refuse rather than being renamed. +// Landing is empty to retain work privately, or the explicitly admitted source +// folder. Matching names or Git remotes never supply landing permission. +type WorkspaceRootRequest struct { + // SourceURI is authoritative once admitted; Source is a local path hint. + SourceURI string `json:"source_uri,omitempty"` + LandingURI string `json:"landing_uri,omitempty"` + Source string `json:"source"` + Slot string `json:"slot"` + Access WorkspaceAccess `json:"access"` + Landing string `json:"landing,omitempty"` +} + +// WorkspaceSetRequest belongs to one execution, not to the conversation's +// lifetime. Slots describe actual sibling directories, never a common ancestor. +type WorkspaceSetRequest struct { + Primary string `json:"primary"` + Roots []WorkspaceRootRequest `json:"roots"` +} + +// WorkspaceRootUse names one admitted copy. Copy.URI retains its existing +// one-copy meaning even when several copies belong to the same execution. +type WorkspaceRootUse struct { + SourceURI string `json:"source_uri,omitempty"` + Slot string `json:"slot"` + Access WorkspaceAccess `json:"access"` + Landing string `json:"landing,omitempty"` + Copy *TaskCopyRecord `json:"copy"` + Outcome string `json:"outcome,omitempty"` +} + +// WorkspaceSet is the durable execution layout. History-only roots remain in +// their earlier result records; they are not required to start this execution. +type WorkspaceSet struct { + Version int `json:"version"` + Primary string `json:"primary"` + Roots []WorkspaceRootUse `json:"roots"` +} + +// StartTaskInWorkspaceSet admits an explicitly declared private task layout. +func (a *Agent) StartTaskInWorkspaceSet(ctx context.Context, brief string, request WorkspaceSetRequest) (uint64, string, string, error) { + if !WorkspaceOwnershipEnabled(a.config.Place) || !bashBeltAsked() { + return 0, "", "", errors.New("workspace sets require private task placement and the Bash task engine") + } + + if strings.TrimSpace(brief) == "" { + return 0, "", "", errors.New("task brief is empty") + } + g := a.graph() + if chatRunEngine == nil || g == nil || g.planPath() == "" { + return 0, "", "", errRunRoadUnavailable + } + if err := admitWorkspaceSetRequest(ctx, a.config.Place, &request); err != nil { + return 0, "", "", err + } + id := g.reserve() + title := taskPersonTitle(brief) + primary := request.Roots[0] + for _, root := range request.Roots { + if root.Slot == request.Primary { + primary = root + } + } + stand := taskStand{dir: primary.Source, mode: TaskModeMirror, workspaceSet: &request} + if err := a.startKnownTaskRun(ctx, id, title, brief, nil, stand, ""); err != nil { + return 0, "", "", err + } + return id, title, "", nil +} + +func cloneWorkspaceSet(set *WorkspaceSet) *WorkspaceSet { + if set == nil { + return nil + } + out := *set + out.Roots = append([]WorkspaceRootUse(nil), set.Roots...) + for i := range out.Roots { + if copy := out.Roots[i].Copy; copy != nil { + v := *copy + v.Untracked = append([]string(nil), copy.Untracked...) + out.Roots[i].Copy = &v + } + } + return &out +} +func sameWorkspaceSetRequest(a, b *WorkspaceSetRequest) bool { return reflect.DeepEqual(a, b) } + +// Clone returns worker-owned metadata; callers cannot change seated routes. +func (s *WorkspaceSet) Clone() *WorkspaceSet { return cloneWorkspaceSet(s) } + +// WorkerInstructions routes every delegated worker, independently of the +// planner's subtask prose. Instruction files stay scoped to their own copies. +func (s *WorkspaceSet) WorkerInstructions() string { + if s == nil { + return "" + } + var text strings.Builder + primaryDir := "unavailable" + for _, root := range s.Roots { + if root.Slot == s.Primary && root.Copy != nil { + primaryDir = root.Copy.Dir + } + } + text.WriteString("The harness prepared these private working roots. Use these copies for all task work; do not write the source folders. Primary current directory (" + s.Primary + "): " + primaryDir + ".\n") + for _, root := range s.Roots { + if root.Copy != nil { + fmt.Fprintf(&text, "- %s: %s (intended %s; landing %s)\n", root.Slot, root.Copy.Dir, root.Access, root.Landing) + } + } + text.WriteString("These routes are not an operating-system sandbox. Read roots must not be changed. No landing destination means keep output in the private copy. Rules from one root, including the primary, apply only within that root; do not blend conflicting rules across sibling roots. Read more specific nested instructions before editing nested files.\n") + spent := 0 + for _, root := range s.Roots { + if root.Copy == nil { + continue + } + for _, name := range []string{agentsFileName, claudeFileName} { + file := filepath.Join(root.Copy.Dir, name) + if spent >= attachedFilesBudget { + fmt.Fprintf(&text, "Read %s for instructions if work enters this root; the quoted instruction budget is exhausted.\n", file) + continue + } + rules, truncated := readInstructionFileWithin(root.Copy.Dir, name, min(attachedFileLimit, attachedFilesBudget-spent)) + if rules == "" && !truncated { + continue + } + spent += len(rules) + fmt.Fprintf(&text, "\n## Instructions for %s\nThese hold for work UNDER %s AND NOWHERE ELSE.\n", root.Copy.Dir, root.Copy.Dir) + fence := fenceFor(rules) + text.WriteString(fence + "text\n" + rules + "\n" + fence + "\n") + if truncated { + fmt.Fprintf(&text, "Instructions were truncated; read %s for the remainder before relevant work.\n", file) + } + } + } + return text.String() +} + +// reopenWorkspaceSet adopts every required copy before execution is allowed. +// Old rows retain their exact singleton adoption road. +func reopenWorkspaceSet(copy *TaskCopyRecord, set *WorkspaceSet, place Place) (taskTree, error) { + if set != nil { + if err := workspaceFinalizationPending(place); err != nil { + return taskTree{}, err + } + if err := taskcopy.WorkspaceReady(cell.Cell{Root: place.Dir}); err != nil { + return taskTree{}, err + } + } + + if set == nil { + return runCopyTree(copy, place) + } + if !WorkspaceOwnershipEnabled(place) || set.Version != 1 || len(set.Roots) == 0 || len(set.Roots) > workspaceSetRootLimit { + return taskTree{}, errors.New("unsupported workspace set record") + } + set = cloneWorkspaceSet(set) + seen := map[string]bool{} + folded := map[string]bool{} + primary := false + layoutBase := "" + var grounds []string + for _, use := range set.Roots { + if use.Copy == nil || use.Copy.URI == "" || !validWorkspaceSlot(use.Slot) || seen[use.Slot] || folded[strings.ToLower(use.Slot)] || use.Access != WorkspaceRead && use.Access != WorkspaceWrite { + return taskTree{}, errors.New("invalid workspace root record") + } + seen[use.Slot] = true + folded[strings.ToLower(use.Slot)] = true + if use.Landing != "" && (use.Access != WorkspaceWrite || use.Landing != use.Copy.GroundURI) { + return taskTree{}, errors.New("workspace root landing does not match admitted source identity") + } + resolved, err := resolveOwnedWorkspace(use.Copy, place) + if err != nil { + return taskTree{}, err + } + if filepath.Base(resolved) != use.Slot || layoutBase != "" && filepath.Dir(resolved) != layoutBase { + return taskTree{}, errors.New("workspace copy bindings no longer preserve the declared sibling layout") + } + layoutBase = filepath.Dir(resolved) + _, ground, err := runCopyLandingPaths(use.Copy) + if err != nil { + return taskTree{}, err + } + for _, prior := range grounds { + if withinDir(prior, ground) || withinDir(ground, prior) { + return taskTree{}, errors.New("workspace source bindings now overlap; explicit ownership mapping is required") + } + } + grounds = append(grounds, ground) + if use.Slot == set.Primary { + primary = true + if copy == nil || copy.URI != use.Copy.URI { + return taskTree{}, errors.New("workspace primary does not match its legacy copy projection") + } + } + } + if !primary { + return taskTree{}, errors.New("workspace primary copy is absent") + } + var tree taskTree + for i, use := range set.Roots { + adopted, err := runCopyTree(use.Copy, place) + if err != nil { + return taskTree{}, err + } + set.Roots[i].Copy = runCopyOf(adopted) + if use.Slot == set.Primary { + tree = adopted + } + } + tree.workspaceSet = set + return tree, nil +} + +func workspaceSetRequestOf(set *WorkspaceSet) *WorkspaceSetRequest { + if set == nil { + return nil + } + request := &WorkspaceSetRequest{Primary: set.Primary} + for _, use := range set.Roots { + landing := "" + if use.Landing != "" { + landing = use.Copy.Ground + } + request.Roots = append(request.Roots, WorkspaceRootRequest{Source: use.Copy.Ground, Slot: use.Slot, Access: use.Access, Landing: landing}) + } + return request +} + +func cloneWorkspaceSetRequest(request *WorkspaceSetRequest) *WorkspaceSetRequest { + if request == nil { + return nil + } + copied := *request + copied.Roots = append([]WorkspaceRootRequest(nil), request.Roots...) + return &copied +} + +// workspaceTransferProjection is derived from the authoritative run records in +// the same checkpoint. It never discovers membership by scanning directories. +func workspaceTransferProjection(document taskDocument, place Place) (*taskcopy.WorkspacePlan, error) { + hasSet := false + for _, row := range document.Runs { + if row.WorkspaceSet != nil || row.PendingRun != nil && row.PendingRun.WorkspaceSet != nil { + hasSet = true + break + } + } + if !hasSet { + return nil, nil + } + + if err := validateWorkspaceTransferCoverage(document); err != nil { + return nil, err + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return nil, err + } + chat, err := registry.Register(workspaceidentity.Chat, canonicalPath(place.Dir)) + if err != nil { + return nil, err + } + return workspaceTransferProjectionFor(document, place, chat.URI) +} + +// workspaceTransferProjectionFor is pure: receiving code supplies authenticated +// chat authority and resolved layout paths before comparing the projection. +func workspaceTransferProjectionFor(document taskDocument, place Place, chatURI string) (*taskcopy.WorkspacePlan, error) { + if err := validateWorkspaceTransferCoverage(document); err != nil { + return nil, err + } + var plan *taskcopy.WorkspacePlan + for _, row := range document.Runs { + if row.WorkspaceSet == nil && (row.PendingRun == nil || row.PendingRun.WorkspaceSet == nil) { + continue + } + if plan == nil { + plan = &taskcopy.WorkspacePlan{Version: 2, ChatURI: chatURI} + } + + group := taskcopy.WorkspaceGroup{TaskID: row.ID, Required: row.State == TaskQueued || row.State == TaskRunning} + if row.WorkspaceSet != nil { + group.Primary = row.WorkspaceSet.Primary + for _, use := range row.WorkspaceSet.Roots { + entry := taskcopy.WorkspaceEntry{ChatURI: plan.ChatURI, RootURI: use.SourceURI, Layout: strconv.FormatUint(row.ID, 10) + "/" + use.Slot, Access: string(use.Access)} + if use.Access == WorkspaceWrite { + entry.Access = string(WorkspaceWrite) + } + if use.Copy == nil { + group.Incomplete = true + } else { + copy := use.Copy + entry.CopyURI, entry.ChatURI, entry.ProjectURI, entry.RootURI, entry.GroundURI = copy.URI, copy.ChatURI, copy.ProjectURI, copy.RootURI, copy.GroundURI + if entry.RootURI == "" { + entry.RootURI = use.SourceURI + } + if entry.RootURI == "" { + entry.RootURI = copy.GroundURI + } + + layout, err := filepath.Rel(place.Trees(), copy.Dir) + if err != nil || filepath.IsAbs(layout) || strings.HasPrefix(layout, ".."+string(filepath.Separator)) || layout == ".." { + return nil, errors.New("workspace transfer copy lies outside owned trees") + } + entry.Layout = filepath.ToSlash(layout) + } + group.Entries = append(group.Entries, entry) + } + } else { + request := row.PendingRun.WorkspaceSet + group.Primary, group.Incomplete = request.Primary, true + for _, root := range request.Roots { + access := string(WorkspaceRead) + if root.Access == WorkspaceWrite { + access = string(WorkspaceWrite) + } + group.Entries = append(group.Entries, taskcopy.WorkspaceEntry{ChatURI: plan.ChatURI, RootURI: root.SourceURI, Layout: strconv.FormatUint(row.ID, 10) + "/" + root.Slot, Access: access}) + } + } + plan.Groups = append(plan.Groups, group) + } + return plan, nil +} + +func validateWorkspaceTransferCoverage(document taskDocument) error { + hasSet := false + for _, row := range document.Runs { + if row.WorkspaceSet != nil || row.PendingRun != nil && row.PendingRun.WorkspaceSet != nil { + hasSet = true + break + } + } + if !hasSet { + return nil + } + covered := map[string]bool{} + for _, row := range document.Runs { + if row.WorkspaceSet != nil { + for _, root := range row.WorkspaceSet.Roots { + if root.Copy != nil { + covered[root.Copy.URI] = true + } + } + } + } + for _, row := range document.Runs { + if row.WorkspaceSet == nil && (row.Copy != nil && row.Copy.URI != "" && !covered[row.Copy.URI] || row.PendingRun != nil && row.PendingRun.WorkspaceSet == nil) { + return errors.New("workspace-set prototype cannot preserve mixed legacy task copies; keep this conversation local and start the multi-root prototype in a new conversation") + } + } + for _, node := range document.Nodes { + if node.WorkspaceURI != "" && !covered[node.WorkspaceURI] { + return errors.New("workspace-set prototype cannot preserve mixed legacy node copies; original work is retained locally") + } + } + + return nil +} diff --git a/internal/session/workspace_set_prepare.go b/internal/session/workspace_set_prepare.go new file mode 100644 index 0000000000..02b2aaf40b --- /dev/null +++ b/internal/session/workspace_set_prepare.go @@ -0,0 +1,546 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func validateWorkspaceSetRequest(request *WorkspaceSetRequest) error { + if len(request.Roots) == 0 || len(request.Roots) > workspaceSetRootLimit { + return fmt.Errorf("workspace set requires between 1 and %d explicit roots", workspaceSetRootLimit) + } + request.Roots = append([]WorkspaceRootRequest(nil), request.Roots...) + slots := map[string]bool{} + folded := map[string]bool{} + for i := range request.Roots { + root := &request.Roots[i] + if !validWorkspaceSlot(root.Slot) || folded[strings.ToLower(root.Slot)] { + return errors.New("workspace slots require distinct portable ASCII directory names, including case-insensitive uniqueness; unsupported layouts are not renamed") + } + slots[root.Slot] = true + folded[strings.ToLower(root.Slot)] = true + if root.Access != WorkspaceRead && root.Access != WorkspaceWrite { + return errors.New("workspace root requires explicit read or write access") + } + if root.SourceURI != "" { + source, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), root.SourceURI) + if err != nil { + return fmt.Errorf("resolve declared workspace source: %w", err) + } + if source.Role != workspaceidentity.Project && source.Role != workspaceidentity.Run { + return errors.New("workspace source identity does not name a root or owned copy") + } + root.Source = source.Path + } + if root.LandingURI != "" { + if root.SourceURI == "" || root.LandingURI != root.SourceURI || root.Access != WorkspaceWrite { + return errors.New("workspace landing identity must name its admitted writable source") + } + root.Landing = root.Source + } + if !filepath.IsAbs(root.Source) { + return errors.New("workspace source must be an explicit absolute folder") + } + root.Source = filepath.Clean(root.Source) + if err := workspacePathWithoutSymlinks(root.Source); err != nil { + return err + } + if root.Landing != "" { + if root.Access != WorkspaceWrite || filepath.Clean(root.Landing) != root.Source { + return errors.New("workspace landing must explicitly name its writable source folder") + } + root.Landing = root.Source + } + for j := 0; j < i; j++ { + other := request.Roots[j].Source + if withinDir(other, root.Source) || withinDir(root.Source, other) { + return errors.New("overlapping workspace sources require an explicit ownership layout") + } + } + } + if !slots[request.Primary] { + return errors.New("workspace primary must name one declared root slot") + } + sort.Slice(request.Roots, func(i, j int) bool { return request.Roots[i].Slot < request.Roots[j].Slot }) + return nil +} + +func workspacePathWithoutSymlinks(path string) error { + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return fmt.Errorf("workspace source unavailable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace source uses a symlink; an explicit supported layout is required") + } + if current == path && !info.IsDir() { + return errors.New("workspace source is not a folder") + } + if filepath.Dir(current) == current { + break + } + } + return nil +} + +func prepareWorkspaceSet(ctx context.Context, place Place, id uint64, title string, request WorkspaceSetRequest) (primary taskTree, err error) { + if !WorkspaceOwnershipEnabled(place) || place.Trees() == "" { + return taskTree{}, errors.New("workspace sets require an owned conversation tree root") + } + if err := validateWorkspaceSetRequest(&request); err != nil { + return taskTree{}, err + } + base := filepath.Join(place.Trees(), strconv.FormatUint(id, 10)) + if _, err := validateWorkspaceSetSources(ctx, place, request, base); err != nil { + return taskTree{}, err + } + + // A partially prepared set is preserved for diagnosis; never recarve it. + if _, err := os.Lstat(base); err == nil { + if receipt, readErr := readWorkspaceSetPreparation(place, id); readErr == nil { + return taskTree{workspaceSet: receipt.Set}, errors.New("workspace preparation already has retained copies; its incomplete record requires review before retry") + } + return taskTree{}, errors.New("workspace set already exists; its retained preparation needs review") + } else if !os.IsNotExist(err) { + return taskTree{}, err + } + set := &WorkspaceSet{Version: 1, Primary: request.Primary} + for _, use := range request.Roots { + set.Roots = append(set.Roots, WorkspaceRootUse{SourceURI: use.SourceURI, Slot: use.Slot, Access: use.Access, Landing: use.LandingURI}) + } + defer func() { + primary.workspaceSet = set + if receiptErr := writeWorkspaceSetPreparation(place, id, title, request, set, err == nil); receiptErr != nil { + err = errors.Join(err, fmt.Errorf("retain workspace preparation receipt: %w", receiptErr)) + } + }() + if err := writeWorkspaceSetPreparation(place, id, title, request, set, false); err != nil { + return primary, err + } + for i, use := range request.Roots { + if err := ctx.Err(); err != nil { + return taskTree{}, err + } + dir := filepath.Join(base, use.Slot) + order := groundOrder{place: place, ground: use.Source, dir: dir, mode: 0700, title: title, promise: TaskModeMirror} + if root, ok := repositoryRoot(use.Source); ok && canonicalPath(root) == canonicalPath(use.Source) && hasCommit(root) { + order = worktreeOrder(place, root, dir, 0700, taskBranchName(title+"-"+use.Slot), title, "") + } + tree, err := carveGround(ctx, order) + if err != nil { + return taskTree{}, fmt.Errorf("prepare workspace root %s: %w", use.Slot, err) + } + if order.promise == TaskModeMirror { + rememberGroundBaseline(tree.dir) + tree = openFamilyTree(tree) + // The native mirror is opened as Git only here, after the carve. Record + // its immutable admission commit before any worker can change this copy. + baseline, baseErr := git(tree.dir, "rev-parse", "--verify", "HEAD") + if baseErr != nil { + return taskTree{}, fmt.Errorf("record workspace root %s baseline before starting work: %w", use.Slot, baseErr) + } + tree.checkBase = strings.TrimSpace(baseline) + } + tree, err = registerOwnedWorkspace(tree, place) + if err != nil { + return taskTree{}, err + } + tree.bashBelt = true + landingURI := "" + if use.Landing != "" { + landingURI = tree.groundURI + } + set.Roots[i] = WorkspaceRootUse{SourceURI: use.SourceURI, Slot: use.Slot, Access: use.Access, Landing: landingURI, Copy: runCopyOf(tree)} + if err := writeWorkspaceSetPreparation(place, id, title, request, set, false); err != nil { + return primary, err + } + if use.Slot == request.Primary { + primary = tree + } + } + primary.workspaceSet = set + return primary, nil +} + +func (a *Agent) landWorkspaceSet(ctx context.Context, engine RunEngine, run *beltRun) RunLanding { + result := RunLanding{Home: mergeKept} + if !a.beginBeltHomecoming(run) { + return result + } + set := run.tree.workspaceSet + for i := range set.Roots { + use := &set.Roots[i] + tree, err := runCopyTree(use.Copy, a.config.Place) + if err != nil { + use.Outcome = mergeAborted + result.Refused += use.Slot + ": " + err.Error() + "; " + continue + } + receipt, err := engine.Land(ctx, run.store, tree.dir, tree.checkBase, run.root) + if err != nil { + use.Outcome = mergeAborted + result.Refused += use.Slot + ": " + err.Error() + "; " + continue + } + if receipt.Refused == runNothingToLand && len(receipt.Changed) == 0 { + use.Outcome = mergeKept + _, _ = run.store.AddNote(run.root, run.root, "workspace "+use.Slot+": unchanged, retained privately") + continue + } + if receipt.Refused != "" { + use.Outcome = mergeAborted + result.Refused += use.Slot + ": " + receipt.Refused + "; " + _, _ = run.store.AddNote(run.root, run.root, "workspace "+use.Slot+": kept privately; "+receipt.Refused) + continue + } + use.Outcome = mergeKept + if use.Access == WorkspaceWrite && use.Landing != "" { + merged, said, _, _ := tree.comeHome(run.title, receipt.Changed, a.signsGitWork()) + use.Outcome = merged + if merged != mergeMerged && merged != mergeInPlace { + result.Refused += use.Slot + ": " + said + "; " + } + } + _, _ = run.store.AddNote(run.root, run.root, "workspace "+use.Slot+": "+use.Outcome) + if use.Slot == set.Primary { + result.Branch = receipt.Branch + result.Changed = receipt.Changed + } + } + if result.Refused != "" { + result.Home = mergeAborted + } + if g := a.graph(); g != nil { + if notice, ok := runRowOf(g, run.row); ok { + notice.WorkspaceSet = cloneWorkspaceSet(set) + a.publishRunRow(g, notice) + } + } + return result +} + +// Stopping never lands unfinished work. Keep each member locally, including +// native forks: the singleton keptWork road may fetch into its source repo. +func (a *Agent) keepStoppedWorkspaceSet(run *beltRun) (merge, branch string, changed []string, report string) { + merge = mergeKept + for i := range run.tree.workspaceSet.Roots { + use := &run.tree.workspaceSet.Roots[i] + tree, err := runCopyTree(use.Copy, a.config.Place) + problem := "" + var rootChanged []string + if err != nil { + problem = err.Error() + } else { + if tree.checkBase != "" { + rootChanged, _, err = runCommittedPaths(tree.dir, tree.checkBase) + if err != nil { + problem = err.Error() + } + } + saved, saveProblem, _ := commitTaskWork(tree.dir, run.title, rootChanged, a.signsGitWork(), true) + rootChanged = alsoChanged(rootChanged, saved) + if saveProblem != "" { + problem = withReport(problem, saveProblem) + } + } + use.Outcome = mergeKept + if problem != "" { + use.Outcome = mergeAborted + merge = mergeAborted + } + note := "workspace " + use.Slot + ": " + use.Outcome + " in its private copy" + if problem != "" { + note = withReport(note, problem) + } + _, _ = run.store.AddNote(run.root, run.root, note) + report += " · " + note + if use.Slot == run.tree.workspaceSet.Primary { + changed = rootChanged + if len(rootChanged) > 0 { + branch = currentBranch(tree.dir) + } + } + } + return +} + +// Keep declared spelling while refusing layouts that alias on other supported +// filesystems. Unicode normalization and hidden metadata-root layouts need a +// separately tested mapping; they are never silently renamed. +func validWorkspaceSlot(slot string) bool { return taskcopy.ValidWorkspaceSlot(slot) } + +func validateWorkspaceSetSources(ctx context.Context, place Place, request WorkspaceSetRequest, base string) (*workspaceidentity.Registry, error) { + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return nil, err + } + chat, chatErr := registry.LookupRecorded(workspaceidentity.Chat, canonicalPath(place.Dir)) + if chatErr != nil && !errors.Is(chatErr, workspaceidentity.ErrUnknown) { + return nil, chatErr + } + // Validate every source before carving even the primary: a secondary may + // still be owned by an external program, or contain the destination itself. + for _, use := range request.Roots { + for path := use.Source; ; path = filepath.Dir(path) { + if err := ctx.Err(); err != nil { + return nil, err + } + owned, err := registry.LookupRecorded(workspaceidentity.Run, path) + if err == nil { + if chatErr != nil || owned.OwnerURI != chat.URI || path != use.Source || !ownedTreePath(path, place) { + return nil, errors.New("workspace source belongs to another execution; cooperate in its admitted workspace instead") + } + break + } + if !errors.Is(err, workspaceidentity.ErrUnknown) { + return nil, err + } + if filepath.Dir(path) == path { + break + } + } + if refusal := programHoldRefusal(use.Source); refusal != "" { + return nil, errors.New(refusal) + } + if withinDir(use.Source, base) || withinDir(base, use.Source) { + return nil, errors.New("workspace source overlaps its owned destination") + } + if ownershipPlainMode(use.Source) == TaskModeReference { + return nil, errors.New("a personal home cannot be copied as a workspace root; select an explicit working folder") + } + } + + return registry, nil +} + +// Admit roots while work is queued, without carving any execution copies. +// Sealed checkpoints can then omit machine-local paths and still reopen every +// selected root through its authenticated local identity. +func admitWorkspaceSetRequest(ctx context.Context, place Place, request *WorkspaceSetRequest) error { + if place.Trees() == "" { + return errors.New("workspace sets require an owned conversation tree root") + } + if err := validateWorkspaceSetRequest(request); err != nil { + return err + } + registry, err := validateWorkspaceSetSources(ctx, place, *request, place.Trees()) + if err != nil { + return err + } + for i := range request.Roots { + root := &request.Roots[i] + if root.SourceURI == "" { + record, err := registry.LookupRecorded(workspaceidentity.Run, root.Source) + if errors.Is(err, workspaceidentity.ErrUnknown) { + record, err = registry.Register(workspaceidentity.Project, root.Source) + } + if err != nil { + return err + } + root.SourceURI = record.URI + } + if root.Landing != "" { + root.LandingURI = root.SourceURI + } + } + return nil +} + +const workspaceSetPreparationFile = ".workspace-set.json" + +type workspaceSetPreparation struct { + Version int `json:"version"` + Run uint64 `json:"run"` + ChatURI string `json:"chat_uri"` + Title string `json:"title"` + Request WorkspaceSetRequest `json:"request"` + Set *WorkspaceSet `json:"set"` + Complete bool `json:"complete"` +} + +func writeWorkspaceSetPreparation(place Place, id uint64, title string, request WorkspaceSetRequest, set *WorkspaceSet, complete bool) error { + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return err + } + chat, err := registry.Register(workspaceidentity.Chat, canonicalPath(place.Dir)) + if err != nil { + return err + } + request.Roots = append([]WorkspaceRootRequest(nil), request.Roots...) + for i := range request.Roots { + request.Roots[i].Source = "" + request.Roots[i].Landing = "" + } + row := runRecord{WorkspaceSet: set} + codec := codecFor(place.Dir, place.Workspace) + for _, field := range row.pathFields() { + if *field != "" { + *field, _ = codec.encode(*field) + } + } + receipt := workspaceSetPreparation{Version: 1, Run: id, ChatURI: chat.URI, Title: title, Request: request, Set: row.WorkspaceSet, Complete: complete} + raw, err := json.Marshal(receipt) + if err != nil { + return err + } + base := filepath.Join(place.Trees(), strconv.FormatUint(id, 10)) + if err := os.MkdirAll(base, 0700); err != nil { + return err + } + file, err := os.CreateTemp(base, ".workspace-set-") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if _, err = file.Write(raw); err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Rename(name, filepath.Join(base, workspaceSetPreparationFile)); err != nil { + return err + } + directory, err := os.Open(base) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} + +func readWorkspaceSetPreparation(place Place, id uint64) (workspaceSetPreparation, error) { + var receipt workspaceSetPreparation + base := filepath.Join(place.Trees(), strconv.FormatUint(id, 10)) + path := filepath.Join(base, workspaceSetPreparationFile) + info, err := os.Lstat(path) + if err != nil { + return receipt, err + } + if !info.Mode().IsRegular() || info.Size() > taskcopy.WorkspaceCheckpointLimit { + return receipt, errors.New("invalid workspace preparation receipt") + } + file, err := os.Open(path) + if err != nil { + return receipt, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, taskcopy.WorkspaceCheckpointLimit+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receipt); err != nil { + return receipt, errors.New("invalid workspace preparation receipt") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return receipt, errors.New("invalid workspace preparation receipt trailer") + } + if receipt.Version != 1 || receipt.Run != id || receipt.Set == nil || receipt.Set.Version != 1 || len(receipt.Set.Roots) == 0 || len(receipt.Set.Roots) > workspaceSetRootLimit { + return receipt, errors.New("unsupported workspace preparation receipt") + } + chat, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), receipt.ChatURI) + if err != nil || chat.Role != workspaceidentity.Chat || chat.Path != canonicalPath(place.Dir) { + return receipt, errors.New("workspace preparation belongs to another conversation") + } + row := runRecord{WorkspaceSet: receipt.Set} + codec := codecFor(place.Dir, place.Workspace) + for _, field := range row.pathFields() { + if *field != "" { + *field, _ = codec.resolve(*field) + } + } + receipt.Set = row.WorkspaceSet + for _, use := range receipt.Set.Roots { + if !validWorkspaceSlot(use.Slot) { + return receipt, errors.New("invalid workspace preparation slot") + } + if use.Copy == nil { + continue + } + resolved, err := resolveOwnedWorkspace(use.Copy, place) + if err != nil || filepath.Dir(resolved) != base || filepath.Base(resolved) != use.Slot { + return receipt, errors.New("workspace preparation copy ownership does not match its layout") + } + } + return receipt, nil +} + +func recoverWorkspaceSetPreparations(place Place, document taskDocument) (taskDocument, bool) { + if !WorkspaceOwnershipEnabled(place) || place.Trees() == "" { + return document, false + } + directory, err := os.Open(place.Trees()) + if err != nil { + return document, false + } + defer directory.Close() + known := map[uint64]bool{} + for _, row := range document.Runs { + if row.PendingRun == nil { + known[row.ID] = true + } + } + for _, node := range document.Nodes { + known[node.ID] = true + } + changed := false + for { + entries, readErr := directory.ReadDir(128) + for _, entry := range entries { + id, err := strconv.ParseUint(entry.Name(), 10, 64) + if err != nil || id == 0 || !entry.IsDir() || known[id] { + continue + } + receipt, err := readWorkspaceSetPreparation(place, id) + if err != nil { + continue + } + record := runRecord{ID: id, Title: receipt.Title, State: TaskFailed, WorkspaceSet: receipt.Set, Report: "workspace preparation was interrupted; its private copies and ownership receipt are retained for review"} + for _, use := range receipt.Set.Roots { + if use.Slot == receipt.Set.Primary { + record.Copy = use.Copy + } + } + replaced := false + for i := range document.Runs { + if document.Runs[i].ID == id { + record.CrewState = document.Runs[i].CrewState + document.Runs[i] = record + replaced = true + break + } + } + if !replaced { + document.Runs = append(document.Runs, record) + } + document.Seq = max(document.Seq, id) + changed = true + } + if readErr != nil { + break + } + } + if changed { + document.Type = taskDocumentType + document.Version = taskWorkspaceSetFileVersion + document = filterDeletedDocument(document, taskDeletions(place.Transcript())) + } + return document, changed +} diff --git a/internal/session/workspace_set_test.go b/internal/session/workspace_set_test.go new file mode 100644 index 0000000000..cc10bd490d --- /dev/null +++ b/internal/session/workspace_set_test.go @@ -0,0 +1,827 @@ +package session + +import ( + "context" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestWorkspaceSetTaskUsesOwnedSiblingCopies(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + library := newTestRepo(t) + writeFile(t, filepath.Join(library, "library.txt"), "relative dependency\n") + app := t.TempDir() + writeFile(t, filepath.Join(app, "app.txt"), "../library/library.txt\n") + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "read both sibling roots", WorkspaceSetRequest{ + Primary: "app", Roots: []WorkspaceRootRequest{ + {Source: app, Slot: "app", Access: WorkspaceWrite}, + {Source: library, Slot: "library", Access: WorkspaceRead}, + }, + }) + if err != nil { + t.Fatal(err) + } + <-engine.entered + t.Cleanup(func() { endBeltRun(t, agent, engine) }) + var wire struct { + Version int `json:"version"` + } + raw, err := os.ReadFile(agent.config.checkpointFile()) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + if wire.Version != 2 { + t.Fatalf("workspace-set checkpoint version=%d; a V1 reader could silently resume only primary", wire.Version) + } + plan, _, planErr := taskcopy.ReadWorkspacePlan(cell.Cell{Root: place.Dir}) + if planErr != nil { + t.Fatalf("checkpoint transfer projection: %v", planErr) + } + if plan == nil || len(plan.Groups) != 1 || len(plan.Groups[0].Entries) != 2 || !plan.Groups[0].Required { + t.Fatal("checkpoint lost required-root closure") + } + + engine.mu.Lock() + spec := engine.spec + engine.mu.Unlock() + if !withinDir(place.Trees(), spec.Workspace) || filepath.Base(spec.Workspace) != "app" { + t.Fatalf("primary copy = %q", spec.Workspace) + } + if got := readFile(t, filepath.Join(spec.Workspace, "..", "library", "library.txt")); got != "relative dependency\n" { + t.Fatalf("sibling input = %q", got) + } + writeFile(t, filepath.Join(spec.Workspace, "private.txt"), "owned output\n") + if _, err := os.Stat(filepath.Join(app, "private.txt")); !os.IsNotExist(err) { + t.Fatalf("original changed: %v", err) + } + if spec.WorkspaceSet == nil || !strings.Contains(spec.WorkspaceSet.WorkerInstructions(), "library") { + t.Fatal("worker lacks sibling routing") + } + row, ok := runRowOf(agent.graph(), id) + if !ok || row.Copy == nil || row.Copy.URI == "" { + t.Fatal("primary copy not recorded") + } + + joined, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "cooperate on both roots", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: library, Slot: "library", Access: WorkspaceRead}, {Source: app, Slot: "app", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatalf("same declaration did not share live set: %v", err) + } + child, found := runRowOf(agent.graph(), joined) + if !found || child.Parent != id { + t.Fatal("cooperating task started independently") + } + if _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "change root authority", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceWrite}}}); err == nil { + t.Fatal("matching primary silently broadened secondary authority") + } +} + +func TestWorkspaceSetReopensAllCopiesFromDurableCheckpoint(t *testing.T) { + for _, damage := range []string{"missing", "relocated"} { + t.Run(damage, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), newTestRepo(t) + writeFile(t, filepath.Join(library, "library.txt"), "library input\n") + place := Place{Dir: t.TempDir(), Workspace: app} + config := func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + } + firstEngine := newBeltRunDouble("interrupted") + firstEngine.honoursStop = true + registerBeltRunEngine(t, firstEngine) + first, _ := newTestAgent(t, beltRunCompleter{text: "done"}, config) + id, _, _, err := first.StartTaskInWorkspaceSet(context.Background(), "resume both roots", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-firstEngine.entered + firstEngine.mu.Lock() + before := firstEngine.spec + firstEngine.mu.Unlock() + writeFile(t, filepath.Join(before.Workspace, "unfinished.txt"), "keep primary\n") + writeFile(t, filepath.Join(before.Workspace, "..", "library", "unfinished.txt"), "keep sibling\n") + if done := first.cutBeltRun(); done != nil { + <-done + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + secondEngine := newBeltRunDouble("resumed") + secondEngine.honoursStop = true + registerBeltRunEngine(t, secondEngine) + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, config) + select { + case <-secondEngine.entered: + case <-time.After(5 * time.Second): + t.Fatal("reopened Agent did not resume its workspace set") + } + t.Cleanup(func() { endBeltRun(t, second, secondEngine) }) + secondEngine.mu.Lock() + after := secondEngine.spec + secondEngine.mu.Unlock() + if after.Workspace != before.Workspace || after.WorkspaceURI != before.WorkspaceURI { + t.Fatal("reopen recarved primary copy") + } + if after.WorkspaceSet == nil || len(after.WorkspaceSet.Roots) != 2 { + t.Fatal("checkpoint lost the required sibling") + } + if got := readFile(t, filepath.Join(after.Workspace, "unfinished.txt")); got != "keep primary\n" { + t.Fatalf("primary work=%q", got) + } + if got := readFile(t, filepath.Join(after.Workspace, "..", "library", "unfinished.txt")); got != "keep sibling\n" { + t.Fatalf("sibling work=%q", got) + } + if row, ok := runRowOf(second.graph(), id); !ok || row.WorkspaceSet == nil { + t.Fatal("reopened row lost set metadata") + } + + // Losing a required sibling must not silently resume the surviving primary. + if done := second.cutBeltRun(); done != nil { + <-done + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + var missing, copyURI string + for _, use := range after.WorkspaceSet.Roots { + if use.Slot == "library" { + missing = use.Copy.Dir + copyURI = use.Copy.URI + } + } + if missing == "" { + t.Fatal("missing sibling fixture") + } + if damage == "missing" { + if err := os.RemoveAll(missing); err != nil { + t.Fatal(err) + } + } else { + dest := filepath.Join(place.Trees(), "relocated", "library") + if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(missing, dest); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Bind(copyURI, dest); err != nil { + t.Fatal(err) + } + } + refusedEngine := newBeltRunDouble("must not start") + refusedEngine.honoursStop = true + registerBeltRunEngine(t, refusedEngine) + refused, _ := newTestAgent(t, beltRunCompleter{text: "done"}, config) + t.Cleanup(func() { + if refusedEngine.didRun() { + endBeltRun(t, refused, refusedEngine) + } + }) + if _, err := refused.ContinueRun(context.Background(), id); err == nil { + t.Fatal("missing sibling was ignored") + } + nothingStarted(t, refused, refusedEngine) + }) + } +} + +func TestWorkspaceSetChecksHeldSecondaryBeforePreparingAnyCopy(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), t.TempDir() + holdFolder(t, library, "existing library work") + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("unexpected") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place = app, place }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "use held sibling", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceWrite}}}) + if err == nil { + <-engine.entered + endBeltRun(t, agent, engine) + t.Fatal("held secondary root was admitted") + } + if !strings.Contains(err.Error(), "busy") { + t.Fatalf("held-root refusal=%v", err) + } + entries, readErr := os.ReadDir(place.Trees()) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatal("copy preparation happened before all-root hold preflight") + } + nothingStarted(t, agent, engine) +} + +func TestWorkspaceSetRefusesAnotherConversationsOwnedCopy(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + foreignPlace := Place{Dir: t.TempDir()} + foreignCopy := filepath.Join(foreignPlace.Trees(), "1") + if err := os.MkdirAll(foreignCopy, 0700); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, foreignPlace.Dir) + if err != nil { + t.Fatal(err) + } + project, err := registry.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Register(workspaceidentity.Run, foreignCopy, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}); err != nil { + t.Fatal(err) + } + place := Place{Dir: t.TempDir()} + engine := newBeltRunDouble("unexpected") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place = foreignCopy, place }) + _, _, _, err = agent.StartTaskInWorkspaceSet(context.Background(), "copy foreign work", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: foreignCopy, Slot: "work", Access: WorkspaceWrite}}}) + if err == nil { + <-engine.entered + endBeltRun(t, agent, engine) + t.Fatal("foreign execution copy was silently treated as a new project") + } + nothingStarted(t, agent, engine) +} + +func TestWorkspaceSetStopKeepsEachRootWithoutLanding(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), newTestRepo(t) + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("interrupted") + engine.honoursStop = true + engine.early = func(workspace string) { + writeFile(t, filepath.Join(workspace, "unfinished.txt"), "app work\n") + writeFile(t, filepath.Join(workspace, "..", "library", "unfinished.txt"), "library work\n") + } + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "work on both roots", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite, Landing: app}, {Source: library, Slot: "library", Access: WorkspaceWrite, Landing: library}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + agent.beltMu.Lock() + over := agent.beltRun.over + agent.beltMu.Unlock() + if _, err := agent.Cancel("task:" + strconv.FormatUint(id, 10)); err != nil { + t.Fatal(err) + } + select { + case <-over: + case <-time.After(5 * time.Second): + t.Fatal("stop did not settle") + } + row, ok := runRowOf(agent.graph(), id) + if !ok || !row.Stopped || row.WorkspaceSet == nil { + t.Fatal("stopped set receipt missing") + } + for _, use := range row.WorkspaceSet.Roots { + if use.Outcome != mergeKept { + t.Errorf("root %s stop outcome=%q", use.Slot, use.Outcome) + } + if _, err := os.Stat(filepath.Join(use.Copy.Dir, "unfinished.txt")); err != nil { + t.Fatalf("private work lost: %v", err) + } + } + for _, source := range []string{app, library} { + if _, err := os.Stat(filepath.Join(source, "unfinished.txt")); !os.IsNotExist(err) { + t.Fatalf("stopped work landed in source: %v", err) + } + } +} + +func TestWorkspaceSetRejectsNonportableSlotsWithoutRenaming(t *testing.T) { + for _, slot := range []string{"APP", "café", "con", "trailing."} { + t.Run(slot, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, other := t.TempDir(), t.TempDir() + place := Place{Dir: t.TempDir()} + engine := newBeltRunDouble("unexpected") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place = app, place }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "portable sibling layout", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: other, Slot: slot, Access: WorkspaceRead}}}) + if err == nil { + <-engine.entered + endBeltRun(t, agent, engine) + t.Fatal("unsupported or colliding slot was admitted") + } + entries, readErr := os.ReadDir(place.Trees()) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatal(readErr) + } + if len(entries) > 0 { + t.Fatal("unsupported layout created private copies") + } + nothingStarted(t, agent, engine) + }) + } +} + +func TestWorkspaceSetQueuedCellReopensEveryDeclaredRootWithoutCarving(t *testing.T) { + littleMemoryHost(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), t.TempDir() + c, err := cell.Create(home.Dir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + place := Place{Dir: c.Root, Workspace: app} + if err := SaveMeta(place.Dir, Meta{Workspace: app}); err != nil { + t.Fatal(err) + } + profile := t.TempDir() + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace, config.Place, config.SessionFile = app, place, place.Transcript() + config.ProfileDir = profile + config.TaskMaxLoad = 0 + config.TaskMinFreeMB = 1 << 40 + }) + return agent + } + engine := newBeltRunDouble("must remain queued") + registerBeltRunEngine(t, engine) + first := open() + id, _, _, err := first.StartTaskInWorkspaceSet(context.Background(), "queued two-root work", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceRead}}}) + if err != nil { + t.Fatal(err) + } + first.beltMu.Lock() + old := first.beltRun + first.beltMu.Unlock() + if err := first.Close(); err != nil { + t.Fatal(err) + } + select { + case <-old.over: + case <-time.After(5 * time.Second): + t.Fatal("queued driver did not close") + } + second := open() + row, ok := runRowOf(second.graph(), id) + if !ok || row.PendingRun == nil || row.PendingRun.WorkspaceSet == nil { + t.Fatal("queued root declaration did not reopen") + } + sources := map[string]string{} + for _, root := range row.PendingRun.WorkspaceSet.Roots { + sources[root.Slot] = root.Source + } + if sources["app"] != canonicalPath(app) || sources["library"] != canonicalPath(library) { + t.Fatalf("queued root bindings lost across sealed checkpoint: %#v", sources) + } + if engine.didRun() { + t.Fatal("machine-held task started a worker") + } + entries, err := os.ReadDir(place.Trees()) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(entries) > 0 { + t.Fatal("queued reopen carved a workspace before admission") + } +} + +func TestWorkspaceSetPreparationFailureKeepsDurableIncompleteOwnership(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, other := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(app, "first.txt"), "first root bytes\n") + denied := filepath.Join(other, "denied.txt") + writeFile(t, denied, "second root input\n") + if err := os.Chmod(denied, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(denied, 0600) }) + if _, err := os.ReadFile(denied); err == nil { + t.Skip("this platform cannot enforce the synthetic read denial") + } + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("must not start") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = app, place, place.Transcript() }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "prepare both roots", WorkspaceSetRequest{Primary: "a", Roots: []WorkspaceRootRequest{{Source: app, Slot: "a", Access: WorkspaceWrite}, {Source: other, Slot: "z", Access: WorkspaceWrite}}}) + if err == nil { + <-engine.entered + endBeltRun(t, agent, engine) + t.Fatal("unreadable second root started a worker") + } + raw, readErr := os.ReadFile(agent.config.checkpointFile()) + if readErr != nil { + t.Fatalf("prepared first root has no durable incomplete receipt: %v", readErr) + } + var document struct { + Version int `json:"version"` + Runs []runRecord `json:"runs"` + } + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + if document.Version != 2 || len(document.Runs) != 1 || document.Runs[0].State != TaskFailed || document.Runs[0].WorkspaceSet == nil { + t.Fatal("incomplete preparation is not recorded as failed V2 work") + } + row := document.Runs[0] + if len(row.WorkspaceSet.Roots) != 2 { + t.Fatal("incomplete receipt lost the required root declaration") + } + first := row.WorkspaceSet.Roots[0].Copy + if first == nil || first.URI == "" { + t.Fatal("prepared first copy lost its ownership") + } + if got := readFile(t, filepath.Join(first.Dir, "first.txt")); got != "first root bytes\n" { + t.Fatalf("prepared bytes=%q", got) + } + if _, err := agent.ContinueRun(context.Background(), row.ID); err == nil { + t.Fatal("incomplete set resumed through ordinary continuation") + } + nothingStarted(t, agent, engine) + // Simulate a crash before the task checkpoint could name the prepared copy. + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(agent.config.checkpointFile()); err != nil { + t.Fatal(err) + } + reopened, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = app, place, place.Transcript() }) + kept, ok := runRowOf(reopened.graph(), row.ID) + if !ok || kept.State != TaskFailed || kept.WorkspaceSet == nil || kept.WorkspaceSet.Roots[0].Copy == nil || kept.WorkspaceSet.Roots[0].Copy.URI != first.URI { + t.Fatal("preparation journal did not recover incomplete ownership") + } + if _, err := reopened.ContinueRun(context.Background(), row.ID); err == nil { + t.Fatal("journal-only incomplete preparation resumed") + } + nothingStarted(t, reopened, engine) +} + +func TestWorkspaceSetModelProposalUsesOwnedSiblingCopies(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(library, "input.txt"), "sibling input\n") + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + args, err := json.Marshal(map[string]any{ + "title": "Use both roots", "summary": "Build in private sibling copies", "brief": "Read ../library/input.txt and write only private output", "deliverable": "private output", "acceptance": "both roots available", + "workspace_set": WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceRead}}}, + }) + if err != nil { + t.Fatal(err) + } + completer := &scriptedCompleter{steps: []step{toolStep("propose_task", string(args)), finalText("handed off")}} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + events, err := agent.Submit(context.Background(), "use the app and library in private sibling copies") + if err != nil { + t.Fatal(err) + } + collect(t, events) + select { + case <-engine.entered: + case <-time.After(2 * time.Second): + t.Fatal("model proposal never reached workspace-set worker") + } + t.Cleanup(func() { endBeltRun(t, agent, engine) }) + engine.mu.Lock() + spec := engine.spec + engine.mu.Unlock() + if spec.WorkspaceSet == nil || len(spec.WorkspaceSet.Roots) != 2 { + t.Fatal("model task lost declared roots") + } + if got := readFile(t, filepath.Join(spec.Workspace, "..", "library", "input.txt")); got != "sibling input\n" { + t.Fatal(got) + } +} + +func TestWorkspaceSetModelCannotAuthorizeLanding(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app := t.TempDir() + args, _ := json.Marshal(map[string]any{"title": "private task", "summary": "private task", "brief": "write a file", "deliverable": "output", "acceptance": "output exists", "workspace_set": WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite, Landing: app}}}}) + completer := &scriptedCompleter{steps: []step{toolStep("propose_task", string(args)), finalText("refused")}} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace = app + c.Place = Place{Dir: t.TempDir(), Workspace: app} + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + events, err := agent.Submit(context.Background(), "use a private copy") + if err != nil { + t.Fatal(err) + } + collect(t, events) + engine.mu.Lock() + started := engine.spec.Workspace != "" + engine.mu.Unlock() + if started { + t.Fatal("model granted itself borrowed-source landing authority") + } + if agent.graph().node(1) != nil { + t.Fatal("unsafe proposal was admitted") + } +} + +func TestWorkspaceSetCheckpointWriterWaitsForCaptureFence(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app := t.TempDir() + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + release, err := taskcopy.LockWorkspaceCheckpoint(context.Background(), cell.Cell{Root: place.Dir}) + if err != nil { + t.Fatal(err) + } + defer release() + arrived := make(chan struct{}, 1) + agent.graph().store.duringWrite = func() { + select { + case arrived <- struct{}{}: + default: + } + } + done := make(chan error, 1) + go func() { + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "private work", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}}}) + done <- err + }() + select { + case <-arrived: + case <-time.After(2 * time.Second): + t.Fatal("writer never reached checkpoint boundary") + } + select { + case err := <-done: + t.Fatalf("writer escaped held capture fence: %v", err) + case <-time.After(30 * time.Millisecond): + } + release() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("writer did not continue after capture released fence") + } + <-engine.entered + endBeltRun(t, agent, engine) +} + +func TestWorkspaceSetLandingRefusalKeepsPrivateWrites(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app := t.TempDir() + writeFile(t, filepath.Join(app, "base.txt"), "baseline\n") + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + engine.landing = RunLanding{Changed: []string{"private.txt"}, Refused: "verification refused landing"} + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "produce private output", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite, Landing: app}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + dir := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(dir, "private.txt"), "unverified output\n") + endBeltRun(t, agent, engine) + if _, err := os.Stat(filepath.Join(app, "private.txt")); !os.IsNotExist(err) { + t.Fatal("refused landing changed borrowed source") + } + row, ok := runRowOf(agent.graph(), id) + if !ok || row.WorkspaceSet.Roots[0].Outcome != mergeAborted { + t.Fatal("landing refusal reported as successful private retention") + } +} + +func TestWorkspaceSetUnchangedRootCompletesWithoutLandingFailure(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app := t.TempDir() + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + engine.landing = RunLanding{Refused: runNothingToLand} + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "read only", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceRead}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + endBeltRun(t, agent, engine) + row, _ := runRowOf(agent.graph(), id) + if row.WorkspaceSet.Roots[0].Outcome == mergeAborted { + t.Fatal("unchanged read root treated as landing failure") + } +} + +// The live path creates its mirror's Git repository after the native carve. +// Baselines must come from that admission, not from fixture-seeded task rows. +func TestPlainWorkspaceSetPersistsItsPreWorkerGitBaselines(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(app, "input.txt"), "original app\n") + writeFile(t, filepath.Join(library, "input.txt"), "original library\n") + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "produce outputs in two plain roots", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + t.Cleanup(func() { endBeltRun(t, agent, engine) }) + engine.mu.Lock() + set := engine.spec.WorkspaceSet.Clone() + engine.mu.Unlock() + selections := []taskcopy.WorkspaceResultSelection{} + for _, root := range set.Roots { + copy := root.Copy + if copy == nil || copy.CheckBase == "" { + t.Fatalf("fresh plain root %s has no pre-worker Git baseline", root.Slot) + } + if head, err := git(copy.Dir, "rev-parse", "HEAD"); err != nil || strings.TrimSpace(head) != copy.CheckBase { + t.Fatalf("root %s did not capture its admission HEAD: %v", root.Slot, err) + } + selections = append(selections, taskcopy.WorkspaceResultSelection{CopyURI: copy.URI, BaseCommit: copy.CheckBase}) + commitIn(t, copy.Dir, "completed.txt") + } + raw, err := os.ReadFile(place.Tasks()) + if err != nil { + t.Fatal(err) + } + var document struct { + Runs []struct { + WorkspaceSet *WorkspaceSet `json:"workspaceSet"` + } `json:"runs"` + } + if err = json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + if len(document.Runs) != 1 || document.Runs[0].WorkspaceSet == nil { + t.Fatal("actual task checkpoint lost workspace set") + } + for i, root := range document.Runs[0].WorkspaceSet.Roots { + if root.Copy.CheckBase != selections[i].BaseCommit { + t.Fatal("checkpoint replaced the pre-worker baseline after work") + } + } + c := cell.Cell{Root: place.Dir} + plan, _, err := taskcopy.ReadWorkspacePlan(c) + if err != nil || plan == nil { + t.Fatalf("actual task transfer projection: %v", err) + } + if err = (taskcopy.Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + outputs, err := taskcopy.RestoreCompletedWorkspaceResults(c, *plan, filepath.Join(t.TempDir(), "results"), selections) + if err != nil { + t.Fatalf("actual admitted baselines cannot preserve completed results: %v", err) + } + for _, selection := range selections { + dir := outputs[selection.CopyURI] + if got := readFile(t, filepath.Join(dir, "completed.txt")); got != "completed.txt\n" { + t.Fatalf("completed result %q", got) + } + if _, err := os.Stat(filepath.Join(dir, "input.txt")); !os.IsNotExist(err) { + t.Fatal("unchanged input was duplicated as a completed output") + } + } +} + +func TestFallbackWorkspaceSetPreservesUserDirectoryMetadata(t *testing.T) { + testWorkspaceSetDirectoryMetadata(t, false) +} +func TestRealNativeWorkspaceSetPreservesUserDirectoryMetadata(t *testing.T) { + testWorkspaceSetDirectoryMetadata(t, true) +} +func testWorkspaceSetDirectoryMetadata(t *testing.T, native bool) { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + wantRung := GroundRungCopy + if native { + isolatedRetirementFurrow(t) + wantRung = GroundRungUniverse + } else { + installRefusingFurrow(t) + } + app, library := t.TempDir(), t.TempDir() + for _, root := range []string{app, library} { + if err := os.MkdirAll(filepath.Join(root, "user-dir", "empty"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(root, "user-dir"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "user-dir", "private.txt"), []byte("preserve user metadata\n"), 0640); err != nil { + t.Fatal(err) + } + } + place := Place{Dir: t.TempDir(), Workspace: app} + engine := newBeltRunDouble("done") + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = app, place + c.SessionFile = filepath.Join(place.Dir, placeTranscript) + }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "inspect original directory metadata", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite}, {Source: library, Slot: "library", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + t.Cleanup(func() { endBeltRun(t, agent, engine) }) + engine.mu.Lock() + set := engine.spec.WorkspaceSet.Clone() + engine.mu.Unlock() + for _, root := range set.Roots { + if root.Copy.Rung != wantRung { + t.Fatalf("%s admission used %s, required %s", root.Slot, root.Copy.Rung, wantRung) + } + for rel, want := range map[string]os.FileMode{"user-dir": 0750, "user-dir/empty": 0700, "user-dir/private.txt": 0640} { + info, err := os.Lstat(filepath.Join(root.Copy.Dir, rel)) + if err != nil { + t.Errorf("%s %s missing: %v", root.Slot, rel, err) + continue + } + if got := info.Mode().Perm(); got != want { + t.Errorf("%s %s mode=%04o want=%04o", root.Slot, rel, got, want) + } + } + } +} diff --git a/internal/session/workspace_transfer.go b/internal/session/workspace_transfer.go new file mode 100644 index 0000000000..57bd14fbe9 --- /dev/null +++ b/internal/session/workspace_transfer.go @@ -0,0 +1,700 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "sort" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// TaskWorkspaceTransferPlan validates a complete incoming checkpoint and its +// carrier without registering identities, preparing files or changing sources. +func TaskWorkspaceTransferPlan(place Place) (*taskcopy.WorkspacePlan, error) { + _, plan, err := readTaskWorkspaceTransfer(place) + if err != nil || plan == nil { + return plan, err + } + if err := taskcopy.ValidateWorkspace(cell.Cell{Root: place.Dir}, *plan); err != nil { + return nil, err + } + return plan, nil +} + +func readTaskWorkspaceTransfer(place Place) (taskDocument, *taskcopy.WorkspacePlan, error) { + raw, err := readWorkspaceCheckpointBytes(place.Tasks()) + if errors.Is(err, os.ErrNotExist) { + return taskDocument{}, nil, nil + } + if err != nil { + return taskDocument{}, nil, err + } + if len(raw) > taskcopy.WorkspaceCheckpointLimit { + return taskDocument{}, nil, errors.New("workspace checkpoint exceeds transfer bound") + } + document, err := decodeTasks(raw) + if err != nil { + return taskDocument{}, nil, err + } + plan, digest, err := taskcopy.ReadWorkspacePlan(cell.Cell{Root: place.Dir}) + if err != nil { + return taskDocument{}, nil, err + } + if plan == nil { + return document, nil, nil + } + sum := sha256.Sum256(raw) + if digest != hex.EncodeToString(sum[:]) { + return taskDocument{}, nil, errors.New("workspace checkpoint changed during transfer validation") + } + resolved := document.mapPaths(codecFor(place.Dir, place.Workspace).resolve) + expected, err := workspaceTransferProjectionFor(resolved, place, plan.ChatURI) + if err != nil { + return taskDocument{}, nil, err + } + if !reflect.DeepEqual(expected, plan) { + return taskDocument{}, nil, errors.New("workspace transfer projection disagrees with authoritative task records") + } + return resolved, plan, nil +} + +// ValidateTaskWorkspaceSetBindings checks every root before any receiver file or +// identity changes. A supplied path is explicit receiver authority; source paths +// from the portable checkpoint never supply a binding. +func ValidateTaskWorkspaceSetBindings(place Place, bindings map[string]string) error { + return ValidateTaskWorkspaceSetBindingsForDestination(place, place.Dir, bindings) +} + +// ValidateTaskWorkspaceSetBindingsForDestination validates a staged checkpoint +// against the private storage it will replace before takeover acquires a lease. +func ValidateTaskWorkspaceSetBindingsForDestination(place Place, destination string, bindings map[string]string) error { + plan, err := TaskWorkspaceTransferPlan(place) + if err != nil || plan == nil { + return err + } + _, err = workspaceSetTransferTargets(place, *plan, bindings, destination) + return err +} + +// WorkspaceRootBinding is a source identity requiring the person's local folder +// choice. Label describes its declared layout, never a suggested local path. +type WorkspaceRootBinding struct { + URI string + Label string +} + +// WorkspaceBindingsRequired leaves the incoming layout unadmitted until the +// person selects every missing source. Mapping grants no landing permission. +type WorkspaceBindingsRequired struct{ Roots []WorkspaceRootBinding } + +func (e *WorkspaceBindingsRequired) Error() string { + return "choose local folders for this chat's working roots" +} + +func workspaceSetTransferTargets(place Place, plan taskcopy.WorkspacePlan, bindings map[string]string, destination string) (map[string]string, error) { + meta, err := LoadMeta(place.Dir) + if err != nil { + return nil, err + } + if meta.ChatURI != "" && meta.ChatURI != plan.ChatURI { + return nil, errors.New("incoming workspace chat identity conflicts with receiver metadata") + } + type resolvedIdentity struct { + record workspaceidentity.Record + err error + } + knownURIs := map[string]resolvedIdentity{} + resolve := func(uri string) (workspaceidentity.Record, error) { + if cached, ok := knownURIs[uri]; ok { + return cached.record, cached.err + } + record, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), uri) + knownURIs[uri] = resolvedIdentity{record, err} + return record, err + } + chat, chatErr := resolve(plan.ChatURI) + if chatErr == nil && chat.Role != workspaceidentity.Chat { + return nil, errors.New("incoming chat identity has an incompatible local role") + } + if chatErr != nil && !errors.Is(chatErr, workspaceidentity.ErrUnknown) { + return nil, chatErr + } + targets := map[string]string{} + copyURIs := map[string]bool{} + external := map[string]bool{} + for _, group := range plan.Groups { + if group.Required && group.Incomplete { + return nil, errors.New("required workspace set is incomplete and cannot resume on another machine") + } + for _, entry := range group.Entries { + if entry.CopyURI != "" { + copyURIs[entry.CopyURI] = true + targets[entry.CopyURI] = filepath.Join(place.Trees(), filepath.FromSlash(entry.Layout)) + } + } + } + for _, group := range plan.Groups { + for _, entry := range group.Entries { + if entry.CopyURI == "" { + continue + } + if copyURIs[entry.ProjectURI] { + return nil, errors.New("workspace project link names a run copy") + } + existing, err := resolve(entry.CopyURI) + if err == nil && (existing.Role != workspaceidentity.Run || existing.OwnerURI != plan.ChatURI || existing.ProjectURI != entry.ProjectURI) { + return nil, errors.New("incoming copy identity conflicts with existing role or ownership") + } + if err != nil && !errors.Is(err, workspaceidentity.ErrUnknown) { + return nil, err + } + } + } + for _, group := range plan.Groups { + for _, entry := range group.Entries { + for _, uri := range []string{entry.ProjectURI, entry.RootURI, entry.GroundURI} { + if uri != "" && !copyURIs[uri] { + external[uri] = true + } + } + } + } + for uri := range bindings { + if !external[uri] { + return nil, errors.New("workspace binding does not name an incoming external root") + } + } + missing := &WorkspaceBindingsRequired{} + for uri := range external { + path := bindings[uri] + known, lookupErr := resolve(uri) + if lookupErr == nil { + if known.Role != workspaceidentity.Project { + return nil, errors.New("workspace root binding has an incompatible role") + } + if path != "" && canonicalPath(path) != canonicalPath(known.Path) { + return nil, errors.New("workspace root already has a different admitted local binding") + } + path = known.Path + } else if !errors.Is(lookupErr, workspaceidentity.ErrUnknown) { + return nil, lookupErr + } + if path == "" { + label := uri + for _, group := range plan.Groups { + for _, entry := range group.Entries { + if entry.ProjectURI == uri || entry.RootURI == uri || entry.GroundURI == uri { + label = entry.Layout + break + } + } + } + missing.Roots = append(missing.Roots, WorkspaceRootBinding{URI: uri, Label: label}) + continue + } + if !filepath.IsAbs(path) || !noSymlinkAncestors(path) { + return nil, errors.New("workspace binding must be an existing absolute folder without symlink ancestors") + } + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return nil, errors.New("workspace root binding is unavailable") + } + path = canonicalPath(path) + if withinDir(place.Dir, path) || withinDir(path, place.Dir) || withinDir(destination, path) || withinDir(path, destination) { + return nil, errors.New("workspace binding overlaps private receiver storage") + } + if refusal := programHoldRefusal(path); refusal != "" { + return nil, errors.New(refusal) + } + targets[uri] = path + } + if len(missing.Roots) > 0 { + sort.Slice(missing.Roots, func(i, j int) bool { + if missing.Roots[i].Label == missing.Roots[j].Label { + return missing.Roots[i].URI < missing.Roots[j].URI + } + return missing.Roots[i].Label < missing.Roots[j].Label + }) + return nil, missing + } + return targets, nil +} + +// RestoreTaskWorkspaceSets keeps the native whole-layout receipt fenced until +// all root identities are admitted. Landing permissions never travel with data. +func RestoreTaskWorkspaceSets(ctx context.Context, place Place, bindings map[string]string) ([]string, error) { + release, err := taskcopy.LockWorkspaceCheckpoint(ctx, cell.Cell{Root: place.Dir}) + if err != nil { + return nil, err + } + defer release() + if recovered, present, err := recoverWorkspaceFinalization(place); present || err != nil { + return recovered, err + } + + if err := ctx.Err(); err != nil { + return nil, err + } + document, plan, err := readTaskWorkspaceTransfer(place) + if err != nil || plan == nil { + return nil, err + } + if err := taskcopy.ValidateWorkspace(cell.Cell{Root: place.Dir}, *plan); err != nil { + if readyErr := validateAdmittedWorkspaceTransfer(place); readyErr != nil { + return nil, err + } + if _, bindingErr := workspaceSetTransferTargets(place, *plan, bindings, place.Dir); bindingErr != nil { + return nil, bindingErr + } + seen := map[string]bool{} + var layouts []string + for _, group := range plan.Groups { + for _, entry := range group.Entries { + if entry.CopyURI != "" && !seen[entry.Layout] { + seen[entry.Layout] = true + layouts = append(layouts, entry.Layout) + } + } + } + sort.Strings(layouts) + return layouts, nil + } + targets, err := workspaceSetTransferTargets(place, *plan, bindings, place.Dir) + if err != nil { + return nil, err + } + restore, err := (taskcopy.Carry{}).PrepareWorkspace(cell.Cell{Root: place.Dir}, *plan) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + staged, err := restore.Promote() + if err != nil { + return nil, err + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + return nil, err + } + if _, err = registry.Adopt(workspaceidentity.Record{URI: plan.ChatURI, Role: workspaceidentity.Chat}, canonicalPath(place.Dir), workspaceidentity.Links{}); err != nil { + return nil, err + } + copies := map[string]taskcopy.WorkspaceEntry{} + for _, binding := range staged { + copies[binding.Entry.CopyURI] = binding.Entry + targets[binding.Entry.CopyURI] = binding.Path + } + external := map[string]bool{} + for _, group := range plan.Groups { + for _, entry := range group.Entries { + for _, uri := range []string{entry.ProjectURI, entry.RootURI, entry.GroundURI} { + if uri != "" { + if _, copy := copies[uri]; !copy { + external[uri] = true + } + } + } + } + } + for uri := range external { + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err = registry.Adopt(workspaceidentity.Record{URI: uri, Role: workspaceidentity.Project}, targets[uri], workspaceidentity.Links{}); err != nil { + return nil, err + } + } + admitted := map[string]bool{} + for uri := range external { + admitted[uri] = true + } + for len(admitted) < len(external)+len(copies) { + progress := false + for uri, entry := range copies { + if admitted[uri] || !admitted[entry.ProjectURI] || !admitted[entry.RootURI] || !admitted[entry.GroundURI] { + continue + } + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err = registry.Adopt(workspaceidentity.Record{URI: uri, Role: workspaceidentity.Run, OwnerURI: plan.ChatURI, ProjectURI: entry.ProjectURI}, targets[uri], workspaceidentity.Links{OwnerURI: plan.ChatURI, ProjectURI: entry.ProjectURI}); err != nil { + return nil, err + } + admitted[uri] = true + progress = true + } + if !progress { + return nil, errors.New("workspace parent identity closure cannot be admitted") + } + } + for i := range document.Runs { + row := &document.Runs[i] + if row.WorkspaceSet == nil { + continue + } + for j := range row.WorkspaceSet.Roots { + use := &row.WorkspaceSet.Roots[j] + use.Landing = "" + copy := use.Copy + if copy == nil { + continue + } + entry, ok := copies[copy.URI] + if !ok { + return nil, errors.New("workspace copy was omitted from restored closure") + } + copy.Dir, copy.Ground = targets[copy.URI], targets[entry.GroundURI] + if copy.Root != "" { + copy.Root = targets[entry.RootURI] + } + if use.Slot == row.WorkspaceSet.Primary { + primary := *copy + row.Copy = &primary + } + } + } + encoded, err := encodeTaskCheckpoint(document, place.Tasks()) + if err != nil { + return nil, err + } + encoded = append(encoded, '\n') + original, err := readWorkspaceCheckpointBytes(place.Tasks()) + if err != nil { + return nil, err + } + var layouts []string + for _, entry := range copies { + layouts = append(layouts, entry.Layout) + } + sort.Strings(layouts) + receipt := workspaceFinalization{Version: 1, ChatURI: plan.ChatURI, IncomingSHA: workspaceBytesSHA(original), ExpectedSHA: workspaceBytesSHA(encoded), Checkpoint: encoded, Layouts: layouts} + raw, err := json.Marshal(receipt) + if err != nil { + return nil, err + } + if err := writeWorkspaceDurable(workspaceFinalizationPath(place), raw, 0600); err != nil { + return nil, err + } + if err = restore.Acknowledge(); err != nil { + return nil, err + } + if err = finishWorkspaceFinalization(place, receipt); err != nil { + return nil, err + } + return layouts, nil +} + +func noSymlinkAncestors(path string) bool { + for { + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return false + } + next := filepath.Dir(path) + if next == path { + return true + } + path = next + } +} + +// TaskWorkspaceForkResults selects only settled result copies. It does not +// duplicate active work, task stores or authority into an independent chat. +func TaskWorkspaceForkResults(place Place) ([]taskcopy.WorkspaceResultSelection, error) { + document, plan, err := readTaskWorkspaceTransfer(place) + if err != nil || plan == nil { + return nil, err + } + if err := taskcopy.ValidateWorkspace(cell.Cell{Root: place.Dir}, *plan); err != nil { + return nil, err + } + selected := map[string]string{} + for _, row := range document.Runs { + if !row.State.settled() || row.WorkspaceSet == nil { + continue + } + for _, use := range row.WorkspaceSet.Roots { + if use.Copy == nil { + continue + } + copy := use.Copy + if prior, ok := selected[copy.URI]; ok && prior != copy.CheckBase { + return nil, errors.New("settled workspace copy has conflicting result baselines") + } + selected[copy.URI] = copy.CheckBase + } + } + var result []taskcopy.WorkspaceResultSelection + for uri, base := range selected { + result = append(result, taskcopy.WorkspaceResultSelection{CopyURI: uri, BaseCommit: base}) + } + sort.Slice(result, func(i, j int) bool { return result[i].CopyURI < result[j].CopyURI }) + return result, nil +} + +func readWorkspaceCheckpointBytes(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > taskcopy.WorkspaceCheckpointLimit { + return nil, errors.New("workspace checkpoint is not a bounded regular file") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + opened, err := file.Stat() + if err != nil { + return nil, err + } + if !os.SameFile(info, opened) { + return nil, errors.New("workspace checkpoint changed before opening") + } + raw, err := io.ReadAll(io.LimitReader(file, taskcopy.WorkspaceCheckpointLimit+1)) + if err != nil { + return nil, err + } + if len(raw) > taskcopy.WorkspaceCheckpointLimit { + return nil, errors.New("workspace checkpoint exceeds transfer bound") + } + return raw, nil +} + +func validateAdmittedWorkspaceTransfer(place Place) error { + if err := workspaceFinalizationPending(place); err != nil { + return err + } + _, plan, err := readTaskWorkspaceTransfer(place) + if err != nil { + return err + } + if plan == nil { + return errors.New("workspace transfer projection is missing") + } + if err := taskcopy.WorkspaceReady(cell.Cell{Root: place.Dir}); err != nil { + return err + } + if _, err := workspaceSetTransferTargets(place, *plan, nil, place.Dir); err != nil { + return err + } + for _, group := range plan.Groups { + for _, entry := range group.Entries { + if entry.CopyURI == "" { + continue + } + actual, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), entry.CopyURI) + if err != nil { + return err + } + expected := canonicalPath(filepath.Join(place.Trees(), filepath.FromSlash(entry.Layout))) + if actual.Role != workspaceidentity.Run || actual.OwnerURI != plan.ChatURI || actual.ProjectURI != entry.ProjectURI || canonicalPath(actual.Path) != expected { + return errors.New("workspace copy has not been admitted at its declared receiver layout") + } + } + } + return nil +} + +// A finalization receipt is private receiver machinery, never part of the +// portable checkpoint. Its exact hashes fence interruption across acknowledgement. +type workspaceFinalization struct { + ChatURI string `json:"chat_uri"` + Version int `json:"version"` + IncomingSHA string `json:"incoming_sha256"` + ExpectedSHA string `json:"expected_sha256"` + Checkpoint []byte `json:"checkpoint"` + Layouts []string `json:"layouts"` +} + +func workspaceFinalizationPath(place Place) string { + return filepath.Join(place.Dir, ".codeaf", "workspace", "session-finalization.json") +} +func workspaceBytesSHA(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} +func workspaceFinalizationPending(place Place) error { + if !WorkspaceOwnershipEnabled(place) { + return nil + } + _, err := os.Lstat(workspaceFinalizationPath(place)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return errors.New("workspace transfer finalization is incomplete; retain its private recovery receipt and retry the transfer before starting work") +} +func recoverWorkspaceFinalization(place Place) ([]string, bool, error) { + path := workspaceFinalizationPath(place) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, true, err + } + if !info.Mode().IsRegular() || info.Size() > 24<<20 { + return nil, true, errors.New("workspace finalization receipt is not a bounded regular file") + } + file, err := os.Open(path) + if err != nil { + return nil, true, err + } + raw, readErr := io.ReadAll(io.LimitReader(file, (24<<20)+1)) + closeErr := file.Close() + if readErr != nil { + return nil, true, readErr + } + if closeErr != nil { + return nil, true, closeErr + } + if len(raw) > 24<<20 { + return nil, true, errors.New("workspace finalization receipt exceeds its bound") + } + var receipt workspaceFinalization + if err := json.Unmarshal(raw, &receipt); err != nil { + return nil, true, err + } + if receipt.Version != 1 || len(receipt.IncomingSHA) != 64 || receipt.ExpectedSHA != workspaceBytesSHA(receipt.Checkpoint) || len(receipt.Checkpoint) > taskcopy.WorkspaceCheckpointLimit { + return nil, true, errors.New("workspace finalization receipt checksum is invalid") + } + intended, err := decodeTasks(receipt.Checkpoint) + if err != nil { + return nil, true, err + } + if intended.WorkspaceTransfer == nil || intended.WorkspaceTransfer.ChatURI != receipt.ChatURI { + return nil, true, errors.New("workspace finalization chat identity does not match its checkpoint") + } + current, err := readWorkspaceCheckpointBytes(place.Tasks()) + if err != nil { + return nil, true, err + } + digest := workspaceBytesSHA(current) + if digest != receipt.IncomingSHA && digest != receipt.ExpectedSHA { + return nil, true, errors.New("workspace checkpoint changed outside its retained transfer receipt; preserved for review") + } + if digest == receipt.IncomingSHA { + _, plan, err := readTaskWorkspaceTransfer(place) + if err != nil || plan == nil { + return nil, true, errors.New("retained workspace transfer no longer has its original checkpoint") + } + restore, err := (taskcopy.Carry{}).PrepareWorkspace(cell.Cell{Root: place.Dir}, *plan) + if err != nil { + return nil, true, err + } + if _, err = restore.Promote(); err != nil { + return nil, true, err + } + if err = restore.Acknowledge(); err != nil { + return nil, true, err + } + } else if err := taskcopy.WorkspaceReady(cell.Cell{Root: place.Dir}); err != nil { + return nil, true, err + } + if err := finishWorkspaceFinalization(place, receipt); err != nil { + return nil, true, err + } + return receipt.Layouts, true, nil +} +func finishWorkspaceFinalization(place Place, receipt workspaceFinalization) error { + if err := writeWorkspaceDurable(place.Tasks(), receipt.Checkpoint, 0600); err != nil { + return err + } + meta, err := LoadMeta(place.Dir) + if err != nil { + return err + } + if meta.ChatURI != "" && meta.ChatURI != receipt.ChatURI { + return errors.New("workspace finalization cannot replace an existing conversation identity") + } + meta.ChatURI = receipt.ChatURI + if meta.ID == "" { + meta.ID = place.ID() + } + if meta.Workspace == "" { + meta.Workspace = place.Workspace + } + if err := SaveMeta(place.Dir, meta); err != nil { + return err + } + for _, path := range []string{filepath.Join(place.Dir, placeMeta), layoutOf(place.Dir).metaTruth(place.Dir)} { + if path == "" { + continue + } + file, err := os.Open(path) + if err != nil { + return err + } + syncErr := file.Sync() + closeErr := file.Close() + if syncErr != nil { + return syncErr + } + if closeErr != nil { + return closeErr + } + } + if err := syncWorkspaceDirectory(place.Dir); err != nil { + return err + } + if err := syncWorkspaceDirectory(filepath.Dir(place.Tasks())); err != nil { + return err + } + if err := os.Remove(workspaceFinalizationPath(place)); err != nil { + return err + } + return syncWorkspaceDirectory(filepath.Dir(workspaceFinalizationPath(place))) +} +func writeWorkspaceDurable(path string, raw []byte, mode os.FileMode) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0700); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".workspace-finalize-*") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if err = file.Chmod(mode); err == nil { + _, err = file.Write(raw) + } + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if err := os.Rename(name, path); err != nil { + return err + } + return syncWorkspaceDirectory(directory) +} +func syncWorkspaceDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/internal/session/workspace_transfer_preflight.go b/internal/session/workspace_transfer_preflight.go new file mode 100644 index 0000000000..7f30143d01 --- /dev/null +++ b/internal/session/workspace_transfer_preflight.go @@ -0,0 +1,126 @@ +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// ValidateTaskRestoreRoots rejects unbound root sets before any task repository +// or identity is mutated. primary is the receiver's intended folder, never a +// source machine path borrowed from the incoming metadata. +func ValidateTaskRestoreRoots(place Place, primary string) error { + return validateTransferRoots(place, primary, false) +} + +// ValidateForkTaskRoots proves one source root before detaching task history. +// A fork does not rebind that root: only its historical output files are opened. +func ValidateForkTaskRoots(place Place, primary string) error { + return validateTransferRoots(place, primary, true) +} + +func validateTransferRoots(place Place, primary string, fork bool) error { + if !WorkspaceOwnershipEnabled(place) { + return nil + } + raw, err := os.ReadFile(place.Tasks()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + document, err := decodeTasks(raw) + if err != nil { + return err + } + // The singleton transfer contract cannot represent execution layouts. Refuse + // before parent planning or any receiver-side import, including queued work + // which has not acquired a primary Copy yet. + for _, run := range document.Runs { + if run.WorkspaceSet != nil || run.PendingRun != nil && run.PendingRun.WorkspaceSet != nil { + var checked error + if fork { + _, checked = TaskWorkspaceTransferPlan(place) + } else { + checked = validateAdmittedWorkspaceTransfer(place) + } + if checked == nil { + return nil + } + return unboundTransferRoots(fmt.Errorf("this workspace-set layout needs per-root transfer bindings; the singleton restore path cannot restore it: %w", checked)) + } + } + // Resolve portable fields against the already chosen receiving folder. + document = document.mapPaths(codecFor(place.Dir, primary).resolve) + if _, err := incomingRestoreParents(place, document); err != nil { + return err + } + roots, err := transferredRootSet(place, document) + if err != nil { + return unboundTransferRoots(err) + } + return validateKnownTransferRoots(roots, primary, fork) +} + +func validateKnownTransferRoots(roots map[string]bool, primary string, fork bool) error { + knownPath := "" + for root := range roots { + record, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), root) + if errors.Is(err, workspaceidentity.ErrUnknown) && len(roots) == 1 { + return nil + } + if err != nil { + return unboundTransferRoots(err) + } + if record.Role != workspaceidentity.Project { + return unboundTransferRoots(errors.New("root identity does not name a working root")) + } + path := canonicalPath(record.Path) + if knownPath != "" && knownPath != path { + return unboundTransferRoots(errors.New("incoming roots have different local bindings")) + } + if !fork && path != canonicalPath(primary) { + return unboundTransferRoots(errors.New("root is not bound to the receiving folder")) + } + knownPath = path + } + return nil +} + +func transferredRootSet(place Place, document taskDocument) (map[string]bool, error) { + roots := map[string]bool{} + names := map[string]bool{} + meta, err := LoadMeta(place.Dir) + if err != nil { + return nil, err + } + for _, copy := range incomingTaskCopyRecords(document) { + if copy == nil || copy.URI == "" { + continue + } + if err := validateIncomingRestoreCopy(place, copy, meta.ChatURI); err != nil { + return nil, err + } + roots[copy.ProjectURI] = true + names[filepath.Base(copy.Dir)] = true + } + if len(roots) > 0 { + for _, name := range taskcopy.Carried(cell.Cell{ID: place.ID(), Root: place.Dir}) { + if !names[name] { + return nil, errors.New("a carried task has no structured working-root binding") + } + } + } + return roots, nil +} + +func unboundTransferRoots(cause error) error { + return fmt.Errorf("these task working roots need explicit receiving bindings before they can be restored; the incoming archives are preserved and no task was restored: %w", cause) +} diff --git a/internal/session/workspace_transfer_preflight_test.go b/internal/session/workspace_transfer_preflight_test.go new file mode 100644 index 0000000000..0e4ff71b3e --- /dev/null +++ b/internal/session/workspace_transfer_preflight_test.go @@ -0,0 +1,114 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestTransferRootPreflightRequiresExistingAliasesWithoutMutatingRegistry(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := newPlace(t, true) + if err := os.MkdirAll(filepath.Join(place.Dir, ".cell"), 0700); err != nil { + t.Fatal(err) + } + primary := t.TempDir() + uri := func(id string) string { return "codeaf-workspace://11111111111111111111111111111111/" + id } + first := uri("22222222222222222222222222222222") + second := uri("33333333333333333333333333333333") + save := func(roots ...string) { + t.Helper() + document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: uint64(len(roots))} + for index, root := range roots { + id := uint64(index + 1) + document.Runs = append(document.Runs, runRecord{ID: id, State: TaskDone, Copy: &TaskCopyRecord{URI: uri([]string{"44444444444444444444444444444444", "55555555555555555555555555555555"}[index]), ChatURI: uri("66666666666666666666666666666666"), ProjectURI: root, GroundURI: root, Ground: primary, Dir: filepath.Join(place.Trees(), []string{"1", "2"}[index]), Mode: TaskModeMirror}}) + } + raw, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(place.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + } + save(first) + if err := ValidateTaskRestoreRoots(place, primary); err != nil { + t.Fatalf("single unknown primary rejected: %v", err) + } + save(first, second) + if err := ValidateTaskRestoreRoots(place, primary); err == nil { + t.Fatal("two unknown roots silently merged") + } + registryPath := home.Join("workspace-identities") + if _, err := os.Stat(registryPath); !os.IsNotExist(err) { + t.Fatal("preflight created a registry to reject unknown roots") + } + registry, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + canonical, err := registry.Register(workspaceidentity.Project, primary) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Adopt(workspaceidentity.Record{URI: first, Role: workspaceidentity.Project}, primary, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + save(canonical.URI, first) + before, err := os.ReadFile(filepath.Join(registryPath, "registry.sqlite")) + if err != nil { + t.Fatal(err) + } + if err := ValidateTaskRestoreRoots(place, primary); err != nil { + t.Fatalf("explicit aliases rejected: %v", err) + } + if err := ValidateTaskRestoreRoots(place, t.TempDir()); err == nil { + t.Fatal("known aliases reassigned to a different receiving folder") + } + if err := ValidateForkTaskRoots(place, t.TempDir()); err != nil { + t.Fatalf("historical fork could not recognize existing source aliases: %v", err) + } + after, err := os.ReadFile(filepath.Join(registryPath, "registry.sqlite")) + if err != nil || string(after) != string(before) { + t.Fatal("preflight changed admitted root bindings") + } + stray := filepath.Join(place.Dir, ".cell", "trees", "3") + if err := os.MkdirAll(stray, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stray, "tree.json"), []byte(`{"head":"fixture"}`), 0600); err != nil { + t.Fatal(err) + } + if err := ValidateTaskRestoreRoots(place, primary); err == nil { + t.Fatal("unlisted carried task silently assigned to primary root") + } + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + if err := ValidateTaskRestoreRoots(place, primary); err != nil { + t.Fatal("ordinary profiles changed their legacy behavior") + } +} + +func TestTransferRootPreflightRejectsNewWorkspaceSetBeforeLegacyRestore(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := newPlace(t, true) + document := taskDocument{Type: taskDocumentType, Version: taskWorkspaceSetFileVersion, Seq: 1, Runs: []runRecord{ + {ID: 1, State: TaskQueued, PendingRun: &PendingRunRecord{Brief: "fixture", WorkspaceSet: &WorkspaceSetRequest{Primary: "a", Roots: []WorkspaceRootRequest{{Source: "/fixture/a", Slot: "a", Access: WorkspaceWrite}, {Source: "/fixture/b", Slot: "b", Access: WorkspaceRead}}}}}, + }} + raw, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(place.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if err := ValidateTaskRestoreRoots(place, t.TempDir()); err == nil || !strings.Contains(err.Error(), "singleton restore path") { + t.Fatalf("pending workspace set bypassed the singleton transfer gate: %v", err) + } +} diff --git a/internal/session/workspace_transfer_test.go b/internal/session/workspace_transfer_test.go new file mode 100644 index 0000000000..e913d949aa --- /dev/null +++ b/internal/session/workspace_transfer_test.go @@ -0,0 +1,339 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// This module fixture supplies the closed source plan DB explicitly. Production +// relay derives its plan index separately; this is not a relay acceptance test. +func TestWorkspaceSetTransferKeepsAllRootsWithAndWithoutDerivedPlanDatabase(t *testing.T) { + for _, withPlanDB := range []bool{false, true} { + name := "portable-state-only" + if withPlanDB { + name = "explicit-plan-database-module-fixture" + } + t.Run(name, func(t *testing.T) { testWorkspaceSetTransfer(t, withPlanDB, false, false) }) + } +} + +func TestWorkspaceSetTransferFinalizationRetriesDeniedCheckpointWrite(t *testing.T) { + testWorkspaceSetTransfer(t, false, true, false) +} + +func TestWorkspaceSetTransferRefusesKnownForeignCopyBeforeMutation(t *testing.T) { + testWorkspaceSetTransfer(t, false, false, true) +} + +func testWorkspaceSetTransfer(t *testing.T, withPlanDB, denyWrite, foreignCopy bool) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + app, library := t.TempDir(), newTestRepo(t) + writeFile(t, filepath.Join(app, "app.txt"), "../library/input.txt\n") + writeFile(t, filepath.Join(library, "input.txt"), "dependency\n") + source := Place{Dir: t.TempDir(), Workspace: app} + if err := os.Mkdir(filepath.Join(source.Dir, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + engine := newBeltRunDouble("done") + engine.honoursStop = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place = app, source; c.SessionFile = source.Transcript() }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "write both", WorkspaceSetRequest{Primary: "app", Roots: []WorkspaceRootRequest{{Source: app, Slot: "app", Access: WorkspaceWrite, Landing: app}, {Source: library, Slot: "library", Access: WorkspaceRead}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + cwd := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(cwd, "unfinished.txt"), "retained bytes\n") + if done := agent.cutBeltRun(); done != nil { + <-done + } + agent.Close() + if err := (taskcopy.Carry{}).Compose(cell.Cell{Root: source.Dir}); err != nil { + t.Fatal(err) + } + plan, _, err := taskcopy.ReadWorkspacePlan(cell.Cell{Root: source.Dir}) + if err != nil { + t.Fatal(err) + } + receiver := Place{Dir: t.TempDir(), Workspace: t.TempDir()} + if err := os.CopyFS(filepath.Join(receiver.Dir, cell.StateDir), os.DirFS(filepath.Join(source.Dir, cell.StateDir))); err != nil { + t.Fatal(err) + } + if withPlanDB { + planDB, err := os.ReadFile(filepath.Join(source.Dir, planStoreFilename)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(receiver.Dir, planStoreFilename), planDB, 0600); err != nil { + t.Fatal(err) + } + } + // An older portable chat may not have stamped its chat URI into metadata. + if truth := layoutOf(receiver.Dir).metaTruth(receiver.Dir); truth != "" { + if raw, err := os.ReadFile(truth); err == nil { + var metadata map[string]any + if err := json.Unmarshal(raw, &metadata); err != nil { + t.Fatal(err) + } + delete(metadata, "chat_uri") + updated, _ := json.Marshal(metadata) + if err := os.WriteFile(truth, updated, 0600); err != nil { + t.Fatal(err) + } + } + } + t.Setenv(home.EnvVar, t.TempDir()) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + bindings := map[string]string{} + canonical := map[string]string{} + for _, entry := range plan.Groups[0].Entries { + local := t.TempDir() + rec, err := registry.Register(workspaceidentity.Project, local) + if err != nil { + t.Fatal(err) + } + bindings[entry.ProjectURI] = local + canonical[local] = rec.URI + } + if foreignCopy { + incoming := plan.Groups[0].Entries[0] + if _, err := registry.Adopt(workspaceidentity.Record{URI: incoming.ProjectURI, Role: workspaceidentity.Project}, bindings[incoming.ProjectURI], workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + wrongChat, err := registry.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Adopt(workspaceidentity.Record{URI: incoming.CopyURI, Role: workspaceidentity.Run, OwnerURI: wrongChat.URI, ProjectURI: incoming.ProjectURI}, t.TempDir(), workspaceidentity.Links{OwnerURI: wrongChat.URI, ProjectURI: incoming.ProjectURI}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(home.Join("workspace-identities"), "registry.sqlite")) + if err := ValidateTaskWorkspaceSetBindings(receiver, bindings); err == nil { + t.Fatal("known foreign copy ownership accepted") + } + if _, err := RestoreTaskWorkspaceSets(context.Background(), receiver, bindings); err == nil { + t.Fatal("known foreign copy was promoted before owner refusal") + } + if _, err := os.Stat(receiver.Trees()); !os.IsNotExist(err) { + t.Fatal("identity preflight exposed private roots") + } + after, _ := os.ReadFile(filepath.Join(home.Join("workspace-identities"), "registry.sqlite")) + if string(before) != string(after) { + t.Fatal("identity preflight mutated registry") + } + return + } + if err := ValidateTaskWorkspaceSetBindings(receiver, nil); err == nil { + t.Fatal("missing all-root bindings were admitted") + } + if _, err := os.Stat(receiver.Trees()); !os.IsNotExist(err) { + t.Fatal("failed binding preflight created receiver copies") + } + raw, err := os.ReadFile(receiver.Tasks()) + if err != nil { + t.Fatal(err) + } + var tampered taskDocument + if err := json.Unmarshal(raw, &tampered); err != nil { + t.Fatal(err) + } + tampered.WorkspaceTransfer.Groups[0].Entries[0].Access = "read" + mutated, _ := json.Marshal(tampered) + if err := os.WriteFile(receiver.Tasks(), mutated, 0600); err != nil { + t.Fatal(err) + } + if _, err := TaskWorkspaceTransferPlan(receiver); err == nil { + t.Fatal("tampered projection accepted") + } + if err := os.WriteFile(receiver.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if err := ValidateTaskWorkspaceSetBindings(receiver, bindings); err != nil { + t.Fatal(err) + } + if denyWrite { + stateDir := filepath.Join(receiver.Dir, cell.StateDir) + if err := os.Chmod(stateDir, 0500); err != nil { + t.Fatal(err) + } + defer os.Chmod(stateDir, 0700) + if probe, err := os.CreateTemp(stateDir, ".permission-probe-*"); err == nil { + probe.Close() + os.Remove(probe.Name()) + t.Skip("platform cannot deny checkpoint directory writes") + } + if _, err := RestoreTaskWorkspaceSets(context.Background(), receiver, bindings); err == nil { + t.Fatal("denied checkpoint write reported successful transfer") + } + if err := workspaceFinalizationPending(receiver); err == nil { + t.Fatal("failed checkpoint replacement did not fence execution") + } + original, err := readWorkspaceCheckpointBytes(receiver.Tasks()) + if err != nil { + t.Fatal(err) + } + if err := (&taskStore{path: receiver.Tasks()}).writeLocked(taskDocument{Type: taskDocumentType, Version: 1}); err == nil { + t.Fatal("normal writer overwrote pending transfer") + } + unchanged, _ := readWorkspaceCheckpointBytes(receiver.Tasks()) + if string(original) != string(unchanged) { + t.Fatal("normal writer changed pending checkpoint") + } + if err := os.Chmod(stateDir, 0700); err != nil { + t.Fatal(err) + } + } + restored, err := RestoreTaskWorkspaceSets(context.Background(), receiver, bindings) + if err != nil { + t.Fatal(err) + } + meta, err := LoadMeta(receiver.Dir) + if err != nil || meta.ChatURI != plan.ChatURI { + t.Fatal("receiver metadata lost admitted chat identity") + } + if err := workspaceFinalizationPending(receiver); err != nil { + t.Fatal(err) + } + finalRaw, err := readWorkspaceCheckpointBytes(receiver.Tasks()) + if err != nil { + t.Fatal(err) + } + finalDocument, err := decodeTasks(finalRaw) + if err != nil { + t.Fatal(err) + } + for _, row := range finalDocument.Runs { + if row.WorkspaceSet != nil { + for _, use := range row.WorkspaceSet.Roots { + if use.Landing != "" { + t.Fatal("source landing authority travelled to receiver") + } + } + } + } + again, err := RestoreTaskWorkspaceSets(context.Background(), receiver, bindings) + if err != nil || !reflect.DeepEqual(restored, again) { + t.Fatalf("repeated completed restore was not idempotent: %v", err) + } + if len(restored) != 2 { + t.Fatalf("restored %d roots", len(restored)) + } + for local, uri := range canonical { + record, err := registry.Lookup(workspaceidentity.Project, local) + if err != nil || record.URI != uri { + t.Fatal("receiver canonical project identity changed") + } + } + freshEngine := newBeltRunDouble("done") + registerBeltRunEngine(t, freshEngine) + fresh, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place = receiver.Workspace, receiver + c.SessionFile = receiver.Transcript() + }) + row, ok := runRowOf(fresh.graph(), id) + if !ok || row.WorkspaceSet == nil { + t.Fatal("transferred run metadata lost") + } + if !withPlanDB { + freshEngine.mu.Lock() + started := freshEngine.spec.Workspace != "" + freshEngine.mu.Unlock() + if started || row.State != TaskInterrupted { + t.Fatal("portable-state-only fixture unexpectedly reconstructed active execution") + } + if readFile(t, filepath.Join(row.Copy.Dir, "unfinished.txt")) != "retained bytes\n" || readFile(t, filepath.Join(row.Copy.Dir, "..", "library", "input.txt")) != "dependency\n" { + t.Fatal("portable-state-only restore lost root bytes") + } + fresh.Close() + return + } + select { + case <-freshEngine.entered: + case <-time.After(5 * time.Second): + row, _ := runRowOf(fresh.graph(), id) + t.Fatalf("restored required set did not automatically reopen: state=%s report=%s", row.State, row.Report) + } + + freshEngine.mu.Lock() + actual := freshEngine.spec.Workspace + freshEngine.mu.Unlock() + if readFile(t, filepath.Join(actual, "unfinished.txt")) != "retained bytes\n" || readFile(t, filepath.Join(actual, "..", "library", "input.txt")) != "dependency\n" { + t.Fatal("reopen lost sibling output or input") + } + endBeltRun(t, fresh, freshEngine) +} + +func TestWorkspaceSetForkHistoryDropsEveryExecutionAuthority(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := Place{Dir: t.TempDir()} + if err := os.Mkdir(filepath.Join(place.Dir, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + document := taskDocument{Type: taskDocumentType, Version: 2, Seq: 1, Runs: []runRecord{{ID: 1, State: TaskRunning, WorkspaceSet: &WorkspaceSet{Version: 1, Primary: "app", Roots: []WorkspaceRootUse{{Slot: "app", Access: WorkspaceWrite, Landing: "source-authority"}}}}}} + raw, _ := json.Marshal(document) + if err := os.WriteFile(place.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if _, err := ForkTaskHistory(place); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(place.Tasks()) + if err != nil { + t.Fatal(err) + } + decoded, err := decodeTasks(after) + if err != nil { + t.Fatal(err) + } + if decoded.Version != 1 || decoded.WorkspaceTransfer != nil || len(decoded.Runs) != 1 || decoded.Runs[0].WorkspaceSet != nil || decoded.Runs[0].Copy != nil { + t.Fatal("independent history retained execution authority") + } + original, err := os.ReadFile(filepath.Join(ForkHistoryDir(place), "tasks.json")) + if err != nil || string(original) != string(raw) { + t.Fatal("fork failed to retain exact original metadata") + } +} + +func TestWorkspaceSetMixedLegacyCopiesRefuseWithoutReplacingCheckpoint(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := Place{Dir: t.TempDir()} + if err := os.Mkdir(filepath.Join(place.Dir, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + original := []byte(`{"type":"tasks","version":1,"seq":0}`) + if err := os.WriteFile(place.Tasks(), original, 0600); err != nil { + t.Fatal(err) + } + document := taskDocument{Type: taskDocumentType, Version: 2, Seq: 2, Runs: []runRecord{{ID: 1, State: TaskDone, Copy: &TaskCopyRecord{URI: "legacy-copy"}}, {ID: 2, State: TaskRunning, WorkspaceSet: &WorkspaceSet{Version: 1, Primary: "app", Roots: []WorkspaceRootUse{{Slot: "app", Access: WorkspaceWrite, Copy: &TaskCopyRecord{URI: "set-copy"}}}}}}} + if err := (&taskStore{path: place.Tasks()}).writeLocked(document); err == nil || !strings.Contains(err.Error(), "mixed legacy") { + t.Fatalf("mixed legacy copies were not explicitly refused: %v", err) + } + after, _ := os.ReadFile(place.Tasks()) + if string(after) != string(original) { + t.Fatal("mixed refusal replaced original checkpoint") + } + if _, err := workspaceTransferProjectionFor(document, place, "incoming-chat"); err == nil { + t.Fatal("receiver projection silently omitted mixed legacy copies") + } +} diff --git a/internal/session/workspace_write_scope.go b/internal/session/workspace_write_scope.go new file mode 100644 index 0000000000..bc2d336f09 --- /dev/null +++ b/internal/session/workspace_write_scope.go @@ -0,0 +1,191 @@ +package session + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// prepareWorkspaceWriteScope admits identity-backed private roots once, before +// the worker starts. The paths in model prose never grant shell write access. +func prepareWorkspaceWriteScope(config *Config) error { + if config.WorkspaceSet == nil { + return nil + } + set := config.WorkspaceSet.Clone() + if set.Version != 1 || len(set.Roots) == 0 || len(set.Roots) > workspaceSetRootLimit { + return errors.New("invalid worker workspace set") + } + records := map[string]workspaceidentity.Record{} + resolve := func(uri string) (workspaceidentity.Record, error) { + if record, ok := records[uri]; ok { + return record, nil + } + record, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), uri) + if err == nil { + records[uri] = record + } + return record, err + } + writes := map[string]bool{} + for _, root := range set.Roots { + if root.Copy != nil && root.Access == WorkspaceWrite { + writes[root.Copy.URI] = true + } + } + var owner, base string + primary := false + seen := map[string]bool{} + for _, use := range set.Roots { + if use.Copy == nil || use.Copy.URI == "" || !validWorkspaceSlot(use.Slot) || seen[strings.ToLower(use.Slot)] || use.Access != WorkspaceRead && use.Access != WorkspaceWrite { + return errors.New("invalid worker workspace root") + } + seen[strings.ToLower(use.Slot)] = true + record, err := resolve(use.Copy.URI) + if err != nil { + return err + } + if record.Role != workspaceidentity.Run || record.OwnerURI != use.Copy.ChatURI || record.ProjectURI != use.Copy.ProjectURI || record.Path != filepath.Clean(use.Copy.Dir) || filepath.Base(record.Path) != use.Slot { + return errors.New("worker workspace ownership does not match its admitted copy") + } + if owner != "" && owner != record.OwnerURI || base != "" && base != filepath.Dir(record.Path) { + return errors.New("worker workspace roots do not share an admitted layout") + } + owner, base = record.OwnerURI, filepath.Dir(record.Path) + chat, err := resolve(record.OwnerURI) + if err != nil { + return err + } + if chat.Role != workspaceidentity.Chat || filepath.Dir(filepath.Dir(record.Path)) != filepath.Join(chat.Path, cell.TreesDir) { + return errors.New("worker copy is outside its owning conversation layout") + } + project, err := resolve(record.ProjectURI) + if err != nil { + return err + } + if project.Role != workspaceidentity.Project { + return errors.New("worker workspace project identity is invalid") + } + original, err := filepath.EvalSymlinks(project.Path) + if err != nil { + return err + } + config.workspaceProtectedRoots = append(config.workspaceProtectedRoots, original) + protected, err := workerProtectedSources(use, record.OwnerURI, writes, resolve) + if err != nil { + return err + } + config.workspaceProtectedRoots = append(config.workspaceProtectedRoots, protected...) + real, err := filepath.EvalSymlinks(record.Path) + if err != nil { + return err + } + if use.Slot == set.Primary { + primary = true + if filepath.Clean(config.Workspace) != record.Path { + return errors.New("worker primary workspace differs from its set") + } + } + if use.Access == WorkspaceWrite { + config.workspaceWriteRoots = append(config.workspaceWriteRoots, real) + } else { + config.workspaceProtectedRoots = append(config.workspaceProtectedRoots, real) + } + } + if !primary { + return errors.New("worker workspace primary is missing") + } + config.WorkspaceSet = set + return nil +} + +func (a *Agent) taskMayWrite(path string) bool { + if a.config.WorkspaceSet == nil { + return insideGround(a.taskGround(), path) + } + real, ok := workspaceWriteTarget(path) + if !ok { + return false + } + for _, root := range a.config.workspaceWriteRoots { + if withinDir(root, real) { + return true + } + } + for _, root := range a.config.workspaceProtectedRoots { + if withinDir(root, real) { + return false + } + } + if strings.HasPrefix(filepath.Clean(path), "/dev/") { + return true + } + return scratchPath(path) && !scratchPath(a.taskGround()) +} + +// Resolve the closest existing ancestor, including a symlink above a not-yet +// created directory, before deciding whether a named write stays in its copy. +func workspaceWriteTarget(path string) (string, bool) { + path = filepath.Clean(path) + var tail []string + for { + if _, err := os.Lstat(path); err == nil { + real, err := filepath.EvalSymlinks(path) + if err != nil { + return "", false + } + for i := len(tail) - 1; i >= 0; i-- { + real = filepath.Join(real, tail[i]) + } + return real, true + } else if !errors.Is(err, os.ErrNotExist) { + return "", false + } + parent := filepath.Dir(path) + if parent == path { + return "", false + } + tail = append(tail, filepath.Base(path)) + path = parent + } +} + +func workerProtectedSources(use WorkspaceRootUse, owner string, writes map[string]bool, resolve func(string) (workspaceidentity.Record, error)) ([]string, error) { + var paths []string + for _, uri := range []string{use.SourceURI, use.Copy.GroundURI, use.Copy.RootURI} { + if uri == "" || writes[uri] { + continue + } + record, err := resolve(uri) + if err != nil { + return nil, err + } + if record.Role != workspaceidentity.Project && record.Role != workspaceidentity.Run || record.Role == workspaceidentity.Run && record.OwnerURI != owner { + return nil, errors.New("worker source identity is not an admitted project or same-chat copy") + } + path, err := filepath.EvalSymlinks(record.Path) + if err != nil { + return nil, err + } + paths = append(paths, path) + } + return paths, nil +} + +// The task's cwd and its complete write scope are different facts. A refusal +// names the latter so the worker can use a declared sibling without guessing. +func (a *Agent) taskOutsideRefusal(what string) string { + if a.config.WorkspaceSet == nil { + return outsideGroundRefusal(what, a.taskGround()) + } + roots := strings.Join(a.config.workspaceWriteRoots, ", ") + if roots == "" { + return what + " is not writable: this task has no admitted writable copies. Report requested changes instead." + } + return what + " is outside this task's admitted writable copies: " + roots + ". Keep edits in those copies; report requested changes elsewhere. Landing uses the task's explicit association." +} diff --git a/internal/syncsetup/branch.go b/internal/syncsetup/branch.go index aad0fa92bf..b44badb698 100644 --- a/internal/syncsetup/branch.go +++ b/internal/syncsetup/branch.go @@ -18,6 +18,10 @@ import ( // the journal's header changes; every turn and saved tool result stays intact. // This runs before derived indexes are built and before any window opens it. func branchIdentity(_ context.Context, c cell.Cell) (err error) { + place := session.Place{Dir: c.Root, Owned: true} + if err := session.ValidateForkTaskRoots(place, place.Work()); err != nil { + return err + } path := transcriptOfBranch(c) in, err := os.Open(path) if err != nil { @@ -38,7 +42,7 @@ func branchIdentity(_ context.Context, c cell.Cell) (err error) { } id, _ := json.Marshal(c.ID) if string(header["id"]) == string(id) { - return branchRow(c) + return independentBranchRow(c) } header["id"] = id line, err = json.Marshal(header) @@ -69,7 +73,7 @@ func branchIdentity(_ context.Context, c cell.Cell) (err error) { if err := fsreplace.Rename(out.Name(), path); err != nil { return err } - return branchRow(c) + return independentBranchRow(c) } func transcriptOfBranch(c cell.Cell) string { return session.Place{Dir: c.Root}.Transcript() } @@ -88,3 +92,10 @@ func branchRow(c cell.Cell) error { m.Workspace = (session.Place{Dir: c.Root, Owned: true}).Work() return session.SaveMeta(c.Root, m) } + +func independentBranchRow(c cell.Cell) error { + if err := branchRow(c); err != nil { + return err + } + return detachBranchWork(c) +} diff --git a/internal/syncsetup/branch_history.go b/internal/syncsetup/branch_history.go new file mode 100644 index 0000000000..bd62ac0e71 --- /dev/null +++ b/internal/syncsetup/branch_history.go @@ -0,0 +1,96 @@ +package syncsetup + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// detachBranchWork keeps completed outputs discoverable while removing every +// inherited executable frontier. It runs only on an explicitly opened branch, +// before any new agent can load a plan or schedule recovered work. +func detachBranchWork(c cell.Cell) error { + if !session.WorkspaceOwnershipEnabled(session.Place{Dir: c.Root}) { + return nil + } + place := session.Place{Dir: c.Root, Owned: true} + history := session.ForkHistoryDir(place) + ready := filepath.Join(history, "independent-ready") + if _, err := os.Stat(ready); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + workspaceSet, err := detachWorkspaceSetHistory(c, place, history) + if err != nil { + return err + } + var parents map[string]string + var selections []taskcopy.ResultSelection + if !workspaceSet { + parents, err = session.ForkTaskRestoreParents(place) + if err != nil { + return err + } + selections, err = session.ForkTaskHistory(place) + if err != nil { + return err + } + } + if err := os.MkdirAll(history, 0700); err != nil { + return err + } + // A restored fork has no local writer. Preserve the complete plan and WAL + // together under history so the new conversation opens a fresh plan store. + plans, err := filepath.Glob(filepath.Join(c.Root, "plandb.db*")) + if err != nil { + return err + } + for _, path := range plans { + if err := moveForkHistory(path, filepath.Join(history, filepath.Base(path))); err != nil { + return err + } + } + liveArchive := filepath.Join(c.Root, cell.CarriedTreesPath) + archivedTrees := filepath.Join(history, "trees") + // Completed result reconstruction reads the normal carrier layout but writes + // only ordinary historical outputs, never this chat's live trees/ folder. + if _, err := os.Stat(liveArchive); !workspaceSet && err == nil { + outputs, err := taskcopy.RestoreCompletedResultsWithParents(c, workspaceOf(c.Root), filepath.Join(history, "results"), selections, parents) + if err != nil { + return err + } + if err := recordForkArtifacts(c, place, outputs); err != nil { + return err + } + if err := moveForkHistory(liveArchive, archivedTrees); err != nil { + return err + } + } else if !workspaceSet && !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := session.ForkChatWorkspaceIdentity(place); err != nil { + return err + } + return os.WriteFile(ready, []byte("independent\n"), 0600) +} + +func moveForkHistory(source, destination string) error { + if _, err := os.Lstat(source); errors.Is(err, fs.ErrNotExist) { + return nil + } else if err != nil { + return err + } + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("independent conversation history already contains %s", filepath.Base(destination)) + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.Rename(source, destination) +} diff --git a/internal/syncsetup/branch_history_test.go b/internal/syncsetup/branch_history_test.go new file mode 100644 index 0000000000..d7e2e6aead --- /dev/null +++ b/internal/syncsetup/branch_history_test.go @@ -0,0 +1,118 @@ +package syncsetup + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// API-level fixture, with fictional checkpoint metadata. Terminal acceptance +// creates its source frontier through actual /task instead of this fixture. +func TestIndependentBranchGetsNewOwnerAndKeepsOnlyTaskHistory(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + parent, err := registry.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root, Owned: true} + if err := session.SaveMeta(c.Root, session.Meta{ID: "parent", ChatURI: parent.URI, Workspace: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(place.Transcript(), []byte("{\"type\":\"session\",\"id\":\"parent\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"historical answer\"}\n"), 0600); err != nil { + t.Fatal(err) + } + rootURI := "codeaf-workspace://11111111111111111111111111111111/22222222222222222222222222222222" + runPrefix := "codeaf-workspace://11111111111111111111111111111111/" + checkpoint := []byte(fmt.Sprintf(`{"type":"tasks","version":1,"seq":2,"runs":[{"id":1,"title":"report only","state":"done","report":"kept answer","copy":{"uri":%q,"chat_uri":%q,"project_uri":%q,"ground_uri":%q,"dir":"session:trees/1","ground":"workspace:.","mode":"mirror"}},{"id":2,"title":"still active","state":"running","pendingRun":{},"copy":{"uri":%q,"chat_uri":%q,"project_uri":%q,"ground_uri":%q,"dir":"session:trees/2","ground":"workspace:.","mode":"mirror"}}]}`, runPrefix+"33333333333333333333333333333333", parent.URI, rootURI, rootURI, runPrefix+"44444444444444444444444444444444", parent.URI, rootURI, rootURI)) + if err := os.WriteFile(place.Tasks(), checkpoint, 0600); err != nil { + t.Fatal(err) + } + carrier := filepath.Join(c.Root, cell.CarriedTreesPath, "2") + if err := os.MkdirAll(carrier, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(carrier, "tree.json"), []byte(`{"head":"preserved-active-archive"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, "plandb.db"), []byte("old frontier"), 0600); err != nil { + t.Fatal(err) + } + if err := branchIdentity(context.Background(), c); err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(c.Root) + if err != nil || meta.ChatURI == parent.URI || meta.ChatURI == "" { + t.Fatalf("fork reused parent identity: %+v %v", meta, err) + } + if original, err := registry.Resolve(parent.URI); err != nil || original != parent { + t.Fatal("fork rebound parent's identity") + } + current, err := registry.Resolve(meta.ChatURI) + if err != nil || current.Path != c.Root { + t.Fatalf("new owner missing: %+v %v", current, err) + } + raw, err := os.ReadFile(place.Tasks()) + if err != nil { + t.Fatal(err) + } + var tasks struct { + Nodes []json.RawMessage + Runs []struct{ Copy, PendingRun json.RawMessage } + } + if err := json.Unmarshal(raw, &tasks); err != nil { + t.Fatal(err) + } + if len(tasks.Nodes) != 0 || len(tasks.Runs) != 2 { + t.Fatal("history lost or executable nodes retained") + } + for _, row := range tasks.Runs { + if len(row.Copy) > 0 || len(row.PendingRun) > 0 { + t.Fatal("fork retained continuation authority") + } + } + if _, err := os.Stat(filepath.Join(c.Root, "plandb.db")); !os.IsNotExist(err) { + t.Fatal("fork retained active plan store") + } + saved, err := os.ReadFile(filepath.Join(session.ForkHistoryDir(place), "trees", "2", "tree.json")) + if err != nil || !strings.Contains(string(saved), "preserved-active") { + t.Fatalf("active archive lost: %v", err) + } + if _, err := os.Stat(filepath.Join(c.Root, cell.CarriedTreesPath)); !os.IsNotExist(err) { + t.Fatal("fork will restore active task copies") + } + newFrontier := []byte(`{"type":"tasks","version":1,"seq":3,"runs":[{"id":3,"title":"independent new work","state":"running","copy":{"dir":"local new run"}}]}`) + if err := os.WriteFile(place.Tasks(), newFrontier, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, "plandb.db"), []byte("new independent frontier"), 0600); err != nil { + t.Fatal(err) + } + if err := branchIdentity(context.Background(), c); err != nil { + t.Fatalf("repeat fork opening: %v", err) + } + after, err := os.ReadFile(place.Tasks()) + if err != nil || string(after) != string(newFrontier) { + t.Fatal("reopening fork rewrote its independent work") + } + if raw, err := os.ReadFile(filepath.Join(c.Root, "plandb.db")); err != nil || string(raw) != "new independent frontier" { + t.Fatal("reopening fork removed its own plan") + } +} diff --git a/internal/syncsetup/branch_prototype_disabled_test.go b/internal/syncsetup/branch_prototype_disabled_test.go new file mode 100644 index 0000000000..ec271123e3 --- /dev/null +++ b/internal/syncsetup/branch_prototype_disabled_test.go @@ -0,0 +1,118 @@ +package syncsetup + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Fictional checkpoint metadata is seeded at the API seam. Public OpenBranch +// must preserve ordinary profiles' frontier and task-copy restoration policy. +func TestPublicOpenBranchWithoutPrototypePreservesLegacyTaskFrontier(t *testing.T) { + ctx := context.Background() + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + if err := os.WriteFile(filepath.Join(h.work, "input.txt"), []byte("baseline\n"), 0600); err != nil { + t.Fatal(err) + } + git(t, h.work, "init", "-q", "-b", "main") + git(t, h.work, "add", ".") + git(t, h.work, "commit", "-qm", "baseline") + a := h.openA() + a.mustSay("shared initial turn") + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + first, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + at := first.Taken.Cell + work := workspaceOf(at.Root) + tree := filepath.Join(at.Root, cell.TreesDir, "1") + git(t, work, "worktree", "add", "-qb", "task/kept", tree) + if err := os.WriteFile(filepath.Join(tree, "result.txt"), []byte("keep this work\n"), 0600); err != nil { + t.Fatal(err) + } + original := []byte(`{"type":"tasks","version":1,"seq":1,"runs":[{"id":1,"title":"legacy active work","state":"running","pendingRun":{},"copy":{"dir":"session:trees/1","branch":"task/kept"}}]}`) + if err := os.WriteFile(session.Place{Dir: at.Root}.Tasks(), original, 0600); err != nil { + t.Fatal(err) + } + appendTo(t, transcriptOf(at), "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"unpublished work\"}\n") + engine := h.engB + engine.Workspace = work + if _, err := engine.Seal(ctx, at, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + if _, err := h.continuerA().Take(ctx, h.cell.ID); err != nil { + t.Fatal(err) + } + again, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + if again.Taken.Kept == "" { + t.Fatal("fixture did not retain unpublished branch") + } + root := filepath.Join(t.TempDir(), again.Taken.Kept) + opener := h.a.Continuer(h.engine, TakeOptions{RootFor: func(string) string { return root }}) + received, err := opener.OpenBranch(ctx, again.Taken.Kept) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(session.Place{Dir: received.Taken.Cell.Root}.Tasks()) + if err != nil || !bytes.Equal(got, original) { + t.Fatalf("disabled prototype rewrote legacy frontier: %s %v", got, err) + } + if _, err := os.Stat(filepath.Join(root, cell.CarriedTreesPath, "1", "tree.json")); err != nil { + t.Fatalf("legacy carrier was moved: %v", err) + } + if _, err := os.Stat(session.ForkHistoryDir(session.Place{Dir: root})); !os.IsNotExist(err) { + t.Fatal("disabled prototype created independent history") + } + if raw, err := os.ReadFile(filepath.Join(root, cell.TreesDir, "1", "result.txt")); err != nil || string(raw) != "keep this work\n" { + t.Fatalf("legacy task copy did not restore normally: %q %v", raw, err) + } + meta, err := session.LoadMeta(root) + if err != nil || meta.ID != again.Taken.Kept || !meta.Owned { + t.Fatalf("legacy branch identity behavior changed: %+v %v", meta, err) + } +} + +func TestLegacyBranchIdentityLeavesExistingPlanAndCheckpointUntouched(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root} + if err := os.WriteFile(place.Transcript(), []byte("{\"type\":\"session\",\"id\":\"parent\"}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: "parent", Workspace: t.TempDir()}); err != nil { + t.Fatal(err) + } + frontier := []byte(`{"type":"tasks","version":1,"seq":1,"runs":[{"id":1,"title":"legacy active","state":"running","pendingRun":{}}]}`) + if err := os.WriteFile(place.Tasks(), frontier, 0600); err != nil { + t.Fatal(err) + } + plan := []byte("existing plan bytes") + if err := os.WriteFile(filepath.Join(c.Root, "plandb.db"), plan, 0600); err != nil { + t.Fatal(err) + } + if err := branchIdentity(context.Background(), c); err != nil { + t.Fatal(err) + } + for path, want := range map[string][]byte{place.Tasks(): frontier, filepath.Join(c.Root, "plandb.db"): plan} { + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("disabled prototype modified %s: %q %v", path, got, err) + } + } +} diff --git a/internal/syncsetup/branch_relay_results_test.go b/internal/syncsetup/branch_relay_results_test.go new file mode 100644 index 0000000000..713722881b --- /dev/null +++ b/internal/syncsetup/branch_relay_results_test.go @@ -0,0 +1,212 @@ +package syncsetup + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// API-level protocol acceptance: fictional task metadata is explicitly seeded. +// Real native seals, public OpenBranch, and a second public Take prove transport; +// this is not evidence that a model or /task created the seeded frontier. +func TestIndependentBranchResultsSurviveSecondPublicTake(t *testing.T) { + testIndependentBranchResultsSurviveSecondPublicTake(t, false) +} + +func TestNestedPlainBranchResultsSurviveSecondPublicTake(t *testing.T) { + testIndependentBranchResultsSurviveSecondPublicTake(t, true) +} + +func testIndependentBranchResultsSurviveSecondPublicTake(t *testing.T, plainNested bool) { + ctx := context.Background() + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(h.work, "keep.txt"), "baseline\n") + write(filepath.Join(h.work, "large-input.dat"), strings.Repeat("unchanged baseline\n", 1<<17)) + if !plainNested { + git(t, h.work, "init", "-q", "-b", "main") + git(t, h.work, "add", ".") + git(t, h.work, "commit", "-qm", "baseline") + } + a := h.openA() + a.mustSay("initial shared turn") + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + first, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + at := first.Taken.Cell + work := workspaceOf(at.Root) + t.Setenv(home.EnvVar, h.homeB) + var inheritedChatURI string + if plainNested { + copies := seedNestedPlainProtocolFixture(t, at, work) + inheritedChatURI = copies["1"].ChatURI + } else { + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + parent, err := registry.Register(workspaceidentity.Chat, at.Root) + inheritedChatURI = parent.URI + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(at.Root) + if err != nil { + t.Fatal(err) + } + meta.ChatURI = parent.URI + if err := session.SaveMeta(at.Root, meta); err != nil { + t.Fatal(err) + } + tree := filepath.Join(at.Root, cell.TreesDir, "1") + git(t, work, "worktree", "add", "-qb", "task/completed", tree) + write(filepath.Join(tree, "committed.txt"), "completed committed result\n") + git(t, tree, "add", "committed.txt") + git(t, tree, "commit", "-qm", "completed output") + write(filepath.Join(tree, "dirty.txt"), "completed dirty result\n") + if err := os.Remove(filepath.Join(tree, "keep.txt")); err != nil { + t.Fatal(err) + } + active := filepath.Join(at.Root, cell.TreesDir, "2") + git(t, work, "worktree", "add", "-qb", "task/active", active) + write(filepath.Join(active, "active.txt"), "unfinished active output\n") + checkpoint := `{"type":"tasks","version":1,"seq":2,"runs":[{"id":1,"title":"finished fixture","state":"done","report":"completed report","result":"completed answer","costUsd":0.125,"changed":["committed.txt","dirty.txt","keep.txt"],"copy":{"dir":"foreign-completed"}},{"id":2,"title":"active fixture","state":"running","report":"partial report","pendingRun":{},"copy":{"dir":"foreign-active"}}]}` + write(session.Place{Dir: at.Root}.Tasks(), checkpoint) + } + appendTo(t, transcriptOf(at), "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"unpublished task results\"}\n") + eng := h.engB + eng.Workspace = work + if _, err := eng.Seal(ctx, at, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeA) + if _, err := h.continuerA().Take(ctx, h.cell.ID); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeB) + again, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + if again.Taken.Kept == "" { + t.Fatal("unpublished task snapshot was not preserved as a branch") + } + t.Setenv(home.EnvVar, h.homeA) + originalMain, err := os.ReadFile(transcriptOf(h.cell)) + if err != nil { + t.Fatal(err) + } + branchRoot := filepath.Join(t.TempDir(), again.Taken.Kept) + opener := h.a.Continuer(h.engine, TakeOptions{RootFor: func(string) string { return branchRoot }}) + fork, err := opener.OpenBranch(ctx, again.Taken.Kept) + if err != nil { + t.Fatal(err) + } + forkMeta, err := session.LoadMeta(fork.Taken.Cell.Root) + if err != nil || forkMeta.ChatURI == "" || forkMeta.ChatURI == inheritedChatURI { + t.Fatalf("fork identity not independent: %+v %v", forkMeta, err) + } + rows := session.SealedArtifacts(fork.Taken.Cell.Root) + expectedResults := 2 + if plainNested { + expectedResults = 4 + } + if len(rows) != expectedResults { + t.Fatalf("completed outputs not discoverable, or baseline duplicated: %+v", rows) + } + assertForkResultArtifacts(t, rows) + if entries, _ := os.ReadDir(filepath.Join(fork.Taken.Cell.Root, cell.TreesDir)); len(entries) != 0 { + t.Fatal("fork instantiated inherited task work") + } + + // The next normal seal and takeover must carry the historical files and + // resolve their ledger paths under the new device's chat folder. + forkChat := h.openOn(h.a, h.engine, fork.Taken.Cell, workspaceOf(fork.Taken.Cell.Root), nameA) + forkChat.mustSay("new independent turn") + executor.Settle(ctx, forkChat.seat) + forkChat.drive.Idle() + h.durable(fork.Taken.Cell.ID, fork.Taken.Cell) + if err := forkChat.drive.Close(ctx); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeB) + second, err := h.continuerB().Take(ctx, fork.Taken.Cell.ID) + if err != nil { + t.Fatal(err) + } + if second.Taken.Cell.Root == fork.Taken.Cell.Root { + t.Fatal("second transfer did not relocate the chat") + } + secondMeta, err := session.LoadMeta(second.Taken.Cell.Root) + if err != nil || secondMeta.ChatURI != forkMeta.ChatURI { + t.Fatalf("second transfer changed independent identity: %+v %v", secondMeta, err) + } + rows = session.SealedArtifacts(second.Taken.Cell.Root) + if len(rows) != expectedResults { + t.Fatalf("second seal/Take lost historical result ledger: %+v", rows) + } + assertForkResultArtifacts(t, rows) + for _, row := range rows { + relative, err := filepath.Rel(second.Taken.Cell.Root, row.Path) + if err != nil || !filepath.IsLocal(relative) { + t.Fatalf("artifact retained source path: %s", row.Path) + } + } + saved, err := os.ReadFile(session.Place{Dir: second.Taken.Cell.Root}.Tasks()) + if err != nil { + t.Fatal(err) + } + var taskDoc struct { + Nodes []json.RawMessage + Runs []struct { + Copy, PendingRun json.RawMessage + Report string + } + } + if err := json.Unmarshal(saved, &taskDoc); err != nil { + t.Fatal(err) + } + for _, row := range taskDoc.Runs { + if len(row.Copy) > 0 || len(row.PendingRun) > 0 { + t.Fatal("second transfer restored inherited execution authority") + } + } + if raw, err := os.ReadFile(transcriptOf(h.cell)); err != nil || !bytes.Equal(raw, originalMain) { + t.Fatal("opening or relaying fork changed main conversation") + } +} + +func assertForkResultArtifacts(t *testing.T, rows []session.Artifact) { + t.Helper() + want := map[string]string{"committed.txt": "completed committed result\n", "dirty.txt": "completed dirty result\n", "child-committed.txt": "nested committed output\n", "child-dirty.txt": "nested dirty output\n"} + for _, row := range rows { + body, err := os.ReadFile(row.Path) + if err != nil || string(body) != want[filepath.Base(row.Path)] { + t.Fatalf("historical output %s=%q %v", row.Path, body, err) + } + } +} diff --git a/internal/syncsetup/multiroot_preflight_test.go b/internal/syncsetup/multiroot_preflight_test.go new file mode 100644 index 0000000000..f7aab2fba5 --- /dev/null +++ b/internal/syncsetup/multiroot_preflight_test.go @@ -0,0 +1,260 @@ +package syncsetup + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// Labelled API fixture: native/public Take is real; task metadata is supplied +// explicitly to test rejection before task restoration mutates a shared repo. +func TestPublicTakeRejectsTwoUnboundRootsBeforeRepoOrRegistryMutation(t *testing.T) { + ctx := context.Background() + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + if err := os.WriteFile(filepath.Join(h.work, "input.txt"), []byte("primary baseline\n"), 0600); err != nil { + t.Fatal(err) + } + git(t, h.work, "init", "-q", "-b", "main") + git(t, h.work, "add", ".") + git(t, h.work, "commit", "-qm", "baseline") + a := h.openA() + a.mustSay("initial shared turn") + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + first, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + at := first.Taken.Cell + work := workspaceOf(at.Root) + t.Setenv(home.EnvVar, h.homeB) + secondHead := seedTwoRootProtocolFixture(t, at, work) + b := h.openOn(h.b, h.engB, at, work, nameB) + b.mustSay("two separate working roots") + executor.Settle(ctx, b.seat) + b.drive.Idle() + h.durable(h.cell.ID, at) + if err := b.drive.Close(ctx); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeA) + registryPath := filepath.Join(h.homeA, "workspace-identities") + if _, err := os.Stat(registryPath); !os.IsNotExist(err) { + t.Fatalf("fixture receiver already has registry: %v", err) + } + var beforeRefs, beforeObjects string + observed := false + h.engine.Transport = afterRestoreSnapshot{under: h.engine.Transport, after: func(target cellstore.Target) { + observed = true + beforeRefs = gitOut(t, target.Tree, "show-ref") + beforeObjects = gitOut(t, target.Tree, "count-objects", "-v") + if exec.Command("git", "-C", target.Tree, "cat-file", "-e", secondHead+"^{commit}").Run() == nil { + t.Fatal("fixture secondary commit already in primary snapshot") + } + }} + _, err = h.continuerA().Take(ctx, h.cell.ID) + if err == nil || !strings.Contains(err.Error(), "working root") { + t.Fatalf("Take did not explain unbound roots: %v notices=%v", err, h.wall.all()) + } + if !observed { + t.Fatal("native restore observation did not run") + } + if got := gitOut(t, h.work, "show-ref"); got != beforeRefs { + t.Fatal("task restoration changed borrowed primary refs before rejection") + } + if got := gitOut(t, h.work, "count-objects", "-v"); got != beforeObjects { + t.Fatal("task unbundle changed borrowed primary objects before rejection") + } + if exec.Command("git", "-C", h.work, "cat-file", "-e", secondHead+"^{commit}").Run() == nil { + t.Fatal("secondary commit entered borrowed primary repository") + } + if _, err := os.Stat(registryPath); !os.IsNotExist(err) { + t.Fatal("rejected roots created or changed receiver identity registry") + } + for _, id := range []string{"1", "2"} { + if _, err := os.Stat(filepath.Join(h.cell.Root, cell.TreesDir, id)); !os.IsNotExist(err) { + t.Fatalf("rejected task %s was instantiated", id) + } + if _, err := os.Stat(filepath.Join(h.cell.Root, cell.CarriedTreesPath, id, "tree.json")); err != nil { + t.Fatalf("incoming archive %s lost: %v", id, err) + } + } +} + +type afterRestoreSnapshot struct { + under cellstore.Transport + after func(cellstore.Target) +} + +func (s afterRestoreSnapshot) Do(ctx context.Context, target cellstore.Target, operation cellstore.Op) ([]byte, error) { + raw, err := s.under.Do(ctx, target, operation) + if err == nil && operation.Verb() == "materialize" { + s.after(target) + } + return raw, err +} + +func seedTwoRootProtocolFixture(t *testing.T, at cell.Cell, primary string) string { + t.Helper() + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, at.Root) + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(at.Root) + if err != nil { + t.Fatal(err) + } + meta.ChatURI = chat.URI + if err := session.SaveMeta(at.Root, meta); err != nil { + t.Fatal(err) + } + secondary := t.TempDir() + git(t, filepath.Dir(primary), "clone", "-q", "--", primary, secondary) + var rows []map[string]any + var secondHead string + for _, id := range []string{"1", "2"} { + project := primary + if id == "2" { + project = secondary + } + projectRecord, err := registry.Register(workspaceidentity.Project, project) + if err != nil { + t.Fatal(err) + } + tree := filepath.Join(at.Root, cell.TreesDir, id) + git(t, filepath.Dir(primary), "clone", "-q", "--", project, tree) + git(t, tree, "checkout", "-qb", "task/fixture-"+id) + if err := os.WriteFile(filepath.Join(tree, "output-"+id+".txt"), []byte("completed output "+id+"\n"), 0600); err != nil { + t.Fatal(err) + } + git(t, tree, "add", ".") + git(t, tree, "commit", "-qm", "worker output "+id) + record, err := registry.Register(workspaceidentity.Run, tree, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: projectRecord.URI}) + if err != nil { + t.Fatal(err) + } + ground := "workspace:." + if id == "2" { + ground = secondary + secondHead = gitOut(t, tree, "rev-parse", "HEAD") + } + copy := &session.TaskCopyRecord{URI: record.URI, ChatURI: chat.URI, ProjectURI: projectRecord.URI, GroundURI: projectRecord.URI, Dir: "session:trees/" + id, Ground: ground, Branch: "task/fixture-" + id, Mode: session.TaskModeWorktree} + rows = append(rows, map[string]any{"id": int(id[0] - '0'), "title": "root fixture " + id, "state": "done", "report": "result retained", "changed": []string{"output-" + id + ".txt"}, "copy": copy}) + } + raw, err := json.Marshal(map[string]any{"type": "tasks", "version": 1, "seq": 2, "runs": rows}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(session.Place{Dir: at.Root}.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + return secondHead +} + +func TestPublicForkRejectsTwoUnboundRootsBeforeDetachingHistory(t *testing.T) { + ctx := context.Background() + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + if err := os.WriteFile(filepath.Join(h.work, "input.txt"), []byte("baseline\n"), 0600); err != nil { + t.Fatal(err) + } + git(t, h.work, "init", "-q", "-b", "main") + git(t, h.work, "add", ".") + git(t, h.work, "commit", "-qm", "baseline") + a := h.openA() + a.mustSay("shared initial") + if err := a.drive.Close(ctx); err != nil { + t.Fatal(err) + } + first, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + at := first.Taken.Cell + work := workspaceOf(at.Root) + t.Setenv(home.EnvVar, h.homeB) + seedTwoRootProtocolFixture(t, at, work) + original, err := os.ReadFile(session.Place{Dir: at.Root}.Tasks()) + if err != nil { + t.Fatal(err) + } + appendTo(t, transcriptOf(at), "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"unpublished two-root work\"}\n") + engine := h.engB + engine.Workspace = work + if _, err := engine.Seal(ctx, at, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeA) + if _, err := h.continuerA().Take(ctx, h.cell.ID); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeB) + again, err := h.continuerB().Take(ctx, h.cell.ID) + if err != nil { + t.Fatal(err) + } + if again.Taken.Kept == "" { + t.Fatal("fixture did not retain a branch") + } + t.Setenv(home.EnvVar, h.homeA) + root := filepath.Join(t.TempDir(), again.Taken.Kept) + var primary, beforeRefs, beforeObjects string + h.engine.Transport = afterRestoreSnapshot{under: h.engine.Transport, after: func(target cellstore.Target) { + primary = target.Tree + beforeRefs = gitOut(t, primary, "show-ref") + beforeObjects = gitOut(t, primary, "count-objects", "-v") + }} + opener := h.a.Continuer(h.engine, TakeOptions{RootFor: func(string) string { return root }}) + if _, err := opener.OpenBranch(ctx, again.Taken.Kept); err == nil || !strings.Contains(err.Error(), "working root") { + t.Fatalf("fork did not explain unbound roots: %v", err) + } + if primary == "" { + t.Fatal("native materialization was not observed") + } + primary = session.Place{Dir: root, Owned: true}.Work() + if got := gitOut(t, primary, "show-ref"); got != beforeRefs { + t.Fatal("rejected fork modified primary refs") + } + if got := gitOut(t, primary, "count-objects", "-v"); got != beforeObjects { + t.Fatal("rejected fork modified primary objects") + } + got, err := os.ReadFile(session.Place{Dir: root}.Tasks()) + if err != nil || string(got) != string(original) { + t.Fatalf("fork detached original frontier before root preflight: %v", err) + } + digest, err := session.ReadDigest(session.Place{Dir: root}.Transcript()) + if err != nil || digest.ID != h.cell.ID { + t.Fatalf("fork rewrote journal identity before root preflight: %s %v", digest.ID, err) + } + if _, err := os.Stat(session.ForkHistoryDir(session.Place{Dir: root})); !os.IsNotExist(err) { + t.Fatal("rejected fork created detached history") + } + if _, err := os.Stat(filepath.Join(h.homeA, "workspace-identities")); !os.IsNotExist(err) { + t.Fatal("rejected fork created identity registry") + } + for _, id := range []string{"1", "2"} { + if _, err := os.Stat(filepath.Join(root, cell.TreesDir, id)); !os.IsNotExist(err) { + t.Fatalf("rejected fork instantiated copy %s", id) + } + if _, err := os.Stat(filepath.Join(root, cell.CarriedTreesPath, id, "tree.json")); err != nil { + t.Fatalf("incoming archive %s lost: %v", id, err) + } + } +} diff --git a/internal/syncsetup/nested_plain_relay_test.go b/internal/syncsetup/nested_plain_relay_test.go new file mode 100644 index 0000000000..49c7aac6bf --- /dev/null +++ b/internal/syncsetup/nested_plain_relay_test.go @@ -0,0 +1,176 @@ +package syncsetup + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// A labelled API fixture supplies metadata; native capture and the public Take +// door prove parent binding, not model-driven task creation. +func TestNestedPlainProjectCopiesFollowGroundURIThroughPublicTake(t *testing.T) { + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, h.homeA) + plainProtocolInputs(t, h.work) + copies := seedNestedPlainProtocolFixture(t, h.cell, h.work) + a := h.openA() + a.mustSay("completed parent and child fixture") + t.Setenv(home.EnvVar, h.homeB) + received := h.takeOnB(a) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + for id, copy := range copies { + local := filepath.Join(received.Taken.Cell.Root, cell.TreesDir, id) + record, err := registry.Resolve(copy.URI) + if err != nil || record.Path != local { + t.Fatalf("copy %s not admitted locally: %+v %v notices=%v", id, record, err, h.wall.all()) + } + } + parent := filepath.Join(received.Taken.Cell.Root, cell.TreesDir, "1") + child := filepath.Join(received.Taken.Cell.Root, cell.TreesDir, "2") + info, err := os.Stat(filepath.Join(child, ".git")) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("child is no longer linked to its restored parent: %v", err) + } + common := gitOut(t, child, "rev-parse", "--git-common-dir") + commonPath := common + if !filepath.IsAbs(commonPath) { + commonPath = filepath.Join(child, commonPath) + } + if relative, err := filepath.Rel(parent, filepath.Clean(commonPath)); err != nil || !filepath.IsLocal(relative) { + t.Fatalf("child Git history is not in owned parent: %s", common) + } + for name, want := range map[string]string{"child-committed.txt": "nested committed output\n", "child-dirty.txt": "nested dirty output\n"} { + raw, err := os.ReadFile(filepath.Join(child, name)) + if err != nil || string(raw) != want { + t.Fatalf("child %s=%q %v", name, raw, err) + } + } + if _, err := os.Lstat(filepath.Join(workspaceOf(received.Taken.Cell.Root), ".git")); !os.IsNotExist(err) { + t.Fatal("plain receiving project was initialized as Git") + } + if _, err := os.Lstat(filepath.Join(h.work, ".git")); !os.IsNotExist(err) { + t.Fatal("plain borrowed source project was changed") + } +} + +func plainProtocolInputs(t *testing.T, project string) { + t.Helper() + for name, body := range map[string]string{"keep.txt": "baseline\n", "large-input.dat": strings.Repeat("unchanged baseline\n", 1<<17)} { + if err := os.WriteFile(filepath.Join(project, name), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } +} + +func seedNestedPlainProtocolFixture(t *testing.T, at cell.Cell, project string) map[string]*session.TaskCopyRecord { + t.Helper() + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, at.Root) + if err != nil { + t.Fatal(err) + } + proj, err := registry.Register(workspaceidentity.Project, project) + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(at.Root) + if err != nil { + t.Fatal(err) + } + meta.ChatURI = chat.URI + if err := session.SaveMeta(at.Root, meta); err != nil { + t.Fatal(err) + } + parent := filepath.Join(at.Root, cell.TreesDir, "1") + if err := os.MkdirAll(parent, 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"keep.txt", "large-input.dat"} { + raw, err := os.ReadFile(filepath.Join(project, name)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, name), raw, 0600); err != nil { + t.Fatal(err) + } + } + git(t, parent, "init", "-q", "-b", "main") + git(t, parent, "add", ".") + git(t, parent, "commit", "-qm", "private plain baseline") + write := func(path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(parent, "committed.txt"), "completed committed result\n") + git(t, parent, "add", "committed.txt") + git(t, parent, "commit", "-qm", "parent output") + write(filepath.Join(parent, "dirty.txt"), "completed dirty result\n") + child := filepath.Join(at.Root, cell.TreesDir, "2") + git(t, parent, "worktree", "add", "-qb", "task/child", child) + write(filepath.Join(child, "child-committed.txt"), "nested committed output\n") + git(t, child, "add", "child-committed.txt") + git(t, child, "commit", "-qm", "child output") + write(filepath.Join(child, "child-dirty.txt"), "nested dirty output\n") + if err := os.Remove(filepath.Join(child, "keep.txt")); err != nil { + t.Fatal(err) + } + active := filepath.Join(at.Root, cell.TreesDir, "3") + git(t, parent, "worktree", "add", "-qb", "task/active", active) + write(filepath.Join(active, "active.txt"), "active output must not restart\n") + copies := map[string]*session.TaskCopyRecord{} + for _, id := range []string{"1", "2", "3"} { + dir := filepath.Join(at.Root, cell.TreesDir, id) + record, err := registry.Register(workspaceidentity.Run, dir, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: proj.URI}) + if err != nil { + t.Fatal(err) + } + copy := &session.TaskCopyRecord{URI: record.URI, ChatURI: chat.URI, ProjectURI: proj.URI, Dir: dir, GroundURI: proj.URI, Ground: project, Mode: session.TaskModeMirror} + if id != "1" { + copy.GroundURI = copies["1"].URI + copy.Ground = parent + copy.RootURI = copies["1"].URI + copy.Root = parent + copy.Mode = session.TaskModeWorktree + copy.Branch = "task/child" + if id == "3" { + copy.Branch = "task/active" + } + } + copies[id] = copy + } + // The normal checkpoint writer spells these fields with the path codec. + // This labelled API fixture supplies the same portable representation. + for id, copy := range copies { + copy.Dir = "session:trees/" + id + copy.Ground = "workspace:." + if id != "1" { + copy.Ground = "session:trees/1" + copy.Root = "session:trees/1" + } + } + rows := []map[string]any{{"id": 1, "title": "finished parent fixture", "state": "done", "report": "completed report", "result": "completed answer", "costUsd": 0.125, "changed": []string{"committed.txt", "dirty.txt"}, "copy": copies["1"]}, {"id": 2, "title": "finished child fixture", "state": "done", "report": "completed child report", "changed": []string{"child-committed.txt", "child-dirty.txt", "keep.txt"}, "copy": copies["2"]}, {"id": 3, "title": "active child fixture", "state": "running", "report": "partial report", "pendingRun": map[string]any{}, "copy": copies["3"]}} + raw, err := json.Marshal(map[string]any{"type": "tasks", "version": 1, "seq": 3, "runs": rows}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(session.Place{Dir: at.Root}.Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + return copies +} diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 7f454c89c4..fadfb42f42 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -28,6 +28,11 @@ import ( // TakeOptions is what the surface tells the take side about this machine. type TakeOptions struct { + // WorkspaceRoots explicitly associates incoming working-root identities with + // chosen local folders in the ownership prototype. It grants no landing + // permission; task work is restored into private copies. Nil reuses only + // associations already admitted in this device's registry. + WorkspaceRoots map[string]string // DeviceName is this machine's name: the kept-edits sentence names it. DeviceName string // RootFor is where this machine keeps the chat with an id: the folder it @@ -109,6 +114,20 @@ func (c *Continuer) TakeSeen(ctx context.Context, id string, seen *directory.Lea return c.take(ctx, id, seen, false) } +// TakeSeenWithWorkspaceRoots applies only this person's selected receiver +// bindings to one takeover, retaining the holder expectation they approved. +func (c *Continuer) TakeSeenWithWorkspaceRoots(ctx context.Context, id string, seen *directory.LeaseExpectation, roots map[string]string) (Continued, error) { + copy := *c + copy.opt.WorkspaceRoots = make(map[string]string, len(c.opt.WorkspaceRoots)+len(roots)) + for uri, path := range c.opt.WorkspaceRoots { + copy.opt.WorkspaceRoots[uri] = path + } + for uri, path := range roots { + copy.opt.WorkspaceRoots[uri] = path + } + return copy.take(ctx, id, seen, false) +} + // OpenBranch opens kept turns through the continuation door, under the // branch's own id and folder. It never takes or restores the main chat. func (c *Continuer) OpenBranch(ctx context.Context, id string) (Continued, error) { @@ -132,7 +151,27 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx c.retireHolder(ctx, id) sc := c.sync.scope(id) defer sc.Settle() + intendedPrimary := session.Place{Dir: root, Owned: true}.Work() + if project, here := projectOf(root); here { + intendedPrimary = project + } taker := c.taker(sc) + taker.Preflight = func(_ context.Context, staged cell.Cell) error { + place := session.Place{Dir: staged.Root, Workspace: intendedPrimary} + plan, err := session.TaskWorkspaceTransferPlan(place) + if err != nil || plan == nil { + return err + } + // Independent branches extract settled results in branchIdentity; they + // never inherit the original chat's executable roots or source authority. + if view.Cell.ParentCell != "" { + return nil + } + if !session.WorkspaceOwnershipEnabled(place) { + return errors.New("this chat needs supported private task placement on the receiving device") + } + return session.ValidateTaskWorkspaceSetBindingsForDestination(place, root, c.opt.WorkspaceRoots) + } if view.Cell.ParentCell != "" { // The branch's snapshot was sealed before it had its own id. Rebind // its journal before rebuilding the row, so two chats never share an id. @@ -155,6 +194,23 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err != nil { return Continued{}, err } + place := session.Place{Dir: taken.Cell.Root, Workspace: intendedPrimary} + workspacePlan, err := session.TaskWorkspaceTransferPlan(place) + if err != nil { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) + } + if workspacePlan != nil { + if !session.WorkspaceOwnershipEnabled(place) { + err = errors.New("this chat needs supported private task placement on the receiving device") + } else { + err = session.ValidateTaskWorkspaceSetBindings(place, c.opt.WorkspaceRoots) + } + } else { + err = session.ValidateTaskRestoreRoots(place, intendedPrimary) + } + if err != nil { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) + } if err := cellstore.AdoptedFrom(taken.Cell, from); err != nil { return Continued{}, err } @@ -171,7 +227,19 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } taken.Driving.Cell = taken.Cell - out := Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: c.restoreCopies(taken.Cell), Resume: c.arrive(taken.Cell, from)} + var taskCopies []string + if workspacePlan != nil { + taskCopies, err = session.RestoreTaskWorkspaceSets(ctx, session.Place{Dir: taken.Cell.Root, Workspace: workspaceOf(taken.Cell.Root)}, c.opt.WorkspaceRoots) + if err != nil { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) + } + } else { + if err := session.AdoptChatWorkspaceIdentity(session.Place{Dir: taken.Cell.Root}); err != nil { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) + } + taskCopies = c.restoreCopies(taken.Cell) + } + out := Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} if taken.Kept != "" { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) } @@ -233,10 +301,20 @@ func (c *Continuer) arrive(at cell.Cell, from string) preflight.Resume { // still has its files in place, so the takeover carries on and says so on the // same desk as the vault's sentence. func (c *Continuer) restoreCopies(at cell.Cell) []string { - restored, err := taskcopy.Carry{Cutter: session.TaskCopyCutter{Place: session.Place{Dir: at.Root}}}.Restore(at, workspaceOf(at.Root)) + parents, planErr := session.TaskCopyRestoreParents(session.Place{Dir: at.Root}) + if planErr != nil { + if c.opt.Notify != nil { + c.opt.Notify("task working copies were preserved but could not be rebound here: " + planErr.Error()) + } + return nil + } + restored, err := taskcopy.Carry{Cutter: session.TaskCopyCutter{Place: session.Place{Dir: at.Root}}}.RestoreWithParents(at, workspaceOf(at.Root), parents) if err != nil && c.opt.Notify != nil { c.opt.Notify("a task's working copy could not be set up again here: " + err.Error()) } + if adoptionErr := session.AdoptTaskWorkspaceIdentities(session.Place{Dir: at.Root}, workspaceOf(at.Root), restored); adoptionErr != nil && c.opt.Notify != nil { + c.opt.Notify("a restored task's workspace identity could not be rebound here: " + adoptionErr.Error()) + } return restored } diff --git a/internal/syncsetup/workspace_chat_identity_test.go b/internal/syncsetup/workspace_chat_identity_test.go new file mode 100644 index 0000000000..3535cba267 --- /dev/null +++ b/internal/syncsetup/workspace_chat_identity_test.go @@ -0,0 +1,88 @@ +package syncsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// The accepted public takeover transfers a chat that has never created a task. +// Its sealed chat identity must be adopted before the receiver opens any agent. +func TestTasklessTakePreservesConversationWorkspaceIdentity(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + var providerRequests atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + providerRequests.Add(1) + http.Error(w, "no model allowed in takeover proof", 503) + })) + defer provider.Close() + h := newTwoHomes(t) + t.Setenv(home.EnvVar, h.homeA) + // The relay-only fixture writes a synthetic future-version header. Let the + // actual public session create this fresh fixture's current journal instead. + if err := os.Remove(transcriptOf(h.cell)); err != nil { + t.Fatal(err) + } + source, err := session.New(session.Config{Workspace: h.work, Place: session.Place{Dir: h.cell.Root, Workspace: h.work}, SessionFile: transcriptOf(h.cell), Model: "fixture/no-model-call", APIKey: "fixture-no-provider-key", BaseURL: provider.URL, System: "Mock chat without tasks"}) + if err != nil { + t.Fatal(err) + } + if err := source.Close(); err != nil { + t.Fatal(err) + } + source.SettleWrites() + meta, err := session.LoadMeta(h.cell.Root) + if err != nil || meta.ChatURI == "" { + t.Fatalf("source identity: %+v %v", meta, err) + } + expected := meta.ChatURI + a := h.openA() + a.mustSay("MOCK_TASKLESS_CHAT") + h.durable(h.cell.ID, h.cell) + if err := a.drive.Close(context.Background()); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, h.homeB) + took, err := h.continuerB().Take(context.Background(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + if len(took.TaskCopies) != 0 { + t.Fatal("taskless chat fixture restored task copies") + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + bound, err := registry.Resolve(expected) + if err != nil || bound.Role != workspaceidentity.Chat || bound.Path != took.Taken.Cell.Root { + t.Fatalf("accepted taskless take did not adopt sealed chat identity: %+v %v", bound, err) + } + received, err := session.LoadMeta(took.Taken.Cell.Root) + if err != nil || received.ChatURI != expected { + t.Fatal("received metadata changed chat identity") + } + workspace := workspaceOf(took.Taken.Cell.Root) + receiver, err := session.New(session.Config{Workspace: workspace, Place: session.Place{Dir: took.Taken.Cell.Root, Workspace: workspace, Owned: received.Owned}, SessionFile: transcriptOf(took.Taken.Cell), Model: "fixture/no-model-call", APIKey: "fixture-no-provider-key", BaseURL: provider.URL, System: "Mock received chat without tasks"}) + if err != nil { + t.Fatal(err) + } + if err := receiver.Close(); err != nil { + t.Fatal(err) + } + receiver.SettleWrites() + reopened, err := session.LoadMeta(took.Taken.Cell.Root) + if err != nil || reopened.ChatURI != expected { + t.Fatal("opening receiver reminted chat identity") + } + if providerRequests.Load() != 0 { + t.Fatal("taskless takeover unexpectedly called a provider") + } +} diff --git a/internal/syncsetup/workspace_transfer.go b/internal/syncsetup/workspace_transfer.go new file mode 100644 index 0000000000..890efc9cb5 --- /dev/null +++ b/internal/syncsetup/workspace_transfer.go @@ -0,0 +1,91 @@ +package syncsetup + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// detachWorkspaceSetHistory extracts settled outputs before the ordinary fork +// frontier writer replaces the authenticated checkpoint used by the carrier. +func detachWorkspaceSetHistory(c cell.Cell, place session.Place, history string) (bool, error) { + plan, err := session.TaskWorkspaceTransferPlan(place) + if err != nil { + return false, err + } + live := filepath.Join(c.Root, taskcopy.WorkspaceCarrierPath) + archived := filepath.Join(history, filepath.Base(taskcopy.WorkspaceCarrierPath)) + if plan == nil { + // Frontier detachment is written only after result extraction and artifact + // registration. Finish an interrupted archive move without reinterpreting + // the now intentionally authority-free task rows as runnable copies. + if _, err := os.Stat(filepath.Join(history, "frontier-detached")); err != nil { + return false, nil + } + if _, err := os.Stat(archived); err == nil { + return true, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return false, err + } + if _, err := os.Stat(live); errors.Is(err, fs.ErrNotExist) { + return false, nil + } else if err != nil { + return false, err + } + return true, moveForkHistory(live, archived) + } + selections, err := session.TaskWorkspaceForkResults(place) + if err != nil { + return false, err + } + if err := os.MkdirAll(history, 0700); err != nil { + return false, err + } + outputs, err := taskcopy.RestoreCompletedWorkspaceResults(c, *plan, filepath.Join(history, "results"), selections) + if err != nil { + return false, err + } + if err := recordForkArtifacts(c, place, outputs); err != nil { + return false, err + } + if _, err := session.ForkTaskHistory(place); err != nil { + return false, err + } + return true, moveForkHistory(live, archived) +} + +func recordForkArtifacts(c cell.Cell, place session.Place, outputs map[string]string) error { + for id, root := range outputs { + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return errors.New("historical artifact is not a regular output file") + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + label := id + if layout, err := filepath.Rel(filepath.Join(session.ForkHistoryDir(place), "results"), root); err == nil && filepath.IsLocal(layout) { + label = filepath.ToSlash(layout) + } + session.RecordSealedArtifact(place, session.Artifact{Path: path, Session: c.ID, Title: fmt.Sprintf("Task %s · %s", label, relative), Kind: "document", Created: time.Now()}) + return nil + }); err != nil { + return err + } + } + return nil +} diff --git a/internal/syncsetup/workspace_transfer_test.go b/internal/syncsetup/workspace_transfer_test.go new file mode 100644 index 0000000000..3ec6491ba5 --- /dev/null +++ b/internal/syncsetup/workspace_transfer_test.go @@ -0,0 +1,200 @@ +package syncsetup + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// This protocol fixture intentionally seeds mock structured metadata. The live +// acceptance test separately requires the real model/task door to create it. +func TestPublicTakeRestoresEveryWorkspaceSetRoot(t *testing.T) { + h := newTwoHomes(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv(home.EnvVar, h.homeA) + chat := h.openA() + chat.mustSay("mock protocol baseline") + if err := chat.drive.Close(context.Background()); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + owner, err := registry.Register(workspaceidentity.Chat, h.cell.Root) + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(h.cell.Root) + if err != nil { + t.Fatal(err) + } + meta.ChatURI = owner.URI + if err := session.SaveMeta(h.cell.Root, meta); err != nil { + t.Fatal(err) + } + set := &session.WorkspaceSet{Version: 1, Primary: "app"} + plan := &taskcopy.WorkspacePlan{Version: 2, ChatURI: owner.URI} + group := taskcopy.WorkspaceGroup{TaskID: 1, Primary: "app"} + receiverBindings := map[string]string{} + for _, slot := range []string{"app", "library"} { + source := t.TempDir() + project, err := registry.Register(workspaceidentity.Project, source) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(h.cell.Root, cell.TreesDir, "1", slot) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "baseline.txt"), []byte("input "+slot+"\n"), 0600); err != nil { + t.Fatal(err) + } + git(t, dir, "init", "-q", "-b", "task/"+slot) + git(t, dir, "add", ".") + git(t, dir, "commit", "-qm", "mock input") + base, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output() + if err != nil { + t.Fatal(err) + } + run, err := registry.Register(workspaceidentity.Run, dir, workspaceidentity.Links{OwnerURI: owner.URI, ProjectURI: project.URI}) + if err != nil { + t.Fatal(err) + } + copy := &session.TaskCopyRecord{URI: run.URI, ChatURI: owner.URI, ProjectURI: project.URI, GroundURI: project.URI, Dir: "session:trees/1/" + slot, Mode: session.TaskModeMirror, Branch: "task/" + slot, CheckBase: strings.TrimSpace(string(base))} + set.Roots = append(set.Roots, session.WorkspaceRootUse{Slot: slot, SourceURI: project.URI, Access: session.WorkspaceWrite, Copy: copy}) + group.Entries = append(group.Entries, taskcopy.WorkspaceEntry{CopyURI: run.URI, ChatURI: owner.URI, ProjectURI: project.URI, RootURI: project.URI, GroundURI: project.URI, Layout: "1/" + slot, Access: "write"}) + if err := os.WriteFile(filepath.Join(dir, "result.txt"), []byte("private output "+slot+"\n"), 0750); err != nil { + t.Fatal(err) + } + receiverBindings[project.URI] = t.TempDir() + } + plan.Groups = append(plan.Groups, group) + document := map[string]any{"type": "tasks", "version": 2, "seq": 1, "workspace_transfer": plan, + "runs": []any{map[string]any{"id": 1, "title": "mock set", "state": "done", "workspaceSet": set, "copy": set.Roots[0].Copy}}} + raw, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile((session.Place{Dir: h.cell.Root}).Tasks(), raw, 0600); err != nil { + t.Fatal(err) + } + if _, err := h.engine.Seal(context.Background(), h.cell, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + view, err := h.a.Dir.Cell(context.Background(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + engine := h.engine.Sync(cellstore.SyncKeys{CellKey: h.a.Identity.CellKey(), Dedup: h.a.Identity.DedupSecret()}, h.a.Ledger) + publisher := &cellsync.Publisher{Engine: engine, Store: h.a.Store, Dir: h.a.Dir} + driving := cellsync.Driving{Cell: h.cell, Fence: view.Cell.Lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames} + if err := publisher.Publish(context.Background(), &driving, headOf(t, h.cell), cellsync.PublishInfo{}); err != nil { + t.Fatal(err) + } + // Drive the public takeover, not the internal carrier, so hook ordering and + // receiver authority are part of this regression's observable behavior. + t.Setenv(home.EnvVar, h.homeB) + unmapped := h.b.Continuer(h.engB, TakeOptions{RootFor: func(id string) string { return filepath.Join(h.rootsB, id) }}) + before, err := h.b.Dir.Cell(context.Background(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + _, missingErr := unmapped.Take(context.Background(), h.cell.ID) + var missing *session.WorkspaceBindingsRequired + if !errors.As(missingErr, &missing) || len(missing.Roots) != 2 { + t.Fatalf("takeover did not offer both missing roots: %v", missingErr) + } + for _, root := range missing.Roots { + if receiverBindings[root.URI] == "" || !strings.HasPrefix(root.Label, "1/") { + t.Fatalf("root choice lost source identity or declared layout: %+v", root) + } + } + after, err := h.b.Dir.Cell(context.Background(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + if before.Cell.Lease != after.Cell.Lease { + t.Fatal("asking for root mappings displaced the existing holder") + } + // The fetched tree lives beside the final root during admission. A binding + // to the final root must fail before the lease is acquired. + overlap := make(map[string]string, len(receiverBindings)) + for uri, path := range receiverBindings { + overlap[uri] = path + } + receiverRoot := filepath.Join(h.rootsB, h.cell.ID) + scope := h.b.scope(h.cell.ID) + if err := unmapped.taker(scope).Fetch.Fetch(context.Background(), cell.Cell{ID: h.cell.ID, Root: receiverRoot}, before.Cell.Head); err != nil { + t.Fatal(err) + } + scope.Settle() + overlap[missing.Roots[0].URI] = receiverRoot + if _, err := unmapped.TakeSeenWithWorkspaceRoots(context.Background(), h.cell.ID, &directory.LeaseExpectation{Device: before.Cell.Lease.Device, Fence: before.Cell.Lease.Fence}, overlap); err == nil || !strings.Contains(err.Error(), "overlaps private receiver storage") { + t.Fatalf("takeover did not reject overlapping final private storage: %v", err) + } + afterOverlap, err := h.b.Dir.Cell(context.Background(), h.cell.ID) + if err != nil { + t.Fatal(err) + } + if before.Cell.Lease != afterOverlap.Cell.Lease { + t.Fatal("rejecting final-root overlap displaced the existing holder") + } + wrong := &directory.LeaseExpectation{Device: "changed-holder", Fence: before.Cell.Lease.Fence} + if _, err := unmapped.TakeSeenWithWorkspaceRoots(context.Background(), h.cell.ID, wrong, receiverBindings); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("mapping retry ignored changed holder: %v", err) + } + seen := &directory.LeaseExpectation{Device: before.Cell.Lease.Device, Fence: before.Cell.Lease.Fence} + got, err := unmapped.TakeSeenWithWorkspaceRoots(context.Background(), h.cell.ID, seen, receiverBindings) + if err != nil { + t.Fatalf("public Take lost a declared working root: %v", err) + } + for _, slot := range []string{"app", "library"} { + file := filepath.Join(got.Taken.Cell.Root, cell.TreesDir, "1", slot, "result.txt") + raw, err := os.ReadFile(file) + if err != nil || string(raw) != "private output "+slot+"\n" { + t.Fatalf("%s root output: %q, %v", slot, raw, err) + } + } + for _, source := range receiverBindings { + if entries, err := os.ReadDir(source); err != nil || len(entries) != 0 { + t.Fatalf("receiver association was used as a landing destination: %v, %v", entries, err) + } + } + t.Setenv(home.EnvVar, h.homeA) + brancher := cellsync.Brancher{Dir: h.a.Dir, Publisher: publisher, NewID: func() (string, error) { return cell.NewID(h.clock.Now()) }} + branch, err := brancher.Branch(context.Background(), &driving, headOf(t, h.cell), 0, cellsync.PublishInfo{Title: "mock independent results"}) + if err != nil { + t.Fatal(err) + } + forkBase := t.TempDir() + opener := h.a.Continuer(h.engine, TakeOptions{RootFor: func(id string) string { return filepath.Join(forkBase, id) }}) + fork, err := opener.OpenBranch(context.Background(), branch) + if err != nil { + t.Fatalf("public fork could not preserve settled multi-root results: %v", err) + } + history := session.ForkHistoryDir(session.Place{Dir: fork.Taken.Cell.Root}) + for _, slot := range []string{"app", "library"} { + raw, err := os.ReadFile(filepath.Join(history, "results", "1", slot, "result.txt")) + if err != nil || string(raw) != "private output "+slot+"\n" { + t.Fatalf("fork lost %s output: %q, %v", slot, raw, err) + } + if _, err := os.Stat(filepath.Join(history, "results", "1", slot, "baseline.txt")); !os.IsNotExist(err) { + t.Fatalf("fork copied unchanged input as output: %v", err) + } + } +} diff --git a/internal/taskcopy/baseline.go b/internal/taskcopy/baseline.go new file mode 100644 index 0000000000..9f12b15647 --- /dev/null +++ b/internal/taskcopy/baseline.go @@ -0,0 +1,32 @@ +package taskcopy + +import ( + "errors" + "os" + "path/filepath" +) + +const independentBaselineFile = "codeaf-independent-baseline" + +// MarkIndependentBaseline records the harness-created baseline of an owned +// mirror. Its original plain folder cannot supply Git prerequisites on relay. +// Callers use this only while creating their private mirror repository. +func MarkIndependentBaseline(tree string) error { + info, err := os.Lstat(filepath.Join(tree, ".git")) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("independent baseline requires its own repository") + } + return os.WriteFile(filepath.Join(tree, ".git", independentBaselineFile), []byte("independent\n"), 0600) +} + +func independentBaseline(tree string) bool { + info, err := os.Lstat(filepath.Join(tree, ".git")) + if err != nil || !info.IsDir() { + return false + } + marker, err := os.ReadFile(filepath.Join(tree, ".git", independentBaselineFile)) + return err == nil && string(marker) == "independent\n" +} diff --git a/internal/taskcopy/cold_prototype.go b/internal/taskcopy/cold_prototype.go new file mode 100644 index 0000000000..16b2b5c175 --- /dev/null +++ b/internal/taskcopy/cold_prototype.go @@ -0,0 +1,137 @@ +package taskcopy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +const coldRecord = "cold.json" + +type coldCarry struct { + Snapshot string `json:"snapshot"` + Withheld []inventory.Withheld `json:"withheld,omitempty"` +} + +// PrepareCold freezes only the screened, portable task artifact while the +// session still owns the quiescent working copy. Full local archives stay local. +func (k Carry) PrepareCold(c cell.Cell, tree string) error { + rel, e := filepath.Rel(liveRoot(c), tree) + if e != nil || !filepath.IsLocal(rel) || filepath.Dir(rel) != "." { + return errors.New("cold task copy is outside its cell") + } + if !isRepository(tree) { + return errors.New("cold task copy has no portable repository") + } + return carryWithHistory(tree, filepath.Join(carriedRoot(c), rel), k.Project != "" && !isRepository(k.Project) && !isLinkedWorktree(tree)) +} + +// MarkCold binds the already-prepared relay artifact to the verified local +// archive. A missing marker blocks sealing rather than dropping task recovery. +func (Carry) MarkCold(c cell.Cell, tree, snapshot string) error { + rel, e := filepath.Rel(liveRoot(c), tree) + if e != nil || !filepath.IsLocal(rel) || filepath.Dir(rel) != "." || snapshot == "" { + return errors.New("invalid cold task artifact") + } + dest := filepath.Join(carriedRoot(c), rel) + if _, e = readRecord(dest); e != nil { + return e + } + return writeCold(dest, coldCarry{Snapshot: snapshot, Withheld: leftOutOf(tree)}) +} +func writeCold(dest string, marker coldCarry) error { + raw, e := json.Marshal(marker) + if e != nil { + return e + } + temp, e := os.CreateTemp(dest, ".cold-") + if e != nil { + return e + } + name := temp.Name() + defer os.Remove(name) + if e = temp.Chmod(0600); e == nil { + _, e = temp.Write(raw) + } + if e == nil { + e = temp.Sync() + } + closed := temp.Close() + if e == nil { + e = closed + } + if e != nil { + return e + } + return os.Rename(name, filepath.Join(dest, coldRecord)) +} + +// coldCopies validates cached portable artifacts without expanding any archive. +// It runs before the ordinary carried-copy sweep can mistake cold for deleted. +func coldCopies(c cell.Cell) ([]string, []inventory.Withheld, error) { + manager, enabled := taskstorage.Prototype(liveRoot(c)) + if !enabled { + // Capability switches do not erase durable lifecycle evidence. Read it + // without enabling capture, recovery or retirement for this profile. + manager = taskstorage.Manager{Root: home.Join("task-storage"), Trees: liveRoot(c)} + entries, err := os.ReadDir(carriedRoot(c)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, nil, err + } + for _, entry := range entries { + marker := filepath.Join(carriedRoot(c), entry.Name(), coldRecord) + if _, err := os.Lstat(marker); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return nil, nil, err + } + return nil, nil, errors.New("cold task recovery requires task storage support before sealing; portable artifacts were retained") + } + if _, err := os.Lstat(manager.Root); errors.Is(err, os.ErrNotExist) { + return nil, nil, nil + } else if err != nil { + return nil, nil, err + } + } + paths, e := manager.Paths(context.Background()) + if e != nil { + return nil, nil, e + } + var cold []string + var withheld []inventory.Withheld + for _, tree := range paths { + state, e := manager.Inspect(context.Background(), tree) + if e != nil { + return nil, nil, e + } + if state.Phase == taskstorage.Resident { + continue + } + if !enabled { + return nil, nil, errors.New("saved task storage needs its supported reader before sealing; portable artifacts were retained") + } + if state.Phase != taskstorage.Cold { + return nil, nil, errors.New("task storage transition needs recovery before sealing") + } + dest := filepath.Join(carriedRoot(c), filepath.Base(tree)) + var marker coldCarry + raw, e := os.ReadFile(filepath.Join(dest, coldRecord)) + if e != nil || json.Unmarshal(raw, &marker) != nil || marker.Snapshot != state.Snapshot { + return nil, nil, fmt.Errorf("cold task %s has no matching prepared relay artifact", filepath.Base(tree)) + } + if e := screenCarriedHistory(dest); e != nil { + return nil, nil, e + } + cold = append(cold, tree) + withheld = append(withheld, marker.Withheld...) + } + return cold, withheld, nil +} diff --git a/internal/taskcopy/cold_prototype_test.go b/internal/taskcopy/cold_prototype_test.go new file mode 100644 index 0000000000..7df1ba40dd --- /dev/null +++ b/internal/taskcopy/cold_prototype_test.go @@ -0,0 +1,123 @@ +package taskcopy + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +type coldVault struct{ text []byte } + +func (v *coldVault) Capture(_ context.Context, path string) (string, error) { + b, e := os.ReadFile(filepath.Join(path, "README.md")) + v.text = b + return "saved", e +} +func (v *coldVault) Restore(_ context.Context, _ string, path string) error { + if e := os.Mkdir(path, 0700); e != nil { + return e + } + return os.WriteFile(filepath.Join(path, "README.md"), v.text, 0644) +} +func (v *coldVault) Verify(_ context.Context, _ string, path string) error { + b, e := os.ReadFile(filepath.Join(path, "README.md")) + if e != nil { + return e + } + if string(b) != string(v.text) { + return errors.New("different fixture") + } + return nil +} + +func TestSealDoesNotDiscardUnpreparedColdTaskCarry(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + a := newMachine(t) + tree := a.task("one") + a.seal() + manager, enabled := taskstorage.Prototype(liveRoot(a.cell)) + if !enabled { + t.Fatal("prototype disabled") + } + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + if e := manager.Park(context.Background(), tree); e != nil { + t.Fatal(e) + } + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "") + if e := (Carry{}).Compose(a.cell); e == nil { + t.Fatal("disabled storage silently discarded an unmarked cold task") + } + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + if e := (Carry{}).Compose(a.cell); e == nil { + t.Fatal("seal silently accepted a cold copy with no prepared relay cache") + } + if _, e := readRecord(filepath.Join(carriedRoot(a.cell), "one")); e != nil { + t.Fatalf("seal deleted the last relay artifact: %v", e) + } +} + +func TestColdTaskSealsAndTravelsWithoutExpandingItsArchive(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + a := newMachine(t) + tree := a.task("one") + write(t, filepath.Join(tree, "note.txt"), "uncommitted task note\n") + write(t, filepath.Join(tree, "build", "cache.txt"), "local dependency bytes\n") + carry := Carry{} + if e := carry.PrepareCold(a.cell, tree); e != nil { + t.Fatal(e) + } + manager, _ := taskstorage.Prototype(liveRoot(a.cell)) + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { return carry.MarkCold(a.cell, path, snapshot) } + if e := manager.Park(context.Background(), tree); e != nil { + t.Fatal(e) + } + // Restarting without the experimental capability cannot reinterpret a + // retired directory as a deleted task and sweep its portable recovery. + artifact := filepath.Join(carriedRoot(a.cell), "one", recordName) + before, e := os.ReadFile(artifact) + if e != nil { + t.Fatal(e) + } + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "") + if e := carry.Compose(a.cell); e == nil { + t.Fatal("disabled storage silently accepted a cold task") + } + after, e := os.ReadFile(artifact) + if e != nil || string(after) != string(before) { + t.Fatalf("disabled storage changed the cold relay artifact: %v", e) + } + state, e := manager.Inspect(context.Background(), tree) + if e != nil || state.Phase != taskstorage.Cold || state.Snapshot == "" { + t.Fatalf("disabled storage lost local archive recovery: %+v %v", state, e) + } + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + for i := 0; i < 2; i++ { + a.seal() + } + if _, e := os.Stat(tree); !os.IsNotExist(e) { + t.Fatalf("sealing expanded the cold task: %v", e) + } + b := newMachineNoRepo(t) + a.moveTo(b) + names, e := restorer.Restore(b.cell, b.project) + if e != nil || len(names) != 1 || names[0] != "one" { + t.Fatalf("cold task takeover: %v %v", names, e) + } + if body, ok := b.read("one", "note.txt"); !ok || body != "uncommitted task note\n" { + t.Fatalf("restored note: %q %v", body, ok) + } + if _, ok := b.read("one", "build/cache.txt"); ok { + t.Fatal("local-only ignored data was broadened into relay") + } +} diff --git a/internal/taskcopy/compose.go b/internal/taskcopy/compose.go index 86e55e363c..d1fe7bf3dc 100644 --- a/internal/taskcopy/compose.go +++ b/internal/taskcopy/compose.go @@ -16,7 +16,11 @@ import ( // Carry is the mechanism: [Carry.Compose] on the seal side, [Carry.Restore] on // the take side. Compose needs nothing, so the zero value composes; Restore // needs the [Cutter] that makes copies. -type Carry struct{ Cutter Cutter } +type Carry struct { + Cutter Cutter + // Project supplies a plain mirror's source when preparing it before a seal. + Project string +} // Compose writes what every live task copy of c holds beyond its last commit // into the cell's .cell/trees/. It is one of the things a seal composes before @@ -24,17 +28,35 @@ type Carry struct{ Cutter Cutter } // finished and its folder was removed) leaves nothing behind, and a copy with no // ordinary edits leaves only its record. A clean file with mixed line endings // carries its raw bytes, because a checkout cannot reconstruct their layout. -func (Carry) Compose(c cell.Cell) error { +func (k Carry) Compose(c cell.Cell) error { return k.ComposeWorkspace(c, k.Project) } + +// ComposeWorkspace receives the engine's current project, including recovery. +// A plain folder has no Git baseline on the receiving machine, so its private +// mirror roots must carry their entire reachable HEAD, even when child refs +// already contain the parent's commit before its first capture. +func (k Carry) ComposeWorkspace(c cell.Cell, project string) error { + plan, digest, err := ReadWorkspacePlan(c) + if err != nil { + return err + } + if plan != nil { + return k.composeWorkspacePlan(c, *plan, digest) + } + cold, withheld, err := coldCopies(c) + if err != nil { + return err + } live := liveCopies(liveRoot(c)) - if err := dropCarriedExcept(carriedRoot(c), live); err != nil { + keep := append(append([]string(nil), live...), cold...) + if err := dropCarriedExcept(carriedRoot(c), keep); err != nil { return err } for _, tree := range live { - if err := carry(tree, filepath.Join(carriedRoot(c), filepath.Base(tree))); err != nil { + if err := carryWithHistory(tree, filepath.Join(carriedRoot(c), filepath.Base(tree)), project != "" && !isRepository(project) && !isLinkedWorktree(tree)); err != nil { return fmt.Errorf("carry task copy %s: %w", filepath.Base(tree), err) } } - return noteLeftOut(c, live) + return noteLeftOutWith(c, live, withheld) } // liveCopies is the folders under root that are git working trees, which is what @@ -77,7 +99,13 @@ func dropCarriedExcept(root string, live []string) error { } // carry writes one copy's record and changed files into dest. -func carry(tree, dest string) error { +func carry(tree, dest string) error { return carryWithHistory(tree, dest, false) } + +func carryWithHistory(tree, dest string, full bool) error { + full = full || independentBaseline(tree) + if prior, err := readRecord(dest); err == nil && prior.FullHistory { + full = true + } head, err := git(tree, "rev-parse", "HEAD") if err != nil { return err @@ -96,23 +124,29 @@ func carry(tree, dest string) error { return err } branch := branchOf(tree) - if err := carryCommits(tree, branch, strings.TrimSpace(head), dest); err != nil { + if err := carryCommits(tree, branch, strings.TrimSpace(head), dest, full); err != nil { return err } - return writeRecord(dest, record{Branch: branch, Head: strings.TrimSpace(head), Deleted: deleted, Linked: isLinkedWorktree(tree), Modes: portableModes(tree, dest), EOL: styles}) + return writeRecord(dest, record{FullHistory: full, Branch: branch, Head: strings.TrimSpace(head), Deleted: deleted, Linked: isLinkedWorktree(tree), Modes: portableModes(tree, dest), EOL: styles}) } // carryCommits keeps dest's bundle of the commits only this copy holds in step // with the copy's head. A head that has not moved since the last seal leaves the // bundle untouched, so an idle task adds nothing to what the seal uploads. -func carryCommits(tree, branch, head, dest string) error { - if prior, err := readRecord(dest); err == nil && prior.Head == head { +func carryCommits(tree, branch, head, dest string, full bool) error { + if prior, err := readRecord(dest); err == nil && prior.Head == head && (!full || prior.FullHistory) { + if full { + return screenFullHistory(filepath.Join(dest, bundleName)) + } return nil } path := filepath.Join(dest, bundleName) if err := os.MkdirAll(dest, 0o700); err != nil { return err } + if full { + return writeScreenedFullHistory(tree, path) + } if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } diff --git a/internal/taskcopy/full_history_secrets_test.go b/internal/taskcopy/full_history_secrets_test.go new file mode 100644 index 0000000000..1628c0f83d --- /dev/null +++ b/internal/taskcopy/full_history_secrets_test.go @@ -0,0 +1,404 @@ +package taskcopy + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +func TestPlainBaselineSecretsRefusePortableComposition(t *testing.T) { + for _, sample := range []struct { + name, path, body string + removed bool + }{ + {"dotenv", ".env", "MOCK_TOKEN=synthetic-only\n", false}, + {"private-key", "ordinary.txt", "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n-----END PRIVATE KEY-----\n", false}, + {"large-tail-key", "ordinary.dat", strings.Repeat("safe bytes\n", 200000) + "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n", false}, + {"historical-key", "removed.txt", "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n-----END PRIVATE KEY-----\n", true}, + } { + t.Run(sample.name, func(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + path := filepath.Join(tree, sample.path) + write(t, path, sample.body) + run(t, tree, "git", "add", "--force", sample.path) + run(t, tree, "git", "commit", "-qm", "synthetic baseline secret") + if sample.removed { + run(t, tree, "git", "rm", "-q", sample.path) + run(t, tree, "git", "commit", "-qm", "remove synthetic secret") + } + before := run(t, tree, "git", "rev-parse", "HEAD") + err := (Carry{}).ComposeWorkspace(source.cell, source.project) + if err == nil { + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + _, restoreErr := restorer.Restore(receiver.cell, receiver.project) + t.Fatalf("portable composition admitted secret-bearing full history (restore error %v)", restoreErr) + } + if !strings.Contains(err.Error(), "secret") { + t.Fatalf("expected actionable secret refusal, got %v", err) + } + if after := run(t, tree, "git", "rev-parse", "HEAD"); after != before { + t.Fatal("refusal changed local history") + } + if sample.removed { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("removed file changed: %v", err) + } + } else if got, err := os.ReadFile(path); err != nil || string(got) != sample.body { + t.Fatalf("local secret changed: %v", err) + } + }) + } +} + +func TestColdLegacyFullHistorySecretsRefuseComposition(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(tree, "old.txt"), "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n") + run(t, tree, "git", "add", "old.txt") + run(t, tree, "git", "commit", "-qm", "legacy unsafe baseline") + dest := filepath.Join(carriedRoot(source.cell), "1") + run(t, tree, "git", "bundle", "create", filepath.Join(dest, bundleName), "HEAD") + rec, err := readRecord(dest) + if err != nil { + t.Fatal(err) + } + rec.Head = run(t, tree, "git", "rev-parse", "HEAD") + if err := writeRecord(dest, rec); err != nil { + t.Fatal(err) + } + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("cached live full history bypassed secret screening") + } + manager, _ := taskstorage.Prototype(liveRoot(source.cell)) + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { + return (Carry{}).MarkCold(source.cell, path, snapshot) + } + if err := manager.Park(context.Background(), tree); err != nil { + t.Fatal(err) + } + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("cached cold full history bypassed secret screening") + } + if _, err := os.Stat(tree); !os.IsNotExist(err) { + t.Fatalf("screening expanded cold copy: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, bundleName)); err != nil { + t.Fatalf("refusal deleted legacy artifact: %v", err) + } +} + +func TestPortableFullHistoryScreensCommitMessages(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + run(t, tree, "git", "commit", "--allow-empty", "-qm", "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("full history exported secret marker in commit message") + } +} + +func TestSafeFullHistoryPreservesTemplatesAndPriorArtifact(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + write(t, filepath.Join(tree, ".env.example"), "MOCK_TOKEN=replace-me\n") + run(t, tree, "git", "add", ".env.example") + run(t, tree, "git", "commit", "-qm", "safe template") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + artifact := filepath.Join(carriedRoot(source.cell), "1", bundleName) + before, err := os.ReadFile(artifact) + if err != nil { + t.Fatal(err) + } + write(t, filepath.Join(tree, "secret.txt"), "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n") + run(t, tree, "git", "add", "secret.txt") + run(t, tree, "git", "commit", "-qm", "unsafe update") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("unsafe update admitted") + } + after, err := os.ReadFile(artifact) + if err != nil || string(before) != string(after) { + t.Fatalf("refused update lost previous safe artifact: %v", err) + } + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + if _, err := restorer.Restore(receiver.cell, receiver.project); err != nil { + t.Fatal(err) + } + if body, ok := receiver.read("1", ".env.example"); !ok || body != "MOCK_TOKEN=replace-me\n" { + t.Fatal("safe template did not restore exactly") + } +} + +func TestCachedFullHistoryScreensBlobTagsLiveAndCold(t *testing.T) { + for _, cold := range []bool{false, true} { + t.Run(map[bool]string{false: "live", true: "cold"}[cold], func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + marker := filepath.Join(t.TempDir(), "marker.txt") + write(t, marker, "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n") + blob := run(t, tree, "git", "hash-object", "-w", marker) + run(t, tree, "git", "tag", "unreferenced-secret", blob) + artifact := filepath.Join(carriedRoot(source.cell), "1", bundleName) + run(t, tree, "git", "bundle", "create", artifact, "HEAD", "refs/tags/unreferenced-secret") + before, err := os.ReadFile(artifact) + if err != nil { + t.Fatal(err) + } + if cold { + manager, _ := taskstorage.Prototype(liveRoot(source.cell)) + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { + return (Carry{}).MarkCold(source.cell, path, snapshot) + } + if err := manager.Park(context.Background(), tree); err != nil { + t.Fatal(err) + } + } + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("tag-only secret blob escaped full-bundle screening") + } + after, err := os.ReadFile(artifact) + if err != nil || string(before) != string(after) { + t.Fatalf("screening changed cached bundle: %v", err) + } + }) + } +} + +func TestCachedFullHistoryScreensReferenceNames(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + tag := "ghp_" + strings.Repeat("A", 32) + run(t, tree, "git", "tag", tag) + artifact := filepath.Join(carriedRoot(source.cell), "1", bundleName) + run(t, tree, "git", "bundle", "create", artifact, "HEAD", "refs/tags/"+tag) + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil { + t.Fatal("secret-bearing reference name bypassed screening") + } +} + +func TestCompositionRefusesTruncatedHistoryRead(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX executable fault injection") + } + source := unsealedPlainMirrorFixture(t) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + script := "#!/bin/sh\nif [ \"$3\" = cat-file ] && [ \"$4\" = --batch ]; then\n printf '%040d blob 100\\nshort' 0\n exit 0\nfi\nexec " + quote(realGit) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(directory, "git"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil || !strings.Contains(err.Error(), "truncated") { + t.Fatalf("incomplete object judged clean: %v", err) + } + tree := filepath.Join(liveRoot(source.cell), "1") + if got, err := os.ReadFile(filepath.Join(tree, "README.md")); err != nil || string(got) != "plain project input\n" { + t.Fatalf("refusal changed source: %v", err) + } +} + +func TestCachedFullHistoryRejectsNoncanonicalContainer(t *testing.T) { + token := "ghp_" + strings.Repeat("B", 32) + for _, kind := range []string{"custom-reference", "malformed-reference", "trailing-bytes", "duplicate-head", "prerequisite"} { + for _, cold := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/cold=%v", kind, cold), func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + artifact := filepath.Join(carriedRoot(source.cell), "1", bundleName) + raw, err := os.ReadFile(artifact) + if err != nil { + t.Fatal(err) + } + header, pack, ok := bytes.Cut(raw, []byte("\n\n")) + if !ok { + t.Fatal("fixture bundle lacks header") + } + header = append([]byte(nil), header...) + pack = append([]byte(nil), pack...) + head := run(t, tree, "git", "rev-parse", "HEAD") + switch kind { + case "custom-reference": + header = append(header, []byte("\n"+head+" refs/custom/"+token)...) + case "malformed-reference": + header = append(header, []byte("\n"+head+" refs/heads/bad.."+token)...) + case "trailing-bytes": + pack = append(pack, []byte(token)...) + case "duplicate-head": + header = append(header, []byte("\n"+head+" HEAD")...) + case "prerequisite": + header = append(header, []byte("\n-"+head+" "+token)...) + } + wire := append(append(append([]byte(nil), header...), []byte("\n\n")...), pack...) + if err := os.WriteFile(artifact, wire, 0600); err != nil { + t.Fatal(err) + } + if cold { + manager, _ := taskstorage.Prototype(liveRoot(source.cell)) + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { + return (Carry{}).MarkCold(source.cell, path, snapshot) + } + if err := manager.Park(context.Background(), tree); err != nil { + t.Fatal(err) + } + } + err = (Carry{}).ComposeWorkspace(source.cell, source.project) + if err == nil { + t.Fatal("noncanonical container accepted") + } + if strings.Contains(err.Error(), token) { + t.Fatal("refusal echoed secret header or pack bytes") + } + after, err := os.ReadFile(artifact) + if err != nil || !bytes.Equal(wire, after) { + t.Fatalf("refusal changed original container: %v", err) + } + }) + } + } +} + +func TestCanonicalFullHistoryScreensUnreferencedPackBlob(t *testing.T) { + for _, cold := range []bool{false, true} { + t.Run(fmt.Sprintf("cold=%v", cold), func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + t.Setenv("CODEAF_PROTOTYPE_ARCHIVE_BINARY", filepath.Join(t.TempDir(), "unavailable")) + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + marker := filepath.Join(t.TempDir(), "marker.txt") + write(t, marker, "-----BEGIN PRIVATE KEY-----\nSYNTHETIC-ONLY\n") + blob := run(t, tree, "git", "hash-object", "-w", marker) + var objects strings.Builder + for _, entry := range strings.Split(run(t, tree, "git", "rev-list", "--objects", "HEAD"), "\n") { + objects.WriteString(strings.Fields(entry)[0] + "\n") + } + objects.WriteString(blob + "\n") + command := exec.Command("git", "-C", tree, "pack-objects", "--stdout") + command.Stdin = strings.NewReader(objects.String()) + pack, err := command.Output() + if err != nil { + t.Fatal("cannot create mock pack") + } + artifact := filepath.Join(carriedRoot(source.cell), "1", bundleName) + raw, err := os.ReadFile(artifact) + if err != nil { + t.Fatal(err) + } + header, _, ok := bytes.Cut(raw, []byte("\n\n")) + if !ok { + t.Fatal("fixture header missing") + } + wire := append(append(append([]byte(nil), header...), []byte("\n\n")...), pack...) + if err := os.WriteFile(artifact, wire, 0600); err != nil { + t.Fatal(err) + } + if cold { + manager, _ := taskstorage.Prototype(liveRoot(source.cell)) + manager.Vault = &coldVault{} + manager.Busy = func(string) bool { return false } + manager.BeforeRetire = func(_ context.Context, snapshot, path string) error { + return (Carry{}).MarkCold(source.cell, path, snapshot) + } + if err := manager.Park(context.Background(), tree); err != nil { + t.Fatal(err) + } + } + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err == nil || !strings.Contains(err.Error(), "secret material") { + t.Fatalf("unreferenced pack blob escaped object scan: %v", err) + } + after, err := os.ReadFile(artifact) + if err != nil || !bytes.Equal(wire, after) { + t.Fatalf("screening modified original bundle: %v", err) + } + }) + } +} + +func TestFullHistoryCompositionAcceptsCanonicalObjectFormats(t *testing.T) { + for _, format := range []string{"sha1", "sha256"} { + t.Run(format, func(t *testing.T) { + source := newMachineNoRepo(t) + tree := filepath.Join(liveRoot(source.cell), "1") + write(t, filepath.Join(tree, "safe.txt"), "mock safe bytes\n") + command := exec.Command("git", "-C", tree, "init", "-q", "--object-format="+format) + if err := command.Run(); err != nil { + t.Skip("installed Git lacks requested object format") + } + run(t, tree, "git", "add", ".") + run(t, tree, "git", "commit", "-qm", "mock baseline") + for range 2 { + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestHistoryInspectionNeverEchoesGitDiagnostics(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX executable fault injection") + } + source := unsealedPlainMirrorFixture(t) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + token := "ghp_" + strings.Repeat("C", 32) + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + script := "#!/bin/sh\nif [ \"$3\" = index-pack ]; then\n printf '%s\\n' " + quote(token) + " >&2\n exit 1\nfi\nexec " + quote(realGit) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(directory, "git"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) + err = (Carry{}).ComposeWorkspace(source.cell, source.project) + if err == nil || strings.Contains(err.Error(), token) { + t.Fatal("inspection diagnostics were accepted or echoed") + } +} diff --git a/internal/taskcopy/git.go b/internal/taskcopy/git.go index 9673f62933..9fd7c97178 100644 --- a/internal/taskcopy/git.go +++ b/internal/taskcopy/git.go @@ -5,6 +5,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/Agent-Field/codeaf/internal/env" ) // git runs one git command in dir and answers its standard output as it came. @@ -14,6 +16,11 @@ func git(dir string, args ...string) (string, error) { var stderr strings.Builder cmd := exec.Command("git", args...) cmd.Dir = dir + // A cold capture must not be invalidated by an optional index refresh in + // a read-only Git query while its portable relay artifact is prepared. + if env.Get("CODEAF_PROTOTYPE_TASK_STORAGE") == "1" { + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") + } cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { diff --git a/internal/taskcopy/history_screen.go b/internal/taskcopy/history_screen.go new file mode 100644 index 0000000000..a7fdbae72c --- /dev/null +++ b/internal/taskcopy/history_screen.go @@ -0,0 +1,330 @@ +package taskcopy + +import ( + "bufio" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/keys" +) + +// writeScreenedFullHistory publishes only a validated candidate, retaining the +// preceding portable bundle if screening or extraction fails. +func writeScreenedFullHistory(tree, destination string) error { + temporary, err := os.CreateTemp(filepath.Dir(destination), ".screen-history-*") + if err != nil { + return err + } + staged := temporary.Name() + defer os.Remove(staged) + if err := temporary.Close(); err != nil { + return err + } + if _, err := git(tree, "bundle", "create", staged, "HEAD"); err != nil { + return errors.New("cannot create portable full history; local work is preserved") + } + if err := screenFullHistory(staged); err != nil { + return err + } + return os.Rename(staged, destination) +} + +// screenFullHistory judges every object in the exact self-contained bundle. +// A single batch stream covers blobs reached only by tags as well as complete +// history. Tree entries retain filename rules; raw objects retain content rules. +func screenFullHistory(bundle string) error { + file, err := os.Open(bundle) + if err != nil { + return errors.New("cannot read portable full history; local work is preserved") + } + defer file.Close() + header, err := readFullBundleHeader(file) + if err != nil { + return err + } + scratch, err := os.MkdirTemp("", "codeaf-screen-history-") + if err != nil { + return err + } + defer os.RemoveAll(scratch) + if err := historyGitCommand(scratch, "init", "--bare", "-q", "--object-format="+header.format).Run(); err != nil { + return errFullHistoryContainer + } + if err := importFullBundlePack(file, scratch, header); err != nil { + return err + } + kind, err := historyGitCommand(scratch, "cat-file", "-t", header.head).Output() + if err != nil || string(kind) != "commit\n" { + return errFullHistoryContainer + } + if err := historyGitCommand(scratch, "fsck", "--strict", "--no-reflogs", "--no-dangling", header.head).Run(); err != nil { + return errFullHistoryContainer + } + scanner := keys.Scanner{} + return screenHistoryObjects(scratch, &scanner) +} + +var errFullHistoryContainer = errors.New("portable full history is not a supported complete bundle; local work is preserved") + +type fullBundleHeader struct { + head, format string + offset int64 +} + +// The full-history producer writes exactly HEAD, with no prerequisites or +// arbitrary references. Parse that original wire contract before any Git import: +// cloning can silently discard references and is not a container validator. +func readFullBundleHeader(file *os.File) (fullBundleHeader, error) { + input := bufio.NewReaderSize(file, 256) + var consumed int64 + line := func() (string, error) { + raw, err := input.ReadSlice('\n') + consumed += int64(len(raw)) + if err != nil { + return "", errFullHistoryContainer + } + return string(raw[:len(raw)-1]), nil + } + version, err := line() + if err != nil { + return fullBundleHeader{}, err + } + format := "sha1" + switch version { + case "# v2 git bundle": + case "# v3 git bundle": + capability, err := line() + if err != nil { + return fullBundleHeader{}, err + } + if capability != "@object-format=sha1" && capability != "@object-format=sha256" { + return fullBundleHeader{}, errFullHistoryContainer + } + format = strings.TrimPrefix(capability, "@object-format=") + default: + return fullBundleHeader{}, errFullHistoryContainer + } + reference, err := line() + if err != nil { + return fullBundleHeader{}, err + } + head, err := fullBundleHead(reference, format) + if err != nil { + return fullBundleHeader{}, err + } + + empty, err := line() + if err != nil || empty != "" { + return fullBundleHeader{}, errFullHistoryContainer + } + return fullBundleHeader{head: head, format: format, offset: consumed}, nil +} + +func fullBundleHead(reference, format string) (string, error) { + head, name, ok := strings.Cut(reference, " ") + oid, err := hex.DecodeString(head) + expected := 20 + if format == "sha256" { + expected = 32 + } + if !ok || name != "HEAD" || err != nil || len(oid) != expected || hex.EncodeToString(oid) != head { + return "", errFullHistoryContainer + } + return head, nil +} + +// index-pack validates and stores the original pack. It accepts trailing input +// but echoes it after its digest line, so require exactly that bounded line and +// EOF. Never capture or echo those extra bytes or Git's diagnostic text. +func importFullBundlePack(file *os.File, repository string, header fullBundleHeader) error { + if _, err := file.Seek(header.offset, io.SeekStart); err != nil { + return errFullHistoryContainer + } + cmd := historyGitCommand(repository, "index-pack", "--strict", "--stdin") + cmd.Stdin = file + output, err := cmd.StdoutPipe() + if err != nil { + return errFullHistoryContainer + } + defer output.Close() + if err := cmd.Start(); err != nil { + return errFullHistoryContainer + } + input := bufio.NewReaderSize(output, 256) + line, readErr := input.ReadSlice('\n') + valid := validPackReceipt(line, len(header.head)) + _, tailErr := input.ReadByte() + if readErr != nil || !valid || !errors.Is(tailErr, io.EOF) { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if readErr != nil || !valid || !errors.Is(tailErr, io.EOF) || waitErr != nil { + return errFullHistoryContainer + } + return nil +} + +func validPackReceipt(line []byte, hexSize int) bool { + text := string(line) + if len(text) != len("pack\t")+hexSize+1 || !strings.HasPrefix(text, "pack\t") || !strings.HasSuffix(text, "\n") { + return false + } + _, err := hex.DecodeString(text[len("pack\t") : len(text)-1]) + return err == nil +} + +// Inspection must stay in its owned object store even when the caller has Git +// routing/configuration variables for another checkout. PATH remains available +// for the installed executable and the public process/failure regression tests. +func historyGitCommand(repository string, args ...string) *exec.Cmd { + cmd := exec.Command("git", append([]string{"-C", repository}, args...)...) + for _, entry := range os.Environ() { + if !strings.HasPrefix(strings.ToUpper(entry), "GIT_") { + cmd.Env = append(cmd.Env, entry) + } + } + cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1", "GIT_NO_REPLACE_OBJECTS=1", "GIT_OPTIONAL_LOCKS=0") + return cmd +} + +func screenCarriedHistory(dest string) error { + prepared, err := readRecord(dest) + if err != nil { + return err + } + if prepared.FullHistory { + return screenFullHistory(filepath.Join(dest, bundleName)) + } + return nil +} + +// No object bytes are materialized or echoed. On malformed framing, scanner +// errors or findings, stop the child and refuse the bundle without modifying it. +func screenHistoryObjects(repository string, scanner *keys.Scanner) error { + cmd := historyGitCommand(repository, "cat-file", "--batch", "--batch-all-objects") + output, err := cmd.StdoutPipe() + if err != nil { + return err + } + defer output.Close() + if err := cmd.Start(); err != nil { + return err + } + scanErr := screenHistoryBatch(bufio.NewReaderSize(output, 64<<10), scanner) + if scanErr != nil { + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if scanErr != nil { + return scanErr + } + if waitErr != nil { + return fmt.Errorf("cannot read portable history for secret screening: %w", waitErr) + } + return nil +} + +type historyHeader struct { + kind string + size int64 + oidBytes int +} + +func readHistoryHeader(input *bufio.Reader) (historyHeader, error) { + line, err := input.ReadSlice('\n') + if errors.Is(err, io.EOF) && len(line) == 0 { + return historyHeader{}, io.EOF + } + if err != nil { + return historyHeader{}, errors.New("cannot read portable history object header") + } + fields := strings.Fields(string(line)) + if len(fields) != 3 { + return historyHeader{}, errors.New("malformed portable history object header") + } + oid, err := hex.DecodeString(fields[0]) + if err != nil || (len(oid) != 20 && len(oid) != 32) { + return historyHeader{}, errors.New("malformed portable history object identity") + } + size, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil || size < 0 { + return historyHeader{}, errors.New("malformed portable history object size") + } + return historyHeader{kind: fields[1], size: size, oidBytes: len(oid)}, nil +} + +func screenHistoryBatch(input *bufio.Reader, scanner *keys.Scanner) error { + for { + header, err := readHistoryHeader(input) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + object := &io.LimitedReader{R: input, N: header.size} + if err := screenHistoryObject(object, header.kind, header.oidBytes, scanner); err != nil { + return err + } + if object.N != 0 { + return errors.New("truncated portable history object") + } + if separator, err := input.ReadByte(); err != nil || separator != '\n' { + return errors.New("malformed portable history object separator") + } + } +} + +func screenHistoryObject(object io.Reader, kind string, oidBytes int, scanner *keys.Scanner) error { + switch kind { + case "tree": + return screenHistoryTree(object, oidBytes, scanner) + case "blob", "commit", "tag": + return historyFinding(scanner, "history-object", object) + default: + return errors.New("unsupported portable history object type") + } +} + +func screenHistoryTree(object io.Reader, oidBytes int, scanner *keys.Scanner) error { + input := bufio.NewReaderSize(object, 64<<10) + for { + entry, err := input.ReadSlice(0) + if errors.Is(err, io.EOF) && len(entry) == 0 { + return nil + } + if err != nil { + return errors.New("malformed or oversized portable history tree entry") + } + mode, name, ok := strings.Cut(string(entry[:len(entry)-1]), " ") + if !ok || !filepath.IsLocal(name) || filepath.Base(name) != name || name == "." { + return errors.New("unsafe portable history tree entry") + } + if mode != "40000" && mode != "160000" { + if err := historyFinding(scanner, name, strings.NewReader("")); err != nil { + return err + } + } + if _, err := io.CopyN(io.Discard, input, int64(oidBytes)); err != nil { + return errors.New("truncated portable history tree entry") + } + } +} + +func historyFinding(scanner *keys.Scanner, name string, input io.Reader) error { + finding, err := scanner.Reader(name, input) + if err != nil { + return fmt.Errorf("cannot screen portable history: %w", err) + } + if finding.Rule != "" { + return fmt.Errorf("portable history contains secret material (%s); local work is preserved but this copy cannot be transferred", finding.Rule) + } + return nil +} diff --git a/internal/taskcopy/history_screen_bench_test.go b/internal/taskcopy/history_screen_bench_test.go new file mode 100644 index 0000000000..4820a2e9c2 --- /dev/null +++ b/internal/taskcopy/history_screen_bench_test.go @@ -0,0 +1,115 @@ +package taskcopy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// The public composition seam includes bundling, screening and ordinary overlay +// capture. Setup is excluded; every file has distinct bytes and its own blob. +func BenchmarkFullHistoryComposition(b *testing.B) { + for _, cached := range []bool{false, true} { + name := "first" + if cached { + name = "unchanged" + } + b.Run(name, func(b *testing.B) { + root := b.TempDir() + project := filepath.Join(root, "plain") + tree := filepath.Join(root, "chat", cell.TreesDir, "1") + if err := os.MkdirAll(project, 0700); err != nil { + b.Fatal(err) + } + if err := os.MkdirAll(tree, 0700); err != nil { + b.Fatal(err) + } + for n := range 1000 { + if err := os.WriteFile(filepath.Join(tree, fmt.Sprintf("file-%04d.txt", n)), []byte(fmt.Sprintf("unique mock content %04d\n", n)), 0600); err != nil { + b.Fatal(err) + } + } + gitRun := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = tree + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Mock", "GIT_AUTHOR_EMAIL=mock@example.invalid", "GIT_COMMITTER_NAME=Mock", "GIT_COMMITTER_EMAIL=mock@example.invalid", "GIT_CONFIG_GLOBAL=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + b.Fatalf("git: %v %s", err, out) + } + } + gitRun("init", "-q") + gitRun("add", ".") + gitRun("commit", "-qm", "mock baseline") + c := cell.Cell{ID: "benchmark", Root: filepath.Join(root, "chat")} + if cached { + if err := (Carry{}).ComposeWorkspace(c, project); err != nil { + b.Fatal(err) + } + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if !cached { + b.StopTimer() + if err := os.RemoveAll(carriedRoot(c)); err != nil { + b.Fatal(err) + } + b.StartTimer() + } + if err := (Carry{}).ComposeWorkspace(c, project); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestFullHistoryCompositionHasBoundedGitProcessFanout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX executable counter") + } + source := unsealedPlainMirrorFixture(t) + tree := filepath.Join(liveRoot(source.cell), "1") + for n := range 1000 { + write(t, filepath.Join(tree, fmt.Sprintf("mock-%04d.txt", n)), fmt.Sprintf("unique mock content %04d\n", n)) + } + run(t, tree, "git", "add", ".") + run(t, tree, "git", "commit", "-qm", "large mock baseline") + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + log := filepath.Join(directory, "calls") + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + script := "#!/bin/sh\nprintf '.\\n' >> " + quote(log) + "\nexec " + quote(realGit) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(directory, "git"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) + for _, phase := range []string{"first", "unchanged"} { + if err := os.WriteFile(log, nil, 0600); err != nil { + t.Fatal(err) + } + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + calls := strings.Count(string(raw), "\n") + // This is a process budget, not a machine-dependent latency threshold. + // One process per file would exceed it by two orders of magnitude. + if calls > 20 { + t.Fatalf("%s composition spawned %d Git processes for 1000 files, budget 20", phase, calls) + } + t.Logf("%s composition: %d Git processes", phase, calls) + } +} diff --git a/internal/taskcopy/leftout.go b/internal/taskcopy/leftout.go index 7d08c85300..c0a4eadf71 100644 --- a/internal/taskcopy/leftout.go +++ b/internal/taskcopy/leftout.go @@ -24,7 +24,11 @@ func OwnsWithheld(recordPath string) bool { return strings.HasPrefix(recordPath, // noteLeftOut replaces the record's entries for task copies with the install // folders each live copy holds. func noteLeftOut(c cell.Cell, live []string) error { - var entries []inventory.Withheld + return noteLeftOutWith(c, live, nil) +} + +func noteLeftOutWith(c cell.Cell, live []string, cold []inventory.Withheld) error { + entries := append([]inventory.Withheld(nil), cold...) for _, tree := range live { entries = append(entries, leftOutOf(tree)...) } diff --git a/internal/taskcopy/nested_plain_test.go b/internal/taskcopy/nested_plain_test.go new file mode 100644 index 0000000000..ea13b14f86 --- /dev/null +++ b/internal/taskcopy/nested_plain_test.go @@ -0,0 +1,71 @@ +package taskcopy + +import ( + "os" + "path/filepath" + "testing" +) + +func nestedPlainFixture(t *testing.T) *machine { + t.Helper() + source := plainMirrorFixture(t) + parent := filepath.Join(liveRoot(source.cell), "1") + child := filepath.Join(liveRoot(source.cell), "2") + run(t, parent, "git", "worktree", "add", "-qb", "task/child", child) + write(t, filepath.Join(child, "child.txt"), "nested committed result\n") + run(t, child, "git", "add", "child.txt") + run(t, child, "git", "commit", "-qm", "child output") + write(t, filepath.Join(child, "child-dirty.txt"), "nested dirty result\n") + source.seal() + return source +} + +func TestExplicitParentRestoresNestedPlainMirrorAsLinkedWorktree(t *testing.T) { + source := nestedPlainFixture(t) + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + restored, err := restorer.RestoreWithParents(receiver.cell, receiver.project, map[string]string{"2": "1"}) + if err != nil { + t.Fatal(err) + } + if len(restored) != 2 || restored[0] != "1" || restored[1] != "2" { + t.Fatalf("wrong restoration order: %v", restored) + } + child := filepath.Join(liveRoot(receiver.cell), "2") + info, err := os.Stat(filepath.Join(child, ".git")) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("linked child became independent: %v", err) + } + for name, want := range map[string]string{"child.txt": "nested committed result\n", "child-dirty.txt": "nested dirty result\n"} { + raw, err := os.ReadFile(filepath.Join(child, name)) + if err != nil || string(raw) != want { + t.Fatalf("%s=%q %v", name, raw, err) + } + } + assertPlainProjectUnchanged(t, receiver.project) +} + +func TestNestedCompletedResultsReconstructParentOnlyInTemporaryScratch(t *testing.T) { + source := nestedPlainFixture(t) + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + destination := filepath.Join(receiver.cell.Root, ".cell", "history", "results") + results, err := RestoreCompletedResultsWithParents(receiver.cell, receiver.project, destination, []ResultSelection{{ID: "2", Paths: []string{"child.txt", "child-dirty.txt"}}}, map[string]string{"2": "1"}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"child.txt": "nested committed result\n", "child-dirty.txt": "nested dirty result\n"} { + raw, err := os.ReadFile(filepath.Join(results["2"], name)) + if err != nil || string(raw) != want { + t.Fatalf("%s=%q %v", name, raw, err) + } + } + if entries, _ := os.ReadDir(liveRoot(receiver.cell)); len(entries) != 0 { + t.Fatal("historical result reconstruction instantiated active parent/child copies") + } + entries, err := os.ReadDir(destination) + if err != nil || len(entries) != 1 || entries[0].Name() != "2" { + t.Fatalf("parent scratch was retained: %v %v", entries, err) + } + assertPlainProjectUnchanged(t, receiver.project) +} diff --git a/internal/taskcopy/parents.go b/internal/taskcopy/parents.go new file mode 100644 index 0000000000..3519033fb0 --- /dev/null +++ b/internal/taskcopy/parents.go @@ -0,0 +1,58 @@ +package taskcopy + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func restorationOrder(names []string, parents map[string]string) ([]string, error) { + available := make(map[string]bool, len(names)) + for _, name := range names { + available[name] = true + } + for child, parent := range parents { + if !localTreeID(child) || !localTreeID(parent) || !available[child] || !available[parent] { + return nil, fmt.Errorf("task parent binding %q -> %q is outside the carried copies", child, parent) + } + } + var order []string + state := make(map[string]uint8, len(names)) + var visit func(string) error + visit = func(name string) error { + if state[name] == 2 { + return nil + } + if state[name] == 1 { + return errors.New("carried task parents contain a cycle") + } + state[name] = 1 + if parent := parents[name]; parent != "" { + if err := visit(parent); err != nil { + return err + } + } + state[name] = 2 + order = append(order, name) + return nil + } + for _, name := range names { + if err := visit(name); err != nil { + return nil, err + } + } + return order, nil +} + +func localTreeID(name string) bool { + return name != "." && filepath.IsLocal(name) && filepath.Base(name) == name +} + +// ValidateParentBindings checks the whole carried graph before any copy is +// written. Bindings name IDs, never directories outside the receiving cell. +func ValidateParentBindings(c cell.Cell, parents map[string]string) error { + _, err := restorationOrder(Carried(c), parents) + return err +} diff --git a/internal/taskcopy/plain_capture_test.go b/internal/taskcopy/plain_capture_test.go new file mode 100644 index 0000000000..63923e079d --- /dev/null +++ b/internal/taskcopy/plain_capture_test.go @@ -0,0 +1,63 @@ +package taskcopy + +import ( + "os" + "path/filepath" + "testing" +) + +func TestColdPlainMirrorCarriesCompleteBaselineBeforeFirstSeal(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + parent := filepath.Join(liveRoot(source.cell), "1") + child := filepath.Join(liveRoot(source.cell), "2") + run(t, parent, "git", "worktree", "add", "-qb", "task/child", child) + write(t, filepath.Join(child, "child.txt"), "child output\n") + run(t, child, "git", "add", "child.txt") + run(t, child, "git", "commit", "-qm", "child") + carry := Carry{Project: source.project} + if err := carry.PrepareCold(source.cell, parent); err != nil { + t.Fatal(err) + } + verify := func() { + t.Helper() + rec, err := readRecord(filepath.Join(carriedRoot(source.cell), "1")) + if err != nil || !rec.FullHistory { + t.Fatalf("plain baseline completeness not recorded: %+v %v", rec, err) + } + empty := t.TempDir() + run(t, empty, "git", "init", "-q") + run(t, empty, "git", "bundle", "verify", filepath.Join(carriedRoot(source.cell), "1", bundleName)) + } + verify() + // A later preparation without source context must not downgrade its known + // self-contained archive, including after the mirrored parent commits again. + write(t, filepath.Join(parent, "later.txt"), "later output\n") + run(t, parent, "git", "add", "later.txt") + run(t, parent, "git", "commit", "-qm", "later") + if err := (Carry{}).PrepareCold(source.cell, parent); err != nil { + t.Fatal(err) + } + verify() + if err := (Carry{}).Compose(source.cell); err != nil { + t.Fatal(err) + } + verify() + assertPlainProjectUnchanged(t, source.project) +} + +func TestPlainWorkspaceCaptureIncludesBaselineDespiteChildReferences(t *testing.T) { + source := unsealedPlainMirrorFixture(t) + parent := filepath.Join(liveRoot(source.cell), "1") + child := filepath.Join(liveRoot(source.cell), "2") + run(t, parent, "git", "worktree", "add", "-qb", "task/child", child) + if err := (Carry{}).ComposeWorkspace(source.cell, source.project); err != nil { + t.Fatal(err) + } + archive := filepath.Join(carriedRoot(source.cell), "1", bundleName) + if _, err := os.Stat(archive); err != nil { + t.Fatalf("parent has no independent baseline: %v", err) + } + empty := t.TempDir() + run(t, empty, "git", "init", "-q") + run(t, empty, "git", "bundle", "verify", archive) +} diff --git a/internal/taskcopy/plain_project_test.go b/internal/taskcopy/plain_project_test.go new file mode 100644 index 0000000000..877ea74a11 --- /dev/null +++ b/internal/taskcopy/plain_project_test.go @@ -0,0 +1,127 @@ +package taskcopy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func plainMirrorFixture(t *testing.T) *machine { + t.Helper() + m := unsealedPlainMirrorFixture(t) + m.seal() + return m +} + +func unsealedPlainMirrorFixture(t *testing.T) *machine { + t.Helper() + m := newMachineNoRepo(t) + write(t, filepath.Join(m.project, "README.md"), "plain project input\n") + write(t, filepath.Join(m.project, "keep.txt"), "baseline\n") + tree := filepath.Join(m.cell.Root, cell.TreesDir, "1") + write(t, filepath.Join(tree, "README.md"), "plain project input\n") + write(t, filepath.Join(tree, "keep.txt"), "baseline\n") + run(t, tree, "git", "init", "-q", "-b", "main") + run(t, tree, "git", "add", ".") + run(t, tree, "git", "commit", "-qm", "private mirror baseline") + write(t, filepath.Join(tree, "committed.txt"), "committed mirror output\n") + run(t, tree, "git", "add", "committed.txt") + run(t, tree, "git", "commit", "-qm", "output") + write(t, filepath.Join(tree, "dirty.txt"), "dirty mirror output\n") + if err := os.Remove(filepath.Join(tree, "keep.txt")); err != nil { + t.Fatal(err) + } + return m +} + +func TestPlainProjectPrivateMirrorRestoresWithoutChangingBorrowedProject(t *testing.T) { + source := plainMirrorFixture(t) + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + restored, err := restorer.Restore(receiver.cell, receiver.project) + if err != nil { + t.Fatal(err) + } + if len(restored) != 1 || restored[0] != "1" { + t.Fatalf("mirror not restored: %v", restored) + } + for name, want := range map[string]string{"committed.txt": "committed mirror output\n", "dirty.txt": "dirty mirror output\n", "README.md": "plain project input\n"} { + body, ok := receiver.read("1", name) + if !ok || body != want { + t.Fatalf("%s=%q present=%v", name, body, ok) + } + } + if _, err := os.Stat(filepath.Join(liveRoot(receiver.cell), "1", "keep.txt")); !os.IsNotExist(err) { + t.Fatal("deleted mirror file returned") + } + assertPlainProjectUnchanged(t, receiver.project) + if receiver.branch("1") != "main" { + t.Fatal("mirror branch identity changed") + } +} + +func TestPlainProjectCompletedMirrorResultsRemainReadable(t *testing.T) { + source := plainMirrorFixture(t) + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + results, err := RestoreCompletedResults(receiver.cell, receiver.project, filepath.Join(receiver.cell.Root, ".cell", "history", "results"), []ResultSelection{{ID: "1", Paths: []string{"committed.txt", "dirty.txt", "keep.txt"}}}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"committed.txt": "committed mirror output\n", "dirty.txt": "dirty mirror output\n"} { + raw, err := os.ReadFile(filepath.Join(results["1"], name)) + if err != nil || string(raw) != want { + t.Fatalf("%s=%q %v", name, raw, err) + } + } + assertPlainProjectUnchanged(t, receiver.project) +} + +func assertPlainProjectUnchanged(t *testing.T, project string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(project, ".git")); !os.IsNotExist(err) { + t.Fatal("borrowed plain project was made into a Git repository") + } + raw, err := os.ReadFile(filepath.Join(project, "keep.txt")) + if err != nil || string(raw) != "baseline\n" { + t.Fatal("borrowed project was modified") + } + if _, err := os.Stat(filepath.Join(project, "committed.txt")); !os.IsNotExist(err) { + t.Fatal("mirror output was written to borrowed project") + } +} + +func TestPlainProjectIncompleteChildBundleRefusesAndPreservesArchive(t *testing.T) { + source := plainMirrorFixture(t) + parent := filepath.Join(liveRoot(source.cell), "1") + child := filepath.Join(liveRoot(source.cell), "2") + run(t, parent, "git", "worktree", "add", "-qb", "task/child", child) + write(t, filepath.Join(child, "child.txt"), "nested committed result\n") + run(t, child, "git", "add", "child.txt") + run(t, child, "git", "commit", "-qm", "nested output") + source.seal() + receiver := newMachineNoRepo(t) + source.moveTo(receiver) + archive := filepath.Join(carriedRoot(receiver.cell), "2", bundleName) + original, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + restored, err := restorer.Restore(receiver.cell, receiver.project) + if err == nil { + t.Fatal("partial child bundle invented its missing parent baseline") + } + if len(restored) != 1 || restored[0] != "1" { + t.Fatalf("complete parent mirror did not restore independently: %v %v", restored, err) + } + after, readErr := os.ReadFile(archive) + if readErr != nil || string(after) != string(original) { + t.Fatal("failed nested restore changed its original archive") + } + if _, err := RestoreCompletedResults(receiver.cell, receiver.project, filepath.Join(receiver.cell.Root, ".cell", "history", "results"), []ResultSelection{{ID: "2", Paths: []string{"child.txt"}}}); err == nil { + t.Fatal("completed child results invented a missing baseline") + } + assertPlainProjectUnchanged(t, receiver.project) +} diff --git a/internal/taskcopy/record.go b/internal/taskcopy/record.go index d9d1e4906c..34ef17cd6b 100644 --- a/internal/taskcopy/record.go +++ b/internal/taskcopy/record.go @@ -31,11 +31,13 @@ const ( // through is written by whatever umask the machine has and a copy must come back // with the modes it left with. type record struct { - Branch string `json:"branch,omitempty"` - Head string `json:"head"` - Deleted []string `json:"deleted,omitempty"` - Linked bool `json:"linked,omitempty"` - Modes map[string]fs.FileMode `json:"modes,omitempty"` + // FullHistory means HEAD has no prerequisite in the borrowed project. + FullHistory bool `json:"fullHistory,omitempty"` + Branch string `json:"branch,omitempty"` + Head string `json:"head"` + Deleted []string `json:"deleted,omitempty"` + Linked bool `json:"linked,omitempty"` + Modes map[string]fs.FileMode `json:"modes,omitempty"` // EOL records actual working-tree endings rather than this machine's Git defaults. EOL map[string]string `json:"eol,omitempty"` } diff --git a/internal/taskcopy/restore.go b/internal/taskcopy/restore.go index 9a1e42a5da..2d2d82bfff 100644 --- a/internal/taskcopy/restore.go +++ b/internal/taskcopy/restore.go @@ -1,6 +1,7 @@ package taskcopy import ( + "context" "errors" "fmt" "io/fs" @@ -10,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/mirror" + "github.com/Agent-Field/codeaf/internal/taskstorage" ) // Cutter makes a task copy the way a task makes one. Restore asks it instead of @@ -41,17 +43,37 @@ type Spec struct { // its files, in a plain folder at its path, so the edits are found where the // task worked; the error says why it is not a working tree. func (k Carry) Restore(c cell.Cell, project string) ([]string, error) { + return k.RestoreWithParents(c, project, nil) +} + +// RestoreWithParents restores a validated carrier graph parent-first. A parent +// is another tree ID in this cell, never a caller-supplied filesystem path. +func (k Carry) RestoreWithParents(c cell.Cell, project string, parents map[string]string) ([]string, error) { + order, err := restorationOrder(Carried(c), parents) + if err != nil { + return nil, err + } var errs []error if isRepository(project) { _, err := git(project, "worktree", "prune") errs = append(errs, err) } var restored []string - for _, name := range Carried(c) { - if err := k.restoreOne(c, project, name); err != nil { + ready := make(map[string]bool, len(order)) + for _, name := range order { + ground := project + if parent := parents[name]; parent != "" { + if !ready[parent] { + errs = append(errs, fmt.Errorf("task copy %s: parent %s was not restored", name, parent)) + continue + } + ground = filepath.Join(liveRoot(c), parent) + } + if err := k.restoreOne(c, ground, name); err != nil { errs = append(errs, fmt.Errorf("task copy %s: %w", name, err)) continue } + ready[name] = true restored = append(restored, name) } return restored, errors.Join(errs...) @@ -64,6 +86,11 @@ func (k Carry) restoreOne(c cell.Cell, project, name string) error { return err } dest := filepath.Join(liveRoot(c), name) + if manager, enabled := taskstorage.Prototype(liveRoot(c)); enabled { + if err := manager.Ensure(context.Background(), dest); err != nil { + return err + } + } cutErr := k.cutAgain(project, dest, name, from, rec) return errors.Join(cutErr, applyEOL(dest, rec.EOL), overlay(filepath.Join(from, filesDir), dest, rec), applyModes(dest, rec.Modes)) } @@ -104,6 +131,9 @@ func (k Carry) cutAgain(project, dest, name, from string, rec record) error { if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { return err } + if !isRepository(project) { + return restoreStandaloneCopy(project, dest, from, rec) + } if err := unbundle(project, filepath.Join(from, bundleName)); err != nil { return err } diff --git a/internal/taskcopy/results.go b/internal/taskcopy/results.go new file mode 100644 index 0000000000..864d6ff177 --- /dev/null +++ b/internal/taskcopy/results.go @@ -0,0 +1,163 @@ +package taskcopy + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/mirror" +) + +// ResultSelection names a settled task's recorded outputs. Empty Paths means no +// identified output files; its original archive remains available independently. +type ResultSelection struct { + ID string + Paths []string +} + +// RestoreCompletedResults opens recorded outputs as ordinary historical files, +// never working copies. The caller selects only settled tasks. Git reconstruction +// is temporary; unchanged project inputs and execution metadata are not retained. +func RestoreCompletedResults(c cell.Cell, project, destination string, selections []ResultSelection) (map[string]string, error) { + return RestoreCompletedResultsWithParents(c, project, destination, selections, nil) +} + +// RestoreCompletedResultsWithParents reconstructs prerequisite commit history +// only in temporary scratch. It creates no runnable parent or child worktree. +func RestoreCompletedResultsWithParents(c cell.Cell, project, destination string, selections []ResultSelection, parents map[string]string) (map[string]string, error) { + if _, err := restorationOrder(Carried(c), parents); err != nil { + return nil, err + } + results := make(map[string]string) + for _, selection := range selections { + if selection.ID == "." || !filepath.IsLocal(selection.ID) || filepath.Base(selection.ID) != selection.ID { + return nil, errors.New("historical task ID leaves its folder") + } + if len(selection.Paths) == 0 { + continue + } + from := filepath.Join(carriedRoot(c), selection.ID) + record, err := readRecord(from) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + path, err := restoreSelectedResult(c, project, destination, selection, from, record, parents) + if err != nil { + return nil, fmt.Errorf("historical task %s: %w", selection.ID, err) + } + results[selection.ID] = path + } + return results, nil +} + +func restoreSelectedResult(c cell.Cell, project, destination string, selection ResultSelection, from string, record record, parents map[string]string) (string, error) { + if err := os.MkdirAll(destination, 0700); err != nil { + return "", err + } + scratch, err := os.MkdirTemp(destination, ".restore-") + if err != nil { + return "", err + } + defer os.RemoveAll(scratch) + project, err = historicalParentCheckout(c, project, selection.ID, parents, scratch) + if err != nil { + return "", err + } + checkout := filepath.Join(scratch, "checkout") + if err := prepareResultCheckout(project, checkout, from, record); err != nil { + return "", err + } + if err := applyEOL(checkout, record.EOL); err != nil { + return "", err + } + output := filepath.Join(scratch, "output") + if err := os.Mkdir(output, 0700); err != nil { + return "", err + } + for _, relative := range selection.Paths { + if err := copySelectedResult(checkout, from, output, relative, record); err != nil { + return "", err + } + } + final := filepath.Join(destination, selection.ID) + if _, err := os.Lstat(final); err == nil { + return final, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + if err := os.Rename(output, final); err != nil { + return "", err + } + return final, nil +} + +func copySelectedResult(checkout, from, output, relative string, record record) error { + if !filepath.IsLocal(relative) { + return fmt.Errorf("historical output %q leaves its folder", relative) + } + if relative == ".git" || strings.HasPrefix(filepath.ToSlash(relative), ".git/") { + return errors.New("Git metadata is not an output") + } + for _, deleted := range record.Deleted { + if relative == deleted { + return nil + } + } + source := filepath.Join(from, filesDir, relative) + if _, err := os.Lstat(source); errors.Is(err, fs.ErrNotExist) { + source = filepath.Join(checkout, relative) + } else if err != nil { + return err + } + info, err := os.Lstat(source) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("historical output %q is not a regular file; its archive is preserved", relative) + } + resolved, err := filepath.EvalSymlinks(source) + if err != nil { + return err + } + root := checkout + if rel, err := filepath.Rel(filepath.Join(from, filesDir), source); err == nil && filepath.IsLocal(rel) { + root = filepath.Join(from, filesDir) + } + rel, err := filepath.Rel(root, resolved) + if err != nil || !filepath.IsLocal(rel) { + return errors.New("historical output redirects outside its archive") + } + return mirror.Copy(source, filepath.Join(output, relative), info, record.modeOf(filepath.ToSlash(relative), info)) +} + +func historicalParentCheckout(c cell.Cell, project, id string, parents map[string]string, scratch string) (string, error) { + parent := parents[id] + if parent == "" { + return project, nil + } + base, err := historicalParentCheckout(c, project, parent, parents, scratch) + if err != nil { + return "", err + } + from := filepath.Join(carriedRoot(c), parent) + rec, err := readRecord(from) + if err != nil { + return "", err + } + checkout := filepath.Join(scratch, "parent-"+parent) + if err := prepareResultCheckout(base, checkout, from, rec); err != nil { + return "", err + } + return checkout, nil +} diff --git a/internal/taskcopy/results_test.go b/internal/taskcopy/results_test.go new file mode 100644 index 0000000000..586b7f09d1 --- /dev/null +++ b/internal/taskcopy/results_test.go @@ -0,0 +1,74 @@ +package taskcopy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompletedResultsMaterializeCommittedAndDirtyFilesWithoutLiveCopies(t *testing.T) { + m := newMachine(t) + write(t, filepath.Join(m.project, "large-input.dat"), strings.Repeat("unchanged input\n", 1<<17)) + run(t, m.project, "git", "add", "large-input.dat") + run(t, m.project, "git", "commit", "-qm", "large input baseline") + tree := m.task("1") + write(t, filepath.Join(tree, "committed.txt"), "committed result\n") + run(t, tree, "git", "add", "committed.txt") + run(t, tree, "git", "commit", "-qm", "result") + write(t, filepath.Join(tree, "dirty.txt"), "dirty result\n") + if err := os.Remove(filepath.Join(tree, "keep.txt")); err != nil { + t.Fatal(err) + } + _ = m.task("2") + m.seal() + to := newMachineNoRepo(t) + m.moveTo(to) + dest := filepath.Join(to.cell.Root, ".cell", "fork-history", "fork", "results") + results, err := RestoreCompletedResults(to.cell, to.project, dest, []ResultSelection{{ID: "1", Paths: []string{"committed.txt", "dirty.txt", "keep.txt"}}}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"committed.txt": "committed result\n", "dirty.txt": "dirty result\n"} { + raw, err := os.ReadFile(filepath.Join(results["1"], name)) + if err != nil || string(raw) != want { + t.Fatalf("result %s=%q %v", name, raw, err) + } + } + for _, name := range []string{"keep.txt", "README.md", "large-input.dat", ".git"} { + if _, err := os.Lstat(filepath.Join(results["1"], name)); !os.IsNotExist(err) { + t.Fatalf("result copied deleted/input/Git path %s: %v", name, err) + } + } + if _, err := os.Lstat(filepath.Join(dest, "2")); !os.IsNotExist(err) { + t.Fatal("active task copied") + } + if entries, _ := os.ReadDir(liveRoot(to.cell)); len(entries) != 0 { + t.Fatal("results created runnable working copies") + } +} + +func TestReportOnlyResultsNeverMaterializeTheProject(t *testing.T) { + m := newMachine(t) + _ = m.task("1") + m.seal() + dest := filepath.Join(t.TempDir(), "results") + results, err := RestoreCompletedResults(m.cell, m.project, dest, []ResultSelection{{ID: "1"}}) + if err != nil || len(results) != 0 { + t.Fatalf("report-only task materialized files: %v %v", results, err) + } + if _, err := os.Lstat(dest); !os.IsNotExist(err) { + t.Fatal("report-only task made a resident output tree") + } +} + +func TestHistoricalOutputSelectionCannotLeaveItsFolder(t *testing.T) { + m := newMachine(t) + _ = m.task("1") + m.seal() + for _, selection := range []ResultSelection{{ID: "../outside", Paths: []string{"README.md"}}, {ID: "1", Paths: []string{"../../outside"}}, {ID: "1", Paths: []string{".git/config"}}} { + if _, err := RestoreCompletedResults(m.cell, m.project, filepath.Join(t.TempDir(), "results"), []ResultSelection{selection}); err == nil { + t.Fatalf("unsafe output accepted: %+v", selection) + } + } +} diff --git a/internal/taskcopy/standalone.go b/internal/taskcopy/standalone.go new file mode 100644 index 0000000000..b347da41a4 --- /dev/null +++ b/internal/taskcopy/standalone.go @@ -0,0 +1,68 @@ +package taskcopy + +import ( + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" +) + +// prepareResultCheckout reconstructs a carried commit in an owned destination. +// A plain borrowed project remains plain: its independent mirror must carry a +// complete bundle. Missing prerequisites fail without inventing a new baseline. +func prepareResultCheckout(project, destination, from string, rec record) error { + if decoded, err := hex.DecodeString(rec.Head); err != nil || (len(decoded) != 20 && len(decoded) != 32) { + return errors.New("carried task commit identity is malformed") + } + if project != "" && isRepository(project) { + if _, err := git(filepath.Dir(project), "clone", "-q", "--no-checkout", "--no-hardlinks", "--", project, destination); err != nil { + return err + } + } else { + if _, err := git(filepath.Dir(destination), "init", "-q", destination); err != nil { + return err + } + } + if err := unbundle(destination, filepath.Join(from, bundleName)); err != nil { + return fmt.Errorf("restore carried task history (its original archive is preserved): %w", err) + } + if _, err := git(destination, "-c", "core.hooksPath=/dev/null", "checkout", "-q", "--force", "--detach", rec.Head, "--"); err != nil { + return fmt.Errorf("carried task history lacks its recorded commit (its original archive is preserved): %w", err) + } + return nil +} + +func restoreStandaloneCopy(project, destination, from string, rec record) error { + if entries, err := os.ReadDir(destination); err == nil && len(entries) != 0 { + return errors.New("restored task folder already contains files; its files and carried archive were preserved") + } else if err != nil && !os.IsNotExist(err) { + return err + } + scratch, err := os.MkdirTemp(filepath.Dir(destination), ".restore-task-") + if err != nil { + return err + } + defer os.RemoveAll(scratch) + checkout := filepath.Join(scratch, "copy") + if err := prepareResultCheckout(project, checkout, from, rec); err != nil { + return err + } + if rec.Branch != "" { + if _, err := git(checkout, "check-ref-format", "--branch", rec.Branch); err != nil { + return err + } + if _, err := git(checkout, "-c", "core.hooksPath=/dev/null", "checkout", "-q", "-b", rec.Branch, rec.Head); err != nil { + return err + } + } + if _, err := os.Stat(destination); err == nil { + // Only an empty owned destination is removed, never an existing copy's files. + if err := os.Remove(destination); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + return os.Rename(checkout, destination) +} diff --git a/internal/taskcopy/workspace_carrier.go b/internal/taskcopy/workspace_carrier.go new file mode 100644 index 0000000000..96af6b706d --- /dev/null +++ b/internal/taskcopy/workspace_carrier.go @@ -0,0 +1,392 @@ +package taskcopy + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// WorkspaceCarrierPath is the separately versioned portable multi-root carrier. +const WorkspaceCarrierPath = cell.StateDir + "/workspaces-v2" +const workspaceCarrierDir = "workspaces-v2" +const workspaceManifestLimit = 4 << 20 +const workspacePathLimit = 100000 + +type workspaceManifest struct { + Version int `json:"version"` + Checkpoint string `json:"checkpoint_sha256"` + Plan WorkspacePlan `json:"plan"` + Generation string `json:"generation"` + Roots []workspaceRoot `json:"roots"` +} +type workspaceRoot struct { + Entry WorkspaceEntry `json:"entry"` + Token string `json:"token"` + Head string `json:"head"` + DirectoryVersion int `json:"directory_version"` + Directories map[string]fs.FileMode `json:"directories"` +} + +func workspaceCarrierRoot(c cell.Cell) string { + return filepath.Join(c.Root, cell.StateDir, workspaceCarrierDir) +} +func sourceWorkspace(c cell.Cell, e WorkspaceEntry) (string, error) { + registry := home.Join("workspace-identities") + resolve := func(uri string) (workspaceidentity.Record, error) { + return workspaceidentity.ResolveExisting(registry, uri) + } + run, err := resolve(e.CopyURI) + if err != nil { + return "", err + } + chat, err := resolve(e.ChatURI) + if err != nil { + return "", err + } + project, err := resolve(e.ProjectURI) + if err != nil { + return "", err + } + runProject, err := resolve(run.ProjectURI) + if err != nil { + return "", err + } + if run.Role != workspaceidentity.Run || run.OwnerURI != e.ChatURI || chat.Role != workspaceidentity.Chat || chat.Path != filepath.Clean(c.Root) || project.Role != workspaceidentity.Project || runProject.URI != project.URI { + return "", errors.New("workspace source ownership does not match its plan") + } + expected, err := workspacePath(c.Root, filepath.Join(cell.TreesDir, filepath.FromSlash(e.Layout))) + if err != nil { + return "", err + } + if run.Path != expected { + return "", errors.New("workspace source path does not match its admitted layout") + } + for _, uri := range []string{e.RootURI, e.GroundURI} { + link, err := resolve(uri) + if err != nil { + return "", err + } + if link.Role != workspaceidentity.Project && link.Role != workspaceidentity.Run { + return "", errors.New("workspace source link is not a project or copy") + } + if link.Role == workspaceidentity.Run && link.OwnerURI != e.ChatURI { + return "", errors.New("workspace source link belongs to another chat") + } + lp, err := resolve(link.ProjectURI) + if err != nil { + return "", err + } + if lp.URI != project.URI { + return "", errors.New("workspace source link belongs to another project") + } + } + if !isRepository(expected) { + return "", errors.New("workspace source copy is missing its repository") + } + return expected, nil +} +func (k Carry) composeWorkspacePlan(c cell.Cell, plan WorkspacePlan, digest string) error { + entries, err := workspaceEntries(plan) + if err != nil { + return err + } + copySet := map[string]bool{} + for _, entry := range entries { + copySet[entry.CopyURI] = true + } + for _, entry := range entries { + for _, uri := range []string{entry.RootURI, entry.GroundURI} { + link, err := workspaceidentity.ResolveExisting(home.Join("workspace-identities"), uri) + if err != nil { + return err + } + if link.Role == workspaceidentity.Run && !copySet[uri] { + return errors.New("workspace source parent is absent from the transfer closure") + } + } + } + sources := make([]string, len(entries)) + for i, e := range entries { + sources[i], err = sourceWorkspace(c, e) + if err != nil { + return err + } + } + root, err := workspacePath(c.Root, filepath.Join(cell.StateDir, workspaceCarrierDir)) + if err != nil { + return err + } + if err = os.MkdirAll(root, 0700); err != nil { + return err + } + previous, priorErr := readWorkspaceManifest(c) + if priorErr != nil && !errors.Is(priorErr, os.ErrNotExist) { + return priorErr + } + stage, err := prepareWorkspaceCapture(c, previous) + if err != nil { + return err + } + published := false + defer func() { + if !published { + os.RemoveAll(stage) + } + }() + manifest := workspaceManifest{Version: 2, Checkpoint: digest, Plan: plan, Generation: filepath.Base(stage)} + for i, e := range entries { + token := workspaceToken(e.CopyURI) + dest := filepath.Join(stage, token) + if err := carryWithHistory(sources[i], dest, true); err != nil { + return fmt.Errorf("carry workspace %s: %w", e.Layout, err) + } + rec, err := readWorkspaceRecord(dest) + if err != nil { + return err + } + directories, err := captureWorkspaceDirectories(sources[i]) + if err != nil { + return fmt.Errorf("carry workspace directories %s: %w", e.Layout, err) + } + portable := workspaceRoot{Entry: e, Token: token, Head: rec.Head, DirectoryVersion: 1, Directories: directories} + if _, err = validateWorkspaceDirectories(portable, rec); err != nil { + return err + } + manifest.Roots = append(manifest.Roots, portable) + } + after, afterDigest, err := ReadWorkspacePlan(c) + if err != nil { + return err + } + if after == nil || afterDigest != digest || !reflect.DeepEqual(*after, plan) { + return errors.New("workspace checkpoint changed during capture") + } + if err := writeWorkspaceJSON(filepath.Join(root, "manifest.json"), manifest); err != nil { + return err + } + published = true + // Only the preceding manifest grants ownership of the old generation. Unknown + // siblings are retained; carrier capture is not a generic directory sweeper. + if priorErr == nil && previous.Generation != manifest.Generation && validGeneration(previous.Generation) { + if err := os.RemoveAll(filepath.Join(root, previous.Generation)); err != nil { + return err + } + } + return nil +} +func validGeneration(s string) bool { + if !strings.HasPrefix(s, "generation-") || len(s) != len("generation-")+32 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(s, "generation-")) + return err == nil +} +func readWorkspaceManifest(c cell.Cell) (workspaceManifest, error) { + var m workspaceManifest + root, err := workspacePath(c.Root, filepath.Join(cell.StateDir, workspaceCarrierDir)) + if err != nil { + return m, err + } + raw, err := readWorkspaceFile(filepath.Join(root, "manifest.json"), workspaceManifestLimit) + if err != nil { + return m, err + } + err = json.Unmarshal(raw, &m) + if err != nil { + return m, err + } + if m.Version != 2 || !validGeneration(m.Generation) || len(m.Checkpoint) != 64 { + return m, errors.New("workspace carrier manifest is invalid") + } + return m, nil +} +func writeWorkspaceJSON(path string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + if len(raw) > workspaceManifestLimit { + return errors.New("workspace manifest exceeds its bound") + } + file, err := os.CreateTemp(filepath.Dir(path), ".workspace-write-") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if err = file.Chmod(0600); err == nil { + _, err = file.Write(raw) + } + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if err = os.Rename(name, path); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// workspaceCaptureReceipt records the only in-progress carrier generation the +// harness may reclaim after interruption. Other folders never become garbage +// merely because they happen to have a similar name. +type workspaceCaptureReceipt struct { + Version int `json:"version"` + Stage string `json:"stage"` + Previous string `json:"previous,omitempty"` +} + +func prepareWorkspaceCapture(c cell.Cell, previous workspaceManifest) (string, error) { + private, err := workspacePath(c.Root, ".codeaf") + if err != nil { + return "", err + } + if err = os.MkdirAll(private, 0700); err != nil { + return "", err + } + journal := filepath.Join(private, "workspace-capture.json") + raw, err := readWorkspaceFile(journal, workspaceManifestLimit) + if err == nil { + var prior workspaceCaptureReceipt + if err = json.Unmarshal(raw, &prior); err != nil { + return "", err + } + if prior.Version != 2 || !validGeneration(prior.Stage) || prior.Previous != "" && !validGeneration(prior.Previous) { + return "", errors.New("workspace capture journal is invalid; preserved for inspection") + } + for _, owned := range []string{prior.Stage, prior.Previous} { + if owned == "" || owned == previous.Generation { + continue + } + path, err := workspacePath(c.Root, filepath.Join(cell.StateDir, workspaceCarrierDir, owned)) + if err != nil { + return "", err + } + if err = os.RemoveAll(path); err != nil { + return "", err + } + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + nonce := make([]byte, 16) + if _, err = rand.Read(nonce); err != nil { + return "", err + } + name := "generation-" + hex.EncodeToString(nonce) + receipt := workspaceCaptureReceipt{Version: 2, Stage: name, Previous: previous.Generation} + if err = writeWorkspaceJSON(journal, receipt); err != nil { + return "", err + } + stage := filepath.Join(workspaceCarrierRoot(c), name) + if err = os.Mkdir(stage, 0700); err != nil { + return "", err + } + return stage, nil +} + +// captureWorkspaceDirectories carries only the visible working tree. Git names +// ignored subtrees once, so large caches are not walked just to omit their dirs. +func captureWorkspaceDirectories(tree string) (map[string]fs.FileMode, error) { + ignored, err := git(tree, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") + if err != nil { + return nil, err + } + tracked, err := git(tree, "ls-files", "--cached", "-z") + if err != nil { + return nil, err + } + if len(ignored)+len(tracked) > WorkspaceCheckpointLimit { + return nil, errors.New("workspace directory enumeration exceeds its bound") + } + ignoredDirs := map[string]bool{} + for _, path := range strings.Split(ignored, "\x00") { + if strings.HasSuffix(path, "/") { + ignoredDirs[strings.TrimSuffix(path, "/")] = true + } + } + trackedParents := map[string]bool{} + for _, path := range strings.Split(tracked, "\x00") { + for parent := filepath.ToSlash(filepath.Dir(filepath.FromSlash(path))); parent != "." && parent != "/"; parent = filepath.ToSlash(filepath.Dir(filepath.FromSlash(parent))) { + trackedParents[parent] = true + } + } + out := map[string]fs.FileMode{} + scanner := keys.Scanner{} + err = filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(tree, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + if !utf8.ValidString(rel) { + return errors.New("workspace directory spelling is not valid UTF-8") + } + if !d.IsDir() { + return nil + } + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + return filepath.SkipDir + } + ignored := false + for part := rel; part != "."; part = filepath.ToSlash(filepath.Dir(filepath.FromSlash(part))) { + if ignoredDirs[part] { + ignored = true + break + } + } + if ignored && !trackedParents[rel] { + return filepath.SkipDir + } + finding, err := scanner.Reader(rel, strings.NewReader("")) + if err != nil { + return err + } + if finding.Rule != "" { + return filepath.SkipDir + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 || info.Mode().Perm()&0700 != 0700 { + return errors.New("workspace directory permissions require an unsupported retained case") + } + out[rel] = info.Mode().Perm() + if len(out) > workspacePathLimit { + return errors.New("workspace directory count exceeds its bound") + } + return nil + }) + return out, err +} diff --git a/internal/taskcopy/workspace_carrier_test.go b/internal/taskcopy/workspace_carrier_test.go new file mode 100644 index 0000000000..fdf6ee1221 --- /dev/null +++ b/internal/taskcopy/workspace_carrier_test.go @@ -0,0 +1,212 @@ +package taskcopy + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func workspaceFixture(t *testing.T) (cell.Cell, WorkspacePlan) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + c := cell.Cell{ID: "mock", Root: filepath.Join(t.TempDir(), "chat")} + if err := os.MkdirAll(filepath.Join(c.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, c.Root) + if err != nil { + t.Fatal(err) + } + plan := WorkspacePlan{Version: 2, ChatURI: chat.URI, Groups: []WorkspaceGroup{{TaskID: 7, Required: true, Primary: "main"}}} + for _, slot := range []string{"main", "docs"} { + project := t.TempDir() + write(t, filepath.Join(project, "base.txt"), "base\n") + write(t, filepath.Join(project, "deleted.txt"), "delete\n") + run(t, project, "git", "init", "-q", "-b", "main") + run(t, project, "git", "add", ".") + run(t, project, "git", "commit", "-qm", "base") + pr, err := registry.Register(workspaceidentity.Project, project) + if err != nil { + t.Fatal(err) + } + layout := "7/" + slot + tree := filepath.Join(liveRoot(c), filepath.FromSlash(layout)) + if err := os.MkdirAll(filepath.Dir(tree), 0700); err != nil { + t.Fatal(err) + } + run(t, project, "git", "clone", "-q", "--no-hardlinks", project, tree) + write(t, filepath.Join(tree, "base.txt"), "edited "+slot+"\n") + write(t, filepath.Join(tree, "new.txt"), "new "+slot+"\n") + if err := os.Chmod(filepath.Join(tree, "new.txt"), 0750); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(tree, "deleted.txt")); err != nil { + t.Fatal(err) + } + cp, err := registry.Register(workspaceidentity.Run, tree, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: pr.URI}) + if err != nil { + t.Fatal(err) + } + plan.Groups[0].Entries = append(plan.Groups[0].Entries, WorkspaceEntry{CopyURI: cp.URI, ChatURI: chat.URI, ProjectURI: pr.URI, RootURI: pr.URI, GroundURI: pr.URI, Layout: layout, Access: "write"}) + } + writeWorkspaceCheckpoint(t, c, plan) + return c, plan +} +func writeWorkspaceCheckpoint(t *testing.T, c cell.Cell, plan WorkspacePlan) { + t.Helper() + raw, err := json.Marshal(map[string]any{"type": "tasks", "version": 2, "workspace_transfer": plan}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, cell.StateDir, "tasks.json"), raw, 0600); err != nil { + t.Fatal(err) + } +} +func TestWorkspaceCarrierCapturesAllSiblingRoots(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := cell.Cell{ID: "receiver", Root: filepath.Join(t.TempDir(), "chat")} + if err := os.MkdirAll(filepath.Join(receiver.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + run(t, c.Root, "cp", "-R", filepath.Join(c.Root, cell.StateDir, "workspaces-v2"), filepath.Join(receiver.Root, cell.StateDir, "workspaces-v2")) + writeWorkspaceCheckpoint(t, receiver, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatalf("prepare exposed execution roots: %v", err) + } + bindings, err := prepared.Promote() + if err != nil { + t.Fatal(err) + } + if len(bindings) != 2 { + t.Fatalf("bindings: %+v", bindings) + } + for _, slot := range []string{"main", "docs"} { + tree := filepath.Join(liveRoot(receiver), "7", slot) + for name, want := range map[string]string{"base.txt": "edited " + slot + "\n", "new.txt": "new " + slot + "\n"} { + got, err := os.ReadFile(filepath.Join(tree, name)) + if err != nil || string(got) != want { + t.Fatalf("%s/%s: %q %v", slot, name, got, err) + } + } + if _, err := os.Stat(filepath.Join(tree, "deleted.txt")); !os.IsNotExist(err) { + t.Fatal("deleted file restored") + } + info, err := os.Stat(filepath.Join(tree, "new.txt")) + if err != nil || info.Mode().Perm() != 0750 { + t.Fatalf("mode: %v %v", info, err) + } + } + if err := prepared.Acknowledge(); err != nil { + t.Fatal(err) + } +} + +func TestWorkspaceCaptureReusesBoundedGenerationsAndRecoversOwnedCandidate(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + unknown := filepath.Join(workspaceCarrierRoot(c), "generation-unrelated") + write(t, filepath.Join(unknown, "keep"), "unrelated") + // Record a partially written generation exactly as a killed capture leaves it. + orphan := "generation-0123456789abcdef0123456789abcdef" + write(t, filepath.Join(workspaceCarrierRoot(c), orphan, "partial"), "partial") + manifest, err := readWorkspaceManifest(c) + if err != nil { + t.Fatal(err) + } + if err = writeWorkspaceJSON(filepath.Join(c.Root, ".codeaf", "workspace-capture.json"), workspaceCaptureReceipt{Version: 2, Stage: orphan, Previous: manifest.Generation}); err != nil { + t.Fatal(err) + } + if err = (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(workspaceCarrierRoot(c), orphan)); !os.IsNotExist(err) { + t.Fatal("journaled orphan survived") + } + if _, err = os.Stat(filepath.Join(unknown, "keep")); err != nil { + t.Fatal("unrelated generation removed") + } + if err = (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(workspaceCarrierRoot(c)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 { + t.Fatalf("unbounded generations: %v", entries) + } + got, _, err := ReadWorkspacePlan(c) + if err != nil || got == nil || got.ChatURI != plan.ChatURI { + t.Fatal("capture altered checkpoint") + } +} + +func TestWorkspaceCarrierPreservesDirectoriesAndTheirAbsence(t *testing.T) { + c, plan := workspaceFixture(t) + for _, entry := range plan.Groups[0].Entries { + tree := filepath.Join(liveRoot(c), filepath.FromSlash(entry.Layout)) + write(t, filepath.Join(tree, "old-dir", "removed.txt"), "remove this baseline file\n") + run(t, tree, "git", "add", "old-dir/removed.txt") + run(t, tree, "git", "commit", "-qm", "old directory") + if err := os.RemoveAll(filepath.Join(tree, "old-dir")); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(tree, "user-dir", "private.txt"), "private output\n") + if err := os.Chmod(filepath.Join(tree, "user-dir"), 0750); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(tree, "user-dir", "private.txt"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(tree, "user-dir", "empty"), 0700); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(tree, ".gitignore"), "cache/\n") + if err := os.MkdirAll(filepath.Join(tree, "cache", "do-not-carry-empty"), 0700); err != nil { + t.Fatal(err) + } + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + bindings, err := prepared.Promote() + if err != nil { + t.Fatal(err) + } + for _, binding := range bindings { + for path, want := range map[string]os.FileMode{"user-dir": 0750, "user-dir/empty": 0700, "user-dir/private.txt": 0640} { + info, err := os.Stat(filepath.Join(binding.Path, path)) + if err != nil || info.Mode().Perm() != want { + t.Fatalf("%s mode/presence: got %v, %v want %o", path, info, err, want) + } + } + for _, path := range []string{"old-dir", "cache"} { + if _, err = os.Stat(filepath.Join(binding.Path, path)); !os.IsNotExist(err) { + t.Fatalf("absent or ignored directory %s was manufactured", path) + } + } + } +} diff --git a/internal/taskcopy/workspace_fence.go b/internal/taskcopy/workspace_fence.go new file mode 100644 index 0000000000..3eb8be1e35 --- /dev/null +++ b/internal/taskcopy/workspace_fence.go @@ -0,0 +1,88 @@ +package taskcopy + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// LockWorkspaceCheckpoint coordinates checkpoint writes with captures. +// The lock lives in private chat machinery, outside the portable .cell state. +// Every profile takes the fence before examining portable workspace state. +func LockWorkspaceCheckpoint(ctx context.Context, c cell.Cell) (func(), error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !filepath.IsAbs(c.Root) { + return nil, errors.New("workspace checkpoint fence requires an owned absolute chat root") + } + file, err := openCheckpointLock(c.Root) + if err != nil { + return nil, err + } + for { + if err := ctx.Err(); err != nil { + file.Close() + return nil, err + } + err := filelock.Lock(file, true, true) + if err == nil { + var once sync.Once + return func() { once.Do(func() { filelock.Unlock(file); file.Close() }) }, nil + } + if !filelock.IsBusy(err) { + file.Close() + return nil, err + } + timer := time.NewTimer(10 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + file.Close() + return nil, ctx.Err() + case <-timer.C: + } + } +} + +func openCheckpointLock(chat string) (*os.File, error) { + root, err := os.OpenRoot(chat) + if err != nil { + return nil, err + } + defer root.Close() + for _, directory := range []string{cell.TaskDropping, filepath.Join(cell.TaskDropping, "workspace")} { + if err := root.Mkdir(directory, 0700); err != nil && !os.IsExist(err) { + return nil, err + } + info, err := root.Lstat(directory) + if err != nil || !info.IsDir() { + return nil, errors.New("workspace checkpoint lock directory is not private chat machinery") + } + } + name := filepath.Join(cell.TaskDropping, "workspace", "checkpoint.lock") + file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) + if os.IsExist(err) { + info, statErr := root.Lstat(name) + if statErr != nil || !info.Mode().IsRegular() || info.Size() != 0 { + return nil, errors.New("workspace checkpoint lock is not an empty regular file") + } + file, err = root.OpenFile(name, os.O_RDWR, 0) + } + if err != nil { + return nil, err + } + opened, err := file.Stat() + bound, pathErr := root.Lstat(name) + if err != nil || pathErr != nil || !opened.Mode().IsRegular() || opened.Size() != 0 || !bound.Mode().IsRegular() || !os.SameFile(opened, bound) { + file.Close() + return nil, errors.New("workspace checkpoint lock binding changed") + } + return file, nil +} diff --git a/internal/taskcopy/workspace_fence_test.go b/internal/taskcopy/workspace_fence_test.go new file mode 100644 index 0000000000..e84223c4c3 --- /dev/null +++ b/internal/taskcopy/workspace_fence_test.go @@ -0,0 +1,63 @@ +package taskcopy_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func TestWorkspaceCheckpointFenceHonorsCancellationAndReleases(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + c := cell.Cell{Root: t.TempDir()} + release, err := taskcopy.LockWorkspaceCheckpoint(context.Background(), c) + if err != nil { + t.Fatal(err) + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + started := make(chan struct{}) + go func() { + close(started) + unlock, err := taskcopy.LockWorkspaceCheckpoint(ctx, c) + if unlock != nil { + unlock() + } + done <- err + }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("competing checkpoint writer returned %v; want canceled", err) + } + release() + unlock, err := taskcopy.LockWorkspaceCheckpoint(context.Background(), c) + if err != nil { + t.Fatalf("released checkpoint stayed locked: %v", err) + } + unlock() +} + +func TestWorkspaceCheckpointFenceCanceledBeforeIOAndOrdinaryProfileSerialized(t *testing.T) { + c := cell.Cell{Root: t.TempDir()} + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := taskcopy.LockWorkspaceCheckpoint(ctx, c); !errors.Is(err, context.Canceled) { + t.Fatalf("already canceled: %v", err) + } + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "0") + unlock, err := taskcopy.LockWorkspaceCheckpoint(context.Background(), c) + if err != nil { + t.Fatal(err) + } + unlock() + if _, err := os.Stat(filepath.Join(c.Root, cell.TaskDropping)); err != nil { + t.Fatalf("ordinary profile has no checkpoint fence: %v", err) + } +} diff --git a/internal/taskcopy/workspace_plan.go b/internal/taskcopy/workspace_plan.go new file mode 100644 index 0000000000..3f91f7d469 --- /dev/null +++ b/internal/taskcopy/workspace_plan.go @@ -0,0 +1,253 @@ +package taskcopy + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/cell" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// WorkspacePlan is the session-produced projection of a structured checkpoint. +// Paths are portable layouts, never authority over a receiving project. +type WorkspacePlan struct { + Version int `json:"version"` + ChatURI string `json:"chat_uri"` + Groups []WorkspaceGroup `json:"groups"` +} + +// WorkspaceGroup records all roots required by one execution. +type WorkspaceGroup struct { + TaskID uint64 `json:"task_id"` + Required bool `json:"required"` + Incomplete bool `json:"incomplete"` + Primary string `json:"primary"` + Entries []WorkspaceEntry `json:"entries"` +} + +// WorkspaceEntry binds a stable copy identity to its portable sibling layout. +type WorkspaceEntry struct { + CopyURI string `json:"copy_uri"` + ChatURI string `json:"chat_uri"` + ProjectURI string `json:"project_uri"` + RootURI string `json:"root_uri"` + GroundURI string `json:"ground_uri"` + Layout string `json:"layout"` + Access string `json:"access"` +} + +const workspaceLimit = 1024 + +// WorkspaceCheckpointLimit bounds the checkpoint shared by capture and admission. +const WorkspaceCheckpointLimit = 16 << 20 + +// ReadWorkspacePlan reads only the typed projection and checkpoint envelope. +// A legacy checkpoint has no plan; malformed structured checkpoints fail closed. +func ReadWorkspacePlan(c cell.Cell) (*WorkspacePlan, string, error) { + path := filepath.Join(c.Root, cell.StateDir, "tasks.json") + raw, err := readWorkspaceFile(path, WorkspaceCheckpointLimit) + if errors.Is(err, os.ErrNotExist) { + raw, err = readWorkspaceFile(filepath.Join(c.Root, "tasks.json"), WorkspaceCheckpointLimit) + } + if errors.Is(err, os.ErrNotExist) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + var envelope struct { + Type string `json:"type"` + Version int `json:"version"` + Plan *WorkspacePlan `json:"workspace_transfer"` + } + if err = json.Unmarshal(raw, &envelope); err != nil { + return nil, "", err + } + if envelope.Type != "tasks" { + return nil, "", errors.New("workspace checkpoint type is invalid") + } + if envelope.Version == 1 && envelope.Plan == nil { + return nil, "", nil + } + if envelope.Version != 2 || envelope.Plan == nil { + return nil, "", errors.New("structured workspace checkpoint requires its transfer projection") + } + if _, err := workspaceEntries(*envelope.Plan); err != nil { + return nil, "", err + } + digest := sha256.Sum256(raw) + return envelope.Plan, hex.EncodeToString(digest[:]), nil +} + +func readWorkspaceFile(path string, limit int64) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > limit { + return nil, errors.New("workspace metadata is not a bounded regular file") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > limit { + return nil, errors.New("workspace metadata exceeds its bound") + } + return raw, nil +} +func workspaceEntries(plan WorkspacePlan) ([]WorkspaceEntry, error) { + if plan.Version != 2 || plan.ChatURI == "" || len(plan.Groups) == 0 || len(plan.Groups) > workspaceLimit { + return nil, errors.New("workspace transfer plan is invalid") + } + copies := map[string]WorkspaceEntry{} + layouts := map[string]string{} + tasks := map[uint64]bool{} + var out []WorkspaceEntry + for _, g := range plan.Groups { + if g.TaskID == 0 || tasks[g.TaskID] || len(g.Entries) == 0 || len(g.Entries) > workspaceLimit || g.Primary == "" || g.Required && g.Incomplete { + return nil, errors.New("workspace group is incomplete or invalid") + } + tasks[g.TaskID] = true + primary := false + layoutParent := "" + for _, e := range g.Entries { + parts := strings.Split(e.Layout, "/") + if len(parts) != 2 { + return nil, errors.New("workspace layout is invalid") + } + parent, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil || parent == 0 || strconv.FormatUint(parent, 10) != parts[0] || !ValidWorkspaceSlot(parts[1]) || e.RootURI == "" || e.ChatURI != plan.ChatURI || len(e.CopyURI) > 512 || len(e.Layout) > 280 || e.Access != "read" && e.Access != "write" { + return nil, errors.New("workspace entry is invalid") + } + if layoutParent != "" && layoutParent != parts[0] { + return nil, errors.New("workspace group roots are not siblings") + } + layoutParent = parts[0] + primary = primary || parts[1] == g.Primary + if e.CopyURI == "" { + if !g.Incomplete { + return nil, errors.New("workspace copy identity is missing") + } + continue + } + if e.ProjectURI == "" || e.GroundURI == "" { + return nil, errors.New("workspace copy ownership is missing") + } + + if prior, ok := copies[e.CopyURI]; ok { + if prior != e { + return nil, errors.New("workspace identity has conflicting layouts") + } + continue + } + folded := strings.ToLower(e.Layout) + if _, ok := layouts[folded]; ok { + return nil, errors.New("workspace layouts collide") + } + layouts[folded] = e.CopyURI + copies[e.CopyURI] = e + out = append(out, e) + if len(out) > workspaceLimit { + return nil, errors.New("workspace root count exceeds its bound") + } + } + if !primary { + return nil, errors.New("workspace primary slot is missing") + } + } + visiting := map[string]bool{} + done := map[string]bool{} + var visit func(string) error + visit = func(uri string) error { + if done[uri] { + return nil + } + if visiting[uri] { + return errors.New("workspace ground links contain a cycle") + } + visiting[uri] = true + e := copies[uri] + if _, ok := copies[e.GroundURI]; ok { + if err := visit(e.GroundURI); err != nil { + return err + } + } + done[uri] = true + visiting[uri] = false + return nil + } + for _, e := range out { + if err := visit(e.CopyURI); err != nil { + return nil, err + } + } + return out, nil +} + +// ValidWorkspaceSlot preserves supported portable ASCII names without aliases. +func ValidWorkspaceSlot(slot string) bool { + if slot == "" || len(slot) > 255 || strings.HasPrefix(slot, ".") || strings.TrimSpace(slot) != slot || strings.HasSuffix(slot, ".") || strings.ContainsAny(slot, `<>:"/\|?*`) { + return false + } + for _, b := range []byte(slot) { + if b < 32 || b > 126 { + return false + } + } + stem := strings.ToLower(strings.SplitN(slot, ".", 2)[0]) + switch stem { + case "con", "prn", "aux", "nul", "clock$", "conin$", "conout$": + return false + } + if len(stem) == 4 && (strings.HasPrefix(stem, "com") || strings.HasPrefix(stem, "lpt")) && stem[3] >= '1' && stem[3] <= '9' { + return false + } + return true +} +func workspaceSlot(s string) bool { return ValidWorkspaceSlot(s) } + +func workspaceToken(uri string) string { + sum := sha256.Sum256([]byte(uri)) + return hex.EncodeToString(sum[:]) +} + +// workspacePath rejects substitutions in any existing path component. Ownership +// still comes from the registry; containment alone never grants it. +func workspacePath(root, rel string) (string, error) { + if !filepath.IsAbs(root) || !filepath.IsLocal(rel) { + return "", errors.New("workspace path is not local") + } + current := filepath.Clean(root) + for _, part := range strings.Split(filepath.FromSlash(rel), string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("workspace path contains a symbolic link") + } + } + actual, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + if actual != filepath.Clean(root) { + return "", errors.New("workspace root is redirected") + } + return current, nil +} diff --git a/internal/taskcopy/workspace_restore.go b/internal/taskcopy/workspace_restore.go new file mode 100644 index 0000000000..08fd8392d9 --- /dev/null +++ b/internal/taskcopy/workspace_restore.go @@ -0,0 +1,1255 @@ +package taskcopy + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// WorkspaceBinding is a complete local placement awaiting registry admission. +type WorkspaceBinding struct { + Entry WorkspaceEntry + Path string +} + +// WorkspaceRestore preserves a whole-layout preparation until adoption finishes. +// Callers must admit every returned binding before Acknowledge permits execution. +type WorkspaceRestore struct { + cell cell.Cell + manifest workspaceManifest + receipt workspaceReceipt +} +type workspaceReceipt struct { + Version int `json:"version"` + Checkpoint string `json:"checkpoint_sha256"` + Generation string `json:"generation"` + Stage string `json:"stage"` + Phase string `json:"phase"` + Complete []string `json:"complete"` + Hashes map[string]string `json:"hashes"` +} + +func workspaceReceiptPath(c cell.Cell) string { + return filepath.Join(c.Root, ".codeaf", "workspace-restore.json") +} + +// PrepareWorkspace validates every carrier before staging a whole owned layout. +// It never prunes, unbundles into, or changes a borrowed project repository. +func (Carry) PrepareWorkspace(c cell.Cell, plan WorkspacePlan) (*WorkspaceRestore, error) { + incoming, digest, err := ReadWorkspacePlan(c) + if err != nil { + return nil, err + } + if incoming == nil || !reflect.DeepEqual(*incoming, plan) { + return nil, errors.New("workspace checkpoint does not match the requested transfer") + } + // Acknowledged local state may have rewritten path hints since its original + // import. Its existing local registry authority is checked independently; + // incoming ValidateWorkspace remains strict about the exact checkpoint. + if prior, ok, err := adoptedWorkspace(c, plan); err != nil { + return nil, err + } else if ok { + return prior, nil + } + manifest, err := validateWorkspaceCarrier(c, plan, digest) + if err != nil { + return nil, err + } + private, err := workspacePath(c.Root, ".codeaf") + if err != nil { + return nil, err + } + if err = os.MkdirAll(private, 0700); err != nil { + return nil, err + } + restore := &WorkspaceRestore{cell: c, manifest: manifest} + raw, err := readWorkspaceFile(workspaceReceiptPath(c), workspaceManifestLimit) + if err == nil { + if err = json.Unmarshal(raw, &restore.receipt); err != nil { + return nil, err + } + r := restore.receipt + if r.Version != 2 || r.Checkpoint != digest || r.Generation != manifest.Generation || !validWorkspaceStage(r.Stage) || r.Phase != "preparing" && r.Phase != "ready" && r.Phase != "promoted" && r.Phase != "adopted" { + return nil, errors.New("workspace restore receipt does not match this transfer; preserved for recovery") + } + } else if errors.Is(err, os.ErrNotExist) { + nonce := make([]byte, 16) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + restore.receipt = workspaceReceipt{Version: 2, Checkpoint: digest, Generation: manifest.Generation, Stage: "workspace-stage-" + hex.EncodeToString(nonce), Phase: "preparing"} + if err = restore.save(); err != nil { + return nil, err + } + } else { + return nil, err + } + if restore.receipt.Phase == "adopted" { + return restore, nil + } + if restore.receipt.Phase == "promoted" { + if err = restore.validateLayout(liveRoot(c)); err != nil { + return nil, err + } + return restore, nil + } + stage, err := restore.stage() + if err != nil { + return nil, err + } + + if restore.receipt.Phase == "ready" { + if _, statErr := os.Lstat(stage); errors.Is(statErr, os.ErrNotExist) { + if err = restore.validateLayout(liveRoot(c)); err != nil { + return nil, err + } + restore.receipt.Phase = "promoted" + if err = restore.save(); err != nil { + return nil, err + } + return restore, nil + } + } + if err = os.MkdirAll(stage, 0700); err != nil { + return nil, err + } + complete := map[string]bool{} + for _, token := range restore.receipt.Complete { + if _, ok := restore.receipt.Hashes[token]; !ok { + return nil, errors.New("workspace restore receipt is missing its file proof") + } + complete[token] = true + } + for _, root := range manifest.Roots { + destination := filepath.Join(stage, filepath.FromSlash(root.Entry.Layout)) + if complete[root.Token] { + if err = restore.validateRoot(destination, root); err != nil { + return nil, err + } + continue + } + // The exact journaled stage grants ownership of this partial root only. + if err = os.RemoveAll(destination); err != nil { + return nil, err + } + if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return nil, err + } + from := filepath.Join(workspaceCarrierRoot(c), manifest.Generation, root.Token) + rec, err := readWorkspaceRecord(from) + if err != nil { + return nil, err + } + if err = restoreStandaloneCopy("", destination, from, rec); err != nil { + return nil, err + } + if err = os.Chmod(destination, 0700); err != nil { + return nil, err + } + if err = validateWorkspaceOverlay(destination, from, rec); err != nil { + return nil, err + } + if err = prepareWorkspaceDirectories(destination, root.Directories); err != nil { + return nil, err + } + if err = applyEOL(destination, rec.EOL); err != nil { + return nil, err + } + if err = overlay(filepath.Join(from, filesDir), destination, rec); err != nil { + return nil, err + } + if err = applyModes(destination, rec.Modes); err != nil { + return nil, err + } + if err = finishWorkspaceDirectories(destination, root.Directories); err != nil { + return nil, err + } + if err = restore.validateRoot(destination, root); err != nil { + return nil, err + } + + if restore.receipt.Hashes == nil { + restore.receipt.Hashes = map[string]string{} + } + fingerprint, err := workspaceTreeDigest(destination) + if err != nil { + return nil, err + } + restore.receipt.Hashes[root.Token] = fingerprint + restore.receipt.Complete = append(restore.receipt.Complete, root.Token) + if err = restore.save(); err != nil { + return nil, err + } + } + restore.receipt.Phase = "ready" + if err = restore.save(); err != nil { + return nil, err + } + return restore, nil +} +func validWorkspaceStage(name string) bool { + return strings.HasPrefix(name, "workspace-stage-") && len(name) == len("workspace-stage-")+32 && workspaceSlot(name) +} +func (r *WorkspaceRestore) stage() (string, error) { + if !validWorkspaceStage(r.receipt.Stage) { + return "", errors.New("workspace stage identity is invalid") + } + return workspacePath(r.cell.Root, filepath.Join(".codeaf", r.receipt.Stage)) +} +func (r *WorkspaceRestore) save() error { + return writeWorkspaceJSON(workspaceReceiptPath(r.cell), r.receipt) +} + +// Promote exposes all sibling roots in one directory rename. A durable receipt +// spans the rename and the caller's separate registry transaction. +func (r *WorkspaceRestore) Promote() ([]WorkspaceBinding, error) { + if r == nil { + return nil, errors.New("workspace restore is missing") + } + if r.receipt.Phase == "adopted" { + return r.bindings(), nil + } + if r.receipt.Phase == "promoted" { + if err := r.validateLayout(liveRoot(r.cell)); err != nil { + return nil, err + } + return r.bindings(), nil + } + if r.receipt.Phase != "ready" { + return nil, errors.New("workspace layout is incomplete") + } + stage, err := r.stage() + if err != nil { + return nil, err + } + if _, err = os.Stat(stage); errors.Is(err, os.ErrNotExist) { + // A crash may occur after the rename but before its receipt update. Only + // exact complete contents can reconcile that window. + if err = r.validateLayout(liveRoot(r.cell)); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err = r.validateLayout(stage); err != nil { + return nil, err + } + dest, err := workspacePath(r.cell.Root, cell.TreesDir) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dest) + if err == nil { + if len(entries) != 0 { + return nil, errors.New("workspace destination is occupied; preserved existing copies") + } + if err = os.Remove(dest); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if err = os.Rename(stage, dest); err != nil { + return nil, err + } + } + r.receipt.Phase = "promoted" + if err = r.save(); err != nil { + return nil, err + } + return r.bindings(), nil +} + +// Acknowledge records that the caller durably admitted every returned binding. +// The receipt remains as a recovery fence rather than deleting carried data. +func (r *WorkspaceRestore) Acknowledge() error { + if r != nil && r.receipt.Phase == "adopted" { + return nil + } + if r == nil || r.receipt.Phase != "promoted" && r.receipt.Phase != "adopted" { + return errors.New("workspace layout has not been promoted") + } + if err := r.validateLayout(liveRoot(r.cell)); err != nil { + return err + } + r.receipt.Phase = "adopted" + return r.save() +} +func (r *WorkspaceRestore) bindings() []WorkspaceBinding { + out := make([]WorkspaceBinding, 0, len(r.manifest.Roots)) + for _, root := range r.manifest.Roots { + out = append(out, WorkspaceBinding{Entry: root.Entry, Path: filepath.Join(liveRoot(r.cell), filepath.FromSlash(root.Entry.Layout))}) + } + return out +} +func (r *WorkspaceRestore) validateLayout(base string) error { + for _, root := range r.manifest.Roots { + path, err := workspacePath(base, root.Entry.Layout) + if err != nil { + return err + } + if err = r.validateRoot(path, root); err != nil { + return err + } + } + return nil +} +func (r *WorkspaceRestore) validateRoot(destination string, root workspaceRoot) error { + if _, err := workspacePath(destination, "."); err != nil { + return err + } + head, err := git(destination, "rev-parse", "HEAD") + if err != nil || strings.TrimSpace(head) != root.Head { + return errors.New("restored workspace head does not match its carrier") + } + if err := verifyWorkspaceDirectories(destination, root.Directories); err != nil { + return err + } + from := filepath.Join(workspaceCarrierRoot(r.cell), r.manifest.Generation, root.Token) + rec, err := readWorkspaceRecord(from) + if err != nil { + return err + } + if err = validateWorkspaceOverlay(destination, from, rec); err != nil { + return err + } + err = filepath.WalkDir(filepath.Join(from, filesDir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(filepath.Join(from, filesDir), path) + if err != nil { + return err + } + equal, err := workspaceFileEqual(path, filepath.Join(destination, rel)) + if err != nil { + return err + } + if !equal { + return errors.New("restored workspace overlay differs from its carrier") + } + + return nil + }) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for _, rel := range rec.Deleted { + if _, err = os.Lstat(filepath.Join(destination, rel)); !errors.Is(err, os.ErrNotExist) { + return errors.New("restored workspace deletion is missing") + } + } + for rel, mode := range rec.Modes { + info, err := os.Stat(filepath.Join(destination, filepath.FromSlash(rel))) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.Mode().Perm() != mode.Perm() { + return errors.New("restored workspace permissions differ from its carrier") + } + } + if wanted, ok := r.receipt.Hashes[root.Token]; ok { + actual, err := workspaceTreeDigest(destination) + if err != nil { + return err + } + if wanted != actual { + return errors.New("prepared workspace files changed; preserved for inspection") + } + } + return nil +} +func validateWorkspaceCarrier(c cell.Cell, plan WorkspacePlan, digest string) (workspaceManifest, error) { + manifest, err := readWorkspaceManifest(c) + if err != nil { + return manifest, err + } + entries, err := workspaceEntries(plan) + if err != nil { + return manifest, err + } + if manifest.Checkpoint != digest || !reflect.DeepEqual(manifest.Plan, plan) || len(entries) != len(manifest.Roots) { + return manifest, errors.New("workspace manifest does not match its checkpoint") + } + byURI := map[string]WorkspaceEntry{} + for _, e := range entries { + byURI[e.CopyURI] = e + } + seen := map[string]bool{} + metadataBytes := int64(0) + for _, root := range manifest.Roots { + if e, ok := byURI[root.Entry.CopyURI]; !ok || e != root.Entry || seen[root.Token] || root.Token != workspaceToken(root.Entry.CopyURI) { + return manifest, errors.New("workspace manifest root is duplicated or mismatched") + } + seen[root.Token] = true + from, err := workspacePath(c.Root, filepath.Join(cell.StateDir, workspaceCarrierDir, manifest.Generation, root.Token)) + if err != nil { + return manifest, err + } + info, err := os.Lstat(filepath.Join(from, recordName)) + if err != nil { + return manifest, err + } + metadataBytes += info.Size() + if metadataBytes > WorkspaceCheckpointLimit { + return manifest, errors.New("workspace total path metadata exceeds its bound") + } + rec, err := readWorkspaceRecord(from) + if err != nil { + return manifest, err + } + if !rec.FullHistory || rec.Head != root.Head { + return manifest, errors.New("workspace carrier lacks its complete recorded history") + } + if err = validateWorkspaceRecord(from, rec); err != nil { + return manifest, err + } + directoryBytes, err := validateWorkspaceDirectories(root, rec) + if err != nil { + return manifest, err + } + metadataBytes += int64(directoryBytes) + if metadataBytes > WorkspaceCheckpointLimit { + return manifest, errors.New("workspace total directory metadata exceeds its bound") + } + + if err = screenFullHistory(filepath.Join(from, bundleName)); err != nil { + return manifest, err + } + bundle, err := os.Open(filepath.Join(from, bundleName)) + if err != nil { + return manifest, err + } + header, err := readFullBundleHeader(bundle) + bundle.Close() + if err != nil { + return manifest, err + } + if header.head != rec.Head { + return manifest, errors.New("workspace bundle does not name its recorded head") + } + + } + return manifest, nil +} +func validateWorkspaceRecord(from string, rec record) error { + if _, err := hex.DecodeString(rec.Head); err != nil || len(rec.Head) != 40 && len(rec.Head) != 64 { + return errors.New("workspace commit identity is malformed") + } + if rec.Branch != "" { + if _, err := git(from, "check-ref-format", "--branch", rec.Branch); err != nil { + return errors.New("workspace branch identity is invalid") + } + } + if len(rec.Deleted)+len(rec.Modes)+len(rec.EOL) > workspacePathLimit { + return errors.New("workspace path metadata exceeds its bound") + } + check := func(rel string) error { + if !filepath.IsLocal(filepath.FromSlash(rel)) || len(rel) > 4096 || strings.Contains(rel, "\\") || rel == ".git" || strings.HasPrefix(rel, ".git/") { + return errors.New("workspace record path is unsafe") + } + return nil + } + for _, rel := range rec.Deleted { + if err := check(rel); err != nil { + return err + } + } + for rel := range rec.Modes { + if err := check(rel); err != nil { + return err + } + } + for rel, style := range rec.EOL { + if style != "lf" && style != "crlf" { + return errors.New("workspace line ending style is invalid") + } + if err := check(rel); err != nil { + return err + } + } + files := filepath.Join(from, filesDir) + count := 0 + err := filepath.WalkDir(files, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(files, path) + if err != nil { + return err + } + if rel != "." { + if err = check(filepath.ToSlash(rel)); err != nil { + return err + } + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() && !info.Mode().IsRegular() { + return errors.New("workspace overlay contains an unsupported file") + } + count++ + if count > workspacePathLimit { + return errors.New("workspace overlay path count exceeds its bound") + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} +func validateWorkspaceOverlay(destination, from string, rec record) error { + paths := append([]string(nil), rec.Deleted...) + for rel := range rec.Modes { + paths = append(paths, rel) + } + for rel := range rec.EOL { + paths = append(paths, rel) + } + err := filepath.WalkDir(filepath.Join(from, filesDir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + rel, err := filepath.Rel(filepath.Join(from, filesDir), path) + if err != nil { + return err + } + paths = append(paths, rel) + } + return nil + }) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for _, rel := range paths { + if _, err := workspacePath(destination, filepath.FromSlash(rel)); err != nil { + return fmt.Errorf("workspace overlay path: %w", err) + } + } + return nil +} + +// workspaceTreeDigest seals the prepared working files, including clean files +// that an overlay comparison cannot judge. Git's mutable administrative files +// are excluded; the recorded HEAD is checked independently. +func workspaceTreeDigest(root string) (string, error) { + hash := sha256.New() + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == ".git" || cell.IsTaskDropping(filepath.ToSlash(rel)) { + return skipEntry(d) + } + info, err := os.Lstat(path) + if err != nil { + return err + } + fmt.Fprintf(hash, "%d:%s:%o:%d:", len(rel), filepath.ToSlash(rel), info.Mode(), info.Size()) + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + fmt.Fprintf(hash, "%d:%s", len(link), link) + return nil + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return errors.New("prepared workspace contains an unsupported file") + } + file, err := os.Open(path) + if err != nil { + return err + } + _, err = io.Copy(hash, file) + closeErr := file.Close() + if err != nil { + return err + } + return closeErr + }) + if err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func readWorkspaceRecord(from string) (record, error) { + var rec record + raw, err := readWorkspaceFile(filepath.Join(from, recordName), workspaceManifestLimit) + if err != nil { + return rec, err + } + err = json.Unmarshal(raw, &rec) + return rec, err +} +func workspaceFileEqual(a, b string) (bool, error) { + fingerprint := func(path string) (string, int64, error) { + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + hash := sha256.New() + n, err := io.Copy(hash, file) + return hex.EncodeToString(hash.Sum(nil)), n, err + } + ah, an, err := fingerprint(a) + if err != nil { + return false, err + } + bh, bn, err := fingerprint(b) + return ah == bh && an == bn, err +} + +// ValidateWorkspace proves checkpoint membership and every portable root without +// creating a restore receipt or changing a receiver's working directories. +func ValidateWorkspace(c cell.Cell, plan WorkspacePlan) error { + incoming, digest, err := ReadWorkspacePlan(c) + if err != nil { + return err + } + if incoming == nil || !reflect.DeepEqual(*incoming, plan) { + return errors.New("workspace checkpoint does not match the requested transfer") + } + _, err = validateWorkspaceCarrier(c, plan, digest) + return err +} + +// WorkspaceReady fences workers until an incoming whole layout was admitted. +// A locally captured source keeps its existing admitted authority. An adopted +// receiver may edit its working files and rewrite local checkpoint paths without +// changing the immutable provenance recorded by its restore receipt. +func WorkspaceReady(c cell.Cell) error { + manifest, err := readWorkspaceManifest(c) + if errors.Is(err, os.ErrNotExist) { + plan, _, readErr := ReadWorkspacePlan(c) + if readErr != nil { + return readErr + } + if plan == nil { + return nil + } + return localWorkspacePlanReady(c, *plan) + } + if err != nil { + return err + } + raw, err := readWorkspaceFile(workspaceReceiptPath(c), workspaceManifestLimit) + if errors.Is(err, os.ErrNotExist) { + entries, err := workspaceEntries(manifest.Plan) + if err != nil { + return err + } + for _, entry := range entries { + if _, err = sourceWorkspace(c, entry); err != nil { + return errors.New("incoming workspace roots have not been admitted") + } + } + return nil + } + if err != nil { + return err + } + var receipt workspaceReceipt + if err = json.Unmarshal(raw, &receipt); err != nil { + return err + } + if receipt.Version != 2 || receipt.Phase != "adopted" || !validWorkspaceStage(receipt.Stage) { + return errors.New("workspace restore is incomplete; preserved for recovery before starting work") + } + if receipt.Checkpoint != manifest.Checkpoint || receipt.Generation != manifest.Generation { + return localWorkspacePlanReady(c, manifest.Plan) + } + + return nil +} + +func localWorkspacePlanReady(c cell.Cell, plan WorkspacePlan) error { + entries, err := workspaceEntries(plan) + if err != nil { + return err + } + for _, entry := range entries { + if _, err = sourceWorkspace(c, entry); err != nil { + return err + } + } + return nil +} +func adoptedWorkspace(c cell.Cell, plan WorkspacePlan) (*WorkspaceRestore, bool, error) { + raw, err := readWorkspaceFile(workspaceReceiptPath(c), workspaceManifestLimit) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var receipt workspaceReceipt + if err = json.Unmarshal(raw, &receipt); err != nil { + return nil, false, err + } + if receipt.Phase != "adopted" { + return nil, false, nil + } + if receipt.Version != 2 || !validWorkspaceStage(receipt.Stage) { + return nil, false, errors.New("workspace adoption receipt is invalid") + } + manifest, err := readWorkspaceManifest(c) + if err != nil { + return nil, false, err + } + if !reflect.DeepEqual(manifest.Plan, plan) { + return nil, false, nil + } + if err = localWorkspacePlanReady(c, plan); err != nil { + return nil, false, err + } + return &WorkspaceRestore{cell: c, manifest: manifest, receipt: receipt}, true, nil +} + +// WorkspaceResultSelection identifies a settled copy's exact recorded baseline. +type WorkspaceResultSelection struct { + CopyURI string `json:"copy_uri"` + BaseCommit string `json:"base_commit"` +} + +// RestoreCompletedWorkspaceResults retains changed outputs without runnable Git +// copies. Full history remains in the independent portable carrier namespace. +func RestoreCompletedWorkspaceResults(c cell.Cell, plan WorkspacePlan, destination string, selections []WorkspaceResultSelection) (map[string]string, error) { + manifest, err := readWorkspaceManifest(c) + if err != nil { + return nil, err + } + manifest, err = validateWorkspaceCarrier(c, plan, manifest.Checkpoint) + if err != nil { + return nil, err + } + if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination { + return nil, errors.New("workspace result destination must be an owned absolute folder") + } + if len(selections) > workspaceLimit { + return nil, errors.New("workspace result selection exceeds its bound") + } + roots := map[string]workspaceRoot{} + for _, root := range manifest.Roots { + roots[root.Entry.CopyURI] = root + } + chosen := map[string]bool{} + for _, selection := range selections { + if chosen[selection.CopyURI] { + return nil, errors.New("workspace result selection is duplicated") + } + chosen[selection.CopyURI] = true + if _, ok := roots[selection.CopyURI]; !ok { + return nil, errors.New("workspace result selection is outside the carrier") + } + if selection.BaseCommit == "" { + return nil, errors.New("this older task did not record its Git baseline; full saved history is preserved, but changed outputs cannot be selected safely") + } + if _, err := hex.DecodeString(selection.BaseCommit); err != nil || len(selection.BaseCommit) != 40 && len(selection.BaseCommit) != 64 { + return nil, errors.New("workspace result baseline is malformed") + } + } + expected := workspaceResultsRecord{Version: 2, Checkpoint: manifest.Checkpoint, Selections: selections} + if _, err = os.Lstat(destination); err == nil { + return reopenWorkspaceResults(destination, expected) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + parent := filepath.Dir(destination) + if _, err = workspacePath(parent, filepath.Base(destination)); err != nil { + return nil, err + } + stage, err := prepareWorkspaceResultStage(c, destination) + if err != nil { + return nil, err + } + defer os.RemoveAll(stage) + output := filepath.Join(stage, "output") + if err = os.Mkdir(output, 0700); err != nil { + return nil, err + } + expected.Roots = map[string]workspaceResultRoot{} + for _, selection := range selections { + root := roots[selection.CopyURI] + from := filepath.Join(workspaceCarrierRoot(c), manifest.Generation, root.Token) + rec, err := readWorkspaceRecord(from) + if err != nil { + return nil, err + } + checkout := filepath.Join(stage, "checkout-"+root.Token) + if err = prepareResultCheckout("", checkout, from, rec); err != nil { + return nil, err + } + if _, err = git(checkout, "cat-file", "-e", selection.BaseCommit+"^{commit}"); err != nil { + return nil, errors.New("workspace output baseline is unavailable; full carrier history was preserved") + } + if _, err = git(checkout, "merge-base", "--is-ancestor", selection.BaseCommit, rec.Head); err != nil { + return nil, errors.New("workspace result baseline is not an ancestor of its recorded head") + } + if err = validateWorkspaceOverlay(checkout, from, rec); err != nil { + return nil, err + } + if err = os.Chmod(checkout, 0700); err != nil { + return nil, err + } + if err = prepareWorkspaceDirectories(checkout, root.Directories); err != nil { + return nil, err + } + if err = applyEOL(checkout, rec.EOL); err != nil { + return nil, err + } + if err = overlay(filepath.Join(from, filesDir), checkout, rec); err != nil { + return nil, err + } + if err = applyModes(checkout, rec.Modes); err != nil { + return nil, err + } + if err = finishWorkspaceDirectories(checkout, root.Directories); err != nil { + return nil, err + } + diff, err := git(checkout, "diff", "--name-only", "-z", "--no-renames", selection.BaseCommit, rec.Head, "--") + if err != nil { + return nil, err + } + dirty, err := changedPaths(checkout) + if err != nil { + return nil, err + } + paths := map[string]bool{} + for _, path := range append(strings.Split(diff, "\x00"), dirty...) { + if path != "" && !cell.IsTaskDropping(path) { + paths[path] = true + } + } + artifact := filepath.Join(output, filepath.FromSlash(root.Entry.Layout)) + if err = os.MkdirAll(artifact, 0700); err != nil { + return nil, err + } + result := workspaceResultRoot{Layout: root.Entry.Layout, Head: rec.Head, BaseCommit: selection.BaseCommit} + ordered := make([]string, 0, len(paths)) + for path := range paths { + ordered = append(ordered, path) + } + sort.Strings(ordered) + for _, path := range ordered { + if _, err = workspacePath(checkout, path); err != nil { + return nil, err + } + if _, err = os.Lstat(filepath.Join(checkout, path)); errors.Is(err, os.ErrNotExist) { + result.Deleted = append(result.Deleted, filepath.ToSlash(path)) + continue + } else if err != nil { + return nil, err + } + if err = copySelectedResult(checkout, from, artifact, path, rec); err != nil { + return nil, err + } + result.Paths = append(result.Paths, filepath.ToSlash(path)) + } + result.Directories, err = workspaceOutputDirectories(checkout, selection.BaseCommit, root.Directories, result.Paths) + if err != nil { + return nil, err + } + if err = prepareWorkspaceDirectories(artifact, result.Directories); err != nil { + return nil, err + } + if err = finishWorkspaceDirectories(artifact, result.Directories); err != nil { + return nil, err + } + result.Digest, err = workspaceTreeDigest(artifact) + if err != nil { + return nil, err + } + expected.Roots[selection.CopyURI] = result + if err = os.RemoveAll(checkout); err != nil { + return nil, err + } + } + metadata := filepath.Join(output, ".codeaf") + if err = os.Mkdir(metadata, 0700); err != nil { + return nil, err + } + if err = writeWorkspaceJSON(filepath.Join(metadata, "workspace-results.json"), expected); err != nil { + return nil, err + } + if err = os.Rename(output, destination); err != nil { + return nil, err + } + return workspaceResultPaths(destination, expected), nil +} + +type workspaceResultRoot struct { + Directories map[string]fs.FileMode `json:"directories"` + Layout string `json:"layout"` + Head string `json:"head"` + BaseCommit string `json:"base_commit"` + Digest string `json:"digest"` + Paths []string `json:"paths"` + Deleted []string `json:"deleted"` +} +type workspaceResultsRecord struct { + Version int `json:"version"` + Checkpoint string `json:"checkpoint_sha256"` + Selections []WorkspaceResultSelection `json:"selections"` + Roots map[string]workspaceResultRoot `json:"roots"` +} + +func workspaceResultPaths(destination string, record workspaceResultsRecord) map[string]string { + out := map[string]string{} + for uri, root := range record.Roots { + out[uri] = filepath.Join(destination, filepath.FromSlash(root.Layout)) + } + return out +} +func reopenWorkspaceResults(destination string, expected workspaceResultsRecord) (map[string]string, error) { + raw, err := readWorkspaceFile(filepath.Join(destination, ".codeaf", "workspace-results.json"), workspaceManifestLimit) + if err != nil { + return nil, errors.New("workspace history destination is occupied; preserved its existing files") + } + var record workspaceResultsRecord + if err = json.Unmarshal(raw, &record); err != nil { + return nil, err + } + if record.Version != 2 || record.Checkpoint != expected.Checkpoint || !reflect.DeepEqual(record.Selections, expected.Selections) || len(record.Roots) != len(expected.Selections) { + return nil, errors.New("workspace history destination belongs to another result set") + } + for _, selection := range record.Selections { + root, ok := record.Roots[selection.CopyURI] + if !ok { + return nil, errors.New("workspace history result is missing") + } + path, err := workspacePath(destination, root.Layout) + if err != nil { + return nil, err + } + digest, err := workspaceTreeDigest(path) + if err != nil { + return nil, err + } + if digest != root.Digest { + return nil, errors.New("workspace history output changed; preserved its files") + } + } + return workspaceResultPaths(destination, record), nil +} +func prepareWorkspaceResultStage(c cell.Cell, destination string) (string, error) { + private, err := workspacePath(c.Root, ".codeaf") + if err != nil { + return "", err + } + if err = os.MkdirAll(private, 0700); err != nil { + return "", err + } + journal := filepath.Join(private, "workspace-results-stage.json") + target := workspaceToken(destination) + var prior struct { + Version int `json:"version"` + Target string `json:"target"` + Stage string `json:"stage"` + } + raw, err := readWorkspaceFile(journal, workspaceManifestLimit) + if err == nil { + if err = json.Unmarshal(raw, &prior); err != nil { + return "", err + } + if prior.Version != 2 || prior.Target != target || !validWorkspaceResultStage(prior.Stage) { + return "", errors.New("workspace result stage belongs to another operation; preserved for recovery") + } + path, err := workspacePath(c.Root, filepath.Join(".codeaf", prior.Stage)) + if err != nil { + return "", err + } + if err = os.RemoveAll(path); err != nil { + return "", err + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + nonce := make([]byte, 16) + if _, err = rand.Read(nonce); err != nil { + return "", err + } + prior.Version = 2 + prior.Target = target + prior.Stage = "workspace-results-stage-" + hex.EncodeToString(nonce) + if err = writeWorkspaceJSON(journal, prior); err != nil { + return "", err + } + stage := filepath.Join(private, prior.Stage) + if err = os.Mkdir(stage, 0700); err != nil { + return "", err + } + return stage, nil +} + +func validWorkspaceResultStage(name string) bool { + return strings.HasPrefix(name, "workspace-results-stage-") && len(name) == len("workspace-results-stage-")+32 && workspaceSlot(name) +} + +func validateWorkspaceDirectories(root workspaceRoot, rec record) (int, error) { + if root.DirectoryVersion != 1 || root.Directories == nil { + return 0, errors.New("workspace carrier lacks supported directory metadata; preserve it and recapture with the current prototype") + } + if len(root.Directories)+len(rec.Modes)+len(rec.EOL)+len(rec.Deleted) > workspacePathLimit { + return 0, errors.New("workspace path metadata exceeds its bound") + } + bytes := 0 + caser := cases.Fold() + fold := func(path string) string { return norm.NFC.String(caser.String(norm.NFC.String(path))) } + files := map[string]bool{} + for path := range rec.Modes { + files[fold(path)] = true + } + folded := map[string]bool{} + for path, mode := range root.Directories { + alias := fold(path) + if folded[alias] { + return 0, errors.New("workspace directory spellings alias on another filesystem") + } + folded[alias] = true + + if !utf8.ValidString(path) || path == "." || !filepath.IsLocal(filepath.FromSlash(path)) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path || strings.Contains(path, "\\") || len(path) > 4096 { + return 0, errors.New("workspace directory path is unsafe") + } + for _, part := range strings.Split(path, "/") { + if part == ".git" || cell.IsTaskDropping(part) { + return 0, errors.New("workspace directory names private administrative state") + } + } + if mode != mode.Perm() || mode&0700 != 0700 { + return 0, errors.New("workspace directory permissions are unsupported") + } + for part := path; part != "."; part = filepath.ToSlash(filepath.Dir(filepath.FromSlash(part))) { + if files[fold(part)] { + return 0, errors.New("workspace directory conflicts with a regular file") + } + if part != path { + if _, exists := root.Directories[part]; !exists { + return 0, errors.New("workspace directory is missing its parent metadata") + } + } + } + bytes += len(path) + 8 + if bytes > workspaceManifestLimit { + return 0, errors.New("workspace directory metadata exceeds its bound") + } + } + return bytes, nil +} +func workspaceDirectoryOrder(directories map[string]fs.FileMode, deepest bool) []string { + paths := make([]string, 0, len(directories)) + for path := range directories { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + di, dj := strings.Count(paths[i], "/"), strings.Count(paths[j], "/") + if di != dj { + if deepest { + return di > dj + } + return di < dj + } + return paths[i] < paths[j] + }) + return paths +} +func prepareWorkspaceDirectories(destination string, directories map[string]fs.FileMode) error { + // Judge every existing component before creating any declared directory. + for path := range directories { + target, err := workspacePath(destination, filepath.FromSlash(path)) + if err != nil { + return err + } + info, err := os.Lstat(target) + if err == nil && !info.IsDir() { + return errors.New("workspace directory collides with a checkout file") + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + for _, path := range workspaceDirectoryOrder(directories, false) { + if err := os.MkdirAll(filepath.Join(destination, filepath.FromSlash(path)), 0700); err != nil { + return err + } + } + return nil +} +func finishWorkspaceDirectories(destination string, directories map[string]fs.FileMode) error { + var absent []string + err := filepath.WalkDir(destination, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + rel, err := filepath.Rel(destination, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + return filepath.SkipDir + } + rel = filepath.ToSlash(rel) + if _, keep := directories[rel]; !keep { + absent = append(absent, rel) + } + return nil + }) + if err != nil { + return err + } + sort.Slice(absent, func(i, j int) bool { return strings.Count(absent[i], "/") > strings.Count(absent[j], "/") }) + for _, path := range absent { + target := filepath.Join(destination, filepath.FromSlash(path)) + entries, err := os.ReadDir(target) + if err != nil { + return err + } + if len(entries) == 0 { + if err = os.Remove(target); err != nil { + return err + } + } else { + return errors.New("workspace carrier omits a nonempty directory; preserved its private preparation") + } + } + for _, path := range workspaceDirectoryOrder(directories, true) { + if _, err := workspacePath(destination, filepath.FromSlash(path)); err != nil { + return err + } + if err := os.Chmod(filepath.Join(destination, filepath.FromSlash(path)), directories[path]); err != nil { + return err + } + } + return nil +} +func verifyWorkspaceDirectories(destination string, directories map[string]fs.FileMode) error { + err := filepath.WalkDir(destination, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + rel, err := filepath.Rel(destination, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + return filepath.SkipDir + } + if _, ok := directories[filepath.ToSlash(rel)]; !ok { + return errors.New("workspace contains a directory absent from its carrier metadata") + } + return nil + }) + if err != nil { + return err + } + for path, mode := range directories { + target, err := workspacePath(destination, filepath.FromSlash(path)) + if err != nil { + return err + } + info, err := os.Lstat(target) + if err != nil { + return err + } + if !info.IsDir() || info.Mode().Perm() != mode { + return errors.New("restored workspace directory mode or type differs from its carrier") + } + } + return nil +} + +// workspaceOutputDirectories retains output parents and empty directories not +// represented by the Git baseline. Git does not record directory permissions, +// so mode-only changes to an otherwise unchanged input directory remain in the +// full carrier rather than being attributed to this changed-output subset. +func workspaceOutputDirectories(checkout, base string, directories map[string]fs.FileMode, files []string) (map[string]fs.FileMode, error) { + raw, err := git(checkout, "ls-tree", "-r", "-d", "--name-only", "-z", base, "--") + if err != nil { + return nil, err + } + if len(raw) > WorkspaceCheckpointLimit { + return nil, errors.New("workspace baseline directory metadata exceeds its bound") + } + baseline := map[string]bool{} + for _, path := range strings.Split(raw, "\x00") { + baseline[path] = true + } + out := map[string]fs.FileMode{} + add := func(path string) error { + for path != "." { + mode, exists := directories[path] + if !exists { + return errors.New("workspace output parent is absent from its carrier directory metadata") + } + out[path] = mode + path = filepath.ToSlash(filepath.Dir(filepath.FromSlash(path))) + } + return nil + } + for _, path := range files { + if err = add(filepath.ToSlash(filepath.Dir(filepath.FromSlash(path)))); err != nil { + return nil, err + } + } + for path := range directories { + if baseline[path] { + continue + } + entries, err := os.ReadDir(filepath.Join(checkout, filepath.FromSlash(path))) + if err != nil { + return nil, err + } + if len(entries) == 0 { + if err = add(path); err != nil { + return nil, err + } + } + } + return out, nil +} diff --git a/internal/taskcopy/workspace_restore_test.go b/internal/taskcopy/workspace_restore_test.go new file mode 100644 index 0000000000..925faf099c --- /dev/null +++ b/internal/taskcopy/workspace_restore_test.go @@ -0,0 +1,596 @@ +package taskcopy + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func workspaceReceiver(t *testing.T, c cell.Cell, plan WorkspacePlan) cell.Cell { + t.Helper() + receiver := cell.Cell{ID: "receiver", Root: filepath.Join(t.TempDir(), "chat")} + if err := os.MkdirAll(filepath.Join(receiver.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + run(t, c.Root, "cp", "-R", workspaceCarrierRoot(c), workspaceCarrierRoot(receiver)) + writeWorkspaceCheckpoint(t, receiver, plan) + return receiver +} +func TestWorkspaceRestoreReopensSameWholeLayout(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + first, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + stages, _ := filepath.Glob(filepath.Join(receiver.Root, ".codeaf", "workspace-stage-*")) + if len(stages) != 1 { + t.Fatalf("stages: %v", stages) + } + second, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = second.Promote(); err != nil { + t.Fatal(err) + } + third, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = third.Promote(); err != nil { + t.Fatal(err) + } + if err = third.Acknowledge(); err != nil { + t.Fatal(err) + } + if _, err = first.Promote(); err != nil { + t.Fatal(err) + } + stages, _ = filepath.Glob(filepath.Join(receiver.Root, ".codeaf", "workspace-stage-*")) + if len(stages) != 0 { + t.Fatalf("duplicate stages: %v", stages) + } +} +func TestWorkspaceRestoreRejectsDamagedClosureBeforeMutations(t *testing.T) { + for _, kind := range []string{"missing-root", "wrong-checkpoint", "traversal-record", "symlink-carrier", "occupied-destination"} { + t.Run(kind, func(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + manifest, err := readWorkspaceManifest(receiver) + if err != nil { + t.Fatal(err) + } + from := filepath.Join(workspaceCarrierRoot(receiver), manifest.Generation, manifest.Roots[1].Token) + switch kind { + case "missing-root": + if err = os.RemoveAll(from); err != nil { + t.Fatal(err) + } + case "wrong-checkpoint": + write(t, filepath.Join(receiver.Root, cell.StateDir, "tasks.json"), `{"type":"tasks","version":2}`) + case "traversal-record": + rec, err := readRecord(from) + if err != nil { + t.Fatal(err) + } + rec.Deleted = []string{"../../outside"} + if err = writeRecord(from, rec); err != nil { + t.Fatal(err) + } + case "symlink-carrier": + replacement := t.TempDir() + if err = os.Rename(from, filepath.Join(replacement, "root")); err != nil { + t.Fatal(err) + } + if err = os.Symlink(filepath.Join(replacement, "root"), from); err != nil { + t.Fatal(err) + } + case "occupied-destination": + write(t, filepath.Join(liveRoot(receiver), "keep"), "unrelated") + } + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if kind == "occupied-destination" { + if err != nil { + t.Fatal(err) + } + if _, err = prepared.Promote(); err == nil { + t.Fatal("occupied destination accepted") + } + got, _ := os.ReadFile(filepath.Join(liveRoot(receiver), "keep")) + if string(got) != "unrelated" { + t.Fatal("existing root changed") + } + return + } + if err == nil { + t.Fatal("damaged carrier accepted") + } + if _, err = os.Stat(filepath.Join(receiver.Root, ".codeaf")); !os.IsNotExist(err) { + t.Fatalf("validation mutated private restore state: %v", err) + } + if _, err = os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatalf("validation exposed partial roots: %v", err) + } + }) + } +} +func TestWorkspacePromotionRecoversRenameBeforeReceipt(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + // Simulate the filesystem state after successful rename and before the next + // durable receipt write; reopening must adopt that exact layout once. + stage, err := prepared.stage() + if err != nil { + t.Fatal(err) + } + if err = os.Rename(stage, liveRoot(receiver)); err != nil { + t.Fatal(err) + } + reopened, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + bindings, err := reopened.Promote() + if err != nil || len(bindings) != 2 { + t.Fatalf("recovery: %v %v", bindings, err) + } +} +func TestWorkspaceCarrierRejectsInvalidPlansPreservingPublishedGeneration(t *testing.T) { + for _, kind := range []string{"missing-source", "case-alias", "cycle", "required-incomplete", "layout-escape", "wrong-owner"} { + t.Run(kind, func(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(workspaceCarrierRoot(c), "manifest.json")) + if err != nil { + t.Fatal(err) + } + switch kind { + case "missing-source": + if err = os.RemoveAll(filepath.Join(liveRoot(c), "7", "docs")); err != nil { + t.Fatal(err) + } + case "case-alias": + plan.Groups[0].Entries[1].Layout = "7/MAIN" + case "cycle": + plan.Groups[0].Entries[0].GroundURI = plan.Groups[0].Entries[1].CopyURI + plan.Groups[0].Entries[1].GroundURI = plan.Groups[0].Entries[0].CopyURI + case "required-incomplete": + plan.Groups[0].Incomplete = true + case "layout-escape": + plan.Groups[0].Entries[1].Layout = "7/../../escape" + case "wrong-owner": + plan.Groups[0].Entries[1].ChatURI = "other" + } + writeWorkspaceCheckpoint(t, c, plan) + if err := (Carry{}).Compose(c); err == nil { + t.Fatal("invalid plan accepted") + } + after, err := os.ReadFile(filepath.Join(workspaceCarrierRoot(c), "manifest.json")) + if err != nil || string(before) != string(after) { + t.Fatal("prior carrier replaced") + } + }) + } +} +func TestWorkspaceCarrierPreservesCooperatorsAndHistoricalIntent(t *testing.T) { + c, plan := workspaceFixture(t) + plan.Groups = append(plan.Groups, WorkspaceGroup{TaskID: 8, Required: true, Primary: "main", Entries: append([]WorkspaceEntry(nil), plan.Groups[0].Entries...)}) + intent := WorkspaceEntry{ChatURI: plan.ChatURI, RootURI: plan.Groups[0].Entries[0].RootURI, Layout: "9/not-carved", Access: "read"} + plan.Groups = append(plan.Groups, WorkspaceGroup{TaskID: 9, Incomplete: true, Primary: "not-carved", Entries: []WorkspaceEntry{intent}}) + writeWorkspaceCheckpoint(t, c, plan) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(workspaceCarrierRoot(c), "manifest.json")) + if err != nil { + t.Fatal(err) + } + var manifest workspaceManifest + if err = json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + if len(manifest.Roots) != 2 || len(manifest.Plan.Groups) != 3 { + t.Fatalf("closure duplicated or intent discarded: %+v", manifest) + } + receiver := workspaceReceiver(t, c, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + bindings, err := prepared.Promote() + if err != nil || len(bindings) != 2 { + t.Fatalf("cooperator closure: %v %v", bindings, err) + } +} + +func TestWorkspaceReopenRejectsChangedStagedBaseline(t *testing.T) { + c, plan := workspaceFixture(t) + for _, entry := range plan.Groups[0].Entries { + tree := filepath.Join(liveRoot(c), filepath.FromSlash(entry.Layout)) + write(t, filepath.Join(tree, "stable.txt"), "stable baseline\n") + run(t, tree, "git", "add", "stable.txt") + run(t, tree, "git", "commit", "-qm", "stable") + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + stage, err := prepared.stage() + if err != nil { + t.Fatal(err) + } + write(t, filepath.Join(stage, "7", "main", "stable.txt"), "changed after prepare\n") + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatal("changed staged baseline accepted") + } + if _, err = os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatal("damaged preparation exposed") + } +} + +func TestWorkspaceWorkerAdmissionRequiresWholeRestoreReceipt(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + if err := WorkspaceReady(c); err != nil { + t.Fatalf("local source blocked: %v", err) + } + receiver := workspaceReceiver(t, c, plan) + if err := ValidateWorkspace(receiver, plan); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(receiver.Root, ".codeaf")); !os.IsNotExist(err) { + t.Fatal("preflight wrote receiver state") + } + if err := WorkspaceReady(receiver); err == nil { + t.Fatal("incoming unadopted roots runnable") + } + restored, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if err = WorkspaceReady(receiver); err == nil { + t.Fatal("prepared roots runnable") + } + if _, err = restored.Promote(); err != nil { + t.Fatal(err) + } + if err = WorkspaceReady(receiver); err == nil { + t.Fatal("promoted but unadopted roots runnable") + } + if err = restored.Acknowledge(); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(liveRoot(receiver), "7", "main", "base.txt"), "legitimate resumed edit\n") + write(t, filepath.Join(receiver.Root, cell.StateDir, "tasks.json"), `{"type":"tasks","version":2,"receiver_rebound":true}`) + if err = WorkspaceReady(receiver); err != nil { + t.Fatalf("adopted receiver blocked after local updates: %v", err) + } +} + +func TestWorkspaceForkKeepsOnlyChangedOutputsFromEachRoot(t *testing.T) { + ambient := t.TempDir() + run(t, ambient, "git", "init", "-q") + write(t, filepath.Join(ambient, "borrowed-sentinel.txt"), "unrelated working directory\n") + t.Chdir(ambient) + c, plan := workspaceFixture(t) + selections := []WorkspaceResultSelection{} + for _, entry := range plan.Groups[0].Entries { + tree := filepath.Join(liveRoot(c), filepath.FromSlash(entry.Layout)) + write(t, filepath.Join(tree, "input-dir/large-unchanged.bin"), strings.Repeat("unchanged", 1<<17)) + run(t, tree, "git", "add", "input-dir/large-unchanged.bin") + run(t, tree, "git", "commit", "-qm", "input") + base := run(t, tree, "git", "rev-parse", "HEAD") + write(t, filepath.Join(tree, "committed.txt"), "committed "+entry.Layout+"\n") + run(t, tree, "git", "add", "committed.txt") + run(t, tree, "git", "commit", "-qm", "result") + write(t, filepath.Join(tree, "output-dir", "nested.txt"), "nested output\n") + if err := os.Chmod(filepath.Join(tree, "output-dir"), 0750); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(tree, "output-dir", "empty"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(tree, "only-empty-output"), 0750); err != nil { + t.Fatal(err) + } + selections = append(selections, WorkspaceResultSelection{CopyURI: entry.CopyURI, BaseCommit: base}) + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "history") + outputs, err := RestoreCompletedWorkspaceResults(c, plan, destination, selections) + if err != nil { + t.Fatal(err) + } + if len(outputs) != 2 { + t.Fatalf("outputs: %v", outputs) + } + for _, path := range outputs { + for relative, mode := range map[string]os.FileMode{"output-dir": 0750, "output-dir/empty": 0700, "only-empty-output": 0750} { + info, err := os.Stat(filepath.Join(path, relative)) + if err != nil || !info.IsDir() || info.Mode().Perm() != mode { + t.Fatalf("historical directory %s missing or changed: %v %v", relative, info, err) + } + } + for _, name := range []string{"committed.txt", "base.txt", "new.txt"} { + if _, err = os.Stat(filepath.Join(path, name)); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{".git", "input-dir", "deleted.txt"} { + if _, err = os.Stat(filepath.Join(path, name)); !os.IsNotExist(err) { + t.Fatalf("non-output %s retained", name) + } + } + } + if _, err = os.Stat(liveRoot(c)); err != nil { + t.Fatal("source roots changed") + } + if got, err := os.ReadFile(filepath.Join(ambient, "borrowed-sentinel.txt")); err != nil || string(got) != "unrelated working directory\n" { + t.Fatal("unrelated working directory changed") + } + if _, err = RestoreCompletedWorkspaceResults(c, plan, destination, selections); err != nil { + t.Fatalf("result retry: %v", err) + } + selections[1].BaseCommit = strings.Repeat("0", 40) + untouched := filepath.Join(t.TempDir(), "refused") + if _, err = RestoreCompletedWorkspaceResults(c, plan, untouched, selections); err == nil { + t.Fatal("missing base accepted") + } + if _, err = os.Stat(untouched); !os.IsNotExist(err) { + t.Fatal("partial history published") + } +} + +func TestWorkspaceAdoptedReceiverCanRewriteAndPublishAgain(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + bindings, err := prepared.Promote() + if err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, t.TempDir()) + registry, err := workspaceidentity.Open(home.Join("workspace-identities")) + if err != nil { + t.Fatal(err) + } + if _, err = registry.Adopt(workspaceidentity.Record{URI: plan.ChatURI, Role: workspaceidentity.Chat}, receiver.Root, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + for _, binding := range bindings { + entry := binding.Entry + project := t.TempDir() + if _, err = registry.Adopt(workspaceidentity.Record{URI: entry.ProjectURI, Role: workspaceidentity.Project}, project, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + if _, err = registry.Adopt(workspaceidentity.Record{URI: entry.CopyURI, Role: workspaceidentity.Run}, binding.Path, workspaceidentity.Links{OwnerURI: entry.ChatURI, ProjectURI: entry.ProjectURI}); err != nil { + t.Fatal(err) + } + } + if err = WorkspaceReady(receiver); err == nil { + t.Fatal("registry adoption bypassed missing acknowledgement") + } + if err = prepared.Acknowledge(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"type": "tasks", "version": 2, "workspace_transfer": plan, "local_paths_rebound": true}) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(receiver.Root, cell.StateDir, "tasks.json"), raw, 0600); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(liveRoot(receiver), "7", "main", "new.txt"), "legitimate resumed edit\n") + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err != nil { + t.Fatalf("adopted retry rejected local checkpoint: %v", err) + } + if err = (Carry{}).Compose(receiver); err != nil { + t.Fatal(err) + } + if err = WorkspaceReady(receiver); err != nil { + t.Fatalf("next publication blocked resumed source: %v", err) + } +} + +func TestWorkspaceSecondRootFailurePreservesStageAndStartsNothing(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + manifest, err := readWorkspaceManifest(receiver) + if err != nil { + t.Fatal(err) + } + second := manifest.Roots[1] + from := filepath.Join(workspaceCarrierRoot(receiver), manifest.Generation, second.Token) + rec, err := readWorkspaceRecord(from) + if err != nil { + t.Fatal(err) + } + // The carried overlay has new.txt as a regular file. A mode request below + // that file fails only when the staged second checkout is inspected. + rec.Modes["new.txt/child"] = 0644 + if err = writeRecord(from, rec); err != nil { + t.Fatal(err) + } + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatal("inconsistent second-root path accepted") + } + if _, err = os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatal("partial roots became runnable") + } + stages, _ := filepath.Glob(filepath.Join(receiver.Root, ".codeaf", "workspace-stage-*")) + if len(stages) != 1 { + t.Fatalf("private stage lost: %v", stages) + } + if _, err = os.Stat(filepath.Join(stages[0], "7", "main", "base.txt")); err != nil { + t.Fatal("first root preparation was lost") + } + delete(rec.Modes, "new.txt/child") + if err = writeRecord(from, rec); err != nil { + t.Fatal(err) + } + retried, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = retried.Promote(); err != nil { + t.Fatal(err) + } + if err = WorkspaceReady(receiver); err == nil { + t.Fatal("unadopted retry runnable") + } +} + +// Directory metadata refusals are exercised through public carrier preparation. + +func TestWorkspaceDirectoryMetadataRefusesBeforePreparation(t *testing.T) { + for _, kind := range []string{"legacy", "nil-map", "traversal", "git-private", "special-mode", "owner-permission", "missing-parent", "file-parent", "case-alias", "unicode-alias"} { + t.Run(kind, func(t *testing.T) { + c, plan := workspaceFixture(t) + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + manifest, err := readWorkspaceManifest(receiver) + if err != nil { + t.Fatal(err) + } + root := &manifest.Roots[0] + switch kind { + case "legacy": + root.DirectoryVersion = 0 + root.Directories = nil + case "nil-map": + root.Directories = nil + case "traversal": + root.Directories = map[string]os.FileMode{"../outside": 0750} + case "git-private": + root.Directories = map[string]os.FileMode{".git": 0700} + case "special-mode": + root.Directories = map[string]os.FileMode{"unsafe": os.ModeSymlink | 0750} + case "owner-permission": + root.Directories = map[string]os.FileMode{"unreadable": 0500} + case "missing-parent": + root.Directories = map[string]os.FileMode{"parent/child": 0750} + case "file-parent": + root.Directories = map[string]os.FileMode{"base.txt": 0750, "base.txt/child": 0750} + case "case-alias": + root.Directories = map[string]os.FileMode{"output": 0750, "OUTPUT": 0750} + case "unicode-alias": + root.Directories = map[string]os.FileMode{"caf\u00e9": 0750, "cafe\u0301": 0750} + } + if err = writeWorkspaceJSON(filepath.Join(workspaceCarrierRoot(receiver), "manifest.json"), manifest); err != nil { + t.Fatal(err) + } + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatalf("%s metadata accepted", kind) + } + if _, err = os.Stat(filepath.Join(receiver.Root, ".codeaf")); !os.IsNotExist(err) { + t.Fatalf("%s mutated preparation state", kind) + } + }) + } +} + +func TestWorkspaceDirectoryMetadataCannotOmitPayloadParents(t *testing.T) { + c, plan := workspaceFixture(t) + for _, entry := range plan.Groups[0].Entries { + write(t, filepath.Join(liveRoot(c), filepath.FromSlash(entry.Layout), "nested", "output.txt"), "nested output\n") + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + manifest, err := readWorkspaceManifest(receiver) + if err != nil { + t.Fatal(err) + } + manifest.Roots[0].Directories = map[string]os.FileMode{} + if err = writeWorkspaceJSON(filepath.Join(workspaceCarrierRoot(receiver), "manifest.json"), manifest); err != nil { + t.Fatal(err) + } + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatal("incomplete directory set was accepted") + } + if _, err = os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatal("incomplete metadata exposed runnable roots") + } +} + +func TestWorkspaceDirectoryMetadataCannotTraverseCheckoutSymlink(t *testing.T) { + c, plan := workspaceFixture(t) + outside := t.TempDir() + write(t, filepath.Join(outside, "keep"), "unchanged\n") + for _, entry := range plan.Groups[0].Entries { + tree := filepath.Join(liveRoot(c), filepath.FromSlash(entry.Layout)) + if err := os.Symlink(outside, filepath.Join(tree, "redirect")); err != nil { + t.Fatal(err) + } + run(t, tree, "git", "add", "redirect") + run(t, tree, "git", "commit", "-qm", "symlink baseline") + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := workspaceReceiver(t, c, plan) + manifest, err := readWorkspaceManifest(receiver) + if err != nil { + t.Fatal(err) + } + manifest.Roots[0].Directories["redirect"] = 0700 + manifest.Roots[0].Directories["redirect/child"] = 0700 + if err = writeWorkspaceJSON(filepath.Join(workspaceCarrierRoot(receiver), "manifest.json"), manifest); err != nil { + t.Fatal(err) + } + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatal("checkout symlink granted directory authority") + } + got, err := os.ReadFile(filepath.Join(outside, "keep")) + if err != nil || string(got) != "unchanged\n" { + t.Fatal("outside file changed") + } + if _, err = os.Stat(filepath.Join(outside, "child")); !os.IsNotExist(err) { + t.Fatal("outside directory created") + } + if _, err = os.Stat(liveRoot(receiver)); !os.IsNotExist(err) { + t.Fatal("symlink plan exposed runnable roots") + } +} diff --git a/internal/taskstorage/backend.go b/internal/taskstorage/backend.go new file mode 100644 index 0000000000..0117452129 --- /dev/null +++ b/internal/taskstorage/backend.go @@ -0,0 +1,111 @@ +package taskstorage + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" +) + +// CLIBackend uses the prototype engine's private archive store, never its relay codec. +type CLIBackend struct{ Binary, Store string } + +// The native command inherits the same locked open-file description. If the +// manager dies, another recovery cannot acquire its receipt until this writer exits. +// Windows does not inherit this POSIX open-file-description lease. Native +// retirement/recovery must refuse there until equivalent writer fencing exists; +// plain capture calls without a manager receipt lease remain independent. +func (CLIBackend) archiveRecoveryReady() error { + if runtime.GOOS == "windows" { + return fmt.Errorf("native task retirement and recovery are unavailable on Windows because writer leases cannot be inherited; saved work and staging were retained") + } + return nil +} + +type receiptLeaseKey struct{} +type archiveReceipt struct { + Version uint16 `json:"version"` + Snapshot string `json:"snapshot"` +} + +func (b CLIBackend) call(ctx context.Context, verb string, args ...string) (archiveReceipt, error) { + if b.Binary == "" || b.Store == "" { + return archiveReceipt{}, fmt.Errorf("task archive engine is not configured") + } + lease, leased := ctx.Value(receiptLeaseKey{}).(*os.File) + if leased { + if err := b.archiveRecoveryReady(); err != nil { + return archiveReceipt{}, err + } + } + argv := append([]string{"archive", verb, "--store", b.Store}, args...) + // The archive command is a bounded, local storage operation, not a model shell. + cmd := exec.CommandContext(ctx, b.Binary, argv...) //codeaf:plumbing invokes the local archive engine + if leased { + cmd.ExtraFiles = []*os.File{lease} + } + var output, stderr bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if len(detail) > 2048 { + detail = detail[:2048] + } + return archiveReceipt{}, fmt.Errorf("task archive %s failed: %w: %s", verb, err, detail) + } + var receipt archiveReceipt + if err := json.Unmarshal(output.Bytes(), &receipt); err != nil { + return receipt, fmt.Errorf("read archive receipt: %w", err) + } + decoded, err := hex.DecodeString(receipt.Snapshot) + if receipt.Version != 1 || err != nil || len(decoded) != 32 { + return receipt, fmt.Errorf("unsupported task archive receipt") + } + return receipt, nil +} +func (b CLIBackend) Capture(ctx context.Context, path string) (string, error) { + r, e := b.call(ctx, "capture", "--workspace", path) + return r.Snapshot, e +} +func (b CLIBackend) Restore(ctx context.Context, snapshot, destination string) error { + r, e := b.call(ctx, "restore", "--snapshot", snapshot, "--destination", destination) + if e == nil && r.Snapshot != snapshot { + return fmt.Errorf("restored a different task archive") + } + return e +} +func (b CLIBackend) Verify(ctx context.Context, snapshot, path string) error { + r, e := b.call(ctx, "verify", "--snapshot", snapshot, "--workspace", path) + if e == nil && r.Snapshot != snapshot { + return fmt.Errorf("checked a different task archive") + } + return e +} + +func (b CLIBackend) VerifyRemainder(ctx context.Context, snapshot, path string) error { + r, e := b.call(ctx, "verify-remainder", "--snapshot", snapshot, "--workspace", path) + if e == nil && r.Snapshot != snapshot { + return fmt.Errorf("checked a different task remainder") + } + return e +} + +// Prototype enables this unfinished storage format only inside an explicit test +// profile. Stable binaries and ordinary profiles keep their current lifecycle. +func Prototype(trees string) (Manager, bool) { + if env.Get("CODEAF_PROTOTYPE_TASK_STORAGE") != "1" || strings.TrimSpace(env.Get(home.EnvVar)) == "" || trees == "" || !filepath.IsAbs(trees) { + return Manager{}, false + } + root := home.Join("task-storage") + return Manager{Root: root, Trees: trees, Vault: CLIBackend{Binary: env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY"), Store: filepath.Join(root, "vault")}}, true +} diff --git a/internal/taskstorage/backend_test.go b/internal/taskstorage/backend_test.go new file mode 100644 index 0000000000..e5dc725a50 --- /dev/null +++ b/internal/taskstorage/backend_test.go @@ -0,0 +1,93 @@ +package taskstorage_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +func TestNativeArchiveRetiresAndReopensWithoutWritableSharing(t *testing.T) { + binary := env.Get("CODEAF_PROTOTYPE_ARCHIVE_BINARY") + if binary == "" { + t.Skip("prototype archive engine not configured") + } + root := t.TempDir() + trees := filepath.Join(root, "trees") + if e := os.MkdirAll(trees, 0700); e != nil { + t.Fatal(e) + } + m := taskstorage.Manager{Root: filepath.Join(root, "storage"), Trees: trees, Vault: taskstorage.CLIBackend{Binary: binary, Store: filepath.Join(root, "storage", "vault")}, Busy: func(string) bool { return false }} + ctx := context.Background() + payload := "exact private task content\n" + paths := []string{filepath.Join(trees, "a"), filepath.Join(trees, "b")} + for _, path := range paths { + if e := os.MkdirAll(filepath.Join(path, ".git"), 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(path, "work.txt"), []byte(payload), 0440); e != nil { + t.Fatal(e) + } + if e := os.Link(filepath.Join(path, "work.txt"), filepath.Join(path, "alias")); e != nil { + t.Fatal(e) + } + if e := os.Symlink("missing", filepath.Join(path, "link")); e != nil { + t.Fatal(e) + } + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + } + for _, path := range paths { + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil || string(b) != payload { + t.Fatalf("restored %q %v", b, e) + } + a, e := os.Stat(filepath.Join(path, "work.txt")) + if e != nil { + t.Fatal(e) + } + binfo, e := os.Stat(filepath.Join(path, "alias")) + if e != nil || !os.SameFile(a, binfo) { + t.Fatal("hardlink relationship lost") + } + } + if e := os.Chmod(filepath.Join(paths[0], "work.txt"), 0600); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(paths[0], "work.txt"), []byte("independent edit"), 0600); e != nil { + t.Fatal(e) + } + b, e := os.ReadFile(filepath.Join(paths[1], "work.txt")) + if e != nil || string(b) != payload { + t.Fatal("editing one restore changed its sibling") + } + if e := m.Park(ctx, paths[1]); e != nil { + t.Fatal(e) + } + store := filepath.Join(root, "storage", "vault") + if e := os.Rename(store, store+".offline"); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, paths[1]); e == nil { + t.Fatal("unavailable vault accepted") + } + if _, e := os.Stat(paths[1]); !os.IsNotExist(e) { + t.Fatal("unavailable vault produced a partial or fresh workspace") + } + if e := os.RemoveAll(store); e != nil { + t.Fatal(e) + } + if e := os.Rename(store+".offline", store); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, paths[1]); e != nil { + t.Fatal(e) + } +} diff --git a/internal/taskstorage/check_recovery.go b/internal/taskstorage/check_recovery.go new file mode 100644 index 0000000000..05cd6751b1 --- /dev/null +++ b/internal/taskstorage/check_recovery.go @@ -0,0 +1,84 @@ +package taskstorage + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Only one exact verification container is journaled per receipt. Its name is +// bound to that working copy; legacy and other copies' directories are untouched. +func validCheck(s State) error { + if s.Check == "" { + return nil + } + prefix := digest(s.Path) + ".check-" + suffix := strings.TrimPrefix(s.Check, prefix) + nonce, err := hex.DecodeString(suffix) + if suffix == s.Check || len(nonce) != 16 || err != nil || hex.EncodeToString(nonce) != suffix || s.Snapshot == "" { + return errors.New("invalid verification recovery container") + } + return nil +} + +func newCheck(receipt string, s *State, snapshot string) (string, error) { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return "", err + } + name := digest(s.Path) + ".check-" + hex.EncodeToString(nonce[:]) + container := filepath.Join(filepath.Dir(receipt), name) + if _, err := os.Lstat(container); !errors.Is(err, os.ErrNotExist) { + return "", errors.New("verification recovery container already exists or is unavailable") + } + s.Check, s.Snapshot = name, snapshot + // The exact reservation is durable before even an empty container is made. + // Recovery of a reservation whose directory was never created is idempotent. + if err := durable(receipt, *s); err != nil { + return "", err + } + if err := os.Mkdir(container, 0700); err != nil { + // We did not create this path, so never remove it on a failed reservation. + clean := *s + clean.Check = "" + if writeErr := durable(receipt, clean); writeErr != nil { + return "", errors.Join(err, writeErr) + } + *s = clean + return "", err + } + return container, nil +} + +func clearCheck(receipt string, s *State) error { + if s.Check == "" { + return nil + } + if err := validCheck(*s); err != nil { + return err + } + container := filepath.Join(filepath.Dir(receipt), s.Check) + info, err := os.Lstat(container) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) { + return errors.New("verification recovery container is not an owned directory") + } + if err := removeOwnedTree(container); err != nil { + return fmt.Errorf("remove verification recovery container: %w", err) + } + // On removal or receipt-write failure, the durable record remains available + // for retry. Clearing an already absent directory is idempotent. + clean := *s + clean.Check = "" + if err := durable(receipt, clean); err != nil { + return err + } + *s = clean + return nil +} diff --git a/internal/taskstorage/check_recovery_test.go b/internal/taskstorage/check_recovery_test.go new file mode 100644 index 0000000000..b2eef7798a --- /dev/null +++ b/internal/taskstorage/check_recovery_test.go @@ -0,0 +1,218 @@ +package taskstorage_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func recoveryReceipt(t *testing.T, root string) (string, map[string]any) { + t.Helper() + files, e := filepath.Glob(filepath.Join(root, "receipts", "*", "*.json")) + if e != nil || len(files) != 1 { + t.Fatalf("receipt: %v %v", files, e) + } + raw, e := os.ReadFile(files[0]) + if e != nil { + t.Fatal(e) + } + var state map[string]any + if e = json.Unmarshal(raw, &state); e != nil { + t.Fatal(e) + } + return files[0], state +} +func writeRecoveryReceipt(t *testing.T, path string, state map[string]any) { + t.Helper() + raw, e := json.Marshal(state) + if e != nil { + t.Fatal(e) + } + if e = os.WriteFile(path, raw, 0600); e != nil { + t.Fatal(e) + } +} +func TestEnsureRecoversOnlyJournaledVerificationContainer(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + receipt, state := recoveryReceipt(t, m.Root) + name := strings.TrimSuffix(filepath.Base(receipt), ".json") + ".check-" + strings.Repeat("a", 32) + container := filepath.Join(filepath.Dir(receipt), name) + for _, child := range []string{"verified/work.txt", ".furrow-archive-interrupted/partial"} { + file := filepath.Join(container, child) + if e := os.MkdirAll(filepath.Dir(file), 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(file, []byte("disposable restore bytes"), 0600); e != nil { + t.Fatal(e) + } + } + unrelated := filepath.Join(filepath.Dir(receipt), ".check-legacy") + if e := os.Mkdir(unrelated, 0700); e != nil { + t.Fatal(e) + } + state["check"] = name + writeRecoveryReceipt(t, receipt, state) + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(container); !os.IsNotExist(e) { + t.Fatalf("journaled verification container remains: %v", e) + } + if _, e := os.Stat(unrelated); e != nil { + t.Fatalf("unrelated legacy directory changed: %v", e) + } + _, after := recoveryReceipt(t, m.Root) + if after["check"] != nil && after["check"] != "" { + t.Fatalf("recovery journal not cleared: %v", after) + } + data, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil || string(data) != "original uncommitted work\n" { + t.Fatalf("resident bytes changed: %q %v", data, e) + } +} + +func TestEnsureRejectsForgedVerificationContainer(t *testing.T) { + for _, name := range []string{"../valuable", "/tmp/valuable", "valuable", strings.Repeat("b", 64) + ".check-" + strings.Repeat("a", 32)} { + t.Run(name, func(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + receipt, state := recoveryReceipt(t, m.Root) + sentinel := filepath.Join(filepath.Dir(receipt), "valuable") + if e := os.WriteFile(sentinel, []byte("unrelated"), 0600); e != nil { + t.Fatal(e) + } + state["check"] = name + writeRecoveryReceipt(t, receipt, state) + if e := m.Ensure(ctx, path); e == nil { + t.Fatal("forged verification container accepted") + } + data, e := os.ReadFile(sentinel) + if e != nil || string(data) != "unrelated" { + t.Fatalf("unrelated file changed: %q %v", data, e) + } + _, after := recoveryReceipt(t, m.Root) + if after["check"] != name { + t.Fatalf("invalid recovery journal was silently changed: %v", after) + } + }) + } +} + +func TestEnsureRefusesVerificationSymlinkWithoutClearingJournal(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + receipt, state := recoveryReceipt(t, m.Root) + name := strings.TrimSuffix(filepath.Base(receipt), ".json") + ".check-" + strings.Repeat("a", 32) + outside := t.TempDir() + sentinel := filepath.Join(outside, "valuable") + if e := os.WriteFile(sentinel, []byte("unrelated"), 0600); e != nil { + t.Fatal(e) + } + if e := os.Symlink(outside, filepath.Join(filepath.Dir(receipt), name)); e != nil { + t.Fatal(e) + } + state["check"] = name + writeRecoveryReceipt(t, receipt, state) + if e := m.Ensure(ctx, path); e == nil { + t.Fatal("verification symlink accepted") + } + _, after := recoveryReceipt(t, m.Root) + if after["check"] != name { + t.Fatal("failed cleanup cleared journal") + } + data, e := os.ReadFile(sentinel) + if e != nil || string(data) != "unrelated" { + t.Fatalf("external file changed: %q %v", data, e) + } +} + +func TestCleanupFailureRetainsVerificationJournalForRetry(t *testing.T) { + if os.Geteuid() == 0 || runtime.GOOS == "windows" { + t.Skip("directory permission refusal requires ordinary user") + } + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + receipt, state := recoveryReceipt(t, m.Root) + scope := filepath.Dir(receipt) + name := strings.TrimSuffix(filepath.Base(receipt), ".json") + ".check-" + strings.Repeat("a", 32) + if e := os.Mkdir(filepath.Join(scope, name), 0700); e != nil { + t.Fatal(e) + } + state["check"] = name + writeRecoveryReceipt(t, receipt, state) + if e := os.Chmod(scope, 0500); e != nil { + t.Fatal(e) + } + t.Cleanup(func() { _ = os.Chmod(scope, 0700) }) + if e := m.Ensure(ctx, path); e == nil { + t.Fatal("cleanup without writable owner directory succeeded") + } + _, after := recoveryReceipt(t, m.Root) + if after["check"] != name { + t.Fatal("cleanup error discarded verification journal") + } + if e := os.Chmod(scope, 0700); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatalf("cleanup retry failed: %v", e) + } + _, after = recoveryReceipt(t, m.Root) + if after["check"] != nil && after["check"] != "" { + t.Fatal("successful cleanup retry kept journal") + } +} + +func TestEnsureClearsUncreatedVerificationReservation(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if err := m.Park(ctx, path); err != nil { + t.Fatal(err) + } + if err := m.Ensure(ctx, path); err != nil { + t.Fatal(err) + } + receipt, state := recoveryReceipt(t, m.Root) + name := strings.TrimSuffix(filepath.Base(receipt), ".json") + ".check-" + strings.Repeat("a", 32) + state["check"] = name + writeRecoveryReceipt(t, receipt, state) + if err := m.Ensure(ctx, path); err != nil { + t.Fatal(err) + } + _, after := recoveryReceipt(t, m.Root) + if after["check"] != nil && after["check"] != "" { + t.Fatal("uncreated reservation not cleared") + } + if _, err := os.Stat(filepath.Join(filepath.Dir(receipt), name)); !os.IsNotExist(err) { + t.Fatalf("recovery created verification directory: %v", err) + } +} diff --git a/internal/taskstorage/lease_process_test.go b/internal/taskstorage/lease_process_test.go new file mode 100644 index 0000000000..489ae34835 --- /dev/null +++ b/internal/taskstorage/lease_process_test.go @@ -0,0 +1,218 @@ +//go:build !windows + +package taskstorage_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +// This is a real subprocess archive adapter. It deliberately keeps writing +// after its manager dies, like a native archive command outside Go's lifetime. +func TestReceiptLeaseProcessHelper(t *testing.T) { + role := os.Getenv("TASKSTORAGE_LEASE_ROLE") + if role == "" { + t.Skip("subprocess helper") + } + root := os.Getenv("TASKSTORAGE_LEASE_ROOT") + if role == "manager" || role == "ensure" { + m := taskstorage.Manager{Root: filepath.Join(root, "storage"), Trees: filepath.Join(root, "trees"), Busy: func(string) bool { return false }, Vault: taskstorage.CLIBackend{Binary: filepath.Join(root, "engine"), Store: filepath.Join(root, "vault")}} + var err error + if role == "ensure" { + err = m.Ensure(context.Background(), filepath.Join(root, "trees", "task")) + } else { + err = m.Park(context.Background(), filepath.Join(root, "trees", "task")) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + os.Exit(0) + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) < 4 { + os.Exit(3) + } + args = args[1:] + if args[1] == "restore" { + var destination string + for i := 0; i+1 < len(args); i++ { + if args[i] == "--destination" { + destination = args[i+1] + } + } + temporary := filepath.Join(filepath.Dir(destination), ".furrow-archive-active") + if e := os.MkdirAll(temporary, 0700); e != nil { + os.Exit(4) + } + _ = os.WriteFile(filepath.Join(temporary, "partial"), []byte("writer active"), 0600) + _ = os.WriteFile(filepath.Join(root, "ready"), []byte(strconv.Itoa(os.Getpid())), 0600) + deadline := time.Now().Add(20 * time.Second) + for { + if _, e := os.Stat(filepath.Join(root, "release")); e == nil { + break + } + if time.Now().After(deadline) { + os.Exit(5) + } + time.Sleep(10 * time.Millisecond) + } + e := os.MkdirAll(destination, 0700) + if e == nil { + e = os.WriteFile(filepath.Join(destination, "work.txt"), []byte("original"), 0600) + } + _ = os.WriteFile(filepath.Join(root, "writer-done"), []byte("done"), 0600) + if e != nil { + os.Exit(6) + } + } + fmt.Printf(`{"version":1,"snapshot":"%s"}`, strings.Repeat("a", 64)) + os.Exit(0) +} + +func TestKilledManagerKeepsReceiptLockedUntilArchiveWriterExits(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "trees", "task") + if e := os.MkdirAll(path, 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(path, "work.txt"), []byte("original"), 0600); e != nil { + t.Fatal(e) + } + engine := filepath.Join(root, "engine") + script := "#!/bin/sh\nTASKSTORAGE_LEASE_ROLE=archive exec \"$TASKSTORAGE_TEST_BINARY\" -test.run=^TestReceiptLeaseProcessHelper$ -- \"$@\"\n" + if e := os.WriteFile(engine, []byte(script), 0700); e != nil { + t.Fatal(e) + } + command := exec.Command(os.Args[0], "-test.run=^TestReceiptLeaseProcessHelper$") + command.Env = append(os.Environ(), "TASKSTORAGE_LEASE_ROLE=manager", "TASKSTORAGE_LEASE_ROOT="+root, "TASKSTORAGE_TEST_BINARY="+os.Args[0]) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if e := command.Start(); e != nil { + t.Fatal(e) + } + waited := make(chan error, 1) + go func() { waited <- command.Wait() }() + t.Cleanup(func() { + _ = os.WriteFile(filepath.Join(root, "release"), []byte("release"), 0600) + _ = command.Process.Kill() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, e := os.Stat(filepath.Join(root, "writer-done")); e == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + if raw, e := os.ReadFile(filepath.Join(root, "ready")); e == nil { + if id, e := strconv.Atoi(string(raw)); e == nil { + if process, e := os.FindProcess(id); e == nil { + _ = process.Kill() + } + } + } + }) + deadline := time.Now().Add(5 * time.Second) + for { + if _, e := os.Stat(filepath.Join(root, "ready")); e == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("archive writer did not start") + } + time.Sleep(10 * time.Millisecond) + } + receipt, state := recoveryReceipt(t, filepath.Join(root, "storage")) + check, ok := state["check"].(string) + if !ok || check == "" { + t.Fatal("writer started without durable verification journal") + } + container := filepath.Join(filepath.Dir(receipt), check) + if e := command.Process.Kill(); e != nil { + t.Fatal(e) + } + select { + case e := <-waited: + if e == nil { + t.Fatal("manager unexpectedly completed") + } + case <-time.After(3 * time.Second): + t.Fatal("manager did not exit") + } + m := taskstorage.Manager{Root: filepath.Join(root, "storage"), Trees: filepath.Join(root, "trees"), Vault: taskstorage.CLIBackend{Binary: engine, Store: filepath.Join(root, "vault")}} + canceled, cancel := context.WithCancel(context.Background()) + canceledResult := make(chan error, 1) + go func() { canceledResult <- m.Ensure(canceled, path) }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case err := <-canceledResult: + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled recovery: %v", err) + } + case <-time.After(500 * time.Millisecond): + _ = os.WriteFile(filepath.Join(root, "release"), []byte("release"), 0600) + select { + case <-canceledResult: + case <-time.After(2 * time.Second): + } + t.Fatal("canceled recovery waited for the orphan writer") + } + if _, err := os.Stat(container); err != nil { + t.Fatalf("canceled recovery changed active container: %v", err) + } + result := make(chan error, 1) + go func() { result <- m.Ensure(context.Background(), path) }() + select { + case e := <-result: + t.Fatalf("recovery ran while orphan archive writer was alive: %v", e) + case <-time.After(150 * time.Millisecond): + } + if _, e := os.Stat(filepath.Join(container, ".furrow-archive-active", "partial")); e != nil { + t.Fatalf("active writer's directory changed: %v", e) + } + if e := os.WriteFile(filepath.Join(root, "release"), []byte("release"), 0600); e != nil { + t.Fatal(e) + } + select { + case e := <-result: + if e != nil { + t.Fatal(e) + } + case <-time.After(5 * time.Second): + t.Fatal("recovery remained blocked after writer exit") + } + if _, e := os.Stat(container); !os.IsNotExist(e) { + t.Fatalf("journaled scratch remains: %v", e) + } + raw, e := os.ReadFile(receipt) + if e != nil { + t.Fatal(e) + } + var after map[string]any + if e = json.Unmarshal(raw, &after); e != nil { + t.Fatal(e) + } + if after["check"] != nil && after["check"] != "" { + t.Fatal("journal not cleared after writer exit") + } + data, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil || string(data) != "original" { + t.Fatalf("resident task changed: %q %v", data, e) + } +} diff --git a/internal/taskstorage/lifecycle.go b/internal/taskstorage/lifecycle.go new file mode 100644 index 0000000000..b815011741 --- /dev/null +++ b/internal/taskstorage/lifecycle.go @@ -0,0 +1,694 @@ +// Package taskstorage retains inactive owned task copies as exact local archives. +// Policy belongs to the session owner; this module never chooses a task to retire. +package taskstorage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +type Phase string + +const ( + Resident Phase = "resident" + Retiring Phase = "retiring" + Cold Phase = "cold" + Restoring Phase = "restoring" +) + +// State is a device-local recovery receipt. Path is relative to the owner's trees. +type State struct { + Version uint16 `json:"V"` + Path string `json:"path"` + Snapshot string `json:"snapshot"` + Phase Phase `json:"phase"` + Check string `json:"check,omitempty"` +} + +// Vault captures complete local state; it is deliberately separate from screened relay content. +// Implementations must join every writer before returning, including on cancellation. +type Vault interface { + Capture(context.Context, string) (string, error) + Restore(context.Context, string, string) error + Verify(context.Context, string, string) error +} + +// RemainderVault proves that an interrupted removal left only unchanged saved entries. +type RemainderVault interface { + VerifyRemainder(context.Context, string, string) error +} + +// Manager serializes retirement and reopen for copies strictly below Trees. +// Busy must account for every harness worker and process that could still write. +type Manager struct { + Root, Trees string + Vault Vault + Busy func(string) bool + BeforeCapture func(context.Context, string) error + BeforeRetire func(context.Context, string, string) error + AfterRestore func(context.Context, string) error +} + +func digest(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) } + +// Existing system aliases on macOS are trusted; task-controlled links are not. +func validateAncestors(path string) error { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + current := volume + string(filepath.Separator) + for _, part := range strings.Split(strings.TrimPrefix(clean, current), string(filepath.Separator)) { + if part == "" { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + if runtime.GOOS == "darwin" && (current == "/var" || current == "/tmp" || current == "/etc") { + target, err := filepath.EvalSymlinks(current) + if err == nil && target == "/private"+current { + continue + } + } + return errors.New("task storage root traverses a symbolic link") + } + if !info.IsDir() { + return errors.New("task storage ancestor is not a directory") + } + } + return nil +} + +func (m Manager) scope() (string, error) { + if m.Root == "" || m.Trees == "" || !filepath.IsAbs(m.Root) || !filepath.IsAbs(m.Trees) { + return "", errors.New("task storage needs absolute owned roots") + } + if err := validateAncestors(m.Root); err != nil { + return "", err + } + if err := validateAncestors(m.Trees); err != nil { + return "", err + } + return filepath.Join(m.Root, "receipts", digest(filepath.Clean(m.Trees))), nil +} +func (m Manager) owned(path string) (string, error) { + if err := validateAncestors(m.Trees); err != nil { + return "", err + } + if !filepath.IsAbs(path) { + return "", errors.New("task copy path must be absolute") + } + rel, e := filepath.Rel(m.Trees, filepath.Clean(path)) + if e != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("task copy is outside its owner") + } + current := filepath.Clean(m.Trees) + parts := append([]string{""}, strings.Split(rel, string(filepath.Separator))...) + for _, part := range parts { + if part != "" { + current = filepath.Join(current, part) + } + info, e := os.Lstat(current) + if errors.Is(e, os.ErrNotExist) { + continue + } + if e != nil { + return "", e + } + if info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("task storage cannot traverse a symbolic link") + } + if !info.IsDir() { + return "", errors.New("task copy ancestor is not a directory") + } + } + return filepath.ToSlash(rel), nil +} +func (m Manager) acquire(ctx context.Context, path string) (string, string, *os.File, func(), error) { + rel, e := m.owned(path) + if e != nil { + return "", "", nil, nil, e + } + scope, e := m.scope() + if e != nil { + return "", "", nil, nil, e + } + if e = os.MkdirAll(scope, 0700); e != nil { + return "", "", nil, nil, e + } + key := digest(rel) + f, e := os.OpenFile(filepath.Join(scope, key+".lock"), os.O_CREATE|os.O_RDWR, 0600) + if e != nil { + return "", "", nil, nil, e + } + for { + if e = ctx.Err(); e != nil { + f.Close() + return "", "", nil, nil, e + } + e = filelock.Lock(f, true, true) + if e == nil { + break + } + if !filelock.IsBusy(e) { + f.Close() + return "", "", nil, nil, e + } + select { + case <-ctx.Done(): + f.Close() + return "", "", nil, nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } + return rel, filepath.Join(scope, key+".json"), f, func() { _ = filelock.Unlock(f); _ = f.Close() }, nil +} +func read(path, rel string) (State, error) { + raw, e := os.ReadFile(path) + if errors.Is(e, os.ErrNotExist) { + return State{Version: 1, Path: rel, Phase: Resident}, nil + } + if e != nil { + return State{}, e + } + var s State + if e = json.Unmarshal(raw, &s); e != nil { + return State{}, e + } + if s.Version != 1 || s.Path != rel { + return State{}, errors.New("unsupported or mismatched task recovery receipt") + } + switch s.Phase { + case Resident, Retiring, Cold, Restoring: + default: + return State{}, errors.New("unknown task storage state") + } + if s.Phase != Resident && s.Snapshot == "" { + return State{}, errors.New("task recovery snapshot is missing") + } + if err := validCheck(s); err != nil { + return State{}, err + } + return s, nil +} +func durable(path string, s State) error { + raw, e := json.Marshal(s) + if e != nil { + return e + } + f, e := os.CreateTemp(filepath.Dir(path), ".receipt-") + if e != nil { + return e + } + defer os.Remove(f.Name()) + if _, e = f.Write(raw); e != nil { + f.Close() + return e + } + if e = f.Sync(); e != nil { + f.Close() + return e + } + if e = f.Close(); e != nil { + return e + } + if e = os.Rename(f.Name(), path); e != nil { + return e + } + return syncDir(filepath.Dir(path)) +} +func syncDir(path string) error { + f, e := os.Open(path) + if e != nil { + return e + } + defer f.Close() + return f.Sync() +} +func present(path string) (bool, error) { + _, e := os.Lstat(path) + if errors.Is(e, os.ErrNotExist) { + return false, nil + } + return e == nil, e +} +func side(path, kind string) string { + return filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".storage-"+kind+"-"+digest(path)[:16]) +} +func (m Manager) rename(from, to string) error { + if _, err := m.owned(from); err != nil { + return err + } + if _, err := m.owned(to); err != nil { + return err + } + root, err := os.OpenRoot(m.Trees) + if err != nil { + return err + } + defer root.Close() + a, err := filepath.Rel(m.Trees, from) + if err != nil { + return err + } + b, err := filepath.Rel(m.Trees, to) + if err != nil { + return err + } + return root.Rename(a, b) +} + +// removeOwnedTree changes permissions only on detached or verification copies. +// Descriptor-relative traversal prevents a link from redirecting cleanup elsewhere. +func removeOwnedTree(path string) error { + if err := validateAncestors(filepath.Dir(path)); err != nil { + return err + } + parent, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err + } + defer parent.Close() + name := filepath.Base(path) + info, err := parent.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() { + return parent.Remove(name) + } + tree, err := parent.OpenRoot(name) + if err != nil { + return err + } + actual, err := tree.Stat(".") + if err != nil { + tree.Close() + return err + } + if !os.SameFile(info, actual) { + tree.Close() + return errors.New("cleanup tree changed") + } + err = fs.WalkDir(tree.FS(), ".", func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + f, err := tree.Open(name) + if err != nil { + return err + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return err + } + original, err := entry.Info() + if err != nil { + return err + } + if !os.SameFile(original, stat) { + return errors.New("cleanup directory changed") + } + return f.Chmod(stat.Mode() | 0700) + }) + tree.Close() + if err != nil { + return err + } + return parent.RemoveAll(name) +} + +func (m Manager) vault() error { + if m.Vault == nil { + return errors.New("task archive backend is unavailable") + } + // This precedes receipt acquisition and journal cleanup. An old reservation + // is not safe to remove when its native writer cannot inherit our lease. + if backend, ok := m.Vault.(interface{ archiveRecoveryReady() error }); ok { + return backend.archiveRecoveryReady() + } + return nil +} + +// Inspect reads one receipt under the same lock used by retirement and reopen. +func (m Manager) Inspect(ctx context.Context, path string) (State, error) { + if e := ctx.Err(); e != nil { + return State{}, e + } + rel, receipt, _, unlock, e := m.acquire(ctx, path) + if e != nil { + return State{}, e + } + defer unlock() + return read(receipt, rel) +} + +// Paths lists retained identities, including copies whose expanded folder is absent. +func (m Manager) Paths(ctx context.Context) ([]string, error) { + scope, e := m.scope() + if e != nil { + return nil, e + } + entries, e := os.ReadDir(scope) + if errors.Is(e, os.ErrNotExist) { + return nil, nil + } + if e != nil { + return nil, e + } + var out []string + for _, entry := range entries { + if e = ctx.Err(); e != nil { + return nil, e + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + raw, e := os.ReadFile(filepath.Join(scope, entry.Name())) + if e != nil { + return nil, e + } + var s State + if e = json.Unmarshal(raw, &s); e != nil { + return nil, e + } + path := filepath.Join(m.Trees, filepath.FromSlash(s.Path)) + rel, e := m.owned(path) + if e != nil { + return nil, e + } + if _, e = read(filepath.Join(scope, entry.Name()), rel); e != nil { + return nil, e + } + out = append(out, path) + } + return out, nil +} + +// EnsureAll is used only when an operation needs every retained working copy. +func (m Manager) EnsureAll(ctx context.Context) error { + paths, e := m.Paths(ctx) + if e != nil { + return e + } + for _, path := range paths { + if e = m.Ensure(ctx, path); e != nil { + return e + } + } + return nil +} + +// Park captures, independently restores and verifies, then retires one idle copy. +// Any failure before durable retirement leaves the original or its recoverable sibling. +func (m Manager) Park(ctx context.Context, path string) (err error) { + if e := ctx.Err(); e != nil { + return e + } + if e := m.vault(); e != nil { + return e + } + rel, receipt, lease, unlock, e := m.acquire(ctx, path) + if e != nil { + return e + } + defer unlock() + ctx = context.WithValue(ctx, receiptLeaseKey{}, lease) + if m.Busy == nil || m.Busy(path) { + return errors.New("task copy may still have a writer") + } + s, e := read(receipt, rel) + if e != nil { + return e + } + if e = clearCheck(receipt, &s); e != nil { + return e + } + exists, e := present(path) + if e != nil { + return e + } + if !exists && s.Phase == Cold { + return nil + } + if s.Phase != Resident { + return errors.New("task storage transition needs recovery before retirement") + } + if !exists { + return errors.New("task working copy is missing") + } + aside := side(path, "retiring") + if exists, e = present(aside); e != nil { + return e + } else if exists { + return errors.New("a retired task sibling needs recovery") + } + if m.BeforeCapture != nil { + if e = m.BeforeCapture(ctx, path); e != nil { + return e + } + } + snapshot := s.Snapshot + if snapshot == "" || m.Vault.Verify(ctx, snapshot, path) != nil { + snapshot, e = m.Vault.Capture(ctx, path) + if e != nil { + return fmt.Errorf("save task copy: %w", e) + } + } + container, e := newCheck(receipt, &s, snapshot) + if e != nil { + return e + } + defer func() { + if cleanupErr := clearCheck(receipt, &s); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("clean verification copy: %w", cleanupErr)) + } + }() + scratch := filepath.Join(container, "verified") + if e = m.Vault.Restore(ctx, snapshot, scratch); e != nil { + return fmt.Errorf("check task recovery: %w", e) + } + if e = m.Vault.Verify(ctx, snapshot, scratch); e != nil { + return e + } + if e = m.Vault.Verify(ctx, snapshot, path); e != nil { + return fmt.Errorf("task changed during capture: %w", e) + } + if m.Busy(path) { + return errors.New("task copy acquired a writer") + } + if e = ctx.Err(); e != nil { + return e + } + if m.BeforeRetire != nil { + if e = m.BeforeRetire(ctx, snapshot, path); e != nil { + return e + } + } + if e = m.Vault.Verify(ctx, snapshot, path); e != nil { + return fmt.Errorf("task changed preparing retirement: %w", e) + } + s.Snapshot, s.Phase = snapshot, Retiring + if e = durable(receipt, s); e != nil { + return e + } + if _, e = m.owned(path); e != nil { + return e + } + if e = m.rename(path, aside); e != nil { + return e + } + if e = syncDir(filepath.Dir(path)); e != nil { + return e + } + // Verify after detaching too; a writer racing capture must not lose new bytes. + if e = m.Vault.Verify(ctx, snapshot, aside); e != nil { + if restoreErr := m.rename(aside, path); restoreErr == nil { + s.Phase = Resident + _ = durable(receipt, s) + } + return fmt.Errorf("detached task copy changed: %w", e) + } + if e = removeOwnedTree(aside); e != nil { + return e + } + if e = syncDir(filepath.Dir(path)); e != nil { + return e + } + s.Phase = Cold + return durable(receipt, s) +} + +// Ensure restores before a caller may fall back to carving a new working copy. +// A retained receipt with unavailable bytes is an error, never a fresh workspace. +func (m Manager) Ensure(ctx context.Context, path string) error { + if e := ctx.Err(); e != nil { + return e + } + if e := m.vault(); e != nil { + return e + } + rel, receipt, lease, unlock, e := m.acquire(ctx, path) + if e != nil { + return e + } + defer unlock() + ctx = context.WithValue(ctx, receiptLeaseKey{}, lease) + s, e := read(receipt, rel) + if e != nil { + return e + } + if e = clearCheck(receipt, &s); e != nil { + return e + } + if s.Phase == Resident { + exists, err := present(path) + if err != nil { + return err + } + if !exists && s.Snapshot != "" { + return errors.New("saved task copy is missing; recovery is required before continuing") + } + if m.AfterRestore != nil { + return m.AfterRestore(ctx, path) + } + return nil + } + exists, e := present(path) + if e != nil { + return e + } + aside := side(path, "retiring") + old, e := present(aside) + if e != nil { + return e + } + if exists { + if e = m.Vault.Verify(ctx, s.Snapshot, path); e != nil { + return fmt.Errorf("existing task copy conflicts with saved work: %w", e) + } + if old { + return errors.New("task has both an expanded copy and retirement sibling") + } + if m.AfterRestore != nil { + if e = m.AfterRestore(ctx, path); e != nil { + return e + } + } + s.Phase = Resident + return durable(receipt, s) + } + if old { + if s.Phase != Retiring { + return errors.New("unexpected retirement sibling") + } + if err := m.Vault.Verify(ctx, s.Snapshot, aside); err == nil { + if e = m.rename(aside, path); e != nil { + return e + } + if e = syncDir(filepath.Dir(path)); e != nil { + return e + } + if m.AfterRestore != nil { + if e = m.AfterRestore(ctx, path); e != nil { + return e + } + } + s.Phase = Resident + return durable(receipt, s) + } + remainder, ok := m.Vault.(RemainderVault) + if !ok { + return errors.New("retired task copy needs a recovery-capable archive engine") + } + if e = remainder.VerifyRemainder(ctx, s.Snapshot, aside); e != nil { + return fmt.Errorf("retired task has newer or unsupported data; it was retained: %w", e) + } + if e = ctx.Err(); e != nil { + return e + } + if e = removeOwnedTree(aside); e != nil { + return e + } + if e = syncDir(filepath.Dir(path)); e != nil { + return e + } + } + staging := side(path, "restoring") + left, e := present(staging) + if e != nil { + return e + } + if left { + if s.Phase != Restoring { + return errors.New("unexpected task restore sibling") + } + if _, e = m.owned(staging); e != nil { + return e + } + if e = removeOwnedTree(staging); e != nil { + return e + } + } + s.Phase = Restoring + if e = durable(receipt, s); e != nil { + return e + } + if e = m.Vault.Restore(ctx, s.Snapshot, staging); e != nil { + _ = removeOwnedTree(staging) + s.Phase = Cold + _ = durable(receipt, s) + return fmt.Errorf("restore saved task: %w", e) + } + if e = m.Vault.Verify(ctx, s.Snapshot, staging); e != nil { + _ = removeOwnedTree(staging) + s.Phase = Cold + _ = durable(receipt, s) + return e + } + if e = ctx.Err(); e != nil { + return e + } + if exists, e = present(path); e != nil { + return e + } else if exists { + return errors.New("task copy appeared during restore") + } + if e = m.rename(staging, path); e != nil { + return e + } + if e = syncDir(filepath.Dir(path)); e != nil { + return e + } + if m.AfterRestore != nil { + if e = m.AfterRestore(ctx, path); e != nil { + return e + } + } + s.Phase = Resident + return durable(receipt, s) +} diff --git a/internal/taskstorage/lifecycle_test.go b/internal/taskstorage/lifecycle_test.go new file mode 100644 index 0000000000..0b774267db --- /dev/null +++ b/internal/taskstorage/lifecycle_test.go @@ -0,0 +1,250 @@ +package taskstorage_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskstorage" +) + +type vault struct { + contents []byte + broken bool +} + +func (v *vault) Capture(_ context.Context, path string) (string, error) { + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + v.contents = b + return "saved", e +} +func (v *vault) Restore(_ context.Context, id, path string) error { + if v.broken { + return errors.New("saved object unavailable") + } + if e := os.Mkdir(path, 0700); e != nil { + return e + } + return os.WriteFile(filepath.Join(path, "work.txt"), v.contents, 0640) +} +func (v *vault) Verify(_ context.Context, id, path string) error { + if v.broken { + return errors.New("saved object corrupt") + } + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil { + return e + } + if string(b) != string(v.contents) { + return errors.New("contents differ") + } + return nil +} +func fixture(t *testing.T) (taskstorage.Manager, string, *vault) { + t.Helper() + root := t.TempDir() + trees := filepath.Join(root, "trees") + path := filepath.Join(trees, "task-a") + if e := os.MkdirAll(path, 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(path, "work.txt"), []byte("original uncommitted work\n"), 0640); e != nil { + t.Fatal(e) + } + v := &vault{} + return taskstorage.Manager{Root: filepath.Join(root, "storage"), Trees: trees, Vault: v, Busy: func(string) bool { return false }}, path, v +} +func TestParkAndReopenKeepsExactWorkAndIdentity(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(path); !os.IsNotExist(e) { + t.Fatalf("expanded copy retained: %v", e) + } + state, e := m.Inspect(ctx, path) + if e != nil || state.Phase != taskstorage.Cold || state.Snapshot != "saved" { + t.Fatalf("cold state: %+v %v", state, e) + } + paths, e := m.Paths(ctx) + if e != nil || len(paths) != 1 || paths[0] != path { + t.Fatalf("retained identities: %v %v", paths, e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil || string(b) != "original uncommitted work\n" { + t.Fatalf("restored: %q %v", b, e) + } + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } +} +func TestRunningOrForeignWorkspaceNeverRetires(t *testing.T) { + m, path, _ := fixture(t) + m.Busy = func(string) bool { return true } + if e := m.Park(context.Background(), path); e == nil { + t.Fatal("running copy retired") + } + if _, e := os.Stat(filepath.Join(path, "work.txt")); e != nil { + t.Fatal(e) + } + m.Busy = func(string) bool { return false } + if e := m.Park(context.Background(), t.TempDir()); e == nil { + t.Fatal("foreign workspace accepted") + } +} +func TestMissingSavedBytesCannotBecomeFreshCopy(t *testing.T) { + m, path, v := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + v.broken = true + if e := m.Ensure(ctx, path); e == nil { + t.Fatal("missing archive accepted") + } + if _, e := os.Stat(path); !os.IsNotExist(e) { + t.Fatalf("published partial or fresh copy: %v", e) + } + state, e := m.Inspect(ctx, path) + if e != nil || state.Snapshot != "saved" { + t.Fatalf("recovery pointer lost: %+v %v", state, e) + } + v.broken = false + if e := m.Ensure(ctx, path); e != nil { + t.Fatal(e) + } +} +func TestCaptureFailureKeepsOriginal(t *testing.T) { + m, path, v := fixture(t) + v.broken = true + if e := m.Park(context.Background(), path); e == nil { + t.Fatal("corrupt capture retired") + } + if _, e := os.Stat(filepath.Join(path, "work.txt")); e != nil { + t.Fatal(e) + } +} +func TestRecreatedWorkspaceIsNeverOverwritten(t *testing.T) { + m, path, _ := fixture(t) + ctx := context.Background() + if e := m.Park(ctx, path); e != nil { + t.Fatal(e) + } + if e := os.Mkdir(path, 0700); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(filepath.Join(path, "work.txt"), []byte("new external work"), 0600); e != nil { + t.Fatal(e) + } + if e := m.Ensure(ctx, path); e == nil { + t.Fatal("conflicting copy accepted") + } + b, _ := os.ReadFile(filepath.Join(path, "work.txt")) + if string(b) != "new external work" { + t.Fatal("external work overwritten") + } +} + +func TestSymlinkAboveOwnedRootCannotRetireForeignData(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if e := os.MkdirAll(filepath.Join(outside, "trees", "task"), 0700); e != nil { + t.Fatal(e) + } + path := filepath.Join(outside, "trees", "task", "work.txt") + if e := os.WriteFile(path, []byte("foreign"), 0600); e != nil { + t.Fatal(e) + } + if e := os.Symlink(outside, filepath.Join(root, "redirect")); e != nil { + t.Skip(e) + } + m := taskstorage.Manager{Root: filepath.Join(root, "storage"), Trees: filepath.Join(root, "redirect", "trees"), Vault: &vault{}, Busy: func(string) bool { return false }} + if e := m.Park(context.Background(), filepath.Join(m.Trees, "task")); e == nil { + t.Fatal("retired foreign data through an ancestor symlink") + } + if b, e := os.ReadFile(path); e != nil || string(b) != "foreign" { + t.Fatalf("foreign data changed: %q %v", b, e) + } +} + +type interruptedVault struct { + vault + detach bool + changed bool +} + +func (v *interruptedVault) Verify(ctx context.Context, id, path string) error { + if v.detach && strings.Contains(filepath.Base(path), ".storage-retiring-") { + v.detach = false + if v.changed { + _ = os.WriteFile(filepath.Join(path, "work.txt"), []byte("new racing bytes"), 0600) + } else { + _ = os.Remove(filepath.Join(path, "work.txt")) + } + panic("simulated process interruption") + } + return v.vault.Verify(ctx, id, path) +} +func (v *interruptedVault) VerifyRemainder(_ context.Context, id, path string) error { + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + if os.IsNotExist(e) { + return nil + } + if e != nil { + return e + } + if string(b) != string(v.contents) { + return errors.New("remaining work changed") + } + return nil +} +func TestInterruptedRetirementRestoresMissingFiles(t *testing.T) { + m, path, _ := fixture(t) + v := &interruptedVault{detach: true} + m.Vault = v + func() { + defer func() { + if recover() == nil { + t.Error("interruption not reached") + } + }() + _ = m.Park(context.Background(), path) + }() + if e := m.Ensure(context.Background(), path); e != nil { + t.Fatal(e) + } + b, e := os.ReadFile(filepath.Join(path, "work.txt")) + if e != nil || string(b) != "original uncommitted work\n" { + t.Fatalf("recovery lost saved bytes: %q %v", b, e) + } +} +func TestInterruptedRetirementPreservesUnexpectedNewBytes(t *testing.T) { + m, path, _ := fixture(t) + v := &interruptedVault{detach: true, changed: true} + m.Vault = v + func() { defer func() { _ = recover() }(); _ = m.Park(context.Background(), path) }() + if e := m.Ensure(context.Background(), path); e == nil { + t.Fatal("accepted newer unknown retirement data") + } + matches, e := filepath.Glob(filepath.Join(filepath.Dir(path), ".task-a.storage-retiring-*", "work.txt")) + if e != nil || len(matches) != 1 { + t.Fatalf("changed retired copy disappeared: %v %v", matches, e) + } + b, e := os.ReadFile(matches[0]) + if e != nil || string(b) != "new racing bytes" { + t.Fatalf("new bytes lost: %q %v", b, e) + } +} diff --git a/internal/taskstorage/recovery_guard_test.go b/internal/taskstorage/recovery_guard_test.go new file mode 100644 index 0000000000..580920433b --- /dev/null +++ b/internal/taskstorage/recovery_guard_test.go @@ -0,0 +1,124 @@ +package taskstorage + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +var unavailableRecovery = errors.New("synthetic unavailable native writer lease") + +type unavailableRecoveryVault struct{ calls int } + +func (*unavailableRecoveryVault) archiveRecoveryReady() error { return unavailableRecovery } +func (v *unavailableRecoveryVault) Capture(context.Context, string) (string, error) { + v.calls++ + return "", unavailableRecovery +} +func (v *unavailableRecoveryVault) Restore(context.Context, string, string) error { + v.calls++ + return unavailableRecovery +} +func (v *unavailableRecoveryVault) Verify(context.Context, string, string) error { + v.calls++ + return unavailableRecovery +} + +// The public Manager seam must consult a backend's unavailable lease capability +// before it acquires a receipt or interprets an old journal as disposable data. +func TestUnavailableRecoveryLeasePreservesJournalBeforeAnyMutation(t *testing.T) { + for _, operation := range []string{"park", "ensure"} { + t.Run(operation, func(t *testing.T) { + root := t.TempDir() + trees := filepath.Join(root, "trees") + path := filepath.Join(trees, "task") + storage := filepath.Join(root, "storage") + scope := filepath.Join(storage, "receipts", digest(trees)) + check := digest("task") + ".check-" + strings.Repeat("a", 32) + container := filepath.Join(scope, check) + for _, file := range []string{filepath.Join(path, "work.txt"), filepath.Join(container, "verified", "partial")} { + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("keep mock bytes exactly"), 0600); err != nil { + t.Fatal(err) + } + } + state := State{Version: 1, Path: "task", Snapshot: strings.Repeat("b", 64), Phase: Resident, Check: check} + raw, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scope, digest("task")+".json"), raw, 0600); err != nil { + t.Fatal(err) + } + snapshot := func() map[string]string { + out := map[string]string{} + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if entry.IsDir() { + out[rel+"/"] = "" + return nil + } + bytes, err := os.ReadFile(path) + out[rel] = string(bytes) + return err + }); err != nil { + t.Fatal(err) + } + return out + } + before := snapshot() + backend := &unavailableRecoveryVault{} + manager := Manager{Root: storage, Trees: trees, Vault: backend, Busy: func(string) bool { return false }} + if operation == "park" { + err = manager.Park(context.Background(), path) + } else { + err = manager.Ensure(context.Background(), path) + } + if !errors.Is(err, unavailableRecovery) { + t.Fatalf("unavailable recovery capability ignored: %v", err) + } + if backend.calls != 0 { + t.Fatal("native operation launched despite unavailable lease capability") + } + if !reflect.DeepEqual(before, snapshot()) { + t.Fatal("recovery changed journal, staging or receipt files before capability guard") + } + }) + } +} + +func TestWindowsReceiptLeasedNativeCallRefusesBeforeLaunch(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("requires Windows runtime; cross-compilation is not execution proof") + } + lease, err := os.CreateTemp(t.TempDir(), "receipt-lock") + if err != nil { + t.Fatal(err) + } + defer lease.Close() + ctx := context.WithValue(context.Background(), receiptLeaseKey{}, lease) + backend := CLIBackend{Binary: filepath.Join(t.TempDir(), "must-not-launch.exe"), Store: t.TempDir()} + if _, err := backend.Capture(ctx, "mock-workspace"); err == nil || !strings.Contains(err.Error(), "writer lease") { + t.Fatalf("unsupported native recovery did not fail closed: %v", err) + } + // A missing executable still gives the ordinary launch error without a + // receipt lease: this guard does not disable unrelated direct capture. + if _, err := backend.Capture(context.Background(), "mock-workspace"); err == nil || strings.Contains(err.Error(), "writer lease") { + t.Fatalf("ordinary capture was incorrectly subject to recovery policy: %v", err) + } +} diff --git a/internal/tui3/cachecmd.go b/internal/tui3/cachecmd.go index a615ed536f..c86d90c775 100644 --- a/internal/tui3/cachecmd.go +++ b/internal/tui3/cachecmd.go @@ -46,7 +46,7 @@ func (a *app) runCacheCommand(rest string) tea.Cmd { case "": return func() tea.Msg { size := cachedir.Size() - place := shortPath(cachedir.Root(), tilde, 0) + place := cacheLocations(tilde) if size == 0 { return cacheNoteMsg{line: "the cache is empty · " + place} } @@ -60,7 +60,7 @@ func (a *app) runCacheCommand(rest string) tea.Cmd { return cacheNoteMsg{line: cacheEmptyWord} } return cacheNoteMsg{line: "this deletes the shared build cache — " + cachedir.Human(size) + - " at " + shortPath(cachedir.Root(), tilde, 0) + + " at " + cacheLocations(tilde) + ". builds start cold afterwards; conversations and settings are not touched. type /cache clean now to go ahead."} } case "clean now": @@ -85,3 +85,11 @@ func (a *app) runCacheCommand(rest string) tea.Cmd { // cacheEmptyWord answers both the clean that found nothing and the clean that // arrived second: one fact, one sentence, wherever it comes up. const cacheEmptyWord = "the cache is already empty — nothing to delete." + +func cacheLocations(tilde string) string { + locations := cachedir.Locations() + for index, path := range locations { + locations[index] = shortPath(path, tilde, 0) + } + return strings.Join(locations, ", ") +} diff --git a/internal/tui3/cachecmd_test.go b/internal/tui3/cachecmd_test.go index 888748c005..2a58e5793d 100644 --- a/internal/tui3/cachecmd_test.go +++ b/internal/tui3/cachecmd_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/cachedir" + "github.com/Agent-Field/codeaf/internal/home" ) // seedCacheDir fills the cache under the state root the fixture has already @@ -95,3 +96,23 @@ func TestCacheRefusesAnUnknownArgumentCalmly(t *testing.T) { t.Fatal("an unknown argument deleted the cache") } } + +func TestCacheQuestionNamesRetainedLegacyLocation(t *testing.T) { + a, _ := sheetApp(t) + path := home.Join("workspace-caches", "legacy", "cache") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "compiler"), []byte("cache"), 0600); err != nil { + t.Fatal(err) + } + for _, command := range []string{"/cache", "/cache clean"} { + note := runCacheNote(t, a, command) + if !strings.Contains(note, "workspace-caches") { + t.Fatalf("%s omitted retained cache location: %s", command, note) + } + } + if raw, err := os.ReadFile(filepath.Join(path, "compiler")); err != nil || string(raw) != "cache" { + t.Fatal("cache question changed old cache bytes") + } +} diff --git a/internal/tui3/contextmodal.go b/internal/tui3/contextmodal.go index 2e072c4487..2bfd80fc29 100644 --- a/internal/tui3/contextmodal.go +++ b/internal/tui3/contextmodal.go @@ -345,6 +345,9 @@ func (a *app) contextHeadRule(inner int, glyph contextGlyph) string { if a.folder.forWorkspace { title = "choose workspace" } + if a.folder.chooseTitle != "" { + title = a.folder.chooseTitle + } if a.folder.forTarget { // THE SHEET HOME OPENED IS NOT ADDING CONTEXT TO ANYTHING. It is choosing // where the next conversation opens (folderpick.go's [folderPick.forTarget]), diff --git a/internal/tui3/folderact.go b/internal/tui3/folderact.go index 82a1e6e2d1..4896f36ec4 100644 --- a/internal/tui3/folderact.go +++ b/internal/tui3/folderact.go @@ -226,6 +226,16 @@ type folderAdded struct { // what it would do. It is one function because the key and the row must not be // able to drift into meaning two different things. func (a *app) folderConfirm() tea.Cmd { + if choose := a.folder.chooseFolder; choose != nil { + path, dir, ok := a.folder.hereKind() + if !ok || !dir { + a.note("choose an existing local folder") + return nil + } + a.folder.chooseFolder = nil + closed := a.closeFolderSheet() + return tea.Batch(closed, choose(path)) + } if a.folder.forWorkspace { path, dir, ok := a.folder.hereKind() if !ok || !dir { diff --git a/internal/tui3/folderpick.go b/internal/tui3/folderpick.go index 0830610356..5aaecb0723 100644 --- a/internal/tui3/folderpick.go +++ b/internal/tui3/folderpick.go @@ -206,6 +206,10 @@ type folderPick struct { forTarget bool // forWorkspace chooses the current conversation's one-shot project anchor. forWorkspace bool + // chooseFolder makes the existing single-folder picker answer one local + // receiver mapping rather than change the conversation's workspace. + chooseFolder func(string) tea.Cmd + chooseTitle string // hidden reveals the dot-directories and the names the `@` walk prunes. // It is off by default and it is a person's own act — alt+h, or a name @@ -1403,6 +1407,11 @@ func (g folderGeom) actionAt() int { return g.action } // matters most here (folderact.go's header). func (f *folderPick) actionWord(path string, dir bool) string { switch { + case f.chooseFolder != nil: + if dir { + return "use local folder · " + } + return "choose a folder · " case f.forWorkspace: if dir { return "set workspace · " diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index e11d4fb558..9a29a46019 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -290,7 +290,7 @@ func (a *app) openTargetContextPick(query string, files bool) tea.Cmd { // surface has a layer to undraw, routed through one door so none of the ways out // can leave the layer behind. func (a *app) closeFolderSheet() tea.Cmd { - home := a.folder.forTarget + home := a.folder.forTarget || a.folder.chooseFolder != nil a.folder.close() a.touch() if home { @@ -1181,6 +1181,7 @@ func (a *app) tookFolderStore(msg folderStoreMsg) tea.Cmd { marks, pane := a.folder.marks, a.folder.pane paneTop, paneLeft := a.folder.paneTop, a.folder.paneLeft forTarget, forWorkspace := a.folder.forTarget, a.folder.forWorkspace + chooseFolder, chooseTitle := a.folder.chooseFolder, a.folder.chooseTitle a.folder.start(a.folderCandidates(), a.tilde) a.folder.filter, a.folder.facts = filter, facts a.folder.kids, a.folder.asking, a.folder.hidden = kids, asking, hidden @@ -1193,6 +1194,7 @@ func (a *app) tookFolderStore(msg folderStoreMsg) tea.Cmd { // chosen on it went to the conversation BEHIND home. Its sheet holds nothing // either, for the reason that function gives. a.folder.forTarget, a.folder.forWorkspace = forTarget, forWorkspace + a.folder.chooseFolder, a.folder.chooseTitle = chooseFolder, chooseTitle if !forTarget && !forWorkspace { a.markFolderHeld() } diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 3622437e2f..4ae58b2e2a 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -65,6 +65,10 @@ type conditionalTaker interface { TakeSeen(context.Context, string, *directory.LeaseExpectation) (Taken, error) } +type workspaceRootTaker interface { + TakeSeenWithWorkspaceRoots(context.Context, string, *directory.LeaseExpectation, map[string]string) (Taken, error) +} + // BranchActions is what a branch row can do. A nil verb is not offered. type BranchActions struct { Open func(ctx context.Context, branch string) (Taken, error) @@ -247,15 +251,24 @@ type homeTakenMsg struct { row chatlist.Row taken Taken err error + roots map[string]string } // takeRemote asks the taker, off the update loop: a takeover fetches and waits // on the directory, and a keystroke may not wait on a network. func (a *app) takeRemote(row chatlist.Row) tea.Cmd { + return a.takeRemoteWithRoots(row, nil) +} + +func (a *app) takeRemoteWithRoots(row chatlist.Row, roots map[string]string) tea.Cmd { a.home.say(continuing, "") taker, ctx := a.taker, a.ctx return func() tea.Msg { - if conditional, ok := taker.(conditionalTaker); ok && row.DeviceID != "" { + if mapped, ok := taker.(workspaceRootTaker); ok && len(roots) > 0 { + taken, err := mapped.TakeSeenWithWorkspaceRoots(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}, roots) + return homeTakenMsg{row: row, taken: taken, err: err, roots: roots} + } + if conditional, ok := taker.(conditionalTaker); ok { taken, err := conditional.TakeSeen(ctx, row.Cell, &directory.LeaseExpectation{Device: row.DeviceID, Fence: row.Fence}) return homeTakenMsg{row: row, taken: taken, err: err} } @@ -267,6 +280,12 @@ func (a *app) takeRemote(row chatlist.Row) tea.Cmd { // tookTakeover files the answer. A takeover that failed leaves the row where it // was, and one line says why; one that worked opens the chat. func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { + var missing *session.WorkspaceBindingsRequired + if errors.As(msg.err, &missing) { + if _, ok := a.taker.(workspaceRootTaker); ok && len(missing.Roots) > 0 { + return a.chooseTakeRoots(msg.row, missing.Roots, msg.roots) + } + } switch { case errors.Is(msg.err, directory.ErrLeaseHeld): return a.lostRace(msg.row) @@ -285,6 +304,27 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { return nil } +// chooseTakeRoots collects explicit folder choices, then retries against the +// original card's holder. Escape drops the choices without another takeover. +func (a *app) chooseTakeRoots(row chatlist.Row, missing []session.WorkspaceRootBinding, chosen map[string]string) tea.Cmd { + if len(missing) == 0 { + return a.takeRemoteWithRoots(row, chosen) + } + cmd := a.openContextPick("", false) + a.folder.forWorkspace = true + a.folder.held = nil + a.folder.chooseTitle = "local folder for " + missing[0].Label + a.folder.chooseFolder = func(path string) tea.Cmd { + roots := make(map[string]string, len(chosen)+1) + for uri, folder := range chosen { + roots[uri] = folder + } + roots[missing[0].URI] = path + return a.chooseTakeRoots(row, missing[1:], roots) + } + return cmd +} + // lostRace is another device winning the takeover. The row goes back to // `running on ` at once, from the listing this window already holds, // and the directory is asked again for the winner's own name. diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go index 87b1a28f79..e2924dbef2 100644 --- a/internal/tui3/planroom.go +++ b/internal/tui3/planroom.go @@ -474,9 +474,9 @@ func (a *app) planRoomWorkRead(now bool) tea.Cmd { return nil } id, gen := plan.id, a.room.gen + opener, canOpen := a.agent.(session.PlanWorkOpener) plan.working = true - return a.besideLine(func() func(bool) tea.Cmd { - work, found := door.PlanTaskWork(id) + apply := func(work session.PlanTaskWork, found bool) func(bool) tea.Cmd { return func(bool) tea.Cmd { if a.room == nil || a.room.gen != gen || a.room.plan == nil { return nil @@ -491,6 +491,19 @@ func (a *app) planRoomWorkRead(now bool) tea.Cmd { a.roomTouched() return nil } + } + if now && canOpen { + // Restoring a working copy is a gesture: preserve its order relative to + // the person's next action. Only the unchanged background read may pass + // beside the ordered line. + return a.offLoop(func() func(bool) tea.Cmd { + work, found := opener.OpenPlanTaskWork(id) + return apply(work, found) + }) + } + return a.besideLine(func() func(bool) tea.Cmd { + work, found := door.PlanTaskWork(id) + return apply(work, found) }) } @@ -527,6 +540,9 @@ func (a *app) roomWorkRows(width int) []row { return []row{dim(planWorkNoDoorWord)} } work := plan.work + if work.Error != "" { + return []row{dim(work.Error)} + } if !work.Read { return []row{dim(planWorkGoneWord)} } diff --git a/internal/tui3/planroom_test.go b/internal/tui3/planroom_test.go index 4512a10029..d11d6207de 100644 --- a/internal/tui3/planroom_test.go +++ b/internal/tui3/planroom_test.go @@ -496,3 +496,32 @@ func TestTheWorkTabDrawsNoControlBytesFromTheDifference(t *testing.T) { } } } + +type planWorkOpenFake struct { + *planWorkFake + opens int +} + +func (f *planWorkOpenFake) OpenPlanTaskWork(id string) (session.PlanTaskWork, bool) { + f.opens++ + return f.work, true +} + +func TestExplicitWorkTabOpensStorageButPollingOnlyReads(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "saved result", Status: "running"} + a, plan := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + fake := &planWorkOpenFake{planWorkFake: &planWorkFake{planFake: plan, work: session.PlanTaskWork{Dir: "/work/copy", Read: true}}} + a.agent = fake + openPlanRoomNow(t, a, row.ID) + if fake.opens != 0 { + t.Fatal("opening the transcript expanded task storage") + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + if fake.opens != 1 || fake.reads != 0 { + t.Fatalf("explicit work tab: opens=%d reads=%d", fake.opens, fake.reads) + } + drive(t, a, runInline(a.planRoomWorkRead(false))) + if fake.opens != 1 || fake.reads != 1 { + t.Fatalf("background work poll expanded storage: opens=%d reads=%d", fake.opens, fake.reads) + } +} diff --git a/internal/tui3/workspace_binding_test.go b/internal/tui3/workspace_binding_test.go new file mode 100644 index 0000000000..6c8c77f0ea --- /dev/null +++ b/internal/tui3/workspace_binding_test.go @@ -0,0 +1,86 @@ +package tui3 + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" +) + +type bindingTaker struct { + seen *directory.LeaseExpectation + roots map[string]string + calls int +} + +func (f *bindingTaker) Take(ctx context.Context, id string) (Taken, error) { + return f.TakeSeen(ctx, id, nil) +} +func (f *bindingTaker) TakeSeen(_ context.Context, _ string, seen *directory.LeaseExpectation) (Taken, error) { + f.calls++ + f.seen = seen + return Taken{}, &session.WorkspaceBindingsRequired{Roots: []session.WorkspaceRootBinding{{URI: "root-app", Label: "1/app"}, {URI: "root-library", Label: "1/library"}}} +} +func (f *bindingTaker) TakeSeenWithWorkspaceRoots(_ context.Context, _ string, seen *directory.LeaseExpectation, roots map[string]string) (Taken, error) { + f.calls++ + if f.seen == nil || seen == nil || *f.seen != *seen { + return Taken{}, directory.ErrConditionalTake + } + f.roots = roots + return Taken{}, directory.ErrLeaseHeld +} + +func TestTakeRootPickerCollectsExplicitFoldersThroughKeys(t *testing.T) { + for _, cancel := range []bool{false, true} { + t.Run(map[bool]string{false: "select", true: "cancel"}[cancel], func(t *testing.T) { + fake := &bindingTaker{} + rows := chatlist.Static{{Cell: "mapping-chat", Title: "Mapped task", Device: "other", DeviceID: "device-a", Fence: 17, Status: chatlist.Off}} + a := continueHome(t, fake, rows, 200) + base := t.TempDir() + app, library := filepath.Join(base, "local-app"), filepath.Join(base, "local-library") + for _, path := range []string{app, library} { + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + } + a.home.world = session.World{Projects: []session.Project{{Path: app}, {Path: library}}} + standOn(t, a, "mapping-chat") + confirm(t, a) + if got := plain(a.View().Content); !strings.Contains(got, "local folder for 1/app") { + t.Fatalf("mapping choice is absent: %s", got) + } + if cancel { + drive(t, a, key("esc")) + if fake.calls != 1 || len(fake.roots) != 0 { + t.Fatal("cancelling retried takeover or retained bindings") + } + return + } + choose := func(name string) { + msgs := []tea.Msg{tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}} + for _, char := range name { + msgs = append(msgs, key(string(char))) + } + msgs = append(msgs, key("enter")) + drive(t, a, msgs...) + } + choose(app + string(filepath.Separator)) + if got := plain(a.View().Content); !strings.Contains(got, "local folder for 1/library") { + t.Fatalf("second root choice is absent: %s", got) + } + choose(library + string(filepath.Separator)) + if fake.calls != 2 || fake.roots["root-app"] != app || fake.roots["root-library"] != library { + t.Fatalf("explicit choices lost: calls=%d roots=%v", fake.calls, fake.roots) + } + if fake.seen.Device != "device-a" || fake.seen.Fence != 17 { + t.Fatalf("holder expectation changed: %+v", fake.seen) + } + }) + } +} diff --git a/internal/workspaceidentity/existing.go b/internal/workspaceidentity/existing.go new file mode 100644 index 0000000000..f9ace38b75 --- /dev/null +++ b/internal/workspaceidentity/existing.go @@ -0,0 +1,62 @@ +package workspaceidentity + +import ( + "database/sql" + "errors" + "os" + "path/filepath" + "runtime" + + "github.com/Agent-Field/codeaf/internal/sqliteuri" +) + +// ResolveExisting verifies an already admitted binding without creating a +// directory, database, schema, namespace, alias, or migration. Missing local +// state is unknown, never permission to admit an incoming identity. +func ResolveExisting(root, uri string) (Record, error) { + if !validURI(uri) { + return Record{}, errors.New("malformed workspace identity") + } + if !filepath.IsAbs(root) { + return Record{}, errors.New("workspace registry requires an absolute path") + } + info, err := os.Stat(root) + if errors.Is(err, os.ErrNotExist) { + return Record{}, ErrUnknown + } + if err != nil { + return Record{}, err + } + if !info.IsDir() || runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { + return Record{}, errors.New("workspace registry directory must be private") + } + path := filepath.Join(root, "registry.sqlite") + info, err = os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return Record{}, ErrUnknown + } + if err != nil { + return Record{}, err + } + if !info.Mode().IsRegular() { + return Record{}, errors.New("workspace registry database is not a regular file") + } + address := sqliteuri.File(path) + address.RawQuery = "mode=ro&_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", address.String()) + if err != nil { + return Record{}, err + } + defer db.Close() + record, err := byURI(db, uri) + if errors.Is(err, sql.ErrNoRows) { + return Record{}, ErrUnknown + } + if err != nil { + return Record{}, err + } + if err := validateResolvedBinding(record.Role, record.Path); err != nil { + return Record{}, err + } + return record, nil +} diff --git a/internal/workspaceidentity/existing_test.go b/internal/workspaceidentity/existing_test.go new file mode 100644 index 0000000000..e15ca4ced5 --- /dev/null +++ b/internal/workspaceidentity/existing_test.go @@ -0,0 +1,84 @@ +package workspaceidentity_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestExistingLookupNeverCreatesRegistryAndResolvesAdmittedAliasReadOnly(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent-registry") + uri := "codeaf-workspace://11111111111111111111111111111111/22222222222222222222222222222222" + if _, err := workspaceidentity.ResolveExisting(missing, uri); !errors.Is(err, workspaceidentity.ErrUnknown) { + t.Fatalf("missing registry lookup: %v", err) + } + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Fatal("lookup created a registry") + } + root := filepath.Join(t.TempDir(), "registry") + r, err := workspaceidentity.Open(root) + if err != nil { + t.Fatal(err) + } + project, err := r.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + incoming := workspaceidentity.Record{URI: uri, Role: workspaceidentity.Project} + if _, err := r.Adopt(incoming, project.Path, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(root, "registry.sqlite")) + if err != nil { + t.Fatal(err) + } + got, err := workspaceidentity.ResolveExisting(root, uri) + if err != nil || got.URI != uri || got.Path != project.Path { + t.Fatalf("known alias missing: %+v %v", got, err) + } + after, err := os.ReadFile(filepath.Join(root, "registry.sqlite")) + if err != nil || string(before) != string(after) { + t.Fatal("read-only lookup changed registry database") + } +} + +func TestRecordedLookupKeepsAbsentCacheIdentityAndRefusesSubstitutedLinks(t *testing.T) { + r, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + owner, err := r.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + cache := filepath.Join(t.TempDir(), "cache") + if err := os.Mkdir(cache, 0700); err != nil { + t.Fatal(err) + } + admitted, err := r.Register(workspaceidentity.Cache, cache, workspaceidentity.Links{OwnerURI: owner.URI}) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(cache); err != nil { + t.Fatal(err) + } + got, err := r.LookupRecorded(workspaceidentity.Cache, cache) + if err != nil || got != admitted { + t.Fatalf("absent cache lost recorded identity: %+v %v", got, err) + } + if _, err := r.LookupRecorded(workspaceidentity.Temp, cache); err == nil { + t.Fatal("recorded lookup ignored role") + } + if _, err := r.LookupRecorded(workspaceidentity.Cache, filepath.Join(filepath.Dir(cache), "unknown")); err == nil { + t.Fatal("unknown path acquired authority") + } + if err := os.Symlink(t.TempDir(), cache); err != nil { + t.Skip(err) + } + if _, err := r.LookupRecorded(workspaceidentity.Cache, cache); err == nil { + t.Fatal("recorded binding followed substituted symbolic link") + } +} diff --git a/internal/workspaceidentity/migrate.go b/internal/workspaceidentity/migrate.go new file mode 100644 index 0000000000..890abe6271 --- /dev/null +++ b/internal/workspaceidentity/migrate.go @@ -0,0 +1,107 @@ +package workspaceidentity + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "strings" +) + +// migrate reads legacy receipts exactly once, under the database's transaction. +// Original files remain untouched, including after a failed or committed import. +func (r *Registry) migrate(tx *sql.Tx) (string, error) { + root, err := os.OpenRoot(r.root) + if err != nil { + return "", err + } + defer root.Close() + namespace, err := legacyRead(root, "namespace") + missing := errors.Is(err, os.ErrNotExist) + if err != nil && !missing { + return "", err + } + scope := string(namespace) + if !missing && !validID(scope) { + return "", errors.New("invalid legacy workspace namespace; migration refused") + } + directory, err := root.Open(".") + if err != nil { + return "", err + } + defer directory.Close() + entries, err := directory.ReadDir(-1) + if err != nil { + return "", err + } + var records []Record + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + if missing { + return "", errors.New("legacy workspace records have no namespace; migration refused") + } + raw, err := legacyRead(root, entry.Name()) + if err != nil { + return "", err + } + var record Record + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + return "", err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return "", errors.New("legacy workspace record has trailing data") + } + if !validURI(record.URI) || !validRole(record.Role) { + return "", errors.New("malformed legacy workspace record") + } + parts := strings.Split(strings.TrimPrefix(record.URI, "codeaf-workspace://"), "/") + key := parts[1] + if parts[0] != scope { + digest := sha256.Sum256([]byte(record.URI)) + key = hex.EncodeToString(digest[:]) + } + if entry.Name() != key+".json" { + return "", errors.New("legacy workspace record does not match its identity") + } + if err := validateResolvedBinding(record.Role, record.Path); err != nil { + return "", err + } + if err := insert(tx, record); err != nil { + return "", err + } + records = append(records, record) + } + for _, record := range records { + links := Links{OwnerURI: record.OwnerURI, ProjectURI: record.ProjectURI} + if _, err := validatedLinks(tx, links); err != nil { + return "", err + } + } + if missing { + return randomID() + } + return scope, nil +} +func legacyRead(root *os.Root, name string) ([]byte, error) { + file, err := root.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > 65536 { + return nil, errors.New("invalid legacy workspace registry file") + } + return io.ReadAll(io.LimitReader(file, 65537)) +} diff --git a/internal/workspaceidentity/project_alias_test.go b/internal/workspaceidentity/project_alias_test.go new file mode 100644 index 0000000000..71be55f6ff --- /dev/null +++ b/internal/workspaceidentity/project_alias_test.go @@ -0,0 +1,210 @@ +package workspaceidentity_test + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestAcceptedProjectTransferKeepsExistingReceiverIdentity(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + receiverPath := filepath.Join(t.TempDir(), "receiver") + receiver, err := workspaceidentity.Open(receiverPath) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + localPath := t.TempDir() + canonical, err := receiver.Register(workspaceidentity.Project, localPath) + if err != nil { + t.Fatal(err) + } + adopted, err := receiver.Adopt(incoming, localPath, workspaceidentity.Links{}) + if err != nil { + t.Fatal(err) + } + if adopted.URI != incoming.URI || adopted.Path != localPath || adopted.ProjectURI != incoming.URI { + t.Fatalf("incoming identity changed: %#v", adopted) + } + reopened, err := workspaceidentity.Open(receiverPath) + if err != nil { + t.Fatal(err) + } + for _, uri := range []string{incoming.URI, canonical.URI} { + record, err := reopened.Resolve(uri) + if err != nil || record.URI != uri || record.Path != localPath || record.Role != workspaceidentity.Project || record.ProjectURI != uri { + t.Fatalf("project identity disappeared: %#v %v", record, err) + } + } + registered, err := reopened.Register(workspaceidentity.Project, localPath) + if err != nil || registered != canonical { + t.Fatalf("receiver's canonical identity changed: %#v %v", registered, err) + } + lookedUp, err := reopened.Lookup(workspaceidentity.Project, localPath) + if err != nil || lookedUp != canonical { + t.Fatalf("project path lookup is no longer canonical: %#v %v", lookedUp, err) + } +} + +func TestProjectAliasAdmissionIsRepeatableButCannotMoveItsGroup(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + receiver, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "receiver")) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + canonical, err := receiver.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + adopted, err := receiver.Adopt(incoming, canonical.Path, workspaceidentity.Links{}) + if err != nil { + t.Fatal(err) + } + repeated, err := receiver.Adopt(incoming, canonical.Path, workspaceidentity.Links{}) + if err != nil || repeated != adopted { + t.Fatalf("repeated accepted transfer changed equivalence: %#v %v", repeated, err) + } + elsewhere := t.TempDir() + for _, uri := range []string{incoming.URI, canonical.URI} { + if _, err := receiver.Bind(uri, elsewhere); err == nil { + t.Fatalf("individual rebind moved equivalent project %s", uri) + } + } + if _, err := receiver.Adopt(incoming, elsewhere, workspaceidentity.Links{}); err == nil { + t.Fatal("alias adoption moved its canonical project group") + } + if _, err := receiver.Adopt(canonical, elsewhere, workspaceidentity.Links{}); err == nil { + t.Fatal("canonical adoption moved its aliases without a group move") + } + for _, uri := range []string{incoming.URI, canonical.URI} { + record, err := receiver.Resolve(uri) + if err != nil || record.Path != canonical.Path { + t.Fatalf("refused move changed project: %#v %v", record, err) + } + } +} + +func TestProjectAliasPreservesRunLinksAndRefusesOtherOwnership(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + receiver, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "receiver")) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + local, err := receiver.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + chat, err := receiver.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + existing, err := receiver.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: local.URI}) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(incoming, local.Path, workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + linked, err := receiver.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: incoming.URI}) + if err != nil { + t.Fatal(err) + } + for _, want := range []workspaceidentity.Record{existing, linked} { + got, err := receiver.Resolve(want.URI) + if err != nil || got != want { + t.Fatalf("run link changed: %#v %v", got, err) + } + } + foreignChat, err := source.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(foreignChat, chat.Path, workspaceidentity.Links{}); err == nil { + t.Fatal("project equivalence admitted a chat collision") + } + foreignRun, err := source.Register(workspaceidentity.Run, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(foreignRun, existing.Path, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: local.URI}); err == nil { + t.Fatal("project equivalence admitted a run collision") + } + owned, err := receiver.Register(workspaceidentity.Project, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI}) + if err != nil { + t.Fatal(err) + } + otherIncoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(otherIncoming, owned.Path, workspaceidentity.Links{}); err == nil { + t.Fatal("alias admission replaced project owner") + } + if _, err := receiver.Resolve(otherIncoming.URI); err == nil { + t.Fatal("refused alias was retained") + } +} + +func TestConcurrentProjectAliasAdmissionKeepsCanonicalLookup(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(t.TempDir(), "receiver") + receiver, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + canonical, err := receiver.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + errors := make(chan error, 8) + for range 8 { + go func() { + r, err := workspaceidentity.Open(registryPath) + if err == nil { + _, err = r.Adopt(incoming, canonical.Path, workspaceidentity.Links{}) + } + errors <- err + }() + } + for range 8 { + if err := <-errors; err != nil { + t.Fatal(err) + } + } + got, err := receiver.Lookup(workspaceidentity.Project, canonical.Path) + if err != nil || got != canonical { + t.Fatalf("canonical changed after concurrent admission: %#v %v", got, err) + } + alias, err := receiver.Resolve(incoming.URI) + if err != nil || alias.URI != incoming.URI || alias.Path != canonical.Path { + t.Fatalf("alias lost: %#v %v", alias, err) + } +} diff --git a/internal/workspaceidentity/registry.go b/internal/workspaceidentity/registry.go new file mode 100644 index 0000000000..c2984262f7 --- /dev/null +++ b/internal/workspaceidentity/registry.go @@ -0,0 +1,537 @@ +// Package workspaceidentity separates durable workspace names from local paths. +// A record describes placement; it never grants permission to write or delete it. +package workspaceidentity + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/Agent-Field/codeaf/internal/sqliteuri" + _ "modernc.org/sqlite" +) + +// ErrUnknown means transfer or recovery has not admitted an identity locally. +var ErrUnknown = errors.New("workspace identity has not been admitted on this device; take or restore its task first") + +type Role string + +const ( + Project Role = "project" + Chat Role = "chat" + Run Role = "run" + Log Role = "log" + Temp Role = "temp" + Cache Role = "cache" + ExternalRemote Role = "external-remote" +) + +// Record is a private device-local binding, not a deletion or access capability. +type Record struct { + URI string `json:"uri"` + Role Role `json:"role"` + Path string `json:"path"` + OwnerURI string `json:"owner_uri,omitempty"` + ProjectURI string `json:"project_uri,omitempty"` +} + +// Links describe local ownership and an optional project reference. A chat can +// own runs across projects; its own record need not belong to one project. +type Links struct{ OwnerURI, ProjectURI string } + +// Registry owns metadata only. Database handles are scoped to each operation, +// so opening a worker does not retain an engine-lifetime connection or cache. +type Registry struct{ root, scope string } + +const schema = ` +CREATE TABLE IF NOT EXISTS metadata (id INTEGER PRIMARY KEY CHECK(id=1), version INTEGER NOT NULL, namespace TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS workspaces ( + uri TEXT PRIMARY KEY, role TEXT NOT NULL, path TEXT NOT NULL, + owner_uri TEXT NOT NULL, project_uri TEXT NOT NULL, + UNIQUE(role,path) +); +CREATE TABLE IF NOT EXISTS project_aliases ( + alias_uri TEXT PRIMARY KEY, + canonical_uri TEXT NOT NULL REFERENCES workspaces(uri) +); +CREATE INDEX IF NOT EXISTS project_aliases_canonical ON project_aliases(canonical_uri); +` + +// Open opens a private registry. Legacy receipt migration and namespace creation +// commit in one transaction. Failed migration never resets an existing identity. +func Open(path string) (*Registry, error) { + if !filepath.IsAbs(path) { + return nil, errors.New("workspace registry requires an absolute path") + } + if err := os.MkdirAll(path, 0700); err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + info, err := os.Stat(resolved) + if err != nil { + return nil, err + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { + return nil, errors.New("workspace registry directory must be private (0700)") + } + registry := &Registry{root: resolved} + db, err := registry.database(true) + if err != nil { + return nil, err + } + defer db.Close() + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(schema); err != nil { + return nil, err + } + var version int + err = tx.QueryRow("SELECT version,namespace FROM metadata WHERE id=1").Scan(&version, ®istry.scope) + if errors.Is(err, sql.ErrNoRows) { + registry.scope, err = registry.migrate(tx) + if err != nil { + return nil, err + } + if _, err = tx.Exec("INSERT INTO metadata(id,version,namespace) VALUES(1,1,?)", registry.scope); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else if version != 1 || !validID(registry.scope) { + return nil, errors.New("unsupported or corrupt workspace registry") + } + if err := tx.Commit(); err != nil { + return nil, err + } + return registry, nil +} + +// Register admits a workspace once per role and canonical path. Repeated +// admission keeps its identity and refuses a different owner or project. +func (r *Registry) Register(role Role, path string, links ...Links) (Record, error) { + if !validRole(role) { + return Record{}, errors.New("unknown workspace role") + } + if len(links) > 1 { + return Record{}, errors.New("workspace has more than one owner") + } + binding, err := validateBinding(role, path) + if err != nil { + return Record{}, err + } + var link Links + if len(links) == 1 { + link = links[0] + } + var record Record + err = r.transaction(func(tx *sql.Tx) error { + link, err = validatedLinks(tx, link) + if err != nil { + return err + } + previous, err := byPath(tx, role, binding) + if err == nil { + if previous.OwnerURI != link.OwnerURI || link.ProjectURI != "" && previous.ProjectURI != link.ProjectURI { + return errors.New("workspace path already has another owner or project") + } + record = previous + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + id, err := randomID() + if err != nil { + return err + } + record = Record{URI: "codeaf-workspace://" + r.scope + "/" + id, Role: role, Path: binding, OwnerURI: link.OwnerURI, ProjectURI: link.ProjectURI} + if role == Project { + record.ProjectURI = record.URI + } + return insert(tx, record) + }) + return record, err +} + +// Resolve accepts local identities and explicitly adopted relay identities. +// Missing parked trees keep their binding; existing components cannot redirect it. +func (r *Registry) Resolve(uri string) (Record, error) { + if !validURI(uri) { + return Record{}, errors.New("malformed workspace identity") + } + db, err := r.database(false) + if err != nil { + return Record{}, err + } + defer db.Close() + record, err := byURI(db, uri) + if errors.Is(err, sql.ErrNoRows) { + return Record{}, ErrUnknown + } + if err != nil { + return Record{}, err + } + if err := validateResolvedBinding(record.Role, record.Path); err != nil { + return Record{}, err + } + return record, nil +} + +// Lookup finds an already admitted role/path without creating an identity. +// It is used when a nested task's ground is an existing owned parent run. +func (r *Registry) Lookup(role Role, path string) (Record, error) { + if !validRole(role) { + return Record{}, errors.New("unknown workspace role") + } + binding, err := validateBinding(role, path) + if err != nil { + return Record{}, err + } + db, err := r.database(false) + if err != nil { + return Record{}, err + } + defer db.Close() + return byPath(db, role, binding) +} + +// LookupRecorded reads an admitted role/path even when its directory is absent. +// It returns metadata, never permission to reopen, write, or delete the path. +func (r *Registry) LookupRecorded(role Role, path string) (Record, error) { + if !validRole(role) { + return Record{}, errors.New("unknown workspace role") + } + if err := validateResolvedBinding(role, path); err != nil { + return Record{}, err + } + db, err := r.database(false) + if err != nil { + return Record{}, err + } + defer db.Close() + record, err := byPath(db, role, path) + if errors.Is(err, sql.ErrNoRows) { + return Record{}, ErrUnknown + } + return record, err +} + +// Bind explicitly relocates an already admitted identity without changing its +// role or links. Equivalent projects require an explicit group move instead. +// Callers establish relocation authority before calling it. +func (r *Registry) Bind(uri, path string) (Record, error) { + if !validURI(uri) { + return Record{}, errors.New("malformed workspace identity") + } + var record Record + err := r.transaction(func(tx *sql.Tx) error { + var err error + record, err = byURI(tx, uri) + if errors.Is(err, sql.ErrNoRows) { + return ErrUnknown + } + if err != nil { + return err + } + binding, err := validateBinding(record.Role, path) + if err != nil { + return err + } + if binding == record.Path { + return nil + } + if err := refuseProjectGroupMove(tx, record); err != nil { + return err + } + record.Path = binding + collision, err := byPath(tx, record.Role, record.Path) + if err == nil && collision.URI != uri { + return errors.New("workspace path already has another identity") + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.Exec("UPDATE workspaces SET path=? WHERE uri=?", record.Path, uri) + return err + }) + return record, err +} + +// Adopt binds an authenticated incoming identity to restored local material. +// Only an accepted transfer may call it. Ordinary registration cannot invent a +// foreign identity, and importing one grants no permission to delete its files. +// A new incoming project may alias an existing local project with the same owner; +// canonical path registration remains stable and neither identity is replaced. +func (r *Registry) Adopt(incoming Record, path string, links Links) (Record, error) { + if !validURI(incoming.URI) || !validRole(incoming.Role) { + return Record{}, errors.New("malformed incoming workspace identity") + } + binding, err := validateBinding(incoming.Role, path) + if err != nil { + return Record{}, err + } + candidate := Record{URI: incoming.URI, Role: incoming.Role, Path: binding} + err = r.transaction(func(tx *sql.Tx) error { + var err error + links, err = validatedLinks(tx, links) + if err != nil { + return err + } + candidate.OwnerURI, candidate.ProjectURI = links.OwnerURI, links.ProjectURI + if candidate.Role == Project { + candidate.ProjectURI = candidate.URI + } + if incoming.OwnerURI != "" && incoming.OwnerURI != candidate.OwnerURI || incoming.ProjectURI != "" && incoming.ProjectURI != candidate.ProjectURI { + return errors.New("incoming ownership links do not match the accepted transfer") + } + previous, err := byURI(tx, incoming.URI) + exists := err == nil + if exists { + if previous.Role != candidate.Role || previous.OwnerURI != candidate.OwnerURI || previous.ProjectURI != candidate.ProjectURI { + return errors.New("transfer cannot change an existing identity's role or ownership") + } + if previous.Path == candidate.Path { + return nil + } + if err := refuseProjectGroupMove(tx, previous); err != nil { + return err + } + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + collision, err := byPath(tx, candidate.Role, candidate.Path) + if err == nil && collision.URI != candidate.URI { + // Accepted transfer establishes project equivalence, not a second claim + // over a run or chat. The receiver's canonical identity stays unchanged. + if !exists && candidate.Role == Project && collision.OwnerURI == candidate.OwnerURI { + _, err = tx.Exec("INSERT INTO project_aliases(alias_uri,canonical_uri) VALUES(?,?)", candidate.URI, collision.URI) + return err + } + return errors.New("restored workspace path already has another identity") + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if exists { + _, err = tx.Exec("UPDATE workspaces SET path=? WHERE uri=?", candidate.Path, candidate.URI) + return err + } + return insert(tx, candidate) + }) + return candidate, err +} + +// An individual binding cannot relocate a project shared by admitted aliases. +// A future explicit group move must validate every affected local reference. +func refuseProjectGroupMove(tx *sql.Tx, record Record) error { + if record.Role != Project { + return nil + } + var shared bool + if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM project_aliases WHERE alias_uri=? OR canonical_uri=?)", record.URI, record.URI).Scan(&shared); err != nil { + return err + } + if shared { + return errors.New("project has equivalent identities; individual relocation requires an explicit group move") + } + return nil +} + +type queryer interface{ QueryRow(string, ...any) *sql.Row } + +const columns = "uri,role,path,owner_uri,project_uri" + +func scan(row *sql.Row) (Record, error) { + var record Record + if err := row.Scan(&record.URI, &record.Role, &record.Path, &record.OwnerURI, &record.ProjectURI); err != nil { + return Record{}, err + } + if !validURI(record.URI) || !validRole(record.Role) { + return Record{}, errors.New("corrupt workspace registry record") + } + return record, nil +} +func byURI(db queryer, uri string) (Record, error) { + return scan(db.QueryRow("SELECT "+columns+` FROM workspaces WHERE uri=? + UNION ALL SELECT a.alias_uri,w.role,w.path,w.owner_uri,a.alias_uri + FROM project_aliases a JOIN workspaces w ON w.uri=a.canonical_uri + WHERE a.alias_uri=? AND w.role=?`, uri, uri, Project)) +} +func byPath(db queryer, role Role, path string) (Record, error) { + return scan(db.QueryRow("SELECT "+columns+" FROM workspaces WHERE role=? AND path=?", role, path)) +} +func insert(tx *sql.Tx, record Record) error { + _, err := tx.Exec("INSERT INTO workspaces("+columns+") VALUES(?,?,?,?,?)", record.URI, record.Role, record.Path, record.OwnerURI, record.ProjectURI) + return err +} +func validatedLinks(tx *sql.Tx, links Links) (Links, error) { + if links.OwnerURI != "" { + if !validURI(links.OwnerURI) { + return Links{}, errors.New("malformed workspace owner identity") + } + owner, err := byURI(tx, links.OwnerURI) + if err != nil { + return Links{}, err + } + if links.ProjectURI == "" { + links.ProjectURI = owner.ProjectURI + } + } + if links.ProjectURI != "" { + if !validURI(links.ProjectURI) { + return Links{}, errors.New("malformed workspace project identity") + } + project, err := byURI(tx, links.ProjectURI) + if err != nil { + return Links{}, err + } + if project.Role != Project { + return Links{}, errors.New("workspace project reference is not a project") + } + } + return links, nil +} +func (r *Registry) transaction(change func(*sql.Tx) error) error { + db, err := r.database(false) + if err != nil { + return err + } + defer db.Close() + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := change(tx); err != nil { + return err + } + return tx.Commit() +} +func (r *Registry) database(create bool) (*sql.DB, error) { + path := filepath.Join(r.root, "registry.sqlite") + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { + return nil, errors.New("workspace registry database is not a regular file") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + uri := sqliteuri.File(path) + uri.RawQuery = "mode=rw&_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=synchronous(FULL)&_pragma=foreign_keys(1)" + if create { + uri.RawQuery = strings.Replace(uri.RawQuery, "mode=rw&", "mode=rwc&", 1) + } + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + return db, nil +} +func validURI(uri string) bool { + const prefix = "codeaf-workspace://" + if !strings.HasPrefix(uri, prefix) { + return false + } + parts := strings.Split(strings.TrimPrefix(uri, prefix), "/") + return len(parts) == 2 && validID(parts[0]) && validID(parts[1]) +} +func validRole(role Role) bool { + switch role { + case Project, Chat, Run, Log, Temp, Cache, ExternalRemote: + return true + } + return false +} + +func validateBinding(role Role, path string) (string, error) { + if role == ExternalRemote { + u, err := url.Parse(path) + if err != nil || u.Scheme == "" || u.Host == "" || u.Scheme == "file" || u.Scheme == "codeaf-workspace" || u.User != nil { + return "", errors.New("external workspace requires an explicit remote URI without credentials") + } + return path, nil + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return "", errors.New("workspace binding must be a clean absolute directory") + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", err + } + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", errors.New("workspace binding is not a directory") + } + return resolved, nil +} + +// A parked workspace may be absent. Existing path components must still match +// their recorded spelling, so reopening cannot silently follow a substituted link. +func validateResolvedBinding(role Role, path string) error { + if role == ExternalRemote { + _, err := validateBinding(role, path) + return err + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("invalid workspace binding path") + } + current := path + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + if resolved != current { + return errors.New("workspace binding traverses a symbolic link") + } + info, err := os.Stat(current) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("workspace binding is not a directory") + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + parent := filepath.Dir(current) + if parent == current { + return err + } + current = parent + } +} + +func randomID() (string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return hex.EncodeToString(bytes[:]), nil +} +func validID(id string) bool { + if len(id) != 32 { + return false + } + for _, c := range id { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} diff --git a/internal/workspaceidentity/registry_benchmark_test.go b/internal/workspaceidentity/registry_benchmark_test.go new file mode 100644 index 0000000000..aa43a07114 --- /dev/null +++ b/internal/workspaceidentity/registry_benchmark_test.go @@ -0,0 +1,67 @@ +package workspaceidentity_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +// Metadata-only admission measurements deliberately exclude file-tree copying, +// terminal rendering, and model latency. Every fixture uses the public registry. +func BenchmarkWorkspaceRegistry(b *testing.B) { + for _, count := range []int{100, 1000} { + b.Run(fmt.Sprint(count), func(b *testing.B) { + root := b.TempDir() + registry, err := workspaceidentity.Open(filepath.Join(root, "registry")) + if err != nil { + b.Fatal(err) + } + var last workspaceidentity.Record + for index := range count { + path := filepath.Join(root, "workspaces", fmt.Sprint(index)) + if err := os.MkdirAll(path, 0700); err != nil { + b.Fatal(err) + } + last, err = registry.Register(workspaceidentity.Run, path) + if err != nil { + b.Fatal(err) + } + } + b.Run("RegisterExisting", func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := registry.Register(workspaceidentity.Run, last.Path); err != nil { + b.Fatal(err) + } + } + }) + b.Run("RegisterNew", func(b *testing.B) { + paths := make([]string, b.N) + for index := range b.N { + paths[index] = filepath.Join(root, "new", fmt.Sprint(index)) + if err := os.MkdirAll(paths[index], 0700); err != nil { + b.Fatal(err) + } + } + b.ResetTimer() + b.ReportAllocs() + for _, path := range paths { + if _, err := registry.Register(workspaceidentity.Run, path); err != nil { + b.Fatal(err) + } + } + }) + b.Run("Resolve", func(b *testing.B) { + b.ReportAllocs() + for range b.N { + if _, err := registry.Resolve(last.URI); err != nil { + b.Fatal(err) + } + } + }) + }) + } +} diff --git a/internal/workspaceidentity/registry_test.go b/internal/workspaceidentity/registry_test.go new file mode 100644 index 0000000000..7bef3854bf --- /dev/null +++ b/internal/workspaceidentity/registry_test.go @@ -0,0 +1,436 @@ +package workspaceidentity_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/workspaceidentity" +) + +func TestIdentitySurvivesRelocationAndRestart(t *testing.T) { + registryPath := filepath.Join(t.TempDir(), "registry") + registry, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + original := t.TempDir() + record, err := registry.Register(workspaceidentity.Run, original) + if err != nil { + t.Fatal(err) + } + moved := original + "-moved" + if err := os.Rename(original, moved); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(moved) }) + if missing, err := registry.Resolve(record.URI); err != nil || missing.Path != original { + t.Fatalf("cold binding lost: %#v %v", missing, err) + } + rebound, err := registry.Bind(record.URI, moved) + if err != nil { + t.Fatal(err) + } + reopened, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + actual, err := reopened.Resolve(record.URI) + if err != nil { + t.Fatal(err) + } + if rebound.URI != record.URI || actual.URI != record.URI || actual.Path != moved || actual.Role != workspaceidentity.Run { + t.Fatalf("identity changed: %#v", actual) + } +} + +func TestForeignAndMalformedIdentitiesCannotResolveOrRebind(t *testing.T) { + first, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + second, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + record, err := first.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, uri := range []string{record.URI, "../../outside", record.URI + "/../other", record.URI + "?path=/", record.URI + "%2f.."} { + if _, err := second.Resolve(uri); err == nil { + t.Fatalf("foreign/malformed identity resolved: %q", uri) + } + if _, err := second.Bind(uri, t.TempDir()); err == nil { + t.Fatalf("foreign/malformed identity rebound: %q", uri) + } + } +} + +func TestLocalBindingDoesNotFollowAReplacedDirectoryLink(t *testing.T) { + registry, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + original := t.TempDir() + record, err := registry.Register(workspaceidentity.Run, original) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(original); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), original); err != nil { + t.Fatal(err) + } + if _, err := registry.Resolve(record.URI); err == nil { + t.Fatal("workspace silently followed replacement link into foreign directory") + } +} + +func TestOwnershipLinksAndRepeatedAdmissionSurviveRestart(t *testing.T) { + registryPath := filepath.Join(t.TempDir(), "registry") + registry, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + project, err := registry.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + chat, err := registry.Register(workspaceidentity.Chat, t.TempDir(), workspaceidentity.Links{OwnerURI: project.URI}) + if err != nil { + t.Fatal(err) + } + runPath := t.TempDir() + run, err := registry.Register(workspaceidentity.Run, runPath, workspaceidentity.Links{OwnerURI: chat.URI}) + if err != nil { + t.Fatal(err) + } + reopened, err := workspaceidentity.Open(registryPath) + if err != nil { + t.Fatal(err) + } + again, err := reopened.Register(workspaceidentity.Run, runPath, workspaceidentity.Links{OwnerURI: chat.URI}) + if err != nil { + t.Fatal(err) + } + if again != run || run.OwnerURI != chat.URI || run.ProjectURI != project.URI { + t.Fatalf("ownership changed: %#v", again) + } + otherChat, err := registry.Register(workspaceidentity.Chat, t.TempDir(), workspaceidentity.Links{OwnerURI: project.URI}) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Register(workspaceidentity.Run, runPath, workspaceidentity.Links{OwnerURI: otherChat.URI}); err == nil { + t.Fatal("another chat silently acquired this run") + } + foreign, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + if _, err := foreign.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI}); err == nil { + t.Fatal("foreign owner accepted") + } +} + +func TestRebindRefusesAnotherWorkspaceBinding(t *testing.T) { + registry, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + first, err := registry.Register(workspaceidentity.Run, t.TempDir()) + if err != nil { + t.Fatal(err) + } + second, err := registry.Register(workspaceidentity.Run, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Bind(first.URI, second.Path); err == nil { + t.Fatal("two identities acquired one run path") + } +} + +func TestRegistryRefusesPublicMetadataDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "public") + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + if _, err := workspaceidentity.Open(path); err == nil { + t.Fatal("private identities accepted a public registry") + } +} + +func TestConcurrentAdmissionProducesOneIdentity(t *testing.T) { + registryPath, runPath := filepath.Join(t.TempDir(), "registry"), t.TempDir() + const count = 12 + records := make(chan workspaceidentity.Record, count) + failures := make(chan error, count) + var workers sync.WaitGroup + for range count { + workers.Add(1) + go func() { + defer workers.Done() + registry, err := workspaceidentity.Open(registryPath) + if err != nil { + failures <- err + return + } + record, err := registry.Register(workspaceidentity.Run, runPath) + if err != nil { + failures <- err + return + } + records <- record + }() + } + workers.Wait() + close(records) + close(failures) + for err := range failures { + t.Error(err) + } + uri := "" + for record := range records { + if uri != "" && uri != record.URI { + t.Fatalf("concurrent admission duplicated identity: %s and %s", uri, record.URI) + } + uri = record.URI + } + if uri == "" { + t.Fatal("no identity issued") + } +} + +func TestRolesSeparateRemoteReferencesFromLocalDirectories(t *testing.T) { + registry, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "registry")) + if err != nil { + t.Fatal(err) + } + for _, role := range []workspaceidentity.Role{workspaceidentity.Project, workspaceidentity.Chat, workspaceidentity.Run, workspaceidentity.Log, workspaceidentity.Temp, workspaceidentity.Cache} { + record, err := registry.Register(role, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if got, err := registry.Resolve(record.URI); err != nil || got.Role != role { + t.Fatalf("role %s: %#v %v", role, got, err) + } + } + remote, err := registry.Register(workspaceidentity.ExternalRemote, "ssh://example.test/project") + if err != nil { + t.Fatal(err) + } + if got, err := registry.Resolve(remote.URI); err != nil || got.Path != "ssh://example.test/project" { + t.Fatalf("remote: %#v %v", got, err) + } + for _, path := range []string{"relative/project", "/a/../b", "file:///tmp/project", "ssh://user:password@example.test/project"} { + if _, err := registry.Register(workspaceidentity.Run, path); err == nil { + t.Fatalf("invalid local binding accepted: %q", path) + } + if _, err := registry.Register(workspaceidentity.ExternalRemote, path); err == nil { + t.Fatalf("invalid remote binding accepted: %q", path) + } + } +} + +func TestExplicitTransferAdoptsForeignIdentityAndKeepsItsLocalBinding(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + receiverPath := filepath.Join(t.TempDir(), "receiver") + receiver, err := workspaceidentity.Open(receiverPath) + if err != nil { + t.Fatal(err) + } + project, err := source.Register(workspaceidentity.Project, t.TempDir()) + if err != nil { + t.Fatal(err) + } + chat, err := source.Register(workspaceidentity.Chat, t.TempDir()) + if err != nil { + t.Fatal(err) + } + run, err := source.Register(workspaceidentity.Run, t.TempDir(), workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Resolve(run.URI); err == nil { + t.Fatal("foreign identity resolved before explicit adoption") + } + if _, err := receiver.Adopt(project, t.TempDir(), workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(chat, t.TempDir(), workspaceidentity.Links{}); err != nil { + t.Fatal(err) + } + local := t.TempDir() + adopted, err := receiver.Adopt(run, local, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}) + if err != nil { + t.Fatal(err) + } + reopened, err := workspaceidentity.Open(receiverPath) + if err != nil { + t.Fatal(err) + } + resolved, err := reopened.Resolve(run.URI) + if err != nil { + t.Fatal(err) + } + if adopted.URI != run.URI || resolved.Path != local || resolved.OwnerURI != chat.URI || resolved.ProjectURI != project.URI { + t.Fatalf("transfer lost identity: %#v", resolved) + } + again, err := reopened.Register(workspaceidentity.Run, local, workspaceidentity.Links{OwnerURI: chat.URI, ProjectURI: project.URI}) + if err != nil || again.URI != run.URI { + t.Fatalf("admission forgot imported identity: %#v %v", again, err) + } +} + +func TestTransferRefusesMalformedIdentityAndOwnershipOrPathReplacement(t *testing.T) { + source, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "source")) + if err != nil { + t.Fatal(err) + } + receiver, err := workspaceidentity.Open(filepath.Join(t.TempDir(), "receiver")) + if err != nil { + t.Fatal(err) + } + incoming, err := source.Register(workspaceidentity.Run, t.TempDir()) + if err != nil { + t.Fatal(err) + } + local := t.TempDir() + adopted, err := receiver.Adopt(incoming, local, workspaceidentity.Links{}) + if err != nil { + t.Fatal(err) + } + for _, uri := range []string{"../../outside", incoming.URI + "/../other", incoming.URI + "?path=/", strings.ToUpper(incoming.URI)} { + if _, err := receiver.Adopt(workspaceidentity.Record{URI: uri, Role: workspaceidentity.Run}, t.TempDir(), workspaceidentity.Links{}); err == nil { + t.Fatalf("malformed identity imported: %q", uri) + } + } + changed := incoming + changed.Role = workspaceidentity.Cache + if _, err := receiver.Adopt(changed, local, workspaceidentity.Links{}); err == nil { + t.Fatal("transfer changed an existing role") + } + collision, err := receiver.Register(workspaceidentity.Run, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := receiver.Adopt(incoming, collision.Path, workspaceidentity.Links{}); err == nil { + t.Fatal("transfer took another run's local path") + } + if got, err := receiver.Resolve(adopted.URI); err != nil || got.Path != local || got.Role != workspaceidentity.Run { + t.Fatalf("refused transfer changed admitted binding: %#v %v", got, err) + } +} + +func TestLegacyRegistryMigrationPreservesAllBindingsAndNamespace(t *testing.T) { + root := filepath.Join(t.TempDir(), "registry") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + scope := "0123456789abcdef0123456789abcdef" + id := "abcdef0123456789abcdef0123456789" + uri := "codeaf-workspace://" + scope + "/" + id + path := t.TempDir() + projectID, chatID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + projectURI, chatURI := "codeaf-workspace://"+scope+"/"+projectID, "codeaf-workspace://"+scope+"/"+chatID + for _, record := range []workspaceidentity.Record{ + {URI: projectURI, Role: workspaceidentity.Project, Path: t.TempDir(), ProjectURI: projectURI}, + {URI: chatURI, Role: workspaceidentity.Chat, Path: t.TempDir()}, + } { + raw, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, filepath.Base(record.URI)+".json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + legacyRaw, err := json.Marshal(workspaceidentity.Record{URI: uri, Role: workspaceidentity.Run, Path: path, OwnerURI: chatURI, ProjectURI: projectURI}) + if err != nil { + t.Fatal(err) + } + legacy := string(legacyRaw) + if err := os.WriteFile(filepath.Join(root, "namespace"), []byte(scope), 0600); err != nil { + t.Fatal(err) + } + legacyFile := filepath.Join(root, id+".json") + if err := os.WriteFile(legacyFile, []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + registry, err := workspaceidentity.Open(root) + if err != nil { + t.Fatal(err) + } + first, err := registry.Resolve(uri) + if err != nil || first.Path != path || first.OwnerURI != chatURI || first.ProjectURI != projectURI { + t.Fatalf("migration lost workspace: %#v %v", first, err) + } + registered, err := registry.Register(workspaceidentity.Run, path, workspaceidentity.Links{OwnerURI: chatURI, ProjectURI: projectURI}) + if err != nil || registered.URI != uri { + t.Fatalf("migration duplicated identity: %#v %v", registered, err) + } + // Old receipts are retained evidence, not read authorities after successful migration. + if err := os.WriteFile(legacyFile, []byte("corrupted obsolete receipt"), 0600); err != nil { + t.Fatal(err) + } + reopened, err := workspaceidentity.Open(root) + if err != nil { + t.Fatal(err) + } + if got, err := reopened.Resolve(uri); err != nil || got != first { + t.Fatalf("old receipt corrupted committed migration: %#v %v", got, err) + } + next, err := reopened.Register(workspaceidentity.Cache, t.TempDir()) + if err != nil || !strings.HasPrefix(next.URI, "codeaf-workspace://"+scope+"/") { + t.Fatalf("namespace changed: %#v %v", next, err) + } +} + +func TestFailedLegacyMigrationDoesNotResetExistingIdentities(t *testing.T) { + root := filepath.Join(t.TempDir(), "registry") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + scope := "0123456789abcdef0123456789abcdef" + id := "abcdef0123456789abcdef0123456789" + if err := os.WriteFile(filepath.Join(root, "namespace"), []byte(scope), 0600); err != nil { + t.Fatal(err) + } + path := t.TempDir() + legacy := `{"uri":"codeaf-workspace://` + scope + `/` + id + `","role":"run","path":"` + path + `"}` + recordFile := filepath.Join(root, id+".json") + if err := os.WriteFile(recordFile, []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + broken := filepath.Join(root, "ffffffffffffffffffffffffffffffff.json") + if err := os.WriteFile(broken, []byte("broken legacy record"), 0600); err != nil { + t.Fatal(err) + } + if _, err := workspaceidentity.Open(root); err == nil { + t.Fatal("corrupt legacy registry silently reset") + } + if raw, err := os.ReadFile(recordFile); err != nil || string(raw) != legacy { + t.Fatalf("failed migration modified source: %q %v", raw, err) + } + if raw, err := os.ReadFile(filepath.Join(root, "namespace")); err != nil || string(raw) != scope { + t.Fatalf("failed migration replaced namespace: %q %v", raw, err) + } + if err := os.Remove(broken); err != nil { + t.Fatal(err) + } + repaired, err := workspaceidentity.Open(root) + if err != nil { + t.Fatal(err) + } + if record, err := repaired.Resolve("codeaf-workspace://" + scope + "/" + id); err != nil || record.Path != path { + t.Fatalf("retry did not import original identity: %#v %v", record, err) + } +} diff --git a/relay/hosted/src/directory.js b/relay/hosted/src/directory.js index 4e8b381896..f8776063d9 100644 --- a/relay/hosted/src/directory.js +++ b/relay/hosted/src/directory.js @@ -189,7 +189,7 @@ export class Directory { const rec = kind === 'cells' ? this.#lift(id, JSON.parse(doc)) : JSON.parse(doc); // The list is the home screen's hot read: frame plans are for takers, and they // read them off the cell answer, so the list never carries them. - if (kind === 'cells') delete rec.frames; + if (kind === 'cells') { delete rec.frames; delete rec.frame_hints; } out[kind][id] = rec; } } diff --git a/relay/hosted/src/rules.js b/relay/hosted/src/rules.js index d313f8baea..692379ae22 100644 --- a/relay/hosted/src/rules.js +++ b/relay/hosted/src/rules.js @@ -46,9 +46,9 @@ export function makeRules({ ttlMs, renewOnPublish }) { const expires = renewOnPublish ? now + ttlMs : c.lease.expires; // The frame plan describes the head it was set with, so every publish replaces it // wholesale: a publish that says nothing is a true statement, a stale one is not kept. - const { frames, ...kept } = c; + const { frames, frame_hints, ...kept } = c; const next = { ...kept, head: p.head, size: p.size, class: p.class, durable_at: now, lease: { ...c.lease, expires, pending: p.pending } }; - return omitEmpty(next, { title, frames: p.frames }); + return omitEmpty(next, { title, frames: p.frames, frame_hints: boundedFrameHints(p.frame_hints) }); }; const releaseOf = (c, device, fence, now) => { @@ -59,7 +59,7 @@ export function makeRules({ ttlMs, renewOnPublish }) { const created = (init, device, now) => { const c = { V: 1, head: init.head, durable_at: now, class: init.class, size: init.size, keys: init.keys, lease: { device, fence: 1, expires: now + ttlMs, pending: 0 } }; - return omitEmpty(c, { parent_cell: init.parent_cell, title: init.title, orphan_turns: init.orphan_turns, frames: init.frames }); + return omitEmpty(c, { parent_cell: init.parent_cell, title: init.title, orphan_turns: init.orphan_turns, frames: init.frames, frame_hints: boundedFrameHints(init.frame_hints) }); }; return { acquire, heartbeat, publishTo, releaseOf, created }; @@ -69,3 +69,14 @@ const omitEmpty = (c, optional) => Object.assign(c, Object.fromEntries(Object.entries(optional).filter(([, v]) => v !== undefined && v !== '' && v !== 0 && !(Array.isArray(v) && !v.length)))); export const { acquire, heartbeat, publishTo, releaseOf, created } = makeRules(AMENDED); + + +function boundedFrameHints(hints) { + if (!Array.isArray(hints) || hints.length===0 || hints.length>2) return undefined; + let remaining=64*1024;const seen=new Set();const out=[]; + for (const h of hints) { + if (!h || typeof h.id!=='string' || !/^[0-9a-f]{64}$/.test(h.id) || !Number.isSafeInteger(h.bytes) || h.bytes<=0 || h.bytes>=remaining || seen.has(h.id)) return undefined; + remaining-=h.bytes+1;seen.add(h.id);out.push({id:h.id,bytes:h.bytes}); + } + return out; +} diff --git a/relay/hosted/test/directory.test.js b/relay/hosted/test/directory.test.js index b042c19372..625015e71b 100644 --- a/relay/hosted/test/directory.test.js +++ b/relay/hosted/test/directory.test.js @@ -144,3 +144,20 @@ test('C31 released time records a quit but background preparation stays quiet', dir.release(id,'dev_a',acquired.cell.lease.fence,true); assert.equal(dir.cell(id).cell.lease.released_at,0); }); + + + test('latest frame hints clear on old-client publish and never enter lists', () => { + const {dir}=open(); + const frame_hints=[{id:'a'.repeat(64),bytes:42}]; + const {cell}=dir.create('hints',{...init(),frame_hints},'dev_a'); + assert.deepEqual(cell.frame_hints,frame_hints); + assert.equal(dir.list().cells.hints.frame_hints,undefined); + const next=dir.publish('hints','dev_a',{fence:1,old_head:cell.head,head:'new',size:1,class:'work',pending:0}); + assert.equal(next.cell.frame_hints,undefined); + }); + +test('frame hints reject duplicate, unsafe integer and over-budget metadata', () => { + for(const hints of [[{id:'a'.repeat(64),bytes:65536}],[{id:'a'.repeat(64),bytes:Number.MAX_SAFE_INTEGER+1}],[{id:'a'.repeat(64),bytes:1},{id:'a'.repeat(64),bytes:1}]]) { + const {dir}=open();const {cell}=dir.create('bad-hints',{...init(),frame_hints:hints},'dev_a');assert.equal(cell.frame_hints,undefined); + } +}); From 8f055d52d8d028d6c61077082ccf621e6c036216 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 18:35:08 -0400 Subject: [PATCH 04/35] fix: register workspace runtime and prototype environment plumbing --- internal/config/settings.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/config/settings.go b/internal/config/settings.go index 6f8fb4914a..c6a35eab71 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1039,6 +1039,15 @@ var OperatorEnvPins = []string{ // for the plainest reason there is: it decides which store the sheet // itself is being read out of. "CODEAF_HOME", + // The harness supplies the admitted working-root identity to each execution. + // This runtime association is not an editable placement preference. + "CODEAF_WORKSPACE_URI", + // These switches select unfinished ownership and archive lifecycles in an + // explicit prototype profile; they are not persisted settings. The archive + // executable path is operator plumbing, like CODEAF_FURROW below. + "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", + "CODEAF_PROTOTYPE_TASK_STORAGE", + "CODEAF_PROTOTYPE_ARCHIVE_BINARY", // CODEAF_RELAY points a headless peer at the relay this host pairs through // (internal/pair). It is an address, so it is plumbing for the same reason // CODEAF_BASE_URL is. From 5856fea255482d18b8f8da6f426640d43b58242d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 18:48:47 -0400 Subject: [PATCH 05/35] feat: review and apply task snapshots without losing private copies --- docs/architecture/workspaces-storage-relay.md | 29 + .../1944-workspace-relay-architecture.md | 4 + internal/manual/chat/task-controls.md | 21 + internal/remote/callclass.go | 4 +- internal/remote/server.go | 20 +- internal/remote/taskworkspace.go | 137 +++++ internal/remote/taskworkspace_test.go | 115 ++++ internal/remote/wire.go | 5 +- internal/remote/wire_task.go | 3 + internal/session/task_workspace_apply.go | 524 ++++++++++++++++++ internal/session/task_workspace_apply_test.go | 291 ++++++++++ internal/tui3/task_workspace_apply.go | 70 +++ internal/tui3/taskcommand.go | 3 + 13 files changed, 1216 insertions(+), 10 deletions(-) create mode 100644 internal/remote/taskworkspace.go create mode 100644 internal/remote/taskworkspace_test.go create mode 100644 internal/session/task_workspace_apply.go create mode 100644 internal/session/task_workspace_apply_test.go create mode 100644 internal/tui3/task_workspace_apply.go diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index fe262ca062..27d405afe3 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -19,6 +19,7 @@ profile/ workspace-identities/ local opaque-URI bindings and ownership workspace-runtime// owned temporary storage workspace-caches// explicitly shared project cache + workspace-apply// local reviewed snapshots and operation receipts chat/ .cell/ portable chat truth and task carriers trees// @@ -71,6 +72,34 @@ local authority and are not copied between devices as a path database. Result application must be explicit for each writable root, with conflicts retained; receiving a root does not authorize applying changes to it. +## Review and apply one root + +Use `/task review ` after the task and its cooperating +writers/checkers finish. The terminal shows the private copy, receiver-local +application destination, exact snapshot identity, and diff. Large previews show +a command for inspecting the complete immutable diff. Review does not change +the original project or the retained private working copy. + +`/task apply ` applies that reviewed snapshot only. +Changes arriving in the private copy after review are retained there and do not +enter this application. Every writable root has a separate operation; read-only +roots cannot be applied. Conflicts preserve both versions. Successful application +records its outcome and prevents a duplicate apply. The original private copy +and its URI remain available after restart. + +Reviewed staging and its receipt are local profile assets, separate from the +portable conversation. A replacement review retires only its verified previous +staging after the new receipt is durable. Invalid ownership, destination, +baseline, or snapshot bindings refuse before application. Uncertain writers still +prevent destructive retirement; they do not authorize deleting the retained +original during a snapshot application. + +The terminal enters these operations through the resident engine, with the +conversation's driver/session binding and cancellable operation deadline. An +older engine without the capability asks for an engine update and reconnect. A +disconnect after application begins can leave an unknown outcome: inspect the +recorded result before retrying. + ## Relay: fetch, map roots, acquire, restore Furrow owns byte capture, cryptographic verification and materialization. diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 9b5afb8872..1ccd8128ed 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -12,6 +12,10 @@ versioned directory metadata, restart receipts and independent fork history. Ask for receiver-local folders before acquiring the source lease; cancellation and invalid mappings preserve the existing holder and allow a valid retry. +Review and apply each writable root explicitly through the terminal. Application +uses the exact reviewed snapshot and retains the original private copy; edits +after review do not land. Conflicts preserve both versions. + Local archive recovery requires the separately reviewed native dependency. Uncertain writers and multi-root reclamation remain excluded. Existing conversations require compatibility safeguards; this change does not silently diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index f71be2c054..2918c8a572 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -226,3 +226,24 @@ this recovered task has no final check receipt and asks you to inspect and accep refute it. A missing copy or changed branch is refused with its location; nothing is recreated in its place. If saving a receipt fails after a merge, the error reports the actual commit and which record could not be saved. Inspect that outcome before retrying. + +## Review and apply an owned task root + +Completed tasks keep their output in their private working copies. Starting a +copy, sharing it or mapping a received copy to a local folder does not give it +permission to write the original folder. + +Use `/task review ` to capture an immutable local snapshot +and read its changes, private copy and locally mapped apply destination. For an ordinary one-root task, +the slot is `work`; a multi-root task uses its declared slots. + +After reviewing, `/task apply ` applies only that root +through the existing conflict checks. The apply uses exactly the reviewed snapshot; +edits made in the original private copy after review stay there. Review again to +select a newer snapshot. Read roots cannot be applied. A known live run, checker +or cooperator blocks apply until it is idle. +Conflicts retain the original and the private result for review. The original +private copy and its identity remain available after a successful apply too. +Uncertain writers continue to prevent destructive retirement of that copy. Applying one root +does not authorize another root or give later tasks automatic landing permission. +A received root stays private until this explicit local action. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 58f1560c49..1cb6845bc5 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -167,7 +167,7 @@ func classify(method string) callClass { MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodClaimChatStart, MethodQuestionResolve, MethodQuestionHold, + case MethodTaskWorkspaceCancel, MethodClaimChatStart, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, @@ -185,7 +185,7 @@ func classify(method string) callClass { MethodAutomationsDelete, MethodAutomationsRunNow, MethodAutomationsStopRun, MethodAutomationsClaim: return classAct - case MethodCompact, MethodOpenPlanTaskWork: + case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodCompact, MethodOpenPlanTaskWork: return classWork default: return classOrdered diff --git a/internal/remote/server.go b/internal/remote/server.go index 1cde26d233..b9ebf93729 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1255,9 +1255,10 @@ func (sess *Session) welcomeLocked(s *server) Welcome { TeamAsk: teamAskKnown(sess.agent), // This revision checks it in the handler, for every engine behind it // ([Session.agentOf]), so the answer is about the wire and not the agent. - SteerOwner: true, - TaskSetup: taskSetupKnown(sess.agent), - TaskRetry: taskRetryKnown(sess.agent), + SteerOwner: true, + TaskSetup: taskSetupKnown(sess.agent), + TaskRetry: taskRetryKnown(sess.agent), + TaskWorkspace: taskWorkspaceKnown(sess.agent), // Whether this engine has a dial on the conversation's own thinking, // asked of the agent it has open — for [Welcome.Effort]'s stated reason: // neither a type assertion at the far end nor the rung itself can tell an @@ -1701,8 +1702,9 @@ type server struct { // running does not write into a session that has already been closed. side sync.WaitGroup // Observers leave with the view, independently of the durable turn pump. - observersMu sync.Mutex - observers map[uint64]*taskFeed + workspaceCalls sync.Map + observersMu sync.Mutex + observers map[uint64]*taskFeed // leaving records how this connection ends, and it is the whole of version // 2's three-roads-out. detached is [MethodDetach] — the surface is going and @@ -2084,13 +2086,19 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // in the room: a watcher is a person watching their own work, not a guest. switch call.Method { case MethodSubmitBash, MethodSubmitSetup, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry: + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodTaskWorkspaceReview, MethodTaskWorkspaceApply: if err := s.mayDrive(); err != nil { return nil, err } } switch call.Method { + case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodTaskWorkspaceCancel: + args, err := arg[taskWorkspaceArgs](call) + if err != nil { + return nil, err + } + return s.taskWorkspaceInvoke(call.Method, args) case MethodTaskRetry: args, err := arg[TaskSetupArgs](call) if err != nil { diff --git a/internal/remote/taskworkspace.go b/internal/remote/taskworkspace.go new file mode 100644 index 0000000000..b1d250ef93 --- /dev/null +++ b/internal/remote/taskworkspace.go @@ -0,0 +1,137 @@ +package remote + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type taskWorkspaceDoor interface { + ReviewTaskWorkspaceRoot(context.Context, uint64, string) (session.TaskWorkspaceReview, error) + ApplyTaskWorkspaceRoot(context.Context, uint64, string) (string, error) +} +type taskWorkspaceArgs struct { + ID uint64 + Slot, Session, Token string + Deadline time.Time +} +type taskWorkspaceResult struct { + Review session.TaskWorkspaceReview + Receipt string +} +type taskWorkspacePending struct { + ctx context.Context + cancel context.CancelFunc +} + +func taskWorkspaceKnown(agent any) bool { _, ok := agent.(taskWorkspaceDoor); return ok } + +func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (session.TaskWorkspaceReview, error) { + result, err := a.taskWorkspaceCall(ctx, MethodTaskWorkspaceReview, id, slot) + return result.Review, err +} +func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + result, err := a.taskWorkspaceCall(ctx, MethodTaskWorkspaceApply, id, slot) + return result.Receipt, err +} +func (a *Agent) taskWorkspaceCall(ctx context.Context, method string, id uint64, slot string) (taskWorkspaceResult, error) { + var result taskWorkspaceResult + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return result, err + } + welcome := a.c.Welcome() + if !welcome.TaskWorkspace { + return result, errors.New("reviewing and applying task roots needs a newer engine; update the engine and reconnect") + } + deadline := time.Now().Add(callDeadline) + if bounded, ok := ctx.Deadline(); ok && bounded.Before(deadline) { + deadline = bounded + } + args := taskWorkspaceArgs{ID: id, Slot: slot, Session: welcome.SessionFile, Token: rand.Text(), Deadline: deadline} + payload, answered, err := a.c.callAnswered(ctx, method, args, callDeadline) + if !answered { + // Cancellation travels independently of the operation and its ordered UI + // gesture. Once atomic landing is admitted, its actual receipt still wins. + go func() { + cancelCtx, cancel := context.WithTimeout(context.Background(), callDeadline) + defer cancel() + _, _ = a.c.call(cancelCtx, MethodTaskWorkspaceCancel, taskWorkspaceArgs{Token: args.Token}) + }() + if err != nil { + return result, fmt.Errorf("task root outcome was not confirmed; inspect it before retrying: %w", err) + } + } + if err != nil { + return result, err + } + err = json.Unmarshal(payload, &result) + return result, err +} + +// A cancel can reach the independent lane before its operation goroutine. A +// short-lived cancelled entry remembers that order; entries always expire. +func (s *server) taskWorkspacePending(args taskWorkspaceArgs) *taskWorkspacePending { + deadline := args.Deadline + limit := time.Now().Add(callDeadline) + if deadline.IsZero() || deadline.After(limit) { + deadline = limit + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + pending := &taskWorkspacePending{ctx: ctx, cancel: cancel} + actual, loaded := s.workspaceCalls.LoadOrStore(args.Token, pending) + if loaded { + cancel() + return actual.(*taskWorkspacePending) + } + time.AfterFunc(callDeadline, func() { s.workspaceCalls.CompareAndDelete(args.Token, pending); cancel() }) + return pending +} +func (s *server) taskWorkspaceInvoke(method string, args taskWorkspaceArgs) ([]byte, error) { + if args.Token == "" || len(args.Token) > 128 { + return nil, errors.New("invalid task workspace request token") + } + pending := s.taskWorkspacePending(args) + if method == MethodTaskWorkspaceCancel { + pending.cancel() + return nil, nil + } + defer func() { s.workspaceCalls.CompareAndDelete(args.Token, pending); pending.cancel() }() + if err := pending.ctx.Err(); err != nil { + return nil, err + } + want, agreed := steerConversation(s.joined, args.Session) + if !agreed || want == "" { + return nil, session.ErrNotThatConversation + } + owner, mine := s.session.agentOf(want) + if !mine { + return nil, session.ErrNotThatConversation + } + door, ok := owner.(taskWorkspaceDoor) + if !ok { + return nil, errors.New("this engine cannot review or apply owned task roots") + } + if args.ID == 0 || strings.TrimSpace(args.Slot) == "" { + return nil, errors.New("a task number and root slot are required") + } + var result taskWorkspaceResult + var err error + if method == MethodTaskWorkspaceApply { + result.Receipt, err = door.ApplyTaskWorkspaceRoot(pending.ctx, args.ID, args.Slot) + } else { + result.Review, err = door.ReviewTaskWorkspaceRoot(pending.ctx, args.ID, args.Slot) + } + if err != nil { + return nil, err + } + return json.Marshal(result) +} diff --git a/internal/remote/taskworkspace_test.go b/internal/remote/taskworkspace_test.go new file mode 100644 index 0000000000..7648eeea9f --- /dev/null +++ b/internal/remote/taskworkspace_test.go @@ -0,0 +1,115 @@ +package remote + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type workspaceFar struct { + *fakeAgent + mu sync.Mutex + id uint64 + slot string + refuse error + entered, cancelled chan struct{} +} + +func (f *workspaceFar) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (session.TaskWorkspaceReview, error) { + f.mu.Lock() + f.id, f.slot = id, slot + err := f.refuse + f.mu.Unlock() + return session.TaskWorkspaceReview{Slot: slot, Copy: "/private/task", Destination: "/project", Snapshot: "reviewed", Changed: []string{"result.txt"}, Diff: "+actual result"}, err +} +func (f *workspaceFar) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + f.mu.Lock() + f.id, f.slot = id, slot + err := f.refuse + f.mu.Unlock() + if f.entered != nil { + close(f.entered) + <-ctx.Done() + close(f.cancelled) + return "", ctx.Err() + } + return "selected snapshot applied", err +} +func workspaceLoop(t *testing.T, far WrappedAgent, watch bool) *Loop { + t.Helper() + loop, err := Loopback(Hello{Version: Version, Watch: watch}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far, SessionFile: "/srv/owned-task.jsonl"}, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + return loop +} +func TestTaskWorkspaceReviewAndApplyCrossResidentConnection(t *testing.T) { + far := &workspaceFar{fakeAgent: &fakeAgent{}} + loop := workspaceLoop(t, far, false) + agent := loop.Client.Agent() + review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), 7, "library") + if err != nil { + t.Fatal(err) + } + if review.Slot != "library" || review.Snapshot != "reviewed" || review.Diff != "+actual result" || review.Destination != "/project" { + t.Fatalf("review changed across wire: %+v", review) + } + receipt, err := agent.ApplyTaskWorkspaceRoot(context.Background(), 7, "library") + if err != nil || receipt != "selected snapshot applied" { + t.Fatalf("apply receipt %q %v", receipt, err) + } + far.mu.Lock() + id, slot := far.id, far.slot + far.refuse = errors.New("original changed; private result retained") + far.mu.Unlock() + if id != 7 || slot != "library" { + t.Fatalf("wrong root reached engine: %d %s", id, slot) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), 7, "library"); err == nil || !strings.Contains(err.Error(), "private result retained") { + t.Fatalf("refusal lost: %v", err) + } +} +func TestTaskWorkspaceCancellationReachesResidentOperation(t *testing.T) { + far := &workspaceFar{fakeAgent: &fakeAgent{}, entered: make(chan struct{}), cancelled: make(chan struct{})} + loop := workspaceLoop(t, far, false) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ended := make(chan error, 1) + go func() { _, err := loop.Client.Agent().ApplyTaskWorkspaceRoot(ctx, 7, "work"); ended <- err }() + select { + case <-far.entered: + case <-time.After(time.Second): + t.Fatal("apply did not reach resident engine") + } + cancel() + select { + case err := <-ended: + if !errors.Is(err, context.Canceled) { + t.Fatalf("caller cancellation lost: %v", err) + } + case <-time.After(time.Second): + t.Fatal("caller did not return after cancellation") + } + select { + case <-far.cancelled: + case <-time.After(time.Second): + t.Fatal("resident operation continued after caller cancellation") + } +} +func TestTaskWorkspaceUnsupportedAndWatchingEnginesRefuse(t *testing.T) { + loop := workspaceLoop(t, &fakeAgent{}, false) + if _, err := loop.Client.Agent().ReviewTaskWorkspaceRoot(context.Background(), 7, "work"); err == nil { + t.Fatal("unsupported engine accepted review") + } + far := &workspaceFar{fakeAgent: &fakeAgent{}} + watch := workspaceLoop(t, far, true) + if _, err := watch.Client.Agent().ApplyTaskWorkspaceRoot(context.Background(), 7, "work"); err == nil { + t.Fatal("watch-only connection applied work") + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 9e610dbd26..531d91efeb 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1183,8 +1183,9 @@ type Welcome struct { SteerOwner bool `json:"steerOwner,omitempty"` // TaskSetup advertises task-scoped model and thinking controls. // TaskRetry advertises retrying incomplete tasks in place. - TaskRetry bool `json:"taskRetry,omitempty"` - TaskSetup bool `json:"taskSetup,omitempty"` + TaskRetry bool `json:"taskRetry,omitempty"` + TaskWorkspace bool `json:"taskWorkspace,omitempty"` + TaskSetup bool `json:"taskSetup,omitempty"` // TaskSettle says this engine can be ASKED TO DECIDE A LANDING — accept, not // right, one more merge round, the hand-over and the take-back // ([MethodTaskSettle]). diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go index 002b43f912..160d6897b8 100644 --- a/internal/remote/wire_task.go +++ b/internal/remote/wire_task.go @@ -6,6 +6,9 @@ import "time" // machine. The surface sends intent; sizing, shaping, admission and spending // remain with the session agent that owns the conversation. const ( + MethodTaskWorkspaceReview = "Task.Workspace.Review" + MethodTaskWorkspaceApply = "Task.Workspace.Apply" + MethodTaskWorkspaceCancel = "Task.Workspace.Cancel" // MethodDelegateList and MethodDelegateStart are the program door // (internal/session's delegate_door.go): the programs the ENGINE machine's // build carries, and handing a brief to one. They belong to the engine side diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go new file mode 100644 index 0000000000..3206ef73bd --- /dev/null +++ b/internal/session/task_workspace_apply.go @@ -0,0 +1,524 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/home" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// TaskWorkspaceReview describes exactly one local copy and the destination +// resolved from its ownership bindings. Reading it grants no landing permission. +type TaskWorkspaceReview struct { + Slot, Copy, Destination, Diff string + Snapshot string + DiffTruncated bool + Changed []string +} + +// ReviewTaskWorkspaceRoot reads actual committed and uncommitted changes. This +// person-facing door is intentionally absent from model tools. +func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (TaskWorkspaceReview, error) { + if err := ctx.Err(); err != nil { + return TaskWorkspaceReview{}, err + } + a.lockBeltStart() + defer a.beltStartMu.Unlock() + g, kept, use, tree, err := a.taskWorkspaceRoot(id, slot) + if err != nil { + return TaskWorkspaceReview{}, err + } + if use.Outcome == mergeMerged || use.Outcome == mergeInPlace { + return TaskWorkspaceReview{}, errors.New("this root was already applied; its original private copy is still available for inspection") + } + if err := a.taskWorkspaceApplyIdle(g, kept, tree); err != nil { + return TaskWorkspaceReview{}, err + } + prior, _, priorErr := loadTaskWorkspaceReview(id, use, tree) + stage, ticket, err := stageTaskWorkspaceReview(ctx, id, use, tree) + if err != nil { + return TaskWorkspaceReview{}, err + } + published := false + defer func() { + if !published { + cleanupTaskWorkspaceReview(ticket) + } + }() + review, err := reviewTaskWorkspaceRoot(ctx, use, stage) + if err != nil { + return review, err + } + review.Copy, review.Snapshot = tree.dir, ticket.Snapshot + if review.DiffTruncated { + review.Diff += "\nComplete reviewed diff: git -C " + quoteShWord(tree.dir) + " diff --no-ext-diff --no-textconv " + quoteShWord(tree.checkBase) + " " + quoteShWord(ticket.Snapshot) + " --\n" + } + raw, err := json.Marshal(ticket) + if err != nil { + return review, err + } + path := taskWorkspaceReviewPath(id, use) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return review, err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".review-") + if err != nil { + return review, err + } + defer os.Remove(temporary.Name()) + if _, err = temporary.Write(raw); err == nil { + err = temporary.Sync() + } + closeErr := temporary.Close() + if err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(temporary.Name(), path) + } + if err == nil { + var directory *os.File + directory, err = os.Open(filepath.Dir(path)) + if err == nil { + err = directory.Sync() + _ = directory.Close() + } + } + if err != nil { + return review, err + } + published = true + if priorErr == nil { + cleanupTaskWorkspaceReview(prior) + } + return review, nil +} + +func (a *Agent) taskWorkspaceRoot(id uint64, slot string) (*TaskGraph, TaskNotice, WorkspaceRootUse, taskTree, error) { + fail := func(err error) (*TaskGraph, TaskNotice, WorkspaceRootUse, taskTree, error) { + return nil, TaskNotice{}, WorkspaceRootUse{}, taskTree{}, err + } + if a.config.InTask || a.config.taskID != 0 { + return fail(errors.New("workspace apply belongs to the conversation's person")) + } + g := a.tasker() + if g == nil { + return fail(errors.New("this conversation has no saved task work")) + } + kept, ok := runRowOf(g, id) + if !ok || kept.State != TaskDone || kept.WorkspaceSet == nil { + return fail(errors.New("select a completed owned task and one of its root slots")) + } + for _, use := range kept.WorkspaceSet.Roots { + if use.Slot != slot { + continue + } + if use.Copy == nil || use.Copy.URI == "" || use.Copy.ChatURI == "" { + return fail(errors.New("this root has no owned working copy")) + } + tree, err := runCopyTree(use.Copy, a.config.Place) + if err != nil { + return fail(err) + } + if use.Copy.Branch != "" && currentBranch(tree.dir) != use.Copy.Branch { + return fail(errors.New("the saved working copy now stands on another branch")) + } + if tree.checkBase == "" { + return fail(errors.New("the saved working copy has no review baseline")) + } + destination := tree.ground + if tree.mode != TaskModeMirror { + destination = tree.root + } + if destination == "" || canonicalPath(destination) == canonicalPath(tree.dir) { + return fail(errors.New("this root has no separate locally mapped destination")) + } + if err := workspacePathWithoutSymlinks(destination); err != nil { + return fail(err) + } + return g, kept, use, tree, nil + } + return fail(fmt.Errorf("task %d has no root slot %q", id, slot)) +} + +func reviewTaskWorkspaceRoot(ctx context.Context, use WorkspaceRootUse, tree taskTree) (TaskWorkspaceReview, error) { + review := TaskWorkspaceReview{Slot: use.Slot, Copy: tree.dir, Destination: tree.ground} + if tree.mode != TaskModeMirror { + review.Destination = tree.root + } + changed, err := gitContext(ctx, tree.dir, nil, "diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", tree.checkBase, "--") + if err != nil { + return review, fmt.Errorf("read root changes: %w", err) + } + review.Changed = gitNULPaths(changed) + diff, err := gitContext(ctx, tree.dir, nil, "diff", "--no-ext-diff", "--no-textconv", tree.checkBase, "--") + if err != nil { + return review, fmt.Errorf("read root diff: %w", err) + } + review.Diff = diff + untracked, err := gitContext(ctx, tree.dir, nil, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return review, err + } + for _, path := range gitNULPaths(untracked) { + if harnessWrote(path) { + continue + } + if err := ctx.Err(); err != nil { + return review, err + } + //codeaf:plumbing git compares one local untracked path without external diff helpers. + cmd := exec.CommandContext(ctx, "git", "diff", "--no-ext-diff", "--no-textconv", "--no-index", "--", "/dev/null", path) + cmd.Dir = tree.dir + out, err := cmd.Output() + if err != nil { + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 { + return review, fmt.Errorf("read untracked root change: %w", err) + } + } + review.Diff += string(out) + review.Changed = append(review.Changed, path) + } + const previewLimit = 256 * 1024 + if len(review.Diff) > previewLimit { + review.Diff = review.Diff[:previewLimit] + "\n[Diff preview truncated; use the complete reviewed diff command below.]\n" + review.DiffTruncated = true + } + return review, nil +} + +// ApplyTaskWorkspaceRoot grants permission only for this selected operation. +// It never records Landing, starts a worker, or broadens another root's access. +func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + epoch, err := a.beltAdmissionEpoch() + if err != nil { + return "", err + } + a.lockBeltStart() + defer a.beltStartMu.Unlock() + g, kept, use, tree, err := a.taskWorkspaceRoot(id, slot) + if err != nil { + return "", err + } + if use.Access != WorkspaceWrite { + return "", errors.New("a read root cannot be applied") + } + if use.Outcome == mergeMerged || use.Outcome == mergeInPlace { + return "", errors.New("this root was already applied; no work was repeated") + } + if err := a.taskWorkspaceApplyIdle(g, kept, tree); err != nil { + return "", err + } + ticket, stage, err := loadTaskWorkspaceReview(id, use, tree) + if err != nil { + return "", err + } + review, err := reviewTaskWorkspaceRoot(ctx, use, stage) + if err != nil { + return "", err + } + if len(review.Changed) == 0 { + return "", errors.New("this root has no changes to apply") + } + if err := a.admitCompletedRunHomecoming(ctx, epoch); err != nil { + return "", err + } + // The existing landing checks changed originals and preserves both versions on + // conflict. This local tree already resolves the receiver's chosen binding. + outcome, detail, _, _ := stage.comeHome(kept.Title, review.Changed, a.signsGitWork()) + kept.WorkspaceSet = cloneWorkspaceSet(kept.WorkspaceSet) + for i := range kept.WorkspaceSet.Roots { + if kept.WorkspaceSet.Roots[i].Slot == slot { + kept.WorkspaceSet.Roots[i].Outcome = outcome + } + } + receipt := fmt.Sprintf("task %d root %s: %s (reviewed snapshot %s)", id, slot, outcome, ticket.Snapshot) + if strings.TrimSpace(detail) != "" { + receipt += "\n" + detail + } + kept.Report = withReport(kept.Report, receipt) + a.publishRunRow(g, kept) + g.mu.Lock() + store := g.store + g.mu.Unlock() + if err := store.write(g); err != nil { + return receipt, fmt.Errorf("%s\nits actual outcome could not be saved; inspect the destination before retrying: %w", receipt, err) + } + if outcome != mergeMerged && outcome != mergeInPlace { + return receipt, errors.New(receipt) + } + cleanupTaskWorkspaceReview(ticket) + _ = os.Remove(taskWorkspaceReviewPath(id, use)) + return receipt, nil +} + +func (a *Agent) taskWorkspaceApplyIdle(g *TaskGraph, selected TaskNotice, tree taskTree) error { + blocked := func() error { + return errors.New("this workspace layout may still have a writer or checker; its private work was retained") + } + if selected.Settling != "" || selected.Mending != "" { + return blocked() + } + g.mu.Lock() + for _, node := range g.nodes { + if node.state.settled() && !node.resolving { + continue + } + if taskStorageOverlaps(node.worktree, tree.dir) || taskStorageOverlaps(node.Ground, tree.dir) { + g.mu.Unlock() + return blocked() + } + } + g.mu.Unlock() + a.beltMu.Lock() + live := a.beltRun != nil + a.beltMu.Unlock() + // An active run can admit another cooperator. Keep its whole layout resident + // until the owning run's final join and receipt have completed. + if live { + return blocked() + } + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + base := filepath.Dir(tree.dir) + for _, row := range rows { + if row.ID == selected.ID { + continue + } + if row.State.settled() && row.Settling == "" && row.Mending == "" { + continue + } + copies := []*TaskCopyRecord{row.Copy} + if row.WorkspaceSet != nil { + for _, root := range row.WorkspaceSet.Roots { + copies = append(copies, root.Copy) + } + } + identified := false + for _, copy := range copies { + if copy == nil { + continue + } + identified = true + if copy.URI == tree.uri || copy.Dir != "" && (filepath.Dir(copy.Dir) == base || taskStorageOverlaps(copy.Dir, tree.dir)) { + return blocked() + } + } + if !identified { + return blocked() + } + } + for _, job := range a.jobs.all() { + if (job.running() || job.group.Alive()) && (taskStorageOverlaps(job.dir, tree.dir) || taskStorageOverlaps(job.dir, tree.ground)) { + return blocked() + } + } + return nil +} + +// Review receipts are local profile state, never portable checkpoint authority. +// The original copy stays resident, including when uncontained writers make +// destructive retirement unsafe. Only this separate immutable snapshot lands. +type taskWorkspaceReviewTicket struct { + CopyURI, Destination, Snapshot, Origin, Place string + TaskID uint64 + Slot, BaselineDigest string + Stage *TaskCopyRecord +} + +func taskWorkspaceReviewPath(id uint64, use WorkspaceRootUse) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%s", id, use.Slot, use.Copy.URI))) + return filepath.Join(canonicalPath(home.Join("workspace-apply")), hex.EncodeToString(sum[:])+".json") +} + +func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootUse, original taskTree) (taskTree, taskWorkspaceReviewTicket, error) { + fail := func(err error) (taskTree, taskWorkspaceReviewTicket, error) { + return taskTree{}, taskWorkspaceReviewTicket{}, err + } + snapshot, err := sealGroundWork(original.dir, "review task workspace") + if err != nil { + return fail(err) + } + if snapshot == "" { + head, err := gitContext(ctx, original.dir, nil, "rev-parse", "HEAD") + if err != nil { + return fail(err) + } + snapshot = strings.TrimSpace(head) + } + root := strings.TrimSuffix(taskWorkspaceReviewPath(id, use), ".json") + if err := os.MkdirAll(root, 0700); err != nil { + return fail(err) + } + owned, err := os.MkdirTemp(root, "snapshot-") + if err != nil { + return fail(err) + } + place := Place{Dir: owned, Workspace: original.ground} + if err := SaveMeta(owned, Meta{WorkspacePolicy: DefaultWorkspacePolicy(), Workspace: original.ground}); err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + stage := original + stage.dir = filepath.Join(place.Trees(), "1", use.Slot) + stage.branch = taskBranchName("review-" + use.Slot) + stage.place = place + stage.uri, stage.chatURI, stage.projectURI, stage.groundURI, stage.rootURI = "", "", "", "", "" + stage.universe = "" + baselineDigest := "" + var baseline []byte + if original.mode == TaskModeMirror { + baseline, err = readTaskDropping(original.dir, groundBaselineRecord) + if err != nil { + _ = os.RemoveAll(owned) + return fail(fmt.Errorf("the folder's original comparison baseline is unavailable: %w", err)) + } + sum := sha256.Sum256(baseline) + baselineDigest = hex.EncodeToString(sum[:]) + } + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + treeID, err := gitContext(ctx, original.dir, nil, "rev-parse", snapshot+"^{tree}") + if err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + message := taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, baselineDigest) + commit, err := gitContext(ctx, original.dir, nil, append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(treeID), "-p", snapshot, "-m", message)...) + if err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + snapshot = strings.TrimSpace(commit) + ready := false + defer func() { + if !ready { + _, _ = git(original.dir, "worktree", "remove", "--force", stage.dir) + _, _ = git(original.dir, "branch", "-D", stage.branch) + _ = os.RemoveAll(owned) + if stage.uri != "" { + _ = os.RemoveAll(workspaceRuntimeRoot(stage.uri)) + } + } + }() + if err := os.MkdirAll(filepath.Dir(stage.dir), 0700); err != nil { + return fail(err) + } + if output, err := gitContext(ctx, original.dir, nil, "worktree", "add", "-b", stage.branch, stage.dir, snapshot); err != nil { + return fail(fmt.Errorf("stage reviewed snapshot: %s: %w", output, err)) + } + if original.mode == TaskModeMirror { + if err := os.MkdirAll(filepath.Join(stage.dir, codeafDroppings), 0700); err != nil { + return fail(err) + } + if err := os.WriteFile(filepath.Join(stage.dir, codeafDroppings, groundBaselineRecord), baseline, 0600); err != nil { + return fail(err) + } + } + stage, err = registerOwnedWorkspace(stage, place) + if err != nil { + return fail(err) + } + if stage.uri == "" { + return fail(errors.New("reviewed staging copy could not record ownership")) + } + ticket := taskWorkspaceReviewTicket{TaskID: id, Slot: use.Slot, BaselineDigest: baselineDigest, CopyURI: use.Copy.URI, Destination: destination, Snapshot: snapshot, Origin: original.dir, Place: owned, Stage: runCopyOf(stage)} + ready = true + return stage, ticket, nil +} + +func loadTaskWorkspaceReview(id uint64, use WorkspaceRootUse, original taskTree) (taskWorkspaceReviewTicket, taskTree, error) { + var ticket taskWorkspaceReviewTicket + raw, err := os.ReadFile(taskWorkspaceReviewPath(id, use)) + if err != nil { + return ticket, taskTree{}, errors.New("review this root first with /task review; no reviewed local snapshot is available") + } + if err := json.Unmarshal(raw, &ticket); err != nil { + return ticket, taskTree{}, err + } + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + if ticket.TaskID != id || ticket.Slot != use.Slot || ticket.CopyURI != use.Copy.URI || ticket.Destination != destination || ticket.Origin != original.dir || filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(id, use), ".json") || ticket.Stage == nil { + return ticket, taskTree{}, errors.New("the reviewed root or its local mapping changed; review it again before applying") + } + stage, err := runCopyTree(ticket.Stage, Place{Dir: ticket.Place, Workspace: destination}) + if err != nil { + return ticket, stage, err + } + stageDestination := stage.ground + if stage.mode != TaskModeMirror { + stageDestination = stage.root + } + if ticket.Stage.Dir != stage.dir || stage.dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", use.Slot) || ticket.Stage.Branch != currentBranch(stage.dir) || stageDestination != destination || stage.ground != original.ground || stage.root != original.root || stage.projectURI != original.projectURI || stage.checkBase != original.checkBase || stage.mode != original.mode || stage.home != original.home || stage.homeSha != original.homeSha { + return ticket, stage, errors.New("the reviewed staging identity or destination changed; review this root again") + } + message, err := git(stage.dir, "show", "-s", "--format=%B", ticket.Snapshot) + if err != nil || strings.TrimSpace(message) != taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, ticket.BaselineDigest) { + return ticket, stage, errors.New("the reviewed snapshot binding does not match this task root") + } + if original.mode == TaskModeMirror { + baseline, err := readTaskDropping(stage.dir, groundBaselineRecord) + if err != nil { + return ticket, stage, errors.New("the reviewed comparison baseline is missing") + } + sum := sha256.Sum256(baseline) + if hex.EncodeToString(sum[:]) != ticket.BaselineDigest { + return ticket, stage, errors.New("the reviewed comparison baseline changed") + } + } + head, err := git(stage.dir, "rev-parse", "HEAD") + if err != nil || strings.TrimSpace(head) != ticket.Snapshot { + return ticket, stage, errors.New("the reviewed snapshot changed; review this root again") + } + dirty, err := git(stage.dir, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return ticket, stage, err + } + // The mirror's baseline is harness metadata, deliberately outside the commit. + for _, line := range strings.Split(strings.TrimSpace(dirty), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if len(line) < 4 || !harnessWrote(strings.TrimSpace(line[3:])) { + return ticket, stage, errors.New("the reviewed staging copy changed; review this root again") + } + } + return ticket, stage, nil +} + +func cleanupTaskWorkspaceReview(ticket taskWorkspaceReviewTicket) { + if ticket.Stage == nil || ticket.CopyURI == "" || ticket.TaskID == 0 || !validWorkspaceSlot(ticket.Slot) { + return + } + use := WorkspaceRootUse{Slot: ticket.Slot, Copy: &TaskCopyRecord{URI: ticket.CopyURI}} + if filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(ticket.TaskID, use), ".json") || ticket.Stage.Dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", ticket.Slot) || !strings.HasPrefix(ticket.Stage.Branch, "task/review-") { + return + } + resolved, err := resolveOwnedWorkspace(ticket.Stage, Place{Dir: ticket.Place}) + if err != nil || resolved != ticket.Stage.Dir { + return + } + // Only the generated review worktree and its generated branch are retired. + _, _ = git(ticket.Origin, "worktree", "remove", "--force", ticket.Stage.Dir) + _, _ = git(ticket.Origin, "branch", "-D", ticket.Stage.Branch) + _ = os.RemoveAll(ticket.Place) + _ = os.RemoveAll(workspaceRuntimeRoot(ticket.Stage.URI)) +} + +func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest string) string { + sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest)) + return "CodeAF reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) +} diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go new file mode 100644 index 0000000000..37c8a75f16 --- /dev/null +++ b/internal/session/task_workspace_apply_test.go @@ -0,0 +1,291 @@ +package session + +import ( + "context" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/home" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { + for _, repository := range []bool{false, true} { + t.Run(map[bool]string{false: "folder", true: "repository"}[repository], func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PROTOTYPE_TASK_STORAGE", "1") + source := t.TempDir() + if repository { + source = newTestRepo(t) + } else { + writeFile(t, filepath.Join(source, "shared.txt"), "the original line\n") + } + place := Place{Dir: t.TempDir(), Workspace: source} + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = source, place, place.Transcript() }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "write private output", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: source, Slot: "work", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + copyDir := engine.spec.Workspace + engine.mu.Unlock() + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("running task was applied") + } + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed private result\n") + endBeltRun(t, agent, engine) + // Edits made after the worker committed must also be reviewed and applied. + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed private result after completion\n") + writeFile(t, filepath.Join(copyDir, "new.txt"), "untracked private result\n") + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatalf("source changed without apply: %q", got) + } + writer, _ := newTestAgent(t, &scriptedCompleter{steps: []step{bashCall("writer-proof", "printf completed"), finalText("done")}}, func(c *Config) { + c.Workspace = copyDir + c.AskConsent = false + c.SessionFile = filepath.Join(t.TempDir(), "writer.jsonl") + }) + events, err := writer.Submit(context.Background(), "run the local command") + if err != nil { + t.Fatal(err) + } + collect(t, events) + if !taskStorageHasUncertainWriter() { + t.Fatal("ordinary shell did not retain writer uncertainty") + } + review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work") + if err != nil { + t.Fatal(err) + } + if review.Destination != source || !strings.Contains(review.Diff, "reviewed private result") || !strings.Contains(review.Diff, "untracked private result") || len(review.Changed) != 2 || !slices.Contains(review.Changed, "shared.txt") || !slices.Contains(review.Changed, "new.txt") { + t.Fatalf("review did not show actual selected root changes: %+v", review) + } + // Later edits stay private; apply uses exactly the immutable reviewed snapshot. + writeFile(t, filepath.Join(copyDir, "shared.txt"), "later private edit\n") + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "reviewed private result after completion\n" { + t.Fatalf("explicit apply did not deliver reviewed output: %q", got) + } + if got := readFile(t, filepath.Join(source, "new.txt")); got != "untracked private result\n" { + t.Fatalf("untracked result not applied: %q", got) + } + if !taskStorageHasUncertainWriter() { + t.Fatal("apply cleared permanent retirement uncertainty") + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "later private edit\n" { + t.Fatalf("original private copy changed: %q", got) + } + kept, _ := runRowOf(agent.graph(), id) + if _, err := runCopyTree(kept.WorkspaceSet.Roots[0].Copy, place); err != nil { + t.Fatalf("original copy URI no longer reopens: %v", err) + } + if kept.WorkspaceSet.Roots[0].Landing != "" { + t.Fatal("apply persisted future landing permission") + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("already applied root was applied twice") + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("already applied root offered a new unapplicable review") + } + + }) + } +} + +func TestTaskWorkspaceApplyPreservesConflictingOriginalAndPrivateCopy(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + source := t.TempDir() + writeFile(t, filepath.Join(source, "shared.txt"), "baseline\n") + place := Place{Dir: t.TempDir(), Workspace: source} + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = source, place, place.Transcript() }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "keep both versions", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: source, Slot: "work", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + copyDir := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(copyDir, "shared.txt"), "private version\n") + endBeltRun(t, agent, engine) + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(source, "shared.txt"), "person changed original\n") + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("conflicting apply succeeded") + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "person changed original\n" { + t.Fatalf("person's change lost: %q", got) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private version\n" { + t.Fatalf("private result lost: %q", got) + } + if _, err := os.Stat(copyDir); err != nil { + t.Fatal(err) + } +} + +func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint64, Place, string, string) { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "unavailable-native-engine")) + source := t.TempDir() + if repository { + source = newTestRepo(t) + } else { + writeFile(t, filepath.Join(source, "shared.txt"), "the original line\n") + } + place := Place{Dir: t.TempDir(), Workspace: source} + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = source, place, place.Transcript() }) + id, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "review safely", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: source, Slot: "work", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + copyDir := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(copyDir, "shared.txt"), "private result\n") + endBeltRun(t, agent, engine) + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + return agent, id, place, source, copyDir +} + +func TestTaskWorkspaceApplyRejectsAlteredStageReceipt(t *testing.T) { + for _, kind := range []string{"cleanup-path", "stage-destination", "comparison-baseline"} { + t.Run(kind, func(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, kind != "comparison-baseline") + // The local receipt is a persisted input. Corrupt it without changing the + // ownership registry and exercise the exported apply door. + paths, err := filepath.Glob(home.Join("workspace-apply", "*.json")) + if err != nil || len(paths) != 1 { + t.Fatalf("review receipts: %v %v", paths, err) + } + raw, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + var ticket taskWorkspaceReviewTicket + if err := json.Unmarshal(raw, &ticket); err != nil { + t.Fatal(err) + } + switch kind { + case "cleanup-path": + ticket.Stage.Dir = copyDir + case "stage-destination": + foreign := newTestRepo(t) + // A second authentic review can be transplanted without inventing any URI. + otherPlace := Place{Dir: t.TempDir(), Workspace: foreign} + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + other, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = foreign, otherPlace, otherPlace.Transcript() }) + otherID, _, _, err := other.StartTaskInWorkspaceSet(context.Background(), "foreign root", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: foreign, Slot: "work", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + foreignCopy := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(foreignCopy, "foreign.txt"), "unselected root\n") + endBeltRun(t, other, engine) + if _, err := other.ReviewTaskWorkspaceRoot(context.Background(), otherID, "work"); err != nil { + t.Fatal(err) + } + all, _ := filepath.Glob(home.Join("workspace-apply", "*.json")) + for _, path := range all { + if path == paths[0] { + continue + } + data, _ := os.ReadFile(path) + var foreignTicket taskWorkspaceReviewTicket + if err := json.Unmarshal(data, &foreignTicket); err != nil { + t.Fatal(err) + } + ticket.Stage, ticket.Place, ticket.Snapshot = foreignTicket.Stage, foreignTicket.Place, foreignTicket.Snapshot + } + case "comparison-baseline": + // A forged metadata baseline must not turn changed originals into consent. + _ = os.Remove(filepath.Join(ticket.Stage.Dir, codeafDroppings, groundBaselineRecord)) + writeFile(t, filepath.Join(source, "shared.txt"), "person changed original\n") + } + raw, err = json.Marshal(ticket) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths[0], raw, 0600); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatalf("altered %s receipt was applied", kind) + } + expected := "the original line\n" + if kind == "comparison-baseline" { + expected = "person changed original\n" + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != expected { + t.Fatalf("original changed: %q", got) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatalf("private copy changed: %q", got) + } + }) + } +} + +func TestTaskWorkspaceReviewReplacesItsOwnedStagingCopy(t *testing.T) { + agent, id, _, _, copyDir := completedWorkspaceApplyFixture(t, true) + before, err := git(copyDir, "worktree", "list", "--porcelain") + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + } + after, err := git(copyDir, "worktree", "list", "--porcelain") + if err != nil { + t.Fatal(err) + } + if strings.Count(before, "worktree ") != strings.Count(after, "worktree ") { + t.Fatalf("repeated review leaked worktrees:\nbefore:%s\nafter:%s", before, after) + } +} + +func TestTaskWorkspaceLargeReviewNamesItsImmutableDiff(t *testing.T) { + agent, id, _, _, copyDir := completedWorkspaceApplyFixture(t, true) + writeFile(t, filepath.Join(copyDir, "large.txt"), strings.Repeat("large immutable line\n", 20000)) + review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work") + if err != nil { + t.Fatal(err) + } + if !review.DiffTruncated || !strings.Contains(review.Diff, "git -C ") || !strings.Contains(review.Diff, review.Snapshot) || !strings.Contains(review.Diff, " diff ") { + t.Fatal("large review did not provide exact immutable diff inspection") + } +} diff --git a/internal/tui3/task_workspace_apply.go b/internal/tui3/task_workspace_apply.go new file mode 100644 index 0000000000..4327ef3cc3 --- /dev/null +++ b/internal/tui3/task_workspace_apply.go @@ -0,0 +1,70 @@ +package tui3 + +import ( + "context" + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +type taskWorkspaceDoor interface { + ReviewTaskWorkspaceRoot(context.Context, uint64, string) (session.TaskWorkspaceReview, error) + ApplyTaskWorkspaceRoot(context.Context, uint64, string) (string, error) +} + +// These commands enter through the person's existing ordered task command +// line. Neither a task result nor model output dispatches this door. +func (a *app) runTaskWorkspaceCommand(arg string) (tea.Cmd, bool) { + words := strings.Fields(arg) + if len(words) == 0 || words[0] != "review" && words[0] != "apply" { + return nil, false + } + if len(words) != 3 { + a.note("usage: /task " + words[0] + " ") + return nil, true + } + id, err := strconv.ParseUint(words[1], 10, 64) + if err != nil || id == 0 { + a.note("name the completed task's number and root slot") + return nil, true + } + door, ok := a.agent.(taskWorkspaceDoor) + if !ok { + a.note("this session cannot review or apply owned task roots") + return nil, true + } + ctx, slot, action := a.ctx, words[2], words[0] + return a.offLoop(func() func(bool) tea.Cmd { + var line string + var err error + if action == "apply" { + line, err = door.ApplyTaskWorkspaceRoot(ctx, id, slot) + } else { + var review session.TaskWorkspaceReview + review, err = door.ReviewTaskWorkspaceRoot(ctx, id, slot) + if err == nil { + line = fmt.Sprintf("task %d root %s\nPrivate copy: %s\nApply destination: %s\nSnapshot: %s\n", id, slot, review.Copy, review.Destination, review.Snapshot) + if len(review.Changed) == 0 { + line += "No changes to apply." + } else { + line += review.Diff + fmt.Sprintf("\nApply this root: /task apply %d %s", id, slot) + } + } + } + return func(here bool) tea.Cmd { + if !here { + return nil + } + if err != nil { + a.note("could not " + action + " task root · " + err.Error()) + } else { + a.note(line) + } + a.touch() + return nil + } + }), true +} diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 6fcb50f129..7644a11dfa 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -117,6 +117,9 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { // The word was typed, bare or with a brief (notice.go). a.noticeEvent(eventTaskTyped) arg = strings.TrimSpace(arg) + if cmd, handled := a.runTaskWorkspaceCommand(arg); handled { + return cmd + } if arg == "" { // A BARE /task IS THE ROSTER AND NOT A USAGE LINE. The margin's `+ /task` // row types this command into the draft (margin.go), so the word arrives in From faa7efb6b78d6fb6d3cc5c3cd93987665ad7f50b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 18:58:01 -0400 Subject: [PATCH 06/35] refactor: separate task snapshot validation and persistence phases --- internal/session/task_workspace_apply.go | 347 ++++++++++++++--------- 1 file changed, 220 insertions(+), 127 deletions(-) diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 3206ef73bd..3aae468eb2 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -60,17 +60,28 @@ func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot str if review.DiffTruncated { review.Diff += "\nComplete reviewed diff: git -C " + quoteShWord(tree.dir) + " diff --no-ext-diff --no-textconv " + quoteShWord(tree.checkBase) + " " + quoteShWord(ticket.Snapshot) + " --\n" } + if err := persistTaskWorkspaceReview(taskWorkspaceReviewPath(id, use), ticket); err != nil { + return review, err + } + published = true + if priorErr == nil { + cleanupTaskWorkspaceReview(prior) + } + return review, nil +} + +// Persist the replacement before retiring the prior reviewed snapshot. +func persistTaskWorkspaceReview(path string, ticket taskWorkspaceReviewTicket) error { raw, err := json.Marshal(ticket) if err != nil { - return review, err + return err } - path := taskWorkspaceReviewPath(id, use) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return review, err + return err } temporary, err := os.CreateTemp(filepath.Dir(path), ".review-") if err != nil { - return review, err + return err } defer os.Remove(temporary.Name()) if _, err = temporary.Write(raw); err == nil { @@ -92,13 +103,9 @@ func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot str } } if err != nil { - return review, err - } - published = true - if priorErr == nil { - cleanupTaskWorkspaceReview(prior) + return err } - return review, nil + return nil } func (a *Agent) taskWorkspaceRoot(id uint64, slot string) (*TaskGraph, TaskNotice, WorkspaceRootUse, taskTree, error) { @@ -120,29 +127,10 @@ func (a *Agent) taskWorkspaceRoot(id uint64, slot string) (*TaskGraph, TaskNotic if use.Slot != slot { continue } - if use.Copy == nil || use.Copy.URI == "" || use.Copy.ChatURI == "" { - return fail(errors.New("this root has no owned working copy")) - } - tree, err := runCopyTree(use.Copy, a.config.Place) + tree, err := adoptTaskWorkspaceRoot(use, a.config.Place) if err != nil { return fail(err) } - if use.Copy.Branch != "" && currentBranch(tree.dir) != use.Copy.Branch { - return fail(errors.New("the saved working copy now stands on another branch")) - } - if tree.checkBase == "" { - return fail(errors.New("the saved working copy has no review baseline")) - } - destination := tree.ground - if tree.mode != TaskModeMirror { - destination = tree.root - } - if destination == "" || canonicalPath(destination) == canonicalPath(tree.dir) { - return fail(errors.New("this root has no separate locally mapped destination")) - } - if err := workspacePathWithoutSymlinks(destination); err != nil { - return fail(err) - } return g, kept, use, tree, nil } return fail(fmt.Errorf("task %d has no root slot %q", id, slot)) @@ -234,6 +222,11 @@ func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot stri // The existing landing checks changed originals and preserves both versions on // conflict. This local tree already resolves the receiver's chosen binding. outcome, detail, _, _ := stage.comeHome(kept.Title, review.Changed, a.signsGitWork()) + return a.recordTaskWorkspaceApplication(g, kept, use, ticket, outcome, detail) +} + +func (a *Agent) recordTaskWorkspaceApplication(g *TaskGraph, kept TaskNotice, use WorkspaceRootUse, ticket taskWorkspaceReviewTicket, outcome, detail string) (string, error) { + id, slot := kept.ID, use.Slot kept.WorkspaceSet = cloneWorkspaceSet(kept.WorkspaceSet) for i := range kept.WorkspaceSet.Roots { if kept.WorkspaceSet.Roots[i].Slot == slot { @@ -267,17 +260,9 @@ func (a *Agent) taskWorkspaceApplyIdle(g *TaskGraph, selected TaskNotice, tree t if selected.Settling != "" || selected.Mending != "" { return blocked() } - g.mu.Lock() - for _, node := range g.nodes { - if node.state.settled() && !node.resolving { - continue - } - if taskStorageOverlaps(node.worktree, tree.dir) || taskStorageOverlaps(node.Ground, tree.dir) { - g.mu.Unlock() - return blocked() - } + if taskWorkspaceNodesBusy(g, tree.dir) { + return blocked() } - g.mu.Unlock() a.beltMu.Lock() live := a.beltRun != nil a.beltMu.Unlock() @@ -286,36 +271,8 @@ func (a *Agent) taskWorkspaceApplyIdle(g *TaskGraph, selected TaskNotice, tree t if live { return blocked() } - g.mu.Lock() - rows := g.runRowsLocked() - g.mu.Unlock() - base := filepath.Dir(tree.dir) - for _, row := range rows { - if row.ID == selected.ID { - continue - } - if row.State.settled() && row.Settling == "" && row.Mending == "" { - continue - } - copies := []*TaskCopyRecord{row.Copy} - if row.WorkspaceSet != nil { - for _, root := range row.WorkspaceSet.Roots { - copies = append(copies, root.Copy) - } - } - identified := false - for _, copy := range copies { - if copy == nil { - continue - } - identified = true - if copy.URI == tree.uri || copy.Dir != "" && (filepath.Dir(copy.Dir) == base || taskStorageOverlaps(copy.Dir, tree.dir)) { - return blocked() - } - } - if !identified { - return blocked() - } + if taskWorkspaceRowsBusy(g, selected.ID, tree) { + return blocked() } for _, job := range a.jobs.all() { if (job.running() || job.group.Alive()) && (taskStorageOverlaps(job.dir, tree.dir) || taskStorageOverlaps(job.dir, tree.ground)) { @@ -374,33 +331,13 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU stage.place = place stage.uri, stage.chatURI, stage.projectURI, stage.groundURI, stage.rootURI = "", "", "", "", "" stage.universe = "" - baselineDigest := "" - var baseline []byte - if original.mode == TaskModeMirror { - baseline, err = readTaskDropping(original.dir, groundBaselineRecord) - if err != nil { - _ = os.RemoveAll(owned) - return fail(fmt.Errorf("the folder's original comparison baseline is unavailable: %w", err)) - } - sum := sha256.Sum256(baseline) - baselineDigest = hex.EncodeToString(sum[:]) - } - destination := original.ground - if original.mode != TaskModeMirror { - destination = original.root - } - treeID, err := gitContext(ctx, original.dir, nil, "rev-parse", snapshot+"^{tree}") + bound, err := bindTaskWorkspaceSnapshot(ctx, use, original, snapshot) if err != nil { _ = os.RemoveAll(owned) return fail(err) } - message := taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, baselineDigest) - commit, err := gitContext(ctx, original.dir, nil, append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(treeID), "-p", snapshot, "-m", message)...) - if err != nil { - _ = os.RemoveAll(owned) - return fail(err) - } - snapshot = strings.TrimSpace(commit) + snapshot = bound.commit + baseline, baselineDigest, destination := bound.baseline, bound.baselineDigest, bound.destination ready := false defer func() { if !ready { @@ -418,13 +355,8 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU if output, err := gitContext(ctx, original.dir, nil, "worktree", "add", "-b", stage.branch, stage.dir, snapshot); err != nil { return fail(fmt.Errorf("stage reviewed snapshot: %s: %w", output, err)) } - if original.mode == TaskModeMirror { - if err := os.MkdirAll(filepath.Join(stage.dir, codeafDroppings), 0700); err != nil { - return fail(err) - } - if err := os.WriteFile(filepath.Join(stage.dir, codeafDroppings, groundBaselineRecord), baseline, 0600); err != nil { - return fail(err) - } + if err := materializeTaskWorkspaceBaseline(stage, baseline); err != nil { + return fail(err) } stage, err = registerOwnedWorkspace(stage, place) if err != nil { @@ -451,41 +383,180 @@ func loadTaskWorkspaceReview(id uint64, use WorkspaceRootUse, original taskTree) if original.mode != TaskModeMirror { destination = original.root } - if ticket.TaskID != id || ticket.Slot != use.Slot || ticket.CopyURI != use.Copy.URI || ticket.Destination != destination || ticket.Origin != original.dir || filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(id, use), ".json") || ticket.Stage == nil { - return ticket, taskTree{}, errors.New("the reviewed root or its local mapping changed; review it again before applying") + if err := validateTaskWorkspaceReviewTicket(id, use, original, ticket, destination); err != nil { + return ticket, taskTree{}, err } stage, err := runCopyTree(ticket.Stage, Place{Dir: ticket.Place, Workspace: destination}) if err != nil { return ticket, stage, err } + if err := validateTaskWorkspaceReviewStage(use, original, ticket, stage, destination); err != nil { + return ticket, stage, err + } + if err := validateTaskWorkspaceSnapshotBinding(use, original, ticket, stage, destination); err != nil { + return ticket, stage, err + } + if err := validateTaskWorkspaceComparisonBaseline(original, ticket, stage); err != nil { + return ticket, stage, err + } + if err := verifyTaskWorkspaceSnapshotFiles(ticket, stage); err != nil { + return ticket, stage, err + } + return ticket, stage, nil +} + +func cleanupTaskWorkspaceReview(ticket taskWorkspaceReviewTicket) { + if ticket.Stage == nil || ticket.CopyURI == "" || ticket.TaskID == 0 || !validWorkspaceSlot(ticket.Slot) { + return + } + use := WorkspaceRootUse{Slot: ticket.Slot, Copy: &TaskCopyRecord{URI: ticket.CopyURI}} + if filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(ticket.TaskID, use), ".json") || ticket.Stage.Dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", ticket.Slot) || !strings.HasPrefix(ticket.Stage.Branch, "task/review-") { + return + } + resolved, err := resolveOwnedWorkspace(ticket.Stage, Place{Dir: ticket.Place}) + if err != nil || resolved != ticket.Stage.Dir { + return + } + // Only the generated review worktree and its generated branch are retired. + _, _ = git(ticket.Origin, "worktree", "remove", "--force", ticket.Stage.Dir) + _, _ = git(ticket.Origin, "branch", "-D", ticket.Stage.Branch) + _ = os.RemoveAll(ticket.Place) + _ = os.RemoveAll(workspaceRuntimeRoot(ticket.Stage.URI)) +} + +func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest string) string { + sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest)) + return "CodeAF reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) +} + +func adoptTaskWorkspaceRoot(use WorkspaceRootUse, place Place) (taskTree, error) { + if use.Copy == nil || use.Copy.URI == "" || use.Copy.ChatURI == "" { + return taskTree{}, errors.New("this root has no owned working copy") + } + tree, err := runCopyTree(use.Copy, place) + if err != nil { + return taskTree{}, err + } + if use.Copy.Branch != "" && currentBranch(tree.dir) != use.Copy.Branch { + return taskTree{}, errors.New("the saved working copy now stands on another branch") + } + if tree.checkBase == "" { + return taskTree{}, errors.New("the saved working copy has no review baseline") + } + destination := tree.ground + if tree.mode != TaskModeMirror { + destination = tree.root + } + if destination == "" || canonicalPath(destination) == canonicalPath(tree.dir) { + return taskTree{}, errors.New("this root has no separate locally mapped destination") + } + if err := workspacePathWithoutSymlinks(destination); err != nil { + return taskTree{}, err + } + return tree, nil +} + +func taskWorkspaceNodesBusy(g *TaskGraph, path string) bool { + g.mu.Lock() + for _, node := range g.nodes { + if node.state.settled() && !node.resolving { + continue + } + if taskStorageOverlaps(node.worktree, path) || taskStorageOverlaps(node.Ground, path) { + g.mu.Unlock() + return true + } + } + g.mu.Unlock() + return false +} + +func taskWorkspaceRowsBusy(g *TaskGraph, selected uint64, tree taskTree) bool { + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + for _, row := range rows { + if taskWorkspaceRowBusy(row, selected, tree) { + return true + } + } + return false +} +func taskWorkspaceRowBusy(row TaskNotice, selected uint64, tree taskTree) bool { + if row.ID == selected { + return false + } + if row.State.settled() && row.Settling == "" && row.Mending == "" { + return false + } + copies := taskWorkspaceRowCopies(row) + identified := false + for _, copy := range copies { + if copy == nil { + continue + } + identified = true + if copy.URI == tree.uri || copy.Dir != "" && (filepath.Dir(copy.Dir) == filepath.Dir(tree.dir) || taskStorageOverlaps(copy.Dir, tree.dir)) { + return true + } + } + return !identified +} +func taskWorkspaceRowCopies(row TaskNotice) []*TaskCopyRecord { + copies := []*TaskCopyRecord{row.Copy} + if row.WorkspaceSet != nil { + for _, root := range row.WorkspaceSet.Roots { + copies = append(copies, root.Copy) + } + } + return copies +} + +func validateTaskWorkspaceReviewTicket(id uint64, use WorkspaceRootUse, original taskTree, ticket taskWorkspaceReviewTicket, destination string) error { + if ticket.TaskID != id || ticket.Slot != use.Slot || ticket.CopyURI != use.Copy.URI || ticket.Destination != destination || ticket.Origin != original.dir || filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(id, use), ".json") || ticket.Stage == nil { + return errors.New("the reviewed root or its local mapping changed; review it again before applying") + } + return nil +} +func validateTaskWorkspaceReviewStage(use WorkspaceRootUse, original taskTree, ticket taskWorkspaceReviewTicket, stage taskTree, destination string) error { stageDestination := stage.ground if stage.mode != TaskModeMirror { stageDestination = stage.root } if ticket.Stage.Dir != stage.dir || stage.dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", use.Slot) || ticket.Stage.Branch != currentBranch(stage.dir) || stageDestination != destination || stage.ground != original.ground || stage.root != original.root || stage.projectURI != original.projectURI || stage.checkBase != original.checkBase || stage.mode != original.mode || stage.home != original.home || stage.homeSha != original.homeSha { - return ticket, stage, errors.New("the reviewed staging identity or destination changed; review this root again") + return errors.New("the reviewed staging identity or destination changed; review this root again") } + return nil +} +func validateTaskWorkspaceSnapshotBinding(use WorkspaceRootUse, original taskTree, ticket taskWorkspaceReviewTicket, stage taskTree, destination string) error { message, err := git(stage.dir, "show", "-s", "--format=%B", ticket.Snapshot) if err != nil || strings.TrimSpace(message) != taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, ticket.BaselineDigest) { - return ticket, stage, errors.New("the reviewed snapshot binding does not match this task root") + return errors.New("the reviewed snapshot binding does not match this task root") } + return nil +} +func validateTaskWorkspaceComparisonBaseline(original taskTree, ticket taskWorkspaceReviewTicket, stage taskTree) error { if original.mode == TaskModeMirror { baseline, err := readTaskDropping(stage.dir, groundBaselineRecord) if err != nil { - return ticket, stage, errors.New("the reviewed comparison baseline is missing") + return errors.New("the reviewed comparison baseline is missing") } sum := sha256.Sum256(baseline) if hex.EncodeToString(sum[:]) != ticket.BaselineDigest { - return ticket, stage, errors.New("the reviewed comparison baseline changed") + return errors.New("the reviewed comparison baseline changed") } } + return nil +} + +func verifyTaskWorkspaceSnapshotFiles(ticket taskWorkspaceReviewTicket, stage taskTree) error { head, err := git(stage.dir, "rev-parse", "HEAD") if err != nil || strings.TrimSpace(head) != ticket.Snapshot { - return ticket, stage, errors.New("the reviewed snapshot changed; review this root again") + return errors.New("the reviewed snapshot changed; review this root again") } dirty, err := git(stage.dir, "status", "--porcelain", "--untracked-files=all") if err != nil { - return ticket, stage, err + return err } // The mirror's baseline is harness metadata, deliberately outside the commit. for _, line := range strings.Split(strings.TrimSpace(dirty), "\n") { @@ -493,32 +564,54 @@ func loadTaskWorkspaceReview(id uint64, use WorkspaceRootUse, original taskTree) continue } if len(line) < 4 || !harnessWrote(strings.TrimSpace(line[3:])) { - return ticket, stage, errors.New("the reviewed staging copy changed; review this root again") + return errors.New("the reviewed staging copy changed; review this root again") } } - return ticket, stage, nil + return nil } -func cleanupTaskWorkspaceReview(ticket taskWorkspaceReviewTicket) { - if ticket.Stage == nil || ticket.CopyURI == "" || ticket.TaskID == 0 || !validWorkspaceSlot(ticket.Slot) { - return +type taskWorkspaceSnapshot struct { + commit, baselineDigest, destination string + baseline []byte +} + +func bindTaskWorkspaceSnapshot(ctx context.Context, use WorkspaceRootUse, original taskTree, snapshot string) (taskWorkspaceSnapshot, error) { + var err error + baselineDigest := "" + var baseline []byte + if original.mode == TaskModeMirror { + baseline, err = readTaskDropping(original.dir, groundBaselineRecord) + if err != nil { + return taskWorkspaceSnapshot{}, fmt.Errorf("the folder's original comparison baseline is unavailable: %w", err) + } + sum := sha256.Sum256(baseline) + baselineDigest = hex.EncodeToString(sum[:]) } - use := WorkspaceRootUse{Slot: ticket.Slot, Copy: &TaskCopyRecord{URI: ticket.CopyURI}} - if filepath.Dir(ticket.Place) != strings.TrimSuffix(taskWorkspaceReviewPath(ticket.TaskID, use), ".json") || ticket.Stage.Dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", ticket.Slot) || !strings.HasPrefix(ticket.Stage.Branch, "task/review-") { - return + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root } - resolved, err := resolveOwnedWorkspace(ticket.Stage, Place{Dir: ticket.Place}) - if err != nil || resolved != ticket.Stage.Dir { - return + treeID, err := gitContext(ctx, original.dir, nil, "rev-parse", snapshot+"^{tree}") + if err != nil { + return taskWorkspaceSnapshot{}, err } - // Only the generated review worktree and its generated branch are retired. - _, _ = git(ticket.Origin, "worktree", "remove", "--force", ticket.Stage.Dir) - _, _ = git(ticket.Origin, "branch", "-D", ticket.Stage.Branch) - _ = os.RemoveAll(ticket.Place) - _ = os.RemoveAll(workspaceRuntimeRoot(ticket.Stage.URI)) + message := taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, baselineDigest) + commit, err := gitContext(ctx, original.dir, nil, append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(treeID), "-p", snapshot, "-m", message)...) + if err != nil { + return taskWorkspaceSnapshot{}, err + } + snapshot = strings.TrimSpace(commit) + return taskWorkspaceSnapshot{commit: snapshot, baseline: baseline, baselineDigest: baselineDigest, destination: destination}, nil } -func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest string) string { - sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest)) - return "CodeAF reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) +func materializeTaskWorkspaceBaseline(stage taskTree, baseline []byte) error { + if stage.mode == TaskModeMirror { + if err := os.MkdirAll(filepath.Join(stage.dir, codeafDroppings), 0700); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(stage.dir, codeafDroppings, groundBaselineRecord), baseline, 0600); err != nil { + return err + } + } + return nil } From 7a84d424c9467a0b102ef8df797a1515954cac64 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 19:20:54 -0400 Subject: [PATCH 07/35] Pin published Furrow v0.3.1 native artifacts --- internal/furrowbin/pin.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/furrowbin/pin.json b/internal/furrowbin/pin.json index e52e1e91b7..60bfb2ee7e 100644 --- a/internal/furrowbin/pin.json +++ b/internal/furrowbin/pin.json @@ -1,31 +1,31 @@ { "repository": "Agent-Field/furrow", - "tag": "v0.2.0", - "version": "0.2.0", + "tag": "v0.3.1", + "version": "0.3.1", "artifacts": { "darwin-amd64": { "asset": "furrow-darwin-amd64", - "sha256": "fc9ead1c78582086d05f859f354fac2ade8b28b43ea54a4e04f9f03a3fb92ed1" + "sha256": "0bad130abb4193221bf00b0ff077f2a0fa9f0ccb294fcd696e4b790e77fa4f43" }, "darwin-arm64": { "asset": "furrow-darwin-arm64", - "sha256": "23c713db4544de5e169a902d6d42e581c205d7c85e3605fb28bbf1250bdd0c86" + "sha256": "d74e47b76fc208ebab5ddc6047fa6efc37c4392e8ac8ee1c931f744d620c2a6f" }, "linux-amd64": { "asset": "furrow-linux-amd64", - "sha256": "be982dc8e991192d338b6daaa4f9a5765e7598eb3664b3dafd2f8e4834c4b508" + "sha256": "95c59f81432dcadf5e32f9762adac321dffbd5cb756517b956c30cb0cad75e3c" }, "linux-arm64": { "asset": "furrow-linux-arm64", - "sha256": "b9cecb657ee253116ebd7c1969e920d9b95cdd99267b2068e8e78b07277b1e2c" + "sha256": "69bba30afaa1127edcf04bbe336e2a90c1cfff45ff654eec65915c20cdd117bc" }, "windows-amd64": { "asset": "furrow-windows-amd64.exe", - "sha256": "327cc31320544e4c24f101cecc71ec7f68feae68921f7bb1ab1a6d165d0e01c7" + "sha256": "a6d92449962c5e7890021b63e6424258d7540c339130851d72dde69cbf5834f0" }, "windows-arm64": { "asset": "furrow-windows-arm64.exe", - "sha256": "f5435d6d38c87d001a008fd19db399dd8e722ca3044c2f236c3174b0a7827e77" + "sha256": "66323c167c5985d44fb69806293c27ad6755214e58dbaeecd43cac66e15a7525" } } } From 0eb0c3ca00ab0d5f037d5b64607ab4d8d8f82795 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:06:47 -0400 Subject: [PATCH 08/35] Preserve portable workspace state with recoverable explicit apply --- internal/manual/chat/task-controls.md | 28 +- internal/remote/client.go | 3 +- internal/remote/taskworkspace.go | 23 +- internal/remote/taskworkspace_test.go | 53 + internal/session/task_run.go | 6 +- internal/session/task_run_copy.go | 8 +- internal/session/task_tree_mirror.go | 10 +- internal/session/task_workspace_apply.go | 377 ++++- internal/session/task_workspace_apply_test.go | 337 +++- internal/session/task_workspace_baseline.go | 114 ++ internal/session/task_workspace_fs_other.go | 36 + internal/session/task_workspace_fs_unix.go | 175 +++ internal/session/task_workspace_recovery.go | 1391 +++++++++++++++++ .../session/task_workspace_rename_darwin.go | 9 + .../session/task_workspace_rename_linux.go | 9 + .../session/task_workspace_transaction.go | 770 +++++++++ .../task_workspace_transaction_test.go | 389 +++++ internal/session/workspace_set_prepare.go | 23 +- internal/syncsetup/workspace_transfer.go | 5 + internal/syncsetup/workspace_transfer_test.go | 50 + internal/taskcopy/compose.go | 62 +- internal/taskcopy/record.go | 2 + internal/taskcopy/restore.go | 57 +- internal/taskcopy/results.go | 15 + internal/taskcopy/results_test.go | 20 + internal/taskcopy/review_metadata_darwin.go | 81 + .../taskcopy/review_metadata_darwin_test.go | 72 + internal/taskcopy/review_metadata_linux.go | 52 + .../taskcopy/review_metadata_linux_test.go | 43 + internal/taskcopy/review_metadata_other.go | 23 + internal/taskcopy/review_metadata_unix.go | 37 + internal/taskcopy/review_snapshot.go | 840 ++++++++++ internal/taskcopy/review_snapshot_test.go | 396 +++++ internal/taskcopy/workspace_carrier.go | 20 +- internal/taskcopy/workspace_restore.go | 74 +- 35 files changed, 5517 insertions(+), 93 deletions(-) create mode 100644 internal/session/task_workspace_baseline.go create mode 100644 internal/session/task_workspace_fs_other.go create mode 100644 internal/session/task_workspace_fs_unix.go create mode 100644 internal/session/task_workspace_recovery.go create mode 100644 internal/session/task_workspace_rename_darwin.go create mode 100644 internal/session/task_workspace_rename_linux.go create mode 100644 internal/session/task_workspace_transaction.go create mode 100644 internal/session/task_workspace_transaction_test.go create mode 100644 internal/taskcopy/review_metadata_darwin.go create mode 100644 internal/taskcopy/review_metadata_darwin_test.go create mode 100644 internal/taskcopy/review_metadata_linux.go create mode 100644 internal/taskcopy/review_metadata_linux_test.go create mode 100644 internal/taskcopy/review_metadata_other.go create mode 100644 internal/taskcopy/review_metadata_unix.go create mode 100644 internal/taskcopy/review_snapshot.go create mode 100644 internal/taskcopy/review_snapshot_test.go diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 2918c8a572..8dda085aa6 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -238,7 +238,7 @@ and read its changes, private copy and locally mapped apply destination. For an the slot is `work`; a multi-root task uses its declared slots. After reviewing, `/task apply ` applies only that root -through the existing conflict checks. The apply uses exactly the reviewed snapshot; +through conflict checks against the recorded original bytes and metadata. The apply uses exactly the reviewed snapshot; edits made in the original private copy after review stay there. Review again to select a newer snapshot. Read roots cannot be applied. A known live run, checker or cooperator blocks apply until it is idle. @@ -247,3 +247,29 @@ private copy and its identity remain available after a successful apply too. Uncertain writers continue to prevent destructive retirement of that copy. Applying one root does not authorize another root or give later tasks automatic landing permission. A received root stays private until this explicit local action. + +The portable apply contract preserves raw file bytes and line endings, file +permissions, empty directories and directory permissions. Changed paths with +unsupported ownership, hardlinks, extended attributes, native flags or access +control metadata are refused before writing the original. Timestamps are outside +this portable contract. Older copies without a historical metadata baseline stay +readable, but this apply door cannot infer their original metadata safely. + +If an apply reports that its outcome could not be saved, retry `/task apply` for +the same root. The retained receipt records work that already arrived, so recovery +does not apply it twice. Recover that pending operation before requesting a new +review of the root. + +New owned plain-folder task copies retain their actual runtime inputs, including +ignored files. Bash task cooperators share that admitted private copy. Files +screened as secrets stay readable there but are excluded from its initial history +and portable payload. Changed or new screened outputs refuse review. A tracked +secret already in Git history refuses portable capture rather than rewriting +history. Receiving a task cannot restore inputs that were deliberately withheld. +The legacy family initializer and its separate child-copy behavior are unchanged. + +Portable task carriers preserve tracked, changed and new symlink targets and +supported link permissions using typed metadata. Both sending and receiving CodeAF versions must support this format; +workspace receivers without its required capability refuse before promotion. +Older standalone receivers do not provide this capability check and must be +updated before taking these carriers. diff --git a/internal/remote/client.go b/internal/remote/client.go index 13ca19d87b..f6dfbbaa38 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -78,7 +78,8 @@ var fallbackClientID atomic.Uint64 // surface asks Model() on frames it repaints, so a measurement there would have // doubled the cost of drawing a status line. -// callDeadline is how long any one call waits for its result. See the law above. +// callDeadline bounds ordinary calls. Compact and taskWorkspaceCall are +// explicit bounded exceptions for off-reader work that outlives this window. const callDeadline = 10 * time.Second // Client is one connection to one engine. It is safe for concurrent use, which diff --git a/internal/remote/taskworkspace.go b/internal/remote/taskworkspace.go index b1d250ef93..735a776a98 100644 --- a/internal/remote/taskworkspace.go +++ b/internal/remote/taskworkspace.go @@ -12,6 +12,11 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) +// Portable filesystem transactions can outlive the getter window: a measured +// 1,000-file apply takes more than ten seconds. These off-reader operations keep +// bounded patience; the independent cancellation lane stays short. +const taskWorkspacePatience = 2 * time.Minute + type taskWorkspaceDoor interface { ReviewTaskWorkspaceRoot(context.Context, uint64, string) (session.TaskWorkspaceReview, error) ApplyTaskWorkspaceRoot(context.Context, uint64, string) (string, error) @@ -52,15 +57,15 @@ func (a *Agent) taskWorkspaceCall(ctx context.Context, method string, id uint64, if !welcome.TaskWorkspace { return result, errors.New("reviewing and applying task roots needs a newer engine; update the engine and reconnect") } - deadline := time.Now().Add(callDeadline) + deadline := time.Now().Add(taskWorkspacePatience) if bounded, ok := ctx.Deadline(); ok && bounded.Before(deadline) { deadline = bounded } args := taskWorkspaceArgs{ID: id, Slot: slot, Session: welcome.SessionFile, Token: rand.Text(), Deadline: deadline} - payload, answered, err := a.c.callAnswered(ctx, method, args, callDeadline) + payload, answered, err := a.c.callAnswered(ctx, method, args, taskWorkspacePatience) if !answered { // Cancellation travels independently of the operation and its ordered UI - // gesture. Once atomic landing is admitted, its actual receipt still wins. + // gesture. After durable commit, the engine finishes recording its actual receipt. go func() { cancelCtx, cancel := context.WithTimeout(context.Background(), callDeadline) defer cancel() @@ -79,9 +84,9 @@ func (a *Agent) taskWorkspaceCall(ctx context.Context, method string, id uint64, // A cancel can reach the independent lane before its operation goroutine. A // short-lived cancelled entry remembers that order; entries always expire. -func (s *server) taskWorkspacePending(args taskWorkspaceArgs) *taskWorkspacePending { +func (s *server) taskWorkspacePending(args taskWorkspaceArgs, patience time.Duration) *taskWorkspacePending { deadline := args.Deadline - limit := time.Now().Add(callDeadline) + limit := time.Now().Add(patience) if deadline.IsZero() || deadline.After(limit) { deadline = limit } @@ -92,14 +97,18 @@ func (s *server) taskWorkspacePending(args taskWorkspaceArgs) *taskWorkspacePend cancel() return actual.(*taskWorkspacePending) } - time.AfterFunc(callDeadline, func() { s.workspaceCalls.CompareAndDelete(args.Token, pending); cancel() }) + time.AfterFunc(patience, func() { s.workspaceCalls.CompareAndDelete(args.Token, pending); cancel() }) return pending } func (s *server) taskWorkspaceInvoke(method string, args taskWorkspaceArgs) ([]byte, error) { if args.Token == "" || len(args.Token) > 128 { return nil, errors.New("invalid task workspace request token") } - pending := s.taskWorkspacePending(args) + patience := taskWorkspacePatience + if method == MethodTaskWorkspaceCancel { + patience = callDeadline + } + pending := s.taskWorkspacePending(args, patience) if method == MethodTaskWorkspaceCancel { pending.cancel() return nil, nil diff --git a/internal/remote/taskworkspace_test.go b/internal/remote/taskworkspace_test.go index 7648eeea9f..f15947ccdf 100644 --- a/internal/remote/taskworkspace_test.go +++ b/internal/remote/taskworkspace_test.go @@ -113,3 +113,56 @@ func TestTaskWorkspaceUnsupportedAndWatchingEnginesRefuse(t *testing.T) { t.Fatal("watch-only connection applied work") } } + +type slowWorkspaceFar struct { + *workspaceFar + delay time.Duration + deadline chan time.Time +} + +func (f *slowWorkspaceFar) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + if deadline, ok := ctx.Deadline(); ok && f.deadline != nil { + f.deadline <- deadline + } + timer := time.NewTimer(f.delay) + defer timer.Stop() + select { + case <-timer.C: + return "selected snapshot applied", nil + case <-ctx.Done(): + return "", ctx.Err() + } +} +func TestTaskWorkspaceApplyCanCompleteAfterGetterDeadline(t *testing.T) { + far := &slowWorkspaceFar{workspaceFar: &workspaceFar{fakeAgent: &fakeAgent{}}, delay: 11 * time.Second, deadline: make(chan time.Time, 1)} + loop := workspaceLoop(t, far, false) + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + receipt, err := loop.Client.Agent().ApplyTaskWorkspaceRoot(ctx, 7, "work") + if err != nil || receipt != "selected snapshot applied" { + t.Fatalf("durable operation lost its delayed receipt: %q %v", receipt, err) + } + select { + case deadline := <-far.deadline: + if deadline.Sub(started) <= 10*time.Second || deadline.Sub(started) > 121*time.Second { + t.Fatalf("resident operation deadline is not bounded to longer task patience: %s", deadline.Sub(started)) + } + default: + t.Fatal("resident operation had no bounded deadline") + } +} +func TestTaskWorkspaceEarlierCallerDeadlineStopsResidentOperation(t *testing.T) { + far := &workspaceFar{fakeAgent: &fakeAgent{}, entered: make(chan struct{}), cancelled: make(chan struct{})} + loop := workspaceLoop(t, far, false) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err := loop.Client.Agent().ApplyTaskWorkspaceRoot(ctx, 7, "work"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("earlier caller deadline lost: %v", err) + } + select { + case <-far.cancelled: + case <-time.After(time.Second): + t.Fatal("resident operation outlived earlier caller deadline") + } +} diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 554060c30f..8c6c18f3c6 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -8193,7 +8193,8 @@ func taskLog(listed *job) io.Writer { // taskTree is where one node works and how its work comes home. type taskTree struct { - workspaceSet *WorkspaceSet + portableBaselineDigest string + workspaceSet *WorkspaceSet // Stable local identity survives a path relocation; it grants no cleanup authority. uri, chatURI, projectURI, groundURI, rootURI string // dir is the node's workspace: a worktree, or the person's own directory @@ -9634,7 +9635,8 @@ func gitWith(dir string, environment []string, args ...string) (string, error) { // gitContext is [gitWith] under a context, for a command somebody may have to // stop before git answers ([worktreeDirtIn]'s watch). The refusal of an empty // directory and the pinned environment live here, so there is one copy of -// each; every other spelling of a git call in this package is a wrapper of it. +// each; the usual git helpers wrap it. The workspace transaction protocol runner +// separates bounded stdout from stderr so Git warnings cannot become object IDs. func gitContext(ctx context.Context, dir string, environment []string, args ...string) (string, error) { // A COMMAND WITH NO DIRECTORY RUNS WHEREVER THE PROCESS HAPPENS TO BE. // diff --git a/internal/session/task_run_copy.go b/internal/session/task_run_copy.go index 328c16656f..57f1c0bc7d 100644 --- a/internal/session/task_run_copy.go +++ b/internal/session/task_run_copy.go @@ -35,6 +35,8 @@ import ( // whose branch genuinely was never recorded and genuinely cannot be resumed. // [runCopyTree] says so out loud rather than guessing ([errNoRunCopy]). type TaskCopyRecord struct { + // PortableBaselineDigest binds historical input metadata; it grants no landing permission. + PortableBaselineDigest string `json:"portable_baseline_sha256,omitempty"` // URI is local workspace identity; paths remain the legacy fallback only when absent. URI string `json:"uri,omitempty"` ChatURI string `json:"chat_uri,omitempty"` @@ -89,7 +91,8 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { return nil } return &TaskCopyRecord{ - URI: tree.uri, ChatURI: tree.chatURI, ProjectURI: tree.projectURI, GroundURI: tree.groundURI, RootURI: tree.rootURI, + PortableBaselineDigest: tree.portableBaselineDigest, + URI: tree.uri, ChatURI: tree.chatURI, ProjectURI: tree.projectURI, GroundURI: tree.groundURI, RootURI: tree.rootURI, Dir: tree.dir, Branch: tree.branch, Root: tree.root, @@ -191,7 +194,8 @@ func runCopyTree(record *TaskCopyRecord, place Place) (taskTree, error) { return taskTree{}, fmt.Errorf("its working copy is gone from %s, so there is nothing left to carry on in", dir) } return taskTree{ - uri: record.URI, chatURI: record.ChatURI, projectURI: record.ProjectURI, groundURI: record.GroundURI, rootURI: record.RootURI, + portableBaselineDigest: record.PortableBaselineDigest, + uri: record.URI, chatURI: record.ChatURI, projectURI: record.ProjectURI, groundURI: record.GroundURI, rootURI: record.RootURI, dir: dir, root: root, branch: record.Branch, diff --git a/internal/session/task_tree_mirror.go b/internal/session/task_tree_mirror.go index afc5f6a557..4467392e9d 100644 --- a/internal/session/task_tree_mirror.go +++ b/internal/session/task_tree_mirror.go @@ -112,6 +112,10 @@ func familyTreeIsOpen(dir string) bool { // so everything this stages is already inside that cap and no second bound is // needed here. func openFamilyRepository(dir string) string { + return openFamilyRepositoryWithPolicy(dir, nil) +} + +func openFamilyRepositoryWithPolicy(dir string, withheld []string) string { if out, err := git(dir, "init", "--quiet"); err != nil { return familyTreeProblem(out, err) } @@ -122,7 +126,11 @@ func openFamilyRepository(dir string) string { // belong to machinery rather than to anybody's world — a task's private // metadata and furrow's bookkeeping — exactly as [sealGroundWork] keeps them // out of the world it hands a child. - addArgs := []string{"add", "--all", "--force", "--", "."} + addArgs := []string{"add", "--all", "--force"} + addArgs = append(addArgs, "--", ".") + for _, name := range withheld { + addArgs = append(addArgs, ":(exclude,literal)"+name) + } for _, dropping := range taskDroppingNames() { addArgs = append(addArgs, ":(exclude)"+dropping) } diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 3aae468eb2..86bca284ec 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -7,11 +7,15 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/home" + "io" "os" "os/exec" "path/filepath" + "sort" "strings" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" ) // TaskWorkspaceReview describes exactly one local copy and the destination @@ -26,7 +30,7 @@ type TaskWorkspaceReview struct { // ReviewTaskWorkspaceRoot reads actual committed and uncommitted changes. This // person-facing door is intentionally absent from model tools. func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (TaskWorkspaceReview, error) { - if err := ctx.Err(); err != nil { + if err := taskWorkspaceOperationEnvironment(ctx); err != nil { return TaskWorkspaceReview{}, err } a.lockBeltStart() @@ -41,7 +45,10 @@ func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot str if err := a.taskWorkspaceApplyIdle(g, kept, tree); err != nil { return TaskWorkspaceReview{}, err } - prior, _, priorErr := loadTaskWorkspaceReview(id, use, tree) + prior, priorErr := priorTaskWorkspaceReview(id, use, tree) + if priorErr != nil { + return TaskWorkspaceReview{}, priorErr + } stage, ticket, err := stageTaskWorkspaceReview(ctx, id, use, tree) if err != nil { return TaskWorkspaceReview{}, err @@ -52,19 +59,19 @@ func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot str cleanupTaskWorkspaceReview(ticket) } }() - review, err := reviewTaskWorkspaceRoot(ctx, use, stage) + review, err := reviewTaskWorkspacePortableRoot(ctx, use, originalPortableReview{tree: tree, ticket: ticket, stage: stage, checkSource: true}) if err != nil { return review, err } review.Copy, review.Snapshot = tree.dir, ticket.Snapshot if review.DiffTruncated { - review.Diff += "\nComplete reviewed diff: git -C " + quoteShWord(tree.dir) + " diff --no-ext-diff --no-textconv " + quoteShWord(tree.checkBase) + " " + quoteShWord(ticket.Snapshot) + " --\n" + review.Diff += "\nComplete reviewed diff: git -C " + quoteShWord(filepath.Join(ticket.Place, "portable")) + " diff --no-ext-diff --no-textconv " + quoteShWord(tree.checkBase) + " " + quoteShWord(ticket.Snapshot) + " --\n" } if err := persistTaskWorkspaceReview(taskWorkspaceReviewPath(id, use), ticket); err != nil { return review, err } published = true - if priorErr == nil { + if prior.Snapshot != "" { cleanupTaskWorkspaceReview(prior) } return review, nil @@ -186,6 +193,9 @@ func reviewTaskWorkspaceRoot(ctx context.Context, use WorkspaceRootUse, tree tas // ApplyTaskWorkspaceRoot grants permission only for this selected operation. // It never records Landing, starts a worker, or broadens another root's access. func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + if err := taskWorkspaceOperationEnvironment(ctx); err != nil { + return "", err + } epoch, err := a.beltAdmissionEpoch() if err != nil { return "", err @@ -199,7 +209,7 @@ func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot stri if use.Access != WorkspaceWrite { return "", errors.New("a read root cannot be applied") } - if use.Outcome == mergeMerged || use.Outcome == mergeInPlace { + if (use.Outcome == mergeMerged || use.Outcome == mergeInPlace) && !taskWorkspaceReviewPending(id, use) { return "", errors.New("this root was already applied; no work was repeated") } if err := a.taskWorkspaceApplyIdle(g, kept, tree); err != nil { @@ -209,7 +219,7 @@ func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot stri if err != nil { return "", err } - review, err := reviewTaskWorkspaceRoot(ctx, use, stage) + review, err := reviewTaskWorkspacePortableRoot(ctx, use, originalPortableReview{tree: tree, ticket: ticket, stage: stage}) if err != nil { return "", err } @@ -219,14 +229,22 @@ func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot stri if err := a.admitCompletedRunHomecoming(ctx, epoch); err != nil { return "", err } - // The existing landing checks changed originals and preserves both versions on - // conflict. This local tree already resolves the receiver's chosen binding. - outcome, detail, _, _ := stage.comeHome(kept.Title, review.Changed, a.signsGitWork()) - return a.recordTaskWorkspaceApplication(g, kept, use, ticket, outcome, detail) + // The transaction compares original bytes and metadata before publishing the + // exact reviewed snapshot. The original private copy stays untouched. + baseline, err := taskWorkspaceHistoricalBaseline(tree) + if err != nil { + return "", err + } + receipt, err := applyReviewedWorkspace(ctx, tree, filepath.Join(ticket.Place, "portable"), baseline, ticket.Portable, ticket.Snapshot, review.Changed) + if err != nil { + return receipt.Detail, err + } + return a.finishTaskWorkspaceApplication(g, kept, use, ticket, receipt) } func (a *Agent) recordTaskWorkspaceApplication(g *TaskGraph, kept TaskNotice, use WorkspaceRootUse, ticket taskWorkspaceReviewTicket, outcome, detail string) (string, error) { id, slot := kept.ID, use.Slot + previous := kept kept.WorkspaceSet = cloneWorkspaceSet(kept.WorkspaceSet) for i := range kept.WorkspaceSet.Roots { if kept.WorkspaceSet.Roots[i].Slot == slot { @@ -237,19 +255,24 @@ func (a *Agent) recordTaskWorkspaceApplication(g *TaskGraph, kept TaskNotice, us if strings.TrimSpace(detail) != "" { receipt += "\n" + detail } - kept.Report = withReport(kept.Report, receipt) + if !strings.Contains(kept.Report, receipt) { + kept.Report = withReport(kept.Report, receipt) + } a.publishRunRow(g, kept) g.mu.Lock() store := g.store g.mu.Unlock() if err := store.write(g); err != nil { - return receipt, fmt.Errorf("%s\nits actual outcome could not be saved; inspect the destination before retrying: %w", receipt, err) + a.publishRunRow(g, previous) + return receipt, fmt.Errorf("%s\nits actual outcome could not be saved; retry /task apply to recover its committed receipt without applying again: %w", receipt, err) + } + if err := syncTaskWorkspaceCheckpoint(g); err != nil { + a.publishRunRow(g, previous) + return receipt, fmt.Errorf("%s\nits checkpoint is not durable; retry /task apply to recover its committed receipt: %w", receipt, err) } if outcome != mergeMerged && outcome != mergeInPlace { return receipt, errors.New(receipt) } - cleanupTaskWorkspaceReview(ticket) - _ = os.Remove(taskWorkspaceReviewPath(id, use)) return receipt, nil } @@ -290,6 +313,8 @@ type taskWorkspaceReviewTicket struct { TaskID uint64 Slot, BaselineDigest string Stage *TaskCopyRecord + Portable taskcopy.PortableState + DeletedDirectories []string } func taskWorkspaceReviewPath(id uint64, use WorkspaceRootUse) string { @@ -301,17 +326,9 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU fail := func(err error) (taskTree, taskWorkspaceReviewTicket, error) { return taskTree{}, taskWorkspaceReviewTicket{}, err } - snapshot, err := sealGroundWork(original.dir, "review task workspace") - if err != nil { + if err := checkTaskWorkspaceWithheldOutputs(original); err != nil { return fail(err) } - if snapshot == "" { - head, err := gitContext(ctx, original.dir, nil, "rev-parse", "HEAD") - if err != nil { - return fail(err) - } - snapshot = strings.TrimSpace(head) - } root := strings.TrimSuffix(taskWorkspaceReviewPath(id, use), ".json") if err := os.MkdirAll(root, 0700); err != nil { return fail(err) @@ -331,7 +348,23 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU stage.place = place stage.uri, stage.chatURI, stage.projectURI, stage.groundURI, stage.rootURI = "", "", "", "", "" stage.universe = "" - bound, err := bindTaskWorkspaceSnapshot(ctx, use, original, snapshot) + portable, err := taskcopy.CapturePortableSnapshot(original.dir, filepath.Join(owned, "carrier"), filepath.Join(owned, "portable")) + if err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + repository := filepath.Join(owned, "portable") + snapshot, err := sealTaskWorkspaceReview(ctx, original, repository) + if err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + deletedDirectories, portableDigest, err := describeTaskWorkspaceSnapshot(original, portable) + if err != nil { + _ = os.RemoveAll(owned) + return fail(err) + } + bound, err := bindTaskWorkspaceSnapshot(ctx, use, original, snapshot, portableDigest, repository) if err != nil { _ = os.RemoveAll(owned) return fail(err) @@ -341,8 +374,8 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU ready := false defer func() { if !ready { - _, _ = git(original.dir, "worktree", "remove", "--force", stage.dir) - _, _ = git(original.dir, "branch", "-D", stage.branch) + _, _ = git(repository, "worktree", "remove", "--force", stage.dir) + _, _ = git(repository, "branch", "-D", stage.branch) _ = os.RemoveAll(owned) if stage.uri != "" { _ = os.RemoveAll(workspaceRuntimeRoot(stage.uri)) @@ -352,27 +385,24 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU if err := os.MkdirAll(filepath.Dir(stage.dir), 0700); err != nil { return fail(err) } - if output, err := gitContext(ctx, original.dir, nil, "worktree", "add", "-b", stage.branch, stage.dir, snapshot); err != nil { + if output, err := gitContext(ctx, repository, nil, "worktree", "add", "-b", stage.branch, stage.dir, snapshot); err != nil { return fail(fmt.Errorf("stage reviewed snapshot: %s: %w", output, err)) } if err := materializeTaskWorkspaceBaseline(stage, baseline); err != nil { return fail(err) } - stage, err = registerOwnedWorkspace(stage, place) + stage, err = registerTaskWorkspaceReviewStage(stage, place) if err != nil { return fail(err) } - if stage.uri == "" { - return fail(errors.New("reviewed staging copy could not record ownership")) - } - ticket := taskWorkspaceReviewTicket{TaskID: id, Slot: use.Slot, BaselineDigest: baselineDigest, CopyURI: use.Copy.URI, Destination: destination, Snapshot: snapshot, Origin: original.dir, Place: owned, Stage: runCopyOf(stage)} + ticket := taskWorkspaceReviewTicket{Portable: portable, DeletedDirectories: deletedDirectories, TaskID: id, Slot: use.Slot, BaselineDigest: baselineDigest, CopyURI: use.Copy.URI, Destination: destination, Snapshot: snapshot, Origin: original.dir, Place: owned, Stage: runCopyOf(stage)} ready = true return stage, ticket, nil } func loadTaskWorkspaceReview(id uint64, use WorkspaceRootUse, original taskTree) (taskWorkspaceReviewTicket, taskTree, error) { var ticket taskWorkspaceReviewTicket - raw, err := os.ReadFile(taskWorkspaceReviewPath(id, use)) + raw, err := readTaskWorkspaceReview(taskWorkspaceReviewPath(id, use)) if err != nil { return ticket, taskTree{}, errors.New("review this root first with /task review; no reviewed local snapshot is available") } @@ -417,15 +447,19 @@ func cleanupTaskWorkspaceReview(ticket taskWorkspaceReviewTicket) { if err != nil || resolved != ticket.Stage.Dir { return } - // Only the generated review worktree and its generated branch are retired. - _, _ = git(ticket.Origin, "worktree", "remove", "--force", ticket.Stage.Dir) - _, _ = git(ticket.Origin, "branch", "-D", ticket.Stage.Branch) + if err := validateTaskWorkspaceReviewRepository(ticket, resolved); err != nil { + return + } + repository := filepath.Join(ticket.Place, "portable") + // Only the generated review repository, worktree and branch are retired. + _, _ = git(repository, "worktree", "remove", "--force", ticket.Stage.Dir) + _, _ = git(repository, "branch", "-D", ticket.Stage.Branch) _ = os.RemoveAll(ticket.Place) _ = os.RemoveAll(workspaceRuntimeRoot(ticket.Stage.URI)) } -func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest string) string { - sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest)) +func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest, portableDigest, creationDigest string) string { + sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest + "\x00" + portableDigest + "\x00" + creationDigest)) return "CodeAF reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) } @@ -526,11 +560,15 @@ func validateTaskWorkspaceReviewStage(use WorkspaceRootUse, original taskTree, t if ticket.Stage.Dir != stage.dir || stage.dir != filepath.Join((Place{Dir: ticket.Place}).Trees(), "1", use.Slot) || ticket.Stage.Branch != currentBranch(stage.dir) || stageDestination != destination || stage.ground != original.ground || stage.root != original.root || stage.projectURI != original.projectURI || stage.checkBase != original.checkBase || stage.mode != original.mode || stage.home != original.home || stage.homeSha != original.homeSha { return errors.New("the reviewed staging identity or destination changed; review this root again") } - return nil + return validateTaskWorkspaceReviewRepository(ticket, stage.dir) } func validateTaskWorkspaceSnapshotBinding(use WorkspaceRootUse, original taskTree, ticket taskWorkspaceReviewTicket, stage taskTree, destination string) error { + portableDigest, err := taskWorkspacePortableReviewDigest(ticket.Portable, ticket.DeletedDirectories) + if err != nil { + return err + } message, err := git(stage.dir, "show", "-s", "--format=%B", ticket.Snapshot) - if err != nil || strings.TrimSpace(message) != taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, ticket.BaselineDigest) { + if err != nil || strings.TrimSpace(message) != taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, ticket.BaselineDigest, portableDigest, original.portableBaselineDigest) { return errors.New("the reviewed snapshot binding does not match this task root") } return nil @@ -575,7 +613,7 @@ type taskWorkspaceSnapshot struct { baseline []byte } -func bindTaskWorkspaceSnapshot(ctx context.Context, use WorkspaceRootUse, original taskTree, snapshot string) (taskWorkspaceSnapshot, error) { +func bindTaskWorkspaceSnapshot(ctx context.Context, use WorkspaceRootUse, original taskTree, snapshot, portableDigest, repository string) (taskWorkspaceSnapshot, error) { var err error baselineDigest := "" var baseline []byte @@ -591,12 +629,12 @@ func bindTaskWorkspaceSnapshot(ctx context.Context, use WorkspaceRootUse, origin if original.mode != TaskModeMirror { destination = original.root } - treeID, err := gitContext(ctx, original.dir, nil, "rev-parse", snapshot+"^{tree}") + treeID, err := gitContext(ctx, repository, nil, "rev-parse", snapshot+"^{tree}") if err != nil { return taskWorkspaceSnapshot{}, err } - message := taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, baselineDigest) - commit, err := gitContext(ctx, original.dir, nil, append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(treeID), "-p", snapshot, "-m", message)...) + message := taskWorkspaceSnapshotMessage(use.Copy.URI, destination, original.checkBase, baselineDigest, portableDigest, original.portableBaselineDigest) + commit, err := gitContext(ctx, repository, nil, append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(treeID), "-p", snapshot, "-m", message)...) if err != nil { return taskWorkspaceSnapshot{}, err } @@ -615,3 +653,250 @@ func materializeTaskWorkspaceBaseline(stage taskTree, baseline []byte) error { } return nil } + +func taskWorkspacePortableReviewDigest(state taskcopy.PortableState, deleted []string) (string, error) { + if _, err := state.Digest(); err != nil { + return "", err + } + raw, err := json.Marshal(struct { + State taskcopy.PortableState + Deleted []string + }{state, deleted}) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func taskWorkspaceDeletedDirectories(original taskTree, reviewed taskcopy.PortableState) ([]string, error) { + baseline, err := taskWorkspaceHistoricalBaseline(original) + if err != nil { + return nil, err + } + var candidates []string + for path, entry := range baseline.Entries { + if entry.Kind == "dir" { + if _, exists := reviewed.Entries[path]; !exists { + candidates = append(candidates, path) + } + } + } + actual, err := taskcopy.InspectPortablePaths(original.dir, candidates) + if err != nil { + return nil, err + } + var deleted []string + for _, path := range candidates { + if _, present := actual.Entries[path]; !present { + deleted = append(deleted, path) + } + } + sort.Strings(deleted) + return deleted, nil +} + +type originalPortableReview struct { + checkSource bool + tree, stage taskTree + ticket taskWorkspaceReviewTicket +} + +func reviewTaskWorkspacePortableRoot(ctx context.Context, use WorkspaceRootUse, input originalPortableReview) (TaskWorkspaceReview, error) { + review, err := reviewTaskWorkspaceRoot(ctx, use, input.stage) + if err != nil { + return review, err + } + baseline, err := taskWorkspaceHistoricalBaseline(input.tree) + if err != nil { + return review, err + } + review.Changed = append(review.Changed, input.ticket.DeletedDirectories...) + delta, err := workspaceApplicationDelta(baseline, input.ticket.Portable, review.Changed) + if err != nil { + return review, err + } + touched := describeTaskWorkspacePortableChanges(&review, delta) + // Before publishing a review, refuse source metadata the portable snapshot + // cannot represent. Later edits to the original remain private after review. + if input.checkSource { + if err = taskcopy.CheckPortableMutationMetadataContext(ctx, input.tree.dir, touched); err != nil { + return review, err + } + } + sort.Strings(review.Changed) + const previewLimit = 256 * 1024 + if len(review.Diff) > previewLimit { + review.Diff = review.Diff[:previewLimit] + "\n[Portable review preview truncated.]\n" + review.DiffTruncated = true + } + return review, nil +} + +func syncTaskWorkspaceCheckpoint(g *TaskGraph) error { + path := g.store.pathOf() + file, err := os.Open(path) + if err != nil { + return err + } + err = errors.Join(file.Sync(), file.Close()) + if err != nil { + return err + } + directory, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + return errors.Join(directory.Sync(), directory.Close()) +} + +func taskWorkspaceReviewPending(id uint64, use WorkspaceRootUse) bool { + info, err := os.Lstat(taskWorkspaceReviewPath(id, use)) + return err == nil && info.Mode().IsRegular() +} + +func sealTaskWorkspaceReview(ctx context.Context, original taskTree, repository string) (string, error) { + if _, err := taskWorkspaceHistoricalBaseline(original); err != nil { + return "", err + } + snapshot, err := sealGroundWork(repository, "review task workspace") + if err != nil { + return "", err + } + if snapshot != "" { + return snapshot, nil + } + head, err := gitContext(ctx, repository, nil, "rev-parse", "HEAD") + return strings.TrimSpace(head), err +} + +func describeTaskWorkspacePortableChanges(review *TaskWorkspaceReview, delta []workspaceApplicationEntry) []string { + paths := map[string]bool{} + for _, path := range review.Changed { + paths[path] = true + } + var touched []string + for _, change := range delta { + touched = append(touched, change.Path) + if !change.Guard && !paths[change.Path] { + review.Changed = append(review.Changed, change.Path) + paths[change.Path] = true + } + if !change.Guard && change.After != nil && (change.Before == nil || change.Before.Mode != change.After.Mode || change.After.Kind == "dir") { + review.Diff += fmt.Sprintf("\nPortable representation: %s (%s %04o)\n", change.Path, change.After.Kind, change.After.Mode) + } + } + + return touched +} + +func (a *Agent) finishTaskWorkspaceApplication(g *TaskGraph, kept TaskNotice, use WorkspaceRootUse, ticket taskWorkspaceReviewTicket, receipt taskWorkspaceApplyReceipt) (string, error) { + message, err := a.recordTaskWorkspaceApplication(g, kept, use, ticket, receipt.Outcome, receipt.Detail) + if err != nil { + return message, err + } + if err = acknowledgeWorkspaceApplication(receipt.Transaction); err != nil { + return message, err + } + cleanupTaskWorkspaceReview(ticket) + _ = os.Remove(taskWorkspaceReviewPath(kept.ID, use)) + return message, nil +} + +func validateTaskWorkspaceReviewRepository(ticket taskWorkspaceReviewTicket, stage string) error { + repository := filepath.Join(ticket.Place, "portable") + if err := workspacePathWithoutSymlinks(repository); err != nil { + return err + } + common, err := git(stage, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil || strings.TrimSpace(common) != filepath.Join(repository, ".git") { + return errors.New("reviewed stage no longer belongs to its generated private repository") + } + return nil +} + +func taskWorkspaceOperationEnvironment(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + upper := strings.ToUpper(name) + if taskWorkspaceGitRoutingVariable(upper) { + return fmt.Errorf("unset %s and relaunch CodeAF before reviewing or applying a task root", name) + } + } + return nil +} +func taskWorkspaceGitRoutingVariable(name string) bool { + switch name { + case "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_NAMESPACE", "GIT_GRAFT_FILE", "GIT_REPLACE_REF_BASE", "GIT_SHALLOW_FILE", "GIT_QUARANTINE_PATH": + return true + } + return strings.HasPrefix(name, "GIT_CONFIG_KEY_") || strings.HasPrefix(name, "GIT_CONFIG_VALUE_") +} + +func readTaskWorkspaceReview(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, (32<<20)+1)) + if err != nil { + return nil, err + } + if len(raw) > 32<<20 { + return nil, errors.New("review receipt exceeds its supported bound") + } + return raw, nil +} + +func priorTaskWorkspaceReview(id uint64, use WorkspaceRootUse, original taskTree) (taskWorkspaceReviewTicket, error) { + var ticket taskWorkspaceReviewTicket + raw, err := readTaskWorkspaceReview(taskWorkspaceReviewPath(id, use)) + if errors.Is(err, os.ErrNotExist) { + return ticket, nil + } + if err == nil { + err = json.Unmarshal(raw, &ticket) + } + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + if err == nil { + err = validateTaskWorkspaceReviewTicket(id, use, original, ticket, destination) + } + if err != nil { + return ticket, fmt.Errorf("existing review recovery information is unreadable; retain this private copy and retry /task apply before replacing its review: %w", err) + } + if err = workspaceApplicationReviewFence(ticket.Snapshot, destination); err != nil { + return ticket, err + } + // A damaged old stage is retained, never silently replaced or cleaned. + if _, _, err = loadTaskWorkspaceReview(id, use, original); err != nil { + return ticket, fmt.Errorf("existing review needs recovery; retry /task apply before replacing its review: %w", err) + } + return ticket, nil +} + +func describeTaskWorkspaceSnapshot(original taskTree, portable taskcopy.PortableState) ([]string, string, error) { + deleted, err := taskWorkspaceDeletedDirectories(original, portable) + if err != nil { + return nil, "", err + } + digest, err := taskWorkspacePortableReviewDigest(portable, deleted) + return deleted, digest, err +} + +func registerTaskWorkspaceReviewStage(stage taskTree, place Place) (taskTree, error) { + stage, err := registerOwnedWorkspace(stage, place) + if err != nil { + return stage, err + } + if stage.uri == "" { + return stage, errors.New("reviewed staging copy could not record ownership") + } + return stage, nil +} diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go index 37c8a75f16..d97c820956 100644 --- a/internal/session/task_workspace_apply_test.go +++ b/internal/session/task_workspace_apply_test.go @@ -3,12 +3,14 @@ package session import ( "context" "encoding/json" - "github.com/Agent-Field/codeaf/internal/home" "os" + "os/exec" "path/filepath" "slices" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/home" ) func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { @@ -143,6 +145,11 @@ func TestTaskWorkspaceApplyPreservesConflictingOriginalAndPrivateCopy(t *testing } func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint64, Place, string, string) { + t.Helper() + return completedWorkspaceApplyFixtureWithInput(t, repository, nil) +} + +func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setup func(string)) (*Agent, uint64, Place, string, string) { t.Helper() t.Setenv("CODEAF_HOME", t.TempDir()) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") @@ -154,6 +161,9 @@ func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint } else { writeFile(t, filepath.Join(source, "shared.txt"), "the original line\n") } + if setup != nil { + setup(source) + } place := Place{Dir: t.TempDir(), Workspace: source} engine := newBeltRunDouble("done") engine.real = true @@ -288,4 +298,329 @@ func TestTaskWorkspaceLargeReviewNamesItsImmutableDiff(t *testing.T) { if !review.DiffTruncated || !strings.Contains(review.Diff, "git -C ") || !strings.Contains(review.Diff, review.Snapshot) || !strings.Contains(review.Diff, " diff ") { t.Fatal("large review did not provide exact immutable diff inspection") } + _, command, ok := strings.Cut(review.Diff, "Complete reviewed diff: ") + if !ok { + t.Fatal("complete reviewed command missing") + } + writeFile(t, filepath.Join(copyDir, "large.txt"), "later mutable private edit\n") + output, err := exec.Command("sh", "-c", strings.TrimSpace(command)).Output() + if err != nil { + t.Fatalf("complete immutable diff command failed: %v", err) + } + if !strings.Contains(string(output), "+large immutable line") || strings.Contains(string(output), "later mutable private edit") { + t.Fatal("complete diff did not inspect the exact reviewed snapshot") + } +} + +func TestTaskWorkspaceApplyPreservesReviewedPortableMetadata(t *testing.T) { + for _, kind := range []string{"mode-and-crlf", "empty-directory", "directory-mode"} { + t.Run(kind, func(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed bytes\n") + switch kind { + case "mode-and-crlf": + writeFile(t, filepath.Join(copyDir, ".gitattributes"), "shared.txt text eol=lf\n") + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed bytes\r\n") + if err := os.Chmod(filepath.Join(copyDir, "shared.txt"), 0640); err != nil { + t.Fatal(err) + } + case "empty-directory": + if err := os.Mkdir(filepath.Join(copyDir, "empty"), 0750); err != nil { + t.Fatal(err) + } + case "directory-mode": + if err := os.Mkdir(filepath.Join(copyDir, "nested"), 0750); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(copyDir, "nested", "result.txt"), "nested result\n") + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + switch kind { + case "mode-and-crlf": + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "reviewed bytes\r\n" { + t.Errorf("reviewed raw EOL changed: %q", got) + } + info, err := os.Stat(filepath.Join(source, "shared.txt")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 { + t.Fatalf("reviewed file mode=%04o want0640", info.Mode().Perm()) + } + case "empty-directory", "directory-mode": + name := "empty" + if kind == "directory-mode" { + name = "nested" + } + info, err := os.Stat(filepath.Join(source, name)) + if err != nil { + t.Fatalf("reviewed directory missing: %v", err) + } + if info.Mode().Perm() != 0750 { + t.Fatalf("reviewed directory mode=%04o want0750", info.Mode().Perm()) + } + } + }) + } +} + +func TestTaskWorkspaceStartsWithPortableSourceRepresentation(t *testing.T) { + for _, repository := range []bool{false, true} { + t.Run(map[bool]string{false: "folder", true: "repository"}[repository], func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_TASK_BELT", "bash") + source := t.TempDir() + if repository { + source = newTestRepo(t) + } + writeFile(t, filepath.Join(source, ".gitattributes"), "shared.txt text eol=lf\n") + writeFile(t, filepath.Join(source, "shared.txt"), "input\r\n") + if err := os.Chmod(filepath.Join(source, "shared.txt"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(source, "empty"), 0750); err != nil { + t.Fatal(err) + } + place := Place{Dir: t.TempDir(), Workspace: source} + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = source, place, place.Transcript() }) + _, _, _, err := agent.StartTaskInWorkspaceSet(context.Background(), "inspect input", WorkspaceSetRequest{Primary: "work", Roots: []WorkspaceRootRequest{{Source: source, Slot: "work", Access: WorkspaceWrite}}}) + if err != nil { + t.Fatal(err) + } + <-engine.entered + engine.mu.Lock() + copyDir := engine.spec.Workspace + engine.mu.Unlock() + defer endBeltRun(t, agent, engine) + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "input\r\n" { + t.Errorf("private input raw EOL changed: %q", got) + } + for name, want := range map[string]os.FileMode{"shared.txt": 0640, "empty": 0750} { + info, err := os.Stat(filepath.Join(copyDir, name)) + if err != nil { + t.Errorf("private input %s: %v", name, err) + continue + } + if info.Mode().Perm() != want { + t.Errorf("private input %s mode=%04o want%04o", name, info.Mode().Perm(), want) + } + } + }) + } +} + +func TestTaskWorkspaceApplyUsesExplicitDirectoryDeletionCoverage(t *testing.T) { + for _, remove := range []bool{false, true} { + t.Run(map[bool]string{false: "newly-ignored-retained", true: "explicit-empty-directory-deletion"}[remove], func(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(source string) { + if err := os.Mkdir(filepath.Join(source, "keep"), 0750); err != nil { + t.Fatal(err) + } + }) + if remove { + if err := os.Remove(filepath.Join(copyDir, "keep")); err != nil { + t.Fatal(err) + } + } else { + writeFile(t, filepath.Join(copyDir, ".gitignore"), "keep/\n") + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(source, "keep")) + if remove { + if !os.IsNotExist(err) { + t.Fatalf("explicit empty directory deletion not applied: %v", err) + } + } else if err != nil || info.Mode().Perm() != 0750 { + t.Fatalf("newly ignored directory was changed: %v %v", info, err) + } + }) + } +} + +func TestTaskWorkspaceApplyRejectsTamperedPortableMetadata(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) + paths, err := filepath.Glob(home.Join("workspace-apply", "*.json")) + if err != nil || len(paths) != 1 { + t.Fatalf("review receipt missing: %v", err) + } + raw, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + var ticket taskWorkspaceReviewTicket + if err = json.Unmarshal(raw, &ticket); err != nil { + t.Fatal(err) + } + entry := ticket.Portable.Entries["shared.txt"] + entry.Mode = 0600 + ticket.Portable.Entries["shared.txt"] = entry + if err = os.Chmod(filepath.Join(ticket.Place, "portable", "shared.txt"), 0600); err != nil { + t.Fatal(err) + } + raw, err = json.Marshal(ticket) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(paths[0], raw, 0600); err != nil { + t.Fatal(err) + } + if _, err = agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("tampered portable metadata acquired landing authority") + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatalf("original changed: %q", got) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatalf("original private copy changed: %q", got) + } +} + +func TestTaskWorkspaceReviewRefusesNewOrChangedWithheldOutput(t *testing.T) { + for _, existing := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "changed"}[existing], func(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(source string) { + if existing { + writeFile(t, filepath.Join(source, ".env"), "FIXTURE_VALUE=original-private-input\n") + } + }) + paths, _ := filepath.Glob(home.Join("workspace-apply", "*.json")) + before, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(copyDir, ".env"), "FIXTURE_VALUE=changed-private-output\n") + if _, err = agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "withheld") { + t.Fatalf("withheld output did not refuse review: %v", err) + } + after, err := os.ReadFile(paths[0]) + if err != nil || string(after) != string(before) { + t.Fatal("failed review replaced prior usable receipt") + } + if got := readFile(t, filepath.Join(copyDir, ".env")); got != "FIXTURE_VALUE=changed-private-output\n" { + t.Fatal("private output changed") + } + if existing { + if got := readFile(t, filepath.Join(source, ".env")); got != "FIXTURE_VALUE=original-private-input\n" { + t.Fatal("original withheld input changed") + } + } else if _, err = os.Stat(filepath.Join(source, ".env")); !os.IsNotExist(err) { + t.Fatal("withheld output reached original") + } + }) + } +} + +func TestTaskWorkspaceApplyRetainsUnchangedWithheldInput(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(source string) { + writeFile(t, filepath.Join(source, ".env"), "FIXTURE_VALUE=original-private-input\n") + }) + if got := readFile(t, filepath.Join(copyDir, ".env")); got != "FIXTURE_VALUE=original-private-input\n" { + t.Fatal("local private input is unavailable") + } + if out, err := git(copyDir, "ls-files", "--", ".env"); err != nil || strings.TrimSpace(out) != "" { + t.Fatalf("withheld input entered history: %q %v", out, err) + } + paths, _ := filepath.Glob(home.Join("workspace-apply", "*.json")) + raw, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + var ticket taskWorkspaceReviewTicket + if err = json.Unmarshal(raw, &ticket); err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(ticket.Place, "portable", ".env")); !os.IsNotExist(err) { + t.Fatal("withheld input entered portable payload") + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(source, ".env")); got != "FIXTURE_VALUE=original-private-input\n" { + t.Fatal("withheld original input was changed by apply") + } +} + +func TestTaskWorkspaceDoorsRefuseInheritedGitRouting(t *testing.T) { + agent, id, _, source, _ := completedWorkspaceApplyFixture(t, false) + foreign := newTestRepo(t) + config := filepath.Join(foreign, ".git", "config") + before, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"GIT_DIR", "GIT_CONFIG"} { + t.Run(name, func(t *testing.T) { + value := filepath.Join(foreign, ".git") + if name == "GIT_CONFIG" { + value = config + } + t.Setenv(name, value) + if _, err = agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "unset") { + t.Fatalf("review routing refusal is not actionable: %v", err) + } + if _, err = agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "unset") { + t.Fatalf("apply routing refusal is not actionable: %v", err) + } + after, err := os.ReadFile(config) + if err != nil || string(after) != string(before) { + t.Fatal("foreign Git config changed") + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatal("original changed under inherited Git routing") + } + + }) + } +} + +func TestTaskWorkspaceFamilyRetainsIgnoredRuntimeInputs(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(source string) { + writeFile(t, filepath.Join(source, ".gitignore"), "cache/\n") + writeFile(t, filepath.Join(source, "cache", "input.txt"), "local build input\n") + }) + if got := readFile(t, filepath.Join(copyDir, "cache", "input.txt")); got != "local build input\n" { + t.Fatal("ignored local input unavailable") + } + if out, err := git(copyDir, "ls-files", "--", "cache"); err != nil || !strings.Contains(out, "cache/input.txt") { + t.Fatalf("family history lost runtime input: %q %v", out, err) + } + child := filepath.Join(t.TempDir(), "child") + if out, err := git(copyDir, "worktree", "add", "--detach", child, "HEAD"); err != nil { + t.Fatalf("restore child world: %s %v", out, err) + } + if got := readFile(t, filepath.Join(child, "cache", "input.txt")); got != "local build input\n" { + t.Fatal("child lost ignored runtime input") + } + paths, _ := filepath.Glob(home.Join("workspace-apply", "*.json")) + raw, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + var ticket taskWorkspaceReviewTicket + if err = json.Unmarshal(raw, &ticket); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(ticket.Place, "portable", "cache", "input.txt")); got != "local build input\n" { + t.Fatal("portable family world lost ignored runtime input") + } + if _, err = agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(source, "cache", "input.txt")); got != "local build input\n" { + t.Fatal("ignored original input changed") + } } diff --git a/internal/session/task_workspace_baseline.go b/internal/session/task_workspace_baseline.go new file mode 100644 index 0000000000..a787393d4e --- /dev/null +++ b/internal/session/task_workspace_baseline.go @@ -0,0 +1,114 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" + + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func finishWorkspaceRootInput(source string, tree taskTree, baseline taskcopy.PortableState) (taskTree, error) { + if err := taskcopy.MaterializePortableInput(source, tree.dir, baseline); err != nil { + return tree, fmt.Errorf("preserve private task input: %w", err) + } + digest, err := taskcopy.WritePortableBaseline(tree.dir, baseline) + if err != nil { + return tree, fmt.Errorf("record original task input metadata: %w", err) + } + tree.portableBaselineDigest = digest + return tree, nil +} + +func taskWorkspaceHistoricalBaseline(tree taskTree) (taskcopy.PortableState, error) { + if tree.portableBaselineDigest == "" { + return taskcopy.PortableState{}, errors.New("this older private copy has no recorded source metadata baseline; its files are retained, but portable apply cannot safely infer their original metadata") + } + state, digest, err := taskcopy.ReadPortableBaseline(tree.dir) + if err != nil { + return state, fmt.Errorf("historical source metadata is unavailable; private copy retained: %w", err) + } + if digest != tree.portableBaselineDigest { + return state, errors.New("historical source metadata changed; private copy retained") + } + return state, nil +} + +func preserveFailedWorkspaceCreation(tree taskTree, cause error) error { + root := home.Join("workspace-creation-failures") + sum := sha256.Sum256([]byte(tree.dir + "\x00" + tree.uri)) + path := filepath.Join(root, hex.EncodeToString(sum[:])+".json") + record := struct { + Copy *TaskCopyRecord + Error string + }{runCopyOf(tree), cause.Error()} + journalErr := os.MkdirAll(root, 0700) + if journalErr == nil { + journalErr = writeJSONAtomic(path, record) + } + if journalErr != nil { + return fmt.Errorf("%w; unused private input retained at %s (identity %s); could not save recovery receipt: %v", cause, tree.dir, tree.uri, journalErr) + } + return fmt.Errorf("%w; unused private input retained at %s (identity %s; recovery receipt %s)", cause, tree.dir, tree.uri, path) +} + +func openPortableWorkspaceFamilyTree(tree taskTree, baseline taskcopy.PortableState) (taskTree, error) { + var names []string + for name := range baseline.Withheld { + names = append(names, name) + } + sort.Strings(names) + var exclusions strings.Builder + for _, name := range names { + if strings.ContainsAny(name, "\n\r") { + return tree, errors.New("withheld input filename cannot safely be excluded from private history") + } + exclusions.WriteString("/" + strings.NewReplacer("\\", "\\\\", "*", "\\*", "?", "\\?", "[", "\\[", " ", "\\ ", "#", "\\#", "!", "\\!").Replace(name) + "\n") + } + if problem := openFamilyRepositoryWithPolicy(tree.dir, names); problem != "" { + return tree, errors.New(problem) + } + if len(names) > 0 { + if err := os.WriteFile(filepath.Join(tree.dir, ".git", "info", "exclude"), []byte(exclusions.String()), 0600); err != nil { + return tree, err + } + } + return tree, nil +} + +func checkTaskWorkspaceWithheldOutputs(tree taskTree) error { + baseline, err := taskWorkspaceHistoricalBaseline(tree) + if err != nil { + return err + } + var names []string + for name := range baseline.Withheld { + names = append(names, name) + } + actual, err := taskcopy.InspectPortablePaths(tree.dir, names) + if err != nil { + return err + } + for name, expected := range baseline.Withheld { + if actual.Entries[name] != expected { + return fmt.Errorf("withheld input %q changed; private copy retained", name) + } + } + current, err := taskcopy.InspectPortableState(tree.dir) + if err != nil { + return err + } + for name := range current.Withheld { + if _, ok := baseline.Withheld[name]; !ok { + return fmt.Errorf("new withheld output %q cannot be included in a reviewed snapshot; private copy retained", name) + } + } + return nil +} diff --git a/internal/session/task_workspace_fs_other.go b/internal/session/task_workspace_fs_other.go new file mode 100644 index 0000000000..6c81de392d --- /dev/null +++ b/internal/session/task_workspace_fs_other.go @@ -0,0 +1,36 @@ +//go:build !linux && !darwin + +package session + +import ( + "errors" + "os" +) + +func workspaceApplicationMoveOut(root, relative, staging, held string) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} +func workspaceApplicationCreateDirectory(root, relative string, mode os.FileMode) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} +func workspaceApplicationRenameExclusive(source, root, relative string) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} +func workspaceApplicationRemove(root, relative string, directory bool) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} +func workspaceApplicationChmod(root, relative string, mode os.FileMode) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} + +func workspaceApplicationDevice(path string) (uint64, error) { + return 0, errors.New("transactional portable apply requires Linux or Darwin") +} + +func workspaceApplicationSetSymlinkMode(root, relative string, mode os.FileMode) error { + return errors.New("transactional portable apply requires Linux or Darwin") +} + +func workspaceApplicationDirectoryIdentity(path string) (string, error) { + return "", errors.New("transactional portable apply requires Linux or Darwin") +} diff --git a/internal/session/task_workspace_fs_unix.go b/internal/session/task_workspace_fs_unix.go new file mode 100644 index 0000000000..872defdf5f --- /dev/null +++ b/internal/session/task_workspace_fs_unix.go @@ -0,0 +1,175 @@ +//go:build linux || darwin + +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/Agent-Field/codeaf/internal/taskcopy" + "golang.org/x/sys/unix" +) + +// Resolve every directory through held descriptors with O_NOFOLLOW. No mutation +// follows an ancestor swapped for a symlink between admission and publication. +func workspaceApplicationParent(root, relative string) (int, string, error) { + if !filepath.IsAbs(root) { + return -1, "", errors.New("application root must be absolute") + } + path := filepath.Join(root, filepath.Dir(filepath.FromSlash(relative))) + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return -1, "", err + } + for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if part == "" || part == "." { + continue + } + next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + _ = unix.Close(fd) + if err != nil { + return -1, "", err + } + fd = next + } + return fd, filepath.Base(filepath.FromSlash(relative)), nil +} +func workspaceApplicationMoveOut(root, relative, staging, held string) error { + from, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(from) + to, target, err := workspaceApplicationParent(staging, held) + if err != nil { + return err + } + defer unix.Close(to) + return workspaceApplicationRenameAtExclusive(from, name, to, target) +} +func workspaceApplicationCreateDirectory(root, relative string, mode os.FileMode) error { + fd, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(fd) + return workspaceApplicationMkdir(fd, name, mode) +} +func workspaceApplicationMkdir(parent int, name string, mode os.FileMode) error { + if err := unix.Mkdirat(parent, name, 0700); err != nil { + return err + } + fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + defer unix.Close(fd) + var opened, named unix.Stat_t + if err := unix.Fstat(fd, &opened); err != nil { + return err + } + if err := unix.Fstatat(parent, name, &named, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return err + } + if opened.Uid != uint32(os.Getuid()) || opened.Dev != named.Dev || opened.Ino != named.Ino { + return errors.New("new output directory ownership changed; recovery retained") + } + if err := unix.Fchown(fd, -1, os.Getgid()); err != nil { + return errors.Join(err, discardWorkspaceApplicationEmptyDirectory(parent, name, opened)) + } + if err := unix.Fchmod(fd, uint32(mode.Perm())); err != nil { + return errors.Join(err, discardWorkspaceApplicationEmptyDirectory(parent, name, opened)) + } + return nil +} + +// Only the still-held created inode may be removed, and only while empty. +func discardWorkspaceApplicationEmptyDirectory(parent int, name string, created unix.Stat_t) error { + var actual unix.Stat_t + if err := unix.Fstatat(parent, name, &actual, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return err + } + if actual.Uid != created.Uid || actual.Dev != created.Dev || actual.Ino != created.Ino { + return errors.New("new directory changed outside the transaction; recovery retained") + } + return unix.Unlinkat(parent, name, unix.AT_REMOVEDIR) +} + +func workspaceApplicationRenameExclusive(source, root, relative string) error { + from, name, err := workspaceApplicationParent(filepath.Dir(source), filepath.Base(source)) + if err != nil { + return err + } + defer unix.Close(from) + to, target, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(to) + return workspaceApplicationRenameAtExclusive(from, name, to, target) +} +func workspaceApplicationRemove(root, relative string, directory bool) error { + fd, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(fd) + flags := 0 + if directory { + flags = unix.AT_REMOVEDIR + } + return unix.Unlinkat(fd, name, flags) +} +func workspaceApplicationChmod(root, relative string, mode os.FileMode) error { + parent, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(parent) + fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Fchmod(fd, uint32(mode.Perm())) +} + +func workspaceApplicationDevice(path string) (uint64, error) { + info, err := os.Lstat(path) + if err != nil { + return 0, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("filesystem identity cannot be verified") + } + return uint64(stat.Dev), nil +} + +func workspaceApplicationSetSymlinkMode(root, relative string, mode os.FileMode) error { + parent, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return err + } + defer unix.Close(parent) + return taskcopy.SetPortableSymlinkModeAt(parent, name, mode) +} + +func workspaceApplicationDirectoryIdentity(path string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", errors.New("application staging directory replaced; retained") + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", errors.New("staging identity cannot be verified") + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), nil +} diff --git a/internal/session/task_workspace_recovery.go b/internal/session/task_workspace_recovery.go new file mode 100644 index 0000000000..d1255e5e30 --- /dev/null +++ b/internal/session/task_workspace_recovery.go @@ -0,0 +1,1391 @@ +package session + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// Review must retain the ticket that names an application awaiting acknowledgement. +// This check never creates, recovers, or deletes transaction state. +func workspaceApplicationReviewFence(identity, destination string) error { + sum := sha256.Sum256([]byte(identity + "\x00" + destination)) + base := home.Join("workspace-applications") + if _, err := os.Lstat(base); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + if err := workspacePathWithoutSymlinks(base); err != nil { + return err + } + root := filepath.Join(canonicalPath(base), hex.EncodeToString(sum[:])) + _, err := readWorkspaceApplication(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("pending application recovery is unreadable; retain this review and retry /task apply: %w", err) + } + return errors.New("this review has a pending application; retry /task apply before reviewing again") +} + +func persistWorkspaceApplication(root string, j *workspaceApplicationJournal) error { + path := filepath.Join(root, "journal.json") + raw, err := json.Marshal(j) + if len(raw) > taskcopy.WorkspaceCheckpointLimit { + return errors.New("application recovery journal exceeds its bound") + } + if err != nil { + return err + } + f, err := os.CreateTemp(root, "journal-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + _, writeErr := f.Write(raw) + syncErr := f.Sync() + closeErr := f.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + if err := os.Rename(f.Name(), path); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(root) +} +func readWorkspaceApplication(root string) (workspaceApplicationJournal, error) { + var j workspaceApplicationJournal + path := filepath.Join(root, "journal.json") + info, err := os.Lstat(path) + if err != nil { + return j, err + } + if !info.Mode().IsRegular() || info.Size() > taskcopy.WorkspaceCheckpointLimit { + return j, errors.New("invalid application recovery journal") + } + raw, err := readWorkspaceApplicationBounded(path, taskcopy.WorkspaceCheckpointLimit) + if err != nil { + return j, err + } + err = json.Unmarshal(raw, &j) + return j, err +} +func validateWorkspaceApplicationJournal(root, identity, destination string, j workspaceApplicationJournal) error { + sum := sha256.Sum256([]byte(identity + "\x00" + destination)) + if filepath.Base(root) != hex.EncodeToString(sum[:]) { + return errors.New("application journal identity does not name its generated root") + } + if j.Version != 1 || j.Nonce == "" || j.Identity != identity || j.Destination != destination || j.Staging != workspaceApplicationStaging(root, destination) || j.Receipt.Transaction != root { + return errors.New("application recovery identity disagrees with this reviewed root") + } + if err := workspacePathWithoutSymlinks(root); err != nil { + return err + } + for _, entry := range j.Entries { + if err := validWorkspaceApplicationPath(entry.Path); err != nil { + return err + } + } + return validateWorkspaceApplicationGitJournal(destination, &j) +} +func validateWorkspaceApplicationGitJournal(destination string, j *workspaceApplicationJournal) error { + if j.Git != nil { + index, err := git(destination, "rev-parse", "--path-format=absolute", "--git-path", "index") + if err != nil { + return err + } + if strings.TrimSpace(index) != j.Git.Index || j.Git.Branch == "" || j.Git.BeforeHead == "" || j.Git.AfterHead == "" { + return errors.New("application recovery Git binding changed") + } + if err := verifyWorkspaceApplicationGitBinding(j); err != nil { + return err + } + + } + return nil +} + +func recoverWorkspaceApplication(root, identity, destination string, expected []workspaceApplicationEntry, outcome string, gitExpected bool) (taskWorkspaceApplyReceipt, bool, error) { + j, err := readWorkspaceApplication(root) + if os.IsNotExist(err) { + return taskWorkspaceApplyReceipt{}, false, nil + } + if err != nil { + return taskWorkspaceApplyReceipt{}, true, err + } + if err := validateWorkspaceApplicationJournal(root, identity, destination, j); err != nil { + return taskWorkspaceApplyReceipt{}, true, err + } + if !workspaceApplicationIntentMatches(j, expected, outcome, gitExpected) { + return taskWorkspaceApplyReceipt{}, true, errors.New("recovery journal does not match the immutable reviewed changes") + } + + switch j.Phase { + case "committed": + if err := verifyWorkspaceApplicationCommitted(&j); err != nil { + return j.Receipt, true, fmt.Errorf("reviewed application was committed, but its destination subsequently changed; no replay was performed: %w", err) + } + return j.Receipt, true, nil + case "preparing", "prepared", "rolledback": + if err := verifyWorkspaceApplicationBefore(&j); err != nil { + return taskWorkspaceApplyReceipt{}, true, fmt.Errorf("recovery phase disagrees with destination; all recovery material retained: %w", err) + } + if err := discardWorkspaceApplication(root, &j); err != nil { + return taskWorkspaceApplyReceipt{}, true, err + } + return taskWorkspaceApplyReceipt{}, false, nil + case "applying": + if err := rollbackWorkspaceApplication(root, &j); err != nil { + return taskWorkspaceApplyReceipt{}, true, fmt.Errorf("interrupted application requires recovery at %s: %w", root, err) + } + return taskWorkspaceApplyReceipt{}, false, nil + default: + return taskWorkspaceApplyReceipt{}, true, errors.New("unsupported application recovery phase") + } +} + +func workspaceApplicationIntentMatches(j workspaceApplicationJournal, expected []workspaceApplicationEntry, outcome string, gitExpected bool) bool { + return !(!reflect.DeepEqual(j.Entries, expected) || j.Receipt.Detail != "" || j.Receipt.Outcome != outcome || !reflect.DeepEqual(j.Receipt.Changes, workspaceApplicationChangedPaths(expected)) || (gitExpected && j.Phase != "preparing" && j.Git == nil) || (!gitExpected && j.Git != nil)) +} + +// Checkpoint acknowledgement is the only door that releases a committed +// transaction's independent preimages. A lost acknowledgement remains replayable. +func acknowledgeWorkspaceApplication(root string) error { + base := canonicalPath(home.Join("workspace-applications")) + if filepath.Dir(root) != base || len(filepath.Base(root)) != 64 { + return errors.New("invalid application acknowledgement") + } + j, err := readWorkspaceApplication(root) + if err != nil { + return err + } + if err := validateWorkspaceApplicationJournal(root, j.Identity, j.Destination, j); err != nil { + return err + } + if j.Phase != "committed" { + return errors.New("application is not durably committed") + } + return discardWorkspaceApplication(root, &j) +} +func discardWorkspaceApplication(root string, j *workspaceApplicationJournal) error { + if j.Staging != workspaceApplicationStaging(root, j.Destination) { + return errors.New("application staging is not generated for this destination") + } + if err := releaseWorkspaceApplicationIndex(j); err != nil { + return err + } + if err := discardWorkspaceApplicationStage(root, j); err != nil { + return err + } + for _, name := range []string{"index-before", "index-after", "index-commit", "index-owned", "index-rollback", "stage-cleanup"} { + if err := os.Remove(filepath.Join(root, name)); err != nil && !os.IsNotExist(err) { + return err + } + } + + if err := discardWorkspaceApplicationTemps(root, j.Nonce); err != nil { + return err + } + if err := syncWorkspaceApplicationDirectory(root); err != nil { + return err + } + + if err := os.Remove(filepath.Join(root, "journal.json")); err != nil && !os.IsNotExist(err) { + return err + } + return syncWorkspaceApplicationDirectory(root) +} + +type workspaceApplicationCleanup struct{ Transaction, Identity, Destination, Nonce, Stage string } + +func workspaceApplicationCleanupState(root string, j *workspaceApplicationJournal) (workspaceApplicationCleanup, error) { + identity, err := workspaceApplicationDirectoryIdentity(j.Staging) + return workspaceApplicationCleanup{root, j.Identity, j.Destination, j.Nonce, identity}, err +} +func workspaceApplicationCleanupMatches(root string, j *workspaceApplicationJournal) bool { + expected, err := workspaceApplicationCleanupState(root, j) + if err != nil { + return false + } + raw, err := readWorkspaceApplicationBounded(filepath.Join(root, "stage-cleanup"), 4096) + if err != nil { + return false + } + var actual workspaceApplicationCleanup + return json.Unmarshal(raw, &actual) == nil && actual == expected +} +func persistWorkspaceApplicationCleanup(root string, j *workspaceApplicationJournal) error { + state, err := workspaceApplicationCleanupState(root, j) + if err != nil { + return err + } + raw, err := json.Marshal(state) + if err != nil { + return err + } + f, err := os.CreateTemp(root, "cleanup-"+j.Nonce+"-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + _, writeErr := f.Write(raw) + if err := errors.Join(writeErr, f.Sync(), f.Close()); err != nil { + return err + } + if err := os.Rename(f.Name(), filepath.Join(root, "stage-cleanup")); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(root) +} +func discardWorkspaceApplicationStage(root string, j *workspaceApplicationJournal) error { + _, err := os.Lstat(j.Staging) + if os.IsNotExist(err) { + return syncWorkspaceApplicationDirectory(filepath.Dir(j.Staging)) + } + if err != nil { + return err + } + if !j.StagingOwned && !workspaceApplicationOwnerMatches(root, j) { + return nil + } + if !workspaceApplicationOwnerMatches(root, j) { + return errors.New("application staging ownership marker changed; no cleanup performed") + } + if err := workspacePathWithoutSymlinks(j.Staging); err != nil { + return err + } + if err := verifyWorkspaceApplicationHeld(j); err != nil { + return err + } + if err := persistWorkspaceApplicationCleanup(root, j); err != nil { + return err + } + if err := os.RemoveAll(j.Staging); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(filepath.Dir(j.Staging)) +} +func discardWorkspaceApplicationTemps(root, nonce string) error { + f, err := os.Open(root) + if err != nil { + return err + } + defer f.Close() + for { + entries, readErr := f.ReadDir(64) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, "index-rollback-"+nonce+"-") && !strings.HasPrefix(name, "cleanup-"+nonce+"-") { + continue + } + if entry.IsDir() { + return errors.New("recovery scratch file replaced by a directory; retained") + } + if err := os.Remove(filepath.Join(root, name)); err != nil && !os.IsNotExist(err) { + return err + } + } + if readErr == io.EOF { + return nil + } + if readErr != nil { + return readErr + } + } +} + +func rollbackWorkspaceApplication(root string, j *workspaceApplicationJournal) error { + if j.Phase != "applying" { + return discardWorkspaceApplication(root, j) + } + if err := preflightWorkspaceApplicationRollback(j); err != nil { + return err + } + if err := rollbackWorkspaceApplicationGit(j); err != nil { + return err + } + if err := prepareWorkspaceApplicationRollbackDirectories(j); err != nil { + return err + } + for _, index := range workspaceApplicationRollbackOrder(j.Entries) { + if err := rollbackWorkspaceApplicationEntry(j, index); err != nil { + return err + } + } + if err := applyWorkspaceApplicationDirectoryModes(context.Background(), j, true); err != nil { + return err + } + if err := verifyWorkspaceApplicationBefore(j); err != nil { + return err + } + if err := syncWorkspaceApplicationDestination(j, true); err != nil { + return err + } + if err := syncWorkspaceApplicationGitPublished(j); err != nil { + return err + } + j.Phase = "rolledback" + if err := persistWorkspaceApplication(root, j); err != nil { + return err + } + return discardWorkspaceApplication(root, j) +} +func workspaceApplicationRollbackOrder(entries []workspaceApplicationEntry) []int { + indices := make([]int, len(entries)) + for index := range entries { + indices[index] = index + } + sort.Slice(indices, func(i, k int) bool { return len(entries[indices[i]].Path) > len(entries[indices[k]].Path) }) + return indices +} +func prepareWorkspaceApplicationRollbackDirectories(j *workspaceApplicationJournal) error { + entries := append([]workspaceApplicationEntry(nil), j.Entries...) + sort.Slice(entries, func(i, k int) bool { return len(entries[i].Path) < len(entries[k].Path) }) + for _, entry := range entries { + if entry.Guard { + continue + } + if entry.Before == nil || entry.Before.Kind != "dir" { + continue + } + path := filepath.Join(j.Destination, filepath.FromSlash(entry.Path)) + actual, err := workspaceApplicationReadEntry(path) + if err != nil { + return err + } + if actual == nil { + if err := workspaceApplicationCreateDirectory(j.Destination, entry.Path, entry.Before.Mode.Perm()); err != nil { + return err + } + continue + } + if actual.Kind != "dir" { + continue + } + if !reflect.DeepEqual(actual, entry.Before) && !reflect.DeepEqual(actual, entry.After) { + return fmt.Errorf("directory changed outside application: %s", entry.Path) + } + if err := workspaceApplicationChmod(j.Destination, entry.Path, entry.Before.Mode.Perm()); err != nil { + return err + } + } + return nil +} +func rollbackWorkspaceApplicationEntry(j *workspaceApplicationJournal, index int) error { + entry := j.Entries[index] + if entry.Guard { + return nil + } + target := filepath.Join(j.Destination, filepath.FromSlash(entry.Path)) + actual, err := workspaceApplicationReadEntry(target) + if err != nil { + return err + } + if reflect.DeepEqual(actual, entry.Before) { + return nil + } + if actual != nil && !reflect.DeepEqual(actual, entry.After) && !workspaceApplicationOwnDirectory(actual, entry) { + return fmt.Errorf("external changes preserved at %s; automatic rollback refused", entry.Path) + } + if actual != nil { + if err := workspaceApplicationRemove(j.Destination, entry.Path, actual.Kind == "dir"); err != nil { + return err + } + } + if entry.Before == nil { + return nil + } + if entry.Before.Kind == "dir" { + return workspaceApplicationCreateDirectory(j.Destination, entry.Path, entry.Before.Mode.Perm()) + } + return restoreWorkspaceApplicationOriginal(j, index, entry) +} +func restoreWorkspaceApplicationOriginal(j *workspaceApplicationJournal, index int, entry workspaceApplicationEntry) error { + held := workspaceApplicationPayload(j.Staging, "held", index) + if _, err := os.Lstat(held); err == nil { + if err := verifyWorkspaceApplicationEntry(held, entry.Before); err != nil { + return err + } + return workspaceApplicationRenameExclusive(held, j.Destination, entry.Path) + } else if !os.IsNotExist(err) { + return err + } + old := workspaceApplicationPayload(j.Staging, "old", index) + if err := verifyWorkspaceApplicationEntry(old, entry.Before); err != nil { + return err + } + return restoreWorkspaceApplicationPreimage(old, j.Destination, entry.Path, *entry.Before) +} +func workspaceApplicationOwnDirectory(actual *taskcopy.PortableEntry, entry workspaceApplicationEntry) bool { + return actual != nil && entry.Before == nil && actual.Kind == "dir" && entry.After != nil && entry.After.Kind == "dir" && actual.Mode == 0700 +} + +func restoreWorkspaceApplicationPreimage(old, root, relative string, entry taskcopy.PortableEntry) error { + temporary, err := os.CreateTemp(filepath.Dir(old), "rollback-") + if err != nil { + return err + } + name := temporary.Name() + if err := temporary.Close(); err != nil { + return err + } + if err := os.Remove(name); err != nil { + return err + } + defer os.Remove(name) + if err := copyWorkspaceApplicationEntry(old, name, entry); err != nil { + return err + } + return workspaceApplicationRenameExclusive(name, root, relative) +} + +func verifyWorkspaceApplicationHeld(j *workspaceApplicationJournal) error { + var paths []string + for index, entry := range j.Entries { + if entry.Before == nil || entry.Before.Kind == "dir" { + continue + } + held := workspaceApplicationPayload(j.Staging, "held", index) + if _, err := os.Lstat(held); os.IsNotExist(err) { + continue + } + paths = append(paths, filepath.ToSlash(filepath.Join("held", fmt.Sprintf("%08d", index)))) + if err := verifyWorkspaceApplicationEntry(held, entry.Before); err != nil { + return fmt.Errorf("an existing writer changed retained preimages; recovery material preserved: %w", err) + } + } + if len(paths) == 0 { + return nil + } + return taskcopy.CheckPortableMutationMetadata(j.Staging, paths) +} +func preflightWorkspaceApplicationRollback(j *workspaceApplicationJournal) error { + if err := verifyWorkspaceApplicationHeld(j); err != nil { + return err + } + if err := errors.Join(taskcopy.CheckPortableMutationMetadata(j.Destination, workspaceApplicationPaths(j.Entries)), taskcopy.CheckPortableRootInheritance(context.Background(), j.Destination)); err != nil { + return err + } + for _, entry := range j.Entries { + actual, err := workspaceApplicationReadEntry(filepath.Join(j.Destination, filepath.FromSlash(entry.Path))) + if err != nil { + return err + } + interimDir := actual != nil && actual.Kind == "dir" && entry.Before == nil && entry.After != nil && entry.After.Kind == "dir" && actual.Mode == 0700 + if actual != nil && !reflect.DeepEqual(actual, entry.Before) && !reflect.DeepEqual(actual, entry.After) && !interimDir { + return fmt.Errorf("external changes preserved at %s; rollback refused", entry.Path) + } + } + return preflightWorkspaceApplicationGitRollback(j) +} +func verifyWorkspaceApplicationCommitted(j *workspaceApplicationJournal) error { + if err := verifyWorkspaceApplicationAfter(j); err != nil { + return err + } + if j.Git == nil { + return nil + } + if currentBranch(j.Destination) != j.Git.Branch || branchCommit(j.Destination, j.Git.Branch) != j.Git.AfterHead { + return errors.New("destination branch changed since application committed") + } + _, err := verifyWorkspaceApplicationIndex(j.Git.Index, j.Git.AfterIndexDigest) + return err +} + +func syncWorkspaceApplicationDirectory(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + return errors.Join(f.Sync(), f.Close()) +} +func syncWorkspaceApplicationAncestors(path string) error { + for { + if err := syncWorkspaceApplicationDirectory(path); err != nil { + return err + } + parent := filepath.Dir(path) + if parent == path { + return nil + } + path = parent + } +} +func syncWorkspaceApplicationTree(root string) error { + var directories []string + if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + directories = append(directories, path) + } + return nil + }); err != nil { + return err + } + sort.Slice(directories, func(i, j int) bool { return len(directories[i]) > len(directories[j]) }) + for _, path := range directories { + if err := syncWorkspaceApplicationDirectory(path); err != nil { + return err + } + } + return nil +} +func syncWorkspaceApplicationDestination(j *workspaceApplicationJournal, before bool) error { + directories := map[string]bool{j.Destination: true, filepath.Dir(j.Destination): true, j.Staging: true, filepath.Join(j.Staging, "old"): true, filepath.Join(j.Staging, "new"): true, filepath.Join(j.Staging, "held"): true} + for _, entry := range j.Entries { + if value := workspaceApplicationEntryState(entry, before); value != nil && value.Kind == "file" { + if err := syncWorkspaceApplicationFile(filepath.Join(j.Destination, filepath.FromSlash(entry.Path))); err != nil { + return err + } + } + } + + for _, entry := range j.Entries { + path := filepath.Join(j.Destination, filepath.FromSlash(entry.Path)) + directories[filepath.Dir(path)] = true + if value := workspaceApplicationEntryState(entry, before); value != nil && value.Kind == "dir" { + directories[path] = true + } + } + ordered := make([]string, 0, len(directories)) + for path := range directories { + ordered = append(ordered, path) + } + sort.Slice(ordered, func(i, k int) bool { return len(ordered[i]) > len(ordered[k]) }) + for _, path := range ordered { + if _, err := os.Lstat(path); os.IsNotExist(err) { + continue + } + if err := syncWorkspaceApplicationDirectory(path); err != nil { + return err + } + } + return nil +} + +func prepareWorkspaceApplicationGit(ctx context.Context, root string, j *workspaceApplicationJournal, original taskTree, reviewedDir string) error { + if err := checkWorkspaceApplicationGitStorage(ctx, j.Destination); err != nil { + return err + } + if err := refuseWorkspaceApplicationSelectedGitChanges(ctx, j); err != nil { + return err + } + state, err := workspaceApplicationGitDestination(ctx, j.Destination) + if err != nil { + return err + } + + commitIndex, nextIndex, err := prepareWorkspaceApplicationIndices(ctx, root, j.Destination, state) + if err != nil { + return err + } + + j.Git = state + if err := updateWorkspaceApplicationGitEntries(ctx, j, reviewedDir, commitIndex, nextIndex); err != nil { + return err + } + tree, err := gitContext(ctx, j.Destination, []string{"GIT_INDEX_FILE=" + commitIndex}, "-c", "core.splitIndex=false", "write-tree") + if err != nil { + return err + } + state.AfterIndexDigest, err = workspaceApplicationFileDigest(nextIndex) + if err != nil { + return err + } + j.Git = state + args := append(codeafGitIdentity(), "commit-tree", strings.TrimSpace(tree), "-p", state.BeforeHead, "-m", workspaceApplicationGitBinding(j)) + commit, err := gitContext(ctx, j.Destination, nil, args...) + if err != nil { + return err + } + state.AfterHead = strings.TrimSpace(commit) + state.AfterIndexDigest, err = workspaceApplicationFileDigest(nextIndex) + if err != nil { + return err + } + j.Git = state + return nil +} +func checkWorkspaceApplicationGitStorage(ctx context.Context, destination string) error { + storage, err := workspaceApplicationGitInput(ctx, destination, nil, "", "config", "--local", "--get", "extensions.refStorage") + var exit *exec.ExitError + if err != nil && (!errors.As(err, &exit) || exit.ExitCode() != 1) { + return fmt.Errorf("cannot verify Git reference storage; private copy retained: %w", err) + } + if value := strings.TrimSpace(storage); value != "" && value != "files" { + return errors.New("portable apply requires Git files reference storage; this repository's reference storage is unsupported and the private copy was retained") + } + split, err := workspaceApplicationGitInput(ctx, destination, nil, "", "config", "--bool", "--get", "core.splitIndex") + if err != nil && (!errors.As(err, &exit) || exit.ExitCode() != 1) { + return err + } + if strings.TrimSpace(split) == "true" { + return errors.New("portable apply cannot yet recover a configured split index; disable core.splitIndex and run git update-index --no-split-index in the destination, then retry /task apply; private copy retained") + } + shared, err := workspaceApplicationGitInput(ctx, destination, nil, "", "rev-parse", "--shared-index-path") + if err != nil { + return err + } + if strings.TrimSpace(shared) != "" { + return errors.New("portable apply cannot yet recover a split index; run git update-index --no-split-index in the destination, then retry /task apply; private copy retained") + } + return nil +} + +func workspaceApplicationGitDestination(ctx context.Context, destination string) (*workspaceApplicationGit, error) { + branch := currentBranch(destination) + if branch == "" { + return nil, errors.New("destination checkout is detached") + } + head, err := gitContext(ctx, destination, nil, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + index, err := gitContext(ctx, destination, nil, "rev-parse", "--path-format=absolute", "--git-path", "index") + if err != nil { + return nil, err + } + state := &workspaceApplicationGit{Branch: branch, BeforeHead: strings.TrimSpace(head), Index: strings.TrimSpace(index)} + if err := workspacePathWithoutSymlinks(filepath.Dir(state.Index)); err != nil { + return nil, err + } + if info, err := os.Lstat(state.Index); err == nil && !info.Mode().IsRegular() { + return nil, errors.New("destination Git index is not a regular file") + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + + return state, nil +} + +func prepareWorkspaceApplicationIndices(ctx context.Context, root, destination string, state *workspaceApplicationGit) (string, string, error) { + before := filepath.Join(root, "index-before") + exists, err := copyWorkspaceApplicationIndex(state.Index, before) + if err != nil { + return "", "", err + } + state.BeforeIndexExists = exists + state.BeforeIndexDigest, err = workspaceApplicationOptionalDigest(before, exists) + if err != nil { + return "", "", err + } + commitIndex := filepath.Join(root, "index-commit") + if err := os.Remove(commitIndex); err != nil && !os.IsNotExist(err) { + return "", "", err + } + if _, err := gitContext(ctx, destination, []string{"GIT_INDEX_FILE=" + commitIndex}, "-c", "core.splitIndex=false", "read-tree", state.BeforeHead); err != nil { + return "", "", err + } + nextIndex := filepath.Join(root, "index-after") + if exists { + if _, err := copyWorkspaceApplicationIndex(before, nextIndex); err != nil { + return "", "", err + } + } else { + if _, err := gitContext(ctx, destination, []string{"GIT_INDEX_FILE=" + nextIndex}, "-c", "core.splitIndex=false", "read-tree", state.BeforeHead); err != nil { + return "", "", err + } + } + return commitIndex, nextIndex, nil +} + +func copyWorkspaceApplicationIndex(source, target string) (bool, error) { + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return false, err + } + input, err := os.Open(source) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + defer input.Close() + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return false, err + } + _, copyErr := io.Copy(output, input) + return true, errors.Join(copyErr, output.Sync(), output.Close()) +} +func updateWorkspaceApplicationGitEntries(ctx context.Context, j *workspaceApplicationJournal, reviewedDir string, indices ...string) error { + records, err := workspaceApplicationGitRecords(ctx, j, reviewedDir) + if err != nil { + return err + } + for _, index := range indices { + if _, err := workspaceApplicationGitInput(ctx, j.Destination, []string{"GIT_INDEX_FILE=" + index}, records, "-c", "core.splitIndex=false", "update-index", "-z", "--index-info"); err != nil { + return err + } + } + return nil +} +func workspaceApplicationGitRecords(ctx context.Context, j *workspaceApplicationJournal, reviewedDir string) (string, error) { + objects, err := workspaceApplicationNewObjects(ctx, j, reviewedDir) + if err != nil { + return "", err + } + var out strings.Builder + for _, entry := range j.Entries { + if entry.Guard || entry.After != nil && entry.After.Kind == "dir" || entry.After == nil && entry.Before != nil && entry.Before.Kind == "dir" { + continue + } + mode, object := "0", strings.Repeat("0", len(j.Git.BeforeHead)) + if entry.After != nil { + mode = workspaceApplicationGitMode(*entry.After) + object = objects[entry.Path] + if object == "" { + return "", errors.New("prepared Git blob is missing") + } + } + fmt.Fprintf(&out, "%s %s\t%s%c", mode, object, entry.Path, 0) + } + return out.String(), nil +} +func workspaceApplicationGitMode(entry taskcopy.PortableEntry) string { + if entry.Kind == "symlink" { + return "120000" + } + if entry.Mode.Perm()&0111 != 0 { + return "100755" + } + return "100644" +} +func workspaceApplicationNewObjects(ctx context.Context, j *workspaceApplicationJournal, reviewedDir string) (map[string]string, error) { + var files, links, linkInputs []string + for index, entry := range j.Entries { + if entry.Guard || entry.After == nil || entry.After.Kind == "dir" { + continue + } + if entry.After.Kind == "file" { + files = append(files, entry.Path) + continue + } + path := filepath.Join(j.Staging, fmt.Sprintf("link-%08d", index)) + if err := os.WriteFile(path, []byte(entry.After.Link), 0600); err != nil { + return nil, err + } + links = append(links, entry.Path) + linkInputs = append(linkInputs, path) + } + result, err := workspaceApplicationBatchObjects(ctx, j.Destination, reviewedDir, files, true, false) + if err != nil { + return nil, err + } + linked, err := workspaceApplicationBatchObjects(ctx, j.Destination, j.Destination, linkInputs, true, true) + if err != nil { + return nil, err + } + for index, path := range links { + result[path] = linked[linkInputs[index]] + } + return result, nil +} +func workspaceApplicationOptionalDigest(path string, exists bool) (string, error) { + if !exists { + return "absent", nil + } + return workspaceApplicationFileDigest(path) +} +func verifyWorkspaceApplicationGitBefore(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + state := j.Git + if currentBranch(j.Destination) != state.Branch || branchCommit(j.Destination, state.Branch) != state.BeforeHead { + return errors.New("destination Git branch moved; application refused") + } + _, err := verifyWorkspaceApplicationIndex(state.Index, state.BeforeIndexDigest) + return err +} +func verifyWorkspaceApplicationIndex(path, want string) (bool, error) { + info, err := os.Lstat(path) + exists := err == nil + if exists && !info.Mode().IsRegular() { + return true, errors.New("Git index is not a regular file; recovery retained") + } + if err != nil && !os.IsNotExist(err) { + return false, err + } + actual, err := workspaceApplicationOptionalDigest(path, exists) + if err != nil { + return false, err + } + if actual != want { + return exists, errors.New("destination Git index changed; recovery retained") + } + return exists, nil +} +func claimWorkspaceApplicationIndex(root string, j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + source := filepath.Join(root, "index-after") + if digest, err := workspaceApplicationFileDigest(source); err != nil || digest != j.Git.AfterIndexDigest { + return errors.New("prepared Git index changed") + } + owned := filepath.Join(root, "index-owned") + if err := copyWorkspaceApplicationIndexExclusive(source, owned); err != nil { + return err + } + if err := syncWorkspaceApplicationDirectory(root); err != nil { + return err + } + if err := os.Link(owned, j.Git.Index+".lock"); err != nil { + return fmt.Errorf("destination Git index is busy: %w", err) + } + return syncWorkspaceApplicationDirectory(filepath.Dir(j.Git.Index)) +} +func copyWorkspaceApplicationIndexExclusive(source, target string) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + return errors.Join(copyErr, output.Sync(), output.Close()) +} +func publishWorkspaceApplicationGit(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + state := j.Git + if err := verifyWorkspaceApplicationGitBefore(j); err != nil { + return err + } + if _, err := git(j.Destination, "update-ref", "refs/heads/"+state.Branch, state.AfterHead, state.BeforeHead); err != nil { + return err + } + if currentBranch(j.Destination) != state.Branch { + return errors.New("destination checkout changed during application") + } + if err := os.Rename(state.Index+".lock", state.Index); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(filepath.Dir(state.Index)) +} +func preflightWorkspaceApplicationGitRollback(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + state := j.Git + if currentBranch(j.Destination) != state.Branch { + return errors.New("destination checkout changed; Git recovery retained") + } + head := branchCommit(j.Destination, state.Branch) + if head != state.BeforeHead && head != state.AfterHead { + return errors.New("destination branch has external commits; Git recovery retained") + } + if _, err := verifyWorkspaceApplicationIndex(state.Index, state.BeforeIndexDigest); err == nil { + return nil + } + _, err := verifyWorkspaceApplicationIndex(state.Index, state.AfterIndexDigest) + return err +} +func rollbackWorkspaceApplicationGit(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + state := j.Git + if err := preflightWorkspaceApplicationGitRollback(j); err != nil { + return err + } + if err := restoreWorkspaceApplicationIndex(j); err != nil { + return err + } + if branchCommit(j.Destination, state.Branch) == state.AfterHead { + if _, err := git(j.Destination, "update-ref", "refs/heads/"+state.Branch, state.BeforeHead, state.AfterHead); err != nil { + return err + } + } + return nil +} +func restoreWorkspaceApplicationIndex(j *workspaceApplicationJournal) error { + state := j.Git + if _, err := verifyWorkspaceApplicationIndex(state.Index, state.BeforeIndexDigest); err == nil { + return releaseWorkspaceApplicationIndex(j) + } + if err := releaseWorkspaceApplicationIndex(j); err != nil { + return err + } + root := j.Receipt.Transaction + rollback := filepath.Join(root, "index-rollback") + if state.BeforeIndexExists { + if err := prepareWorkspaceApplicationRollbackIndex(root, rollback, state.BeforeIndexDigest, j.Nonce); err != nil { + return err + } + if err := os.Link(rollback, state.Index+".lock"); err != nil { + return err + } + if _, err := verifyWorkspaceApplicationIndex(state.Index, state.AfterIndexDigest); err != nil { + return err + } + if err := os.Rename(state.Index+".lock", state.Index); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(filepath.Dir(state.Index)) + } + if _, err := verifyWorkspaceApplicationIndex(state.Index, state.AfterIndexDigest); err != nil { + return err + } + return os.Remove(state.Index) +} +func prepareWorkspaceApplicationRollbackIndex(root, target, digest, nonce string) error { + if _, err := os.Lstat(target); err == nil { + if _, err = verifyWorkspaceApplicationIndex(target, digest); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(root) + } else if !os.IsNotExist(err) { + return err + } + name, err := copyWorkspaceApplicationRollbackCandidate(root, nonce) + if err != nil { + return err + } + defer os.Remove(name) + if _, err := verifyWorkspaceApplicationIndex(name, digest); err != nil { + return err + } + if err := workspaceApplicationRenameExclusive(name, root, filepath.Base(target)); err != nil && !os.IsExist(err) { + return err + } + if _, err := verifyWorkspaceApplicationIndex(target, digest); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(root) +} +func copyWorkspaceApplicationRollbackCandidate(root, nonce string) (string, error) { + input, err := os.Open(filepath.Join(root, "index-before")) + if err != nil { + return "", err + } + defer input.Close() + output, err := os.CreateTemp(root, "index-rollback-"+nonce+"-") + if err != nil { + return "", err + } + name := output.Name() + _, copyErr := io.Copy(output, input) + if err := errors.Join(copyErr, output.Sync(), output.Close()); err != nil { + _ = os.Remove(name) + return "", err + } + return name, nil +} + +func releaseWorkspaceApplicationIndex(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + path := j.Git.Index + ".lock" + actual, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + root := j.Receipt.Transaction + for _, name := range []string{"index-owned", "index-rollback"} { + owned, err := os.Lstat(filepath.Join(root, name)) + if err == nil && os.SameFile(actual, owned) { + return os.Remove(path) + } + } + return errors.New("Git index lock belongs to another writer; retained") +} + +func workspaceApplicationGitBinding(j *workspaceApplicationJournal) string { + state := j.Git + raw, _ := json.Marshal(struct { + Identity, Destination, Branch, BeforeHead, BeforeIndex, AfterIndex string + Entries []workspaceApplicationEntry + }{j.Identity, j.Destination, state.Branch, state.BeforeHead, state.BeforeIndexDigest, state.AfterIndexDigest, j.Entries}) + sum := sha256.Sum256(raw) + return "task: explicitly apply reviewed workspace\n\nApplication-binding: " + hex.EncodeToString(sum[:]) +} +func verifyWorkspaceApplicationGitBinding(j *workspaceApplicationJournal) error { + message, err := git(j.Destination, "show", "-s", "--format=%B", j.Git.AfterHead) + if err != nil || strings.TrimSpace(message) != workspaceApplicationGitBinding(j) { + return errors.New("application Git candidate does not bind this reviewed transaction") + } + parent, err := git(j.Destination, "show", "-s", "--format=%P", j.Git.AfterHead) + if err != nil || strings.TrimSpace(parent) != j.Git.BeforeHead { + return errors.New("application Git candidate parent changed") + } + return nil +} + +func syncWorkspaceApplicationFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + return errors.Join(f.Sync(), f.Close()) +} +func syncWorkspaceApplicationGitCandidate(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + objectDirectory, err := git(j.Destination, "rev-parse", "--path-format=absolute", "--git-path", "objects") + if err != nil { + return err + } + objectDirectory = strings.TrimSpace(objectDirectory) + objects, err := git(j.Destination, "rev-list", "--objects", "--no-object-names", j.Git.AfterHead, "--not", j.Git.BeforeHead) + if err != nil { + return err + } + packed := false + for _, id := range strings.Fields(objects) { + if len(id) < 3 { + return errors.New("invalid Git candidate object") + } + path := filepath.Join(objectDirectory, id[:2], id[2:]) + if _, err := os.Lstat(path); os.IsNotExist(err) { + packed = true + continue + } + if err := syncWorkspaceApplicationFile(path); err != nil { + return err + } + if err := syncWorkspaceApplicationDirectory(filepath.Dir(path)); err != nil { + return err + } + } + if packed { + if err := syncWorkspaceApplicationPacks(objectDirectory); err != nil { + return err + } + } + return syncWorkspaceApplicationDirectory(objectDirectory) +} +func syncWorkspaceApplicationPacks(objects string) error { + directory := filepath.Join(objects, "pack") + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Type().IsRegular() { + if err := syncWorkspaceApplicationFile(filepath.Join(directory, entry.Name())); err != nil { + return err + } + } + } + return syncWorkspaceApplicationDirectory(directory) +} +func syncWorkspaceApplicationGitPublished(j *workspaceApplicationJournal) error { + if j.Git == nil { + return nil + } + if err := syncWorkspaceApplicationFile(j.Git.Index); err != nil { + return err + } + reference, err := git(j.Destination, "rev-parse", "--path-format=absolute", "--git-path", "refs/heads/"+j.Git.Branch) + if err != nil { + return err + } + reference = strings.TrimSpace(reference) + if err := syncWorkspaceApplicationReference(j.Destination, reference); err != nil { + return err + } + return syncWorkspaceApplicationDirectory(filepath.Dir(j.Git.Index)) +} + +func syncWorkspaceApplicationReference(destination, reference string) error { + err := syncWorkspaceApplicationFile(reference) + if os.IsNotExist(err) { + packed, lookupErr := workspaceApplicationGitInput(context.Background(), destination, nil, "", "rev-parse", "--path-format=absolute", "--git-path", "packed-refs") + if lookupErr != nil { + return lookupErr + } + reference = strings.TrimSpace(packed) + err = syncWorkspaceApplicationFile(reference) + } + if err != nil { + return err + } + return syncWorkspaceApplicationAncestors(filepath.Dir(reference)) +} + +func workspaceApplicationGitView(ctx context.Context, destination, view string) ([]string, error) { + gitDir, err := gitContext(ctx, destination, nil, "rev-parse", "--absolute-git-dir") + if err != nil { + return nil, err + } + return []string{"GIT_DIR=" + strings.TrimSpace(gitDir), "GIT_WORK_TREE=" + view}, nil +} +func refuseWorkspaceApplicationSelectedGitChanges(ctx context.Context, j *workspaceApplicationJournal) error { + var paths, regular, links, linkInputs []string + for index, entry := range j.Entries { + if entry.Guard { + continue + } + paths = append(paths, entry.Path) + if entry.Before == nil || entry.Before.Kind == "dir" { + continue + } + if entry.Before.Kind == "file" { + regular = append(regular, entry.Path) + continue + } + input := filepath.Join(j.Staging, fmt.Sprintf("link-before-%08d", index)) + if err := os.WriteFile(input, []byte(entry.Before.Link), 0600); err != nil { + return err + } + links = append(links, entry.Path) + linkInputs = append(linkInputs, input) + } + staged, err := workspaceApplicationIndexObjects(ctx, j.Destination, paths) + if err != nil { + return err + } + objects, err := workspaceApplicationBatchObjects(ctx, j.Destination, j.Destination, regular, false, false) + if err != nil { + return err + } + linked, err := workspaceApplicationBatchObjects(ctx, j.Destination, j.Destination, linkInputs, false, true) + if err != nil { + return err + } + for index, path := range links { + objects[path] = linked[linkInputs[index]] + } + for _, path := range paths { + if objects[path] != "" && (staged[path] == "" || staged[path] != objects[path]) { + return fmt.Errorf("selected destination path %s contains uncommitted or untracked changes; private copy retained", path) + } + } + return nil +} +func workspaceApplicationIndexObjects(ctx context.Context, destination string, paths []string) (map[string]string, error) { + result := map[string]string{} + for start := 0; start < len(paths); start += 64 { + end := start + 64 + if end > len(paths) { + end = len(paths) + } + batch := paths[start:end] + args := append([]string{"diff", "--no-ext-diff", "--no-textconv", "--cached", "--name-only", "-z", "HEAD", "--"}, batch...) + changed, err := workspaceApplicationGitInput(ctx, destination, nil, "", args...) + if err != nil { + return nil, err + } + if changed != "" { + return nil, fmt.Errorf("selected path %s contains staged changes; private copy retained", gitNULPaths(changed)[0]) + } + args = append([]string{"ls-files", "--stage", "-z", "--"}, batch...) + raw, err := workspaceApplicationGitInput(ctx, destination, nil, "", args...) + if err != nil { + return nil, err + } + if err := parseWorkspaceApplicationIndex(raw, result); err != nil { + return nil, err + } + } + return result, nil +} +func parseWorkspaceApplicationIndex(raw string, result map[string]string) error { + for _, record := range gitNULPaths(raw) { + header, path, ok := strings.Cut(record, "\t") + fields := strings.Fields(header) + if !ok || len(fields) != 3 || fields[2] != "0" { + return errors.New("selected Git index has unresolved or invalid entries") + } + result[path] = fields[1] + } + return nil +} +func workspaceApplicationBatchObjects(ctx context.Context, destination, view string, paths []string, write, raw bool) (map[string]string, error) { + result := map[string]string{} + if len(paths) == 0 { + return result, nil + } + environment, err := workspaceApplicationGitView(ctx, destination, view) + if err != nil { + return nil, err + } + if !raw { + if err := workspaceApplicationCheckAttributes(ctx, view, environment, paths); err != nil { + return nil, err + } + } + var input strings.Builder + for _, path := range paths { + input.WriteString(quoteWorkspaceApplicationGitPath(path)) + input.WriteByte('\n') + } + args := []string{"hash-object", "--stdin-paths"} + if write { + args = append(args, "-w") + } + if raw { + args = append(args, "--no-filters") + } + out, err := workspaceApplicationGitInput(ctx, view, environment, input.String(), args...) + if err != nil { + return nil, err + } + ids := strings.Fields(out) + if len(ids) != len(paths) { + return nil, errors.New("Git returned an incomplete object list") + } + for index, path := range paths { + if !validWorkspaceApplicationObject(ids[index]) { + return nil, errors.New("Git returned an invalid object identity") + } + result[path] = ids[index] + } + return result, nil +} +func workspaceApplicationCheckAttributes(ctx context.Context, view string, environment, paths []string) error { + out, err := workspaceApplicationGitInput(ctx, view, environment, strings.Join(paths, "\x00")+"\x00", "check-attr", "-z", "--stdin", "filter", "working-tree-encoding") + if err != nil { + return err + } + fields := strings.Split(out, "\x00") + if len(fields) != len(paths)*6+1 || fields[len(fields)-1] != "" { + return errors.New("Git returned an incomplete attribute list") + } + for index, path := range paths { + for offset, attr := range []string{"filter", "working-tree-encoding"} { + start := index*6 + offset*3 + if fields[start] != path || fields[start+1] != attr { + return errors.New("Git returned mismatched attributes") + } + value := fields[start+2] + if value != "unspecified" && value != "unset" { + return fmt.Errorf("reviewed path %s uses unsupported Git %s; no changes were applied", path, attr) + } + } + } + return nil +} +func quoteWorkspaceApplicationGitPath(path string) string { + var out strings.Builder + out.WriteByte('"') + for _, value := range []byte(path) { + if value == '"' || value == '\\' { + out.WriteByte('\\') + out.WriteByte(value) + } else if value < 32 || value >= 127 { + fmt.Fprintf(&out, "\\%03o", value) + } else { + out.WriteByte(value) + } + } + out.WriteByte('"') + return out.String() +} +func validWorkspaceApplicationObject(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} +func workspaceApplicationOwner(root string, j *workspaceApplicationJournal) string { + return root + "\n" + j.Identity + "\n" + j.Destination + "\n" + j.Nonce + "\n" +} +func writeWorkspaceApplicationOwner(root string, j *workspaceApplicationJournal) error { + path := filepath.Join(j.Staging, "owner") + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + _, writeErr := f.WriteString(workspaceApplicationOwner(root, j)) + return errors.Join(writeErr, f.Sync(), f.Close(), syncWorkspaceApplicationDirectory(j.Staging)) +} +func workspaceApplicationOwnerMatches(root string, j *workspaceApplicationJournal) bool { + return workspaceApplicationOriginalOwnerMatches(root, j) || workspaceApplicationCleanupMatches(root, j) +} + +func workspaceApplicationOriginalOwnerMatches(root string, j *workspaceApplicationJournal) bool { + path := filepath.Join(j.Staging, "owner") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 4096 { + return false + } + raw, err := readWorkspaceApplicationBounded(path, 4096) + return err == nil && string(raw) == workspaceApplicationOwner(root, j) +} + +func readWorkspaceApplicationBounded(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > limit { + return nil, errors.New("application recovery record exceeds its bound") + } + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) + if len(raw) > int(limit) { + return nil, errors.New("application recovery record exceeds its bound") + } + return raw, err +} + +type workspaceApplicationCommandBuffer struct { + bytes.Buffer + limit int + truncate bool +} + +func (b *workspaceApplicationCommandBuffer) Write(p []byte) (int, error) { + if len(p) > b.limit-b.Len() { + if b.truncate { + n := len(p) + _, _ = b.Buffer.Write(p[:b.limit-b.Len()]) + return n, nil + } + return 0, errors.New("Git protocol output exceeded its bound") + } + return b.Buffer.Write(p) +} +func workspaceApplicationGitInput(ctx context.Context, dir string, environment []string, input string, args ...string) (string, error) { + if dir == "" { + return "", errors.New("Git application command has no working directory") + } + //codeaf:plumbing the transaction uses bounded local Git protocol streams. + command := exec.CommandContext(ctx, "git", args...) + command.Dir = dir + command.Env = append(os.Environ(), "GIT_PAGER=cat", "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0") + command.Env = append(command.Env, environment...) + command.Stdin = strings.NewReader(input) + output := &workspaceApplicationCommandBuffer{limit: taskcopy.WorkspaceCheckpointLimit} + stderr := &workspaceApplicationCommandBuffer{limit: 64 * 1024, truncate: true} + command.Stdout, command.Stderr = output, stderr + if err := command.Run(); err != nil { + return "", fmt.Errorf("Git application protocol failed: %s: %w", strings.TrimSpace(stderr.String()), err) + } + return output.String(), nil +} diff --git a/internal/session/task_workspace_rename_darwin.go b/internal/session/task_workspace_rename_darwin.go new file mode 100644 index 0000000000..2982770f6c --- /dev/null +++ b/internal/session/task_workspace_rename_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package session + +import "golang.org/x/sys/unix" + +func workspaceApplicationRenameAtExclusive(from int, name string, to int, target string) error { + return unix.RenameatxNp(from, name, to, target, unix.RENAME_EXCL) +} diff --git a/internal/session/task_workspace_rename_linux.go b/internal/session/task_workspace_rename_linux.go new file mode 100644 index 0000000000..c6183e3288 --- /dev/null +++ b/internal/session/task_workspace_rename_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package session + +import "golang.org/x/sys/unix" + +func workspaceApplicationRenameAtExclusive(from int, name string, to int, target string) error { + return unix.Renameat2(from, name, to, target, unix.RENAME_NOREPLACE) +} diff --git a/internal/session/task_workspace_transaction.go b/internal/session/task_workspace_transaction.go new file mode 100644 index 0000000000..9b1c935156 --- /dev/null +++ b/internal/session/task_workspace_transaction.go @@ -0,0 +1,770 @@ +package session + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +type taskWorkspaceApplyReceipt struct { + Transaction, Outcome, Detail string + Changes []string +} +type workspaceApplicationEntry struct { + Guard bool + Path string + Before, After *taskcopy.PortableEntry +} +type workspaceApplicationGit struct { + Branch, BeforeHead, AfterHead, Index string + BeforeIndexExists bool + BeforeIndexDigest, AfterIndexDigest string +} +type workspaceApplicationJournal struct { + Version int + Identity, Destination, Phase, Staging string + StagingOwned bool + Nonce string + Entries []workspaceApplicationEntry + Git *workspaceApplicationGit + Receipt taskWorkspaceApplyReceipt +} + +// One transaction owns raw filesystem publication, Git publication, and retained +// preimages. A committed receipt is replayed until its task checkpoint is durable. +func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir string, baseline, reviewed taskcopy.PortableState, identity string, wrote []string) (taskWorkspaceApplyReceipt, error) { + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + if err := workspaceApplicationInputs(destination, identity, baseline, reviewed); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + root, err := workspaceApplicationDirectory(identity, destination) + if err != nil { + return taskWorkspaceApplyReceipt{}, err + } + unlock, err := lockWorkspaceApplication(root, destination) + if err != nil { + return taskWorkspaceApplyReceipt{}, err + } + defer unlock() + expected, err := workspaceApplicationTransactionDelta(baseline, reviewed, wrote, original.mode != TaskModeMirror) + if err != nil { + return taskWorkspaceApplyReceipt{}, err + } + expectedOutcome := mergeInPlace + if original.mode != TaskModeMirror { + expectedOutcome = mergeMerged + } + if receipt, found, err := recoverWorkspaceApplication(root, identity, destination, expected, expectedOutcome, original.mode != TaskModeMirror); found || err != nil { + return receipt, err + } + if original.mode != TaskModeMirror && original.landsInThePersonsRepository() { + if reason := original.keptLandingSentence(); reason != "" { + return taskWorkspaceApplyReceipt{}, errors.New(reason) + } + } + return startWorkspaceApplication(ctx, root, original, reviewedDir, baseline, reviewed, identity, expected) +} + +func startWorkspaceApplication(ctx context.Context, root string, original taskTree, reviewedDir string, baseline, reviewed taskcopy.PortableState, identity string, expected []workspaceApplicationEntry) (taskWorkspaceApplyReceipt, error) { + destination := original.root + if original.mode == TaskModeMirror { + destination = original.ground + } + entries, err := planWorkspaceApplication(destination, baseline, expected) + if err != nil { + return taskWorkspaceApplyReceipt{}, err + } + if len(entries) == 0 { + return taskWorkspaceApplyReceipt{}, errors.New("this root has no portable changes to apply") + } + paths := workspaceApplicationPaths(entries) + if err := taskcopy.CheckPortableRootInheritance(ctx, destination); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + if err := taskcopy.CheckPortableMutationMetadataContext(ctx, destination, paths); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + if err := taskcopy.VerifyPortableState(reviewedDir, reviewed); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + j := workspaceApplicationJournal{Version: 1, Identity: identity, Destination: destination, Phase: "preparing", Entries: entries, Nonce: rand.Text()} + j.Staging = workspaceApplicationStaging(root, destination) + j.Receipt = taskWorkspaceApplyReceipt{Transaction: root, Outcome: mergeInPlace, Changes: workspaceApplicationChangedPaths(entries)} + if original.mode != TaskModeMirror { + j.Receipt.Outcome = mergeMerged + } + if err := stageWorkspaceApplication(ctx, root, &j, reviewedDir, original); err != nil { + cleanupErr := discardWorkspaceApplication(root, &j) + return taskWorkspaceApplyReceipt{}, errors.Join(err, cleanupErr) + } + if err := taskcopy.VerifyPortableState(reviewedDir, reviewed); err != nil { + return taskWorkspaceApplyReceipt{}, errors.Join(err, discardWorkspaceApplication(root, &j)) + } + if err := runWorkspaceApplication(ctx, root, &j); err != nil { + rollbackErr := rollbackWorkspaceApplication(root, &j) + if rollbackErr != nil { + return taskWorkspaceApplyReceipt{}, fmt.Errorf("apply failed: %v; recovery retained at %s: %w", err, root, rollbackErr) + } + return taskWorkspaceApplyReceipt{}, err + } + return j.Receipt, nil +} + +func workspaceApplicationInputs(destination, identity string, baseline, reviewed taskcopy.PortableState) error { + if !filepath.IsAbs(destination) || identity == "" { + return errors.New("reviewed application has no absolute destination or identity") + } + if err := workspacePathWithoutSymlinks(destination); err != nil { + return err + } + if _, err := baseline.Digest(); err != nil { + return fmt.Errorf("creation metadata baseline unavailable: %w", err) + } + _, err := reviewed.Digest() + return err +} +func workspaceApplicationDirectory(identity, destination string) (string, error) { + sum := sha256.Sum256([]byte(identity + "\x00" + destination)) + base := home.Join("workspace-applications") + if err := os.MkdirAll(base, 0700); err != nil { + return "", err + } + if err := workspacePathWithoutSymlinks(base); err != nil { + return "", err + } + root := filepath.Join(canonicalPath(base), hex.EncodeToString(sum[:])) + if err := os.MkdirAll(root, 0700); err != nil { + return "", err + } + if err := workspacePathWithoutSymlinks(root); err != nil { + return "", err + } + return root, syncWorkspaceApplicationAncestors(root) +} +func lockWorkspaceApplication(root, destination string) (func(), error) { + sum := sha256.Sum256([]byte(destination)) + path := filepath.Join(filepath.Dir(root), hex.EncodeToString(sum[:])+".lock") + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { + return nil, errors.New("application lock is not a regular file") + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err := filelock.Lock(f, true, true); err != nil { + f.Close() + return nil, fmt.Errorf("another workspace application holds this destination: %w", err) + } + return func() { _ = filelock.Unlock(f); _ = f.Close() }, nil +} +func workspaceApplicationStaging(root, destination string) string { + return filepath.Join(filepath.Dir(destination), ".codeaf-applying-"+filepath.Base(root)) +} +func workspaceApplicationPaths(entries []workspaceApplicationEntry) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + out = append(out, entry.Path) + } + return out +} + +func workspaceApplicationDelta(baseline, reviewed taskcopy.PortableState, wrote []string) ([]workspaceApplicationEntry, error) { + return workspaceApplicationTransactionDelta(baseline, reviewed, wrote, false) +} +func workspaceApplicationTransactionDelta(baseline, reviewed taskcopy.PortableState, wrote []string, gitMode bool) ([]workspaceApplicationEntry, error) { + selected := map[string]bool{} + for _, path := range wrote { + if err := validWorkspaceApplicationPath(path); err != nil { + return nil, err + } + selected[path] = true + } + for path, after := range reviewed.Entries { + if before, ok := baseline.Entries[path]; !ok || before != after { + selected[path] = true + } + } + var entries []workspaceApplicationEntry + for path := range selected { + if err := validWorkspaceApplicationPath(path); err != nil { + return nil, err + } + entry, changed, err := workspaceApplicationDeltaEntry(path, baseline, reviewed) + if err != nil { + return nil, err + } + if changed { + entries = append(entries, entry) + } + } + entries = workspaceApplicationAncestorGuards(entries, baseline) + if gitMode { + entries = workspaceApplicationAttributeGuards(entries, baseline) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + return entries, nil +} +func workspaceApplicationDeltaEntry(path string, baseline, reviewed taskcopy.PortableState) (workspaceApplicationEntry, bool, error) { + before, beforeOK := baseline.Entries[path] + after, afterOK := reviewed.Entries[path] + if beforeOK == afterOK && before == after { + return workspaceApplicationEntry{}, false, nil + } + if beforeOK && afterOK && before.Kind != after.Kind && (before.Kind == "dir" || after.Kind == "dir") { + return workspaceApplicationEntry{}, false, fmt.Errorf("directory type replacement requires explicit recovery and is not supported at %s", path) + } + entry := workspaceApplicationEntry{Path: path} + if beforeOK { + v := before + entry.Before = &v + } + if afterOK { + v := after + entry.After = &v + } + return entry, true, nil +} + +func workspaceApplicationAncestorGuards(entries []workspaceApplicationEntry, baseline taskcopy.PortableState) []workspaceApplicationEntry { + present := map[string]bool{} + for _, entry := range entries { + present[entry.Path] = true + } + originals := append([]workspaceApplicationEntry(nil), entries...) + for _, entry := range originals { + for parent := filepath.ToSlash(filepath.Dir(entry.Path)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) { + if present[parent] { + continue + } + value, ok := baseline.Entries[parent] + if !ok { + continue + } + before, after := value, value + entries = append(entries, workspaceApplicationEntry{Path: parent, Before: &before, After: &after, Guard: true}) + present[parent] = true + } + } + return entries +} +func workspaceApplicationAttributeGuards(entries []workspaceApplicationEntry, baseline taskcopy.PortableState) []workspaceApplicationEntry { + present := map[string]bool{} + for _, entry := range entries { + present[entry.Path] = true + } + originals := append([]workspaceApplicationEntry(nil), entries...) + for _, entry := range originals { + if entry.Guard { + continue + } + for directory := filepath.ToSlash(filepath.Dir(entry.Path)); ; directory = filepath.ToSlash(filepath.Dir(directory)) { + path := filepath.ToSlash(filepath.Join(directory, ".gitattributes")) + if !present[path] { + guard := workspaceApplicationEntry{Path: path, Guard: true} + if value, ok := baseline.Entries[path]; ok { + before, after := value, value + guard.Before = &before + guard.After = &after + } + entries = append(entries, guard) + present[path] = true + } + if directory == "." { + break + } + } + } + return entries +} +func workspaceApplicationChangedPaths(entries []workspaceApplicationEntry) []string { + var out []string + for _, entry := range entries { + if !entry.Guard { + out = append(out, entry.Path) + } + } + return out +} +func planWorkspaceApplication(destination string, baseline taskcopy.PortableState, entries []workspaceApplicationEntry) ([]workspaceApplicationEntry, error) { + current, err := taskcopy.InspectPortablePaths(destination, workspaceApplicationPaths(entries)) + if err != nil { + return nil, err + } + for _, entry := range entries { + if err := workspaceApplicationCompareBaseline(entry.Path, baseline, current); err != nil { + return nil, err + } + } + return entries, workspaceApplicationDirectoryCoverage(destination, entries) +} +func validWorkspaceApplicationPath(path string) error { + if path == "" || path == "." || !filepath.IsLocal(filepath.FromSlash(path)) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path || harnessWrote(path) || path == ".git" || strings.HasPrefix(path, ".git/") { + return fmt.Errorf("invalid application path %q", path) + } + return nil +} +func workspaceApplicationCompareBaseline(path string, baseline, current taskcopy.PortableState) error { + for probe := path; probe != "."; probe = filepath.ToSlash(filepath.Dir(probe)) { + expected, had := baseline.Entries[probe] + actual, has := current.Entries[probe] + if had != has || expected != actual { + return fmt.Errorf("destination metadata or contents changed at %s; reviewed work remains private", probe) + } + } + return nil +} +func workspaceApplicationDirectoryCoverage(destination string, entries []workspaceApplicationEntry) error { + removed := map[string]bool{} + for _, entry := range entries { + if entry.After == nil { + removed[entry.Path] = true + } + } + for _, entry := range entries { + if entry.Before == nil || entry.Before.Kind != "dir" || entry.After != nil { + continue + } + root := filepath.Join(destination, filepath.FromSlash(entry.Path)) + if err := filepath.WalkDir(root, func(path string, info os.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(destination, path) + if err != nil { + return err + } + if !removed[filepath.ToSlash(relative)] { + return fmt.Errorf("directory %s has retained or unselected contents", entry.Path) + } + return nil + }); err != nil { + return err + } + } + return nil +} + +func stageWorkspaceApplication(ctx context.Context, root string, j *workspaceApplicationJournal, reviewedDir string, original taskTree) error { + if err := ctx.Err(); err != nil { + return err + } + if err := persistWorkspaceApplication(root, j); err != nil { + return err + } + if err := createWorkspaceApplicationStaging(root, j); err != nil { + return err + } + if err := persistWorkspaceApplication(root, j); err != nil { + return err + } + for _, directory := range []string{"old", "new", "held"} { + if err := os.Mkdir(filepath.Join(j.Staging, directory), 0700); err != nil { + return err + } + } + if err := stageWorkspaceApplicationEntries(ctx, j, reviewedDir); err != nil { + return err + } + if original.mode != TaskModeMirror { + if err := prepareWorkspaceApplicationGit(ctx, root, j, original, reviewedDir); err != nil { + return err + } + } + j.Phase = "prepared" + if err := syncWorkspaceApplicationTree(j.Staging); err != nil { + return err + } + if err := syncWorkspaceApplicationDirectory(filepath.Dir(j.Staging)); err != nil { + return err + } + if err := syncWorkspaceApplicationGitCandidate(j); err != nil { + return err + } + return persistWorkspaceApplication(root, j) +} +func createWorkspaceApplicationStaging(root string, j *workspaceApplicationJournal) error { + if err := os.Mkdir(j.Staging, 0700); err != nil { + return fmt.Errorf("prepare destination-local transaction: %w", err) + } + if err := os.Chown(j.Staging, -1, os.Getgid()); err != nil { + return err + } + if err := os.Chmod(j.Staging, 0700); err != nil { + return err + } + if err := writeWorkspaceApplicationOwner(root, j); err != nil { + return err + } + j.StagingOwned = true + if err := checkWorkspaceApplicationDevice(j); err != nil { + return err + } + if err := probeWorkspaceApplicationRename(j.Staging); err != nil { + return err + } + return nil +} + +func checkWorkspaceApplicationDevice(j *workspaceApplicationJournal) error { + device, err := workspaceApplicationDevice(j.Staging) + if err != nil { + return err + } + seen := map[string]bool{} + paths := append([]string{j.Destination}, workspaceApplicationPaths(j.Entries)...) + for index, relative := range paths { + path := j.Destination + if index != 0 { + path = filepath.Join(j.Destination, filepath.FromSlash(relative)) + } + for { + if seen[path] { + break + } + seen[path] = true + current, err := workspaceApplicationDevice(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil && current != device { + return fmt.Errorf("destination path %s is on a different filesystem from the prepared transaction; private copy retained", path) + } + if path == j.Destination { + break + } + path = filepath.Dir(path) + } + } + return nil +} + +func probeWorkspaceApplicationRename(staging string) error { + source, target := filepath.Join(staging, "rename-probe-source"), filepath.Join(staging, "rename-probe-target") + defer os.Remove(source) + defer os.Remove(target) + if err := os.WriteFile(source, []byte("first"), 0600); err != nil { + return err + } + if err := workspaceApplicationRenameExclusive(source, staging, "rename-probe-target"); err != nil { + return fmt.Errorf("filesystem cannot publish an exclusive atomic rename; private copy retained: %w", err) + } + if err := os.WriteFile(source, []byte("second"), 0600); err != nil { + return err + } + if err := workspaceApplicationRenameExclusive(source, staging, "rename-probe-target"); !os.IsExist(err) { + return fmt.Errorf("filesystem cannot refuse an existing publication target; private copy retained: %v", err) + } + raw, err := readWorkspaceApplicationBounded(target, 16) + if err != nil { + return err + } + if string(raw) != "first" { + return errors.New("filesystem replaced an exclusive publication target; private copy retained") + } + return nil +} + +func stageWorkspaceApplicationEntries(ctx context.Context, j *workspaceApplicationJournal, reviewedDir string) error { + for index, entry := range j.Entries { + if err := ctx.Err(); err != nil { + return err + } + if err := stageWorkspaceApplicationEntry(j.Staging, index, entry, j.Destination, reviewedDir); err != nil { + return err + } + } + return nil +} + +func stageWorkspaceApplicationEntry(staging string, index int, entry workspaceApplicationEntry, destination, reviewedDir string) error { + if entry.Guard { + return nil + } + if entry.Before != nil && entry.Before.Kind != "dir" { + if err := copyWorkspaceApplicationEntry(filepath.Join(destination, filepath.FromSlash(entry.Path)), workspaceApplicationPayload(staging, "old", index), *entry.Before); err != nil { + return err + } + } + if entry.After != nil && entry.After.Kind != "dir" { + return copyWorkspaceApplicationEntry(filepath.Join(reviewedDir, filepath.FromSlash(entry.Path)), workspaceApplicationPayload(staging, "new", index), *entry.After) + } + return nil +} +func workspaceApplicationPayload(staging, kind string, index int) string { + return filepath.Join(staging, kind, fmt.Sprintf("%08d", index)) +} +func copyWorkspaceApplicationEntry(source, target string, entry taskcopy.PortableEntry) error { + if entry.Kind == "symlink" { + if err := os.Symlink(entry.Link, target); err != nil { + return err + } + return workspaceApplicationSetSymlinkMode(filepath.Dir(target), filepath.Base(target), entry.Mode) + } + if entry.Kind != "file" { + return errors.New("unsupported application payload type") + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + modeErr := output.Chmod(entry.Mode.Perm()) + syncErr := output.Sync() + closeErr := output.Close() + if err := errors.Join(copyErr, modeErr, syncErr, closeErr); err != nil { + return err + } + return verifyWorkspaceApplicationEntry(target, &entry) +} + +func runWorkspaceApplication(ctx context.Context, root string, j *workspaceApplicationJournal) error { + if err := ctx.Err(); err != nil { + return err + } + if err := claimWorkspaceApplicationIndex(root, j); err != nil { + return err + } + if err := verifyWorkspaceApplicationBefore(j); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + j.Phase = "applying" + if err := persistWorkspaceApplication(root, j); err != nil { + return err + } + if err := publishWorkspaceApplicationFiles(ctx, j); err != nil { + return err + } + if err := applyWorkspaceApplicationDirectoryModes(ctx, j, false); err != nil { + return err + } + if err := verifyWorkspaceApplicationHeld(j); err != nil { + return err + } + if err := verifyWorkspaceApplicationAfter(j); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := publishWorkspaceApplicationGit(j); err != nil { + return err + } + if err := syncWorkspaceApplicationDestination(j, false); err != nil { + return err + } + if err := syncWorkspaceApplicationGitPublished(j); err != nil { + return err + } + return commitWorkspaceApplication(ctx, root, j) +} +func commitWorkspaceApplication(ctx context.Context, root string, j *workspaceApplicationJournal) error { + if err := verifyWorkspaceApplicationCommitted(j); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + committed := *j + committed.Phase = "committed" + if err := persistWorkspaceApplication(root, &committed); err != nil { + return err + } + *j = committed + return nil +} +func publishWorkspaceApplicationFiles(ctx context.Context, j *workspaceApplicationJournal) error { + for _, index := range workspaceApplicationRemovalOrder(j.Entries) { + if err := ctx.Err(); err != nil { + return err + } + if err := removeWorkspaceApplicationTarget(j, index); err != nil { + return err + } + } + for _, index := range workspaceApplicationCreationOrder(j.Entries) { + if err := ctx.Err(); err != nil { + return err + } + if err := publishWorkspaceApplicationTarget(j, index); err != nil { + return err + } + } + return nil +} + +func verifyWorkspaceApplicationBefore(j *workspaceApplicationJournal) error { + for _, entry := range j.Entries { + if err := verifyWorkspaceApplicationEntry(filepath.Join(j.Destination, filepath.FromSlash(entry.Path)), entry.Before); err != nil { + return err + } + } + return verifyWorkspaceApplicationGitBefore(j) +} +func verifyWorkspaceApplicationAfter(j *workspaceApplicationJournal) error { + for _, entry := range j.Entries { + if err := verifyWorkspaceApplicationEntry(filepath.Join(j.Destination, filepath.FromSlash(entry.Path)), entry.After); err != nil { + return err + } + } + return errors.Join(taskcopy.CheckPortableMutationMetadata(j.Destination, workspaceApplicationPaths(j.Entries)), taskcopy.CheckPortableRootInheritance(context.Background(), j.Destination)) +} +func workspaceApplicationRemovalOrder(entries []workspaceApplicationEntry) []int { + var indices []int + for index, entry := range entries { + if !entry.Guard && entry.Before != nil && (entry.Before.Kind != "dir" || entry.After == nil || entry.After.Kind != "dir") { + indices = append(indices, index) + } + } + sort.Slice(indices, func(i, j int) bool { return len(entries[indices[i]].Path) > len(entries[indices[j]].Path) }) + return indices +} +func workspaceApplicationCreationOrder(entries []workspaceApplicationEntry) []int { + var indices []int + for index, entry := range entries { + if !entry.Guard && entry.After != nil && (entry.After.Kind != "dir" || entry.Before == nil || entry.Before.Kind != "dir") { + indices = append(indices, index) + } + } + sort.Slice(indices, func(i, j int) bool { return len(entries[indices[i]].Path) < len(entries[indices[j]].Path) }) + return indices +} +func removeWorkspaceApplicationTarget(j *workspaceApplicationJournal, index int) error { + entry := j.Entries[index] + target := filepath.Join(j.Destination, filepath.FromSlash(entry.Path)) + if err := verifyWorkspaceApplicationEntry(target, entry.Before); err != nil { + return err + } + if entry.Before.Kind == "dir" { + return workspaceApplicationRemove(j.Destination, entry.Path, true) + } + held := workspaceApplicationPayload(j.Staging, "held", index) + if err := workspaceApplicationMoveOut(j.Destination, entry.Path, j.Staging, filepath.ToSlash(filepath.Join("held", fmt.Sprintf("%08d", index)))); err != nil { + return err + } + return verifyWorkspaceApplicationEntry(held, entry.Before) +} +func publishWorkspaceApplicationTarget(j *workspaceApplicationJournal, index int) error { + entry := j.Entries[index] + target := filepath.Join(j.Destination, filepath.FromSlash(entry.Path)) + if err := workspacePathWithoutSymlinks(filepath.Dir(target)); err != nil { + return err + } + switch entry.After.Kind { + case "dir": + return workspaceApplicationCreateDirectory(j.Destination, entry.Path, 0700) + case "symlink", "file": + staged := workspaceApplicationPayload(j.Staging, "new", index) + return workspaceApplicationRenameExclusive(staged, j.Destination, entry.Path) + default: + return errors.New("unsupported application entry") + } +} +func workspaceApplicationEntryState(entry workspaceApplicationEntry, before bool) *taskcopy.PortableEntry { + if before { + return entry.Before + } + return entry.After +} + +func applyWorkspaceApplicationDirectoryModes(ctx context.Context, j *workspaceApplicationJournal, before bool) error { + entries := append([]workspaceApplicationEntry(nil), j.Entries...) + sort.Slice(entries, func(i, k int) bool { return len(entries[i].Path) > len(entries[k].Path) }) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + if entry.Guard { + continue + } + value := entry.After + if before { + value = entry.Before + } + if value == nil || value.Kind != "dir" { + continue + } + actual, err := workspaceApplicationReadEntry(filepath.Join(j.Destination, filepath.FromSlash(entry.Path))) + if err != nil { + return err + } + ownNew := workspaceApplicationOwnDirectory(actual, entry) + if !before && !ownNew && !reflect.DeepEqual(actual, entry.Before) && !reflect.DeepEqual(actual, entry.After) { + return fmt.Errorf("directory metadata changed during application: %s", entry.Path) + } + if err := workspaceApplicationChmod(j.Destination, entry.Path, value.Mode.Perm()); err != nil { + return err + } + } + return nil +} + +func workspaceApplicationReadEntry(path string) (*taskcopy.PortableEntry, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + entry := &taskcopy.PortableEntry{Mode: info.Mode().Perm()} + switch { + case info.IsDir(): + entry.Kind = "dir" + case info.Mode()&os.ModeSymlink != 0: + entry.Kind = "symlink" + entry.Link, err = os.Readlink(path) + case info.Mode().IsRegular(): + entry.Kind = "file" + entry.Size = info.Size() + entry.Digest, err = workspaceApplicationFileDigest(path) + default: + err = errors.New("unsupported filesystem entry during application") + } + return entry, err +} +func workspaceApplicationFileDigest(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} +func verifyWorkspaceApplicationEntry(path string, want *taskcopy.PortableEntry) error { + actual, err := workspaceApplicationReadEntry(path) + if err != nil { + return err + } + if !reflect.DeepEqual(actual, want) { + return fmt.Errorf("application path changed: %s", path) + } + return nil +} diff --git a/internal/session/task_workspace_transaction_test.go b/internal/session/task_workspace_transaction_test.go new file mode 100644 index 0000000000..28a1de3bff --- /dev/null +++ b/internal/session/task_workspace_transaction_test.go @@ -0,0 +1,389 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/home" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func TestTaskWorkspaceTransactionRejectsChangedPermissions(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) + path := filepath.Join(source, "shared.txt") + if err := os.Chmod(path, 0640); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("a metadata-only change in the original was overwritten") + } + if got := readFile(t, path); got != "the original line\n" { + t.Fatalf("original contents changed: %q", got) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0640 { + t.Fatalf("original permission change lost: %v %v", info, err) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatalf("private copy changed: %q", got) + } +} + +func TestTaskWorkspaceTransactionGitRefFailureRollsBackPortableState(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, true, func(root string) { + if err := os.Symlink("shared.txt", filepath.Join(root, "pointer")); err != nil { + t.Fatal(err) + } + mustGit(t, root, "add", "--", "pointer") + mustGit(t, root, append(codeafGitIdentity(), "commit", "-m", "include symlink input")...) + }) + if err := os.Remove(filepath.Join(copyDir, "pointer")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(copyDir, "pointer")); err != nil { + t.Fatal(err) + } + mustGit(t, source, "pack-refs", "--all") + writeFile(t, filepath.Join(copyDir, "added.txt"), "added raw\r\n") + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed raw\r\nbytes\r\n") + if err := os.Chmod(filepath.Join(copyDir, "shared.txt"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(copyDir, "empty"), 0750); err != nil { + t.Fatal(err) + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + before, err := taskcopy.InspectPortableState(source) + if err != nil { + t.Fatal(err) + } + head := strings.TrimSpace(gitOut(t, source, "rev-parse", "HEAD")) + indexPath := strings.TrimSpace(gitOut(t, source, "rev-parse", "--path-format=absolute", "--git-path", "index")) + beforeIndex, err := os.ReadFile(indexPath) + if err != nil { + t.Fatal(err) + } + branch := currentBranch(source) + lockPath := strings.TrimSpace(gitOut(t, source, "rev-parse", "--path-format=absolute", "--git-path", "refs/heads/"+branch+".lock")) + if err := os.MkdirAll(filepath.Dir(lockPath), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, []byte("another Git writer owns this lock\n"), 0600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(lockPath) }) + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil { + t.Fatal("apply succeeded despite unavailable branch publication") + } + if err := taskcopy.VerifyPortableState(source, before); err != nil { + t.Fatalf("Git publication failure left a partial portable apply: %v", err) + } + if got := strings.TrimSpace(gitOut(t, source, "rev-parse", "HEAD")); got != head { + t.Fatal("Git publication failure moved HEAD") + } + afterIndex, err := os.ReadFile(indexPath) + if err != nil || string(afterIndex) != string(beforeIndex) { + t.Fatalf("Git publication failure changed index: %v", err) + } + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatalf("retry after verified rollback: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "reviewed raw\r\nbytes\r\n" { + t.Fatalf("raw bytes changed: %q", got) + } + if link, err := os.Readlink(filepath.Join(source, "pointer")); err != nil || link != "missing-target" { + t.Fatalf("reviewed symlink changed: %q %v", link, err) + } + for path, mode := range map[string]os.FileMode{"shared.txt": 0640, "empty": 0750} { + info, err := os.Stat(filepath.Join(source, path)) + if err != nil || info.Mode().Perm() != mode { + t.Fatalf("portable metadata %s: %v %v", path, info, err) + } + } +} + +func TestTaskWorkspaceTransactionPreservesUnrelatedStagedAndDirtyFiles(t *testing.T) { + agent, id, _, source, _ := completedWorkspaceApplyFixture(t, true) + writeFile(t, filepath.Join(source, "staged.txt"), "the person's staged content\n") + mustGit(t, source, "add", "--", "staged.txt") + writeFile(t, filepath.Join(source, "dirty.txt"), "the person's unrelated content\n") + staged := gitOut(t, source, "diff", "--cached", "--binary") + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if after := gitOut(t, source, "diff", "--cached", "--binary"); after != staged { + t.Fatalf("unrelated staged changes were consumed or changed:\nbefore:%s\nafter:%s", staged, after) + } + if got := readFile(t, filepath.Join(source, "dirty.txt")); got != "the person's unrelated content\n" { + t.Fatalf("unrelated dirty file changed: %q", got) + } +} + +func TestTaskWorkspaceTransactionPreservesRawBytesWithCleanGitRepresentation(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + mustGit(t, source, "config", "core.safecrlf", "warn") + writeFile(t, filepath.Join(copyDir, ".gitattributes"), "*.txt text eol=lf\n") + writeFile(t, filepath.Join(copyDir, "shared.txt"), "reviewed CRLF\r\n") + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "reviewed CRLF\r\n" { + t.Fatalf("working bytes normalized: %q", got) + } + if status := strings.TrimSpace(gitOut(t, source, "status", "--porcelain", "--untracked-files=all")); status != "" { + t.Fatalf("applied Git representation is dirty: %s", status) + } + if blob := gitOut(t, source, "show", "HEAD:shared.txt"); blob != "reviewed CRLF\n" { + t.Fatalf("Git clean representation wrong: %q", blob) + } +} + +func TestTaskWorkspaceTransactionCheckpointFailureReplaysWithoutApplyingAgain(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission fault requires an unprivileged process") + } + for _, reopen := range []bool{false, true} { + t.Run(map[bool]string{false: "same-process", true: "reopened-agent"}[reopen], func(t *testing.T) { + agent, id, place, source, _ := completedWorkspaceApplyFixture(t, true) + if err := os.Chmod(place.Dir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(place.Dir, 0700) }) + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "could not be saved") { + t.Fatalf("fault did not reach checkpoint after application: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "private result\n" { + t.Fatalf("checkpoint fault did not occur after application: %q", got) + } + head := gitOut(t, source, "rev-parse", "HEAD") + if reopen { + _ = agent.Close() + } + if err := os.Chmod(place.Dir, 0700); err != nil { + t.Fatal(err) + } + if reopen { + agent, _ = newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { c.Workspace, c.Place, c.SessionFile = source, place, place.Transcript() }) + } + if reopen { + paths, err := filepath.Glob(home.Join("workspace-apply", "*.json")) + if err != nil || len(paths) != 1 { + t.Fatalf("review media unavailable: %v %v", paths, err) + } + raw, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatal(err) + } + var media struct{ Place string } + if err := json.Unmarshal(raw, &media); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(media.Place, "portable", "shared.txt")); err != nil { + t.Fatal(err) + } + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "/task apply") { + t.Fatalf("pending checkpoint review replaced the recoverable ticket: %v", err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatalf("committed checkpoint replay failed: %v", err) + } + if after := gitOut(t, source, "rev-parse", "HEAD"); after != head { + t.Fatal("checkpoint retry created a second Git application") + } + }) + } +} + +func TestTaskWorkspaceTransactionPreservesGitFilenames(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + paths := []string{"has space.txt", "line\nbreak.txt", "-leading.txt", "café-文件.txt"} + for _, path := range paths { + writeFile(t, filepath.Join(copyDir, path), "reviewed "+path+"\r\n") + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + for _, path := range paths { + if got := readFile(t, filepath.Join(source, path)); got != "reviewed "+path+"\r\n" { + t.Fatalf("wrong bytes for %q: %q", path, got) + } + } + if status := strings.TrimSpace(gitOut(t, source, "status", "--porcelain", "--untracked-files=all")); status != "" { + t.Fatalf("applied filename set left dirty: %s", status) + } +} + +func TestTaskWorkspaceTransactionApplyScale(t *testing.T) { + for _, count := range []int{100, 1000} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + for index := 0; index < count; index++ { + writeFile(t, filepath.Join(copyDir, fmt.Sprintf("result-%04d.txt", index)), "reviewed result\r\n") + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + started := time.Now() + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + t.Logf("public Apply %d added files: %s", count, time.Since(started)) + for _, index := range []int{0, count - 1} { + if got := readFile(t, filepath.Join(source, fmt.Sprintf("result-%04d.txt", index))); got != "reviewed result\r\n" { + t.Fatal("scale apply lost bytes") + } + } + }) + } +} + +func TestTaskWorkspaceTransactionRefusesHookModifiedOutput(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + hooks := t.TempDir() + mustGit(t, source, "config", "core.hooksPath", hooks) + script := "#!/bin/sh\nif [ \"$1\" = committed ] && [ ! -e hook-ran ]; then\n printf happened > hook-ran\n printf 'external hook edit\\n' > shared.txt\nfi\n" + if err := os.WriteFile(filepath.Join(hooks, "reference-transaction"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "recovery retained") { + t.Fatalf("hook changed output but apply did not refuse exact success with retained recovery: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "external hook edit\n" { + t.Fatalf("hook edit overwritten: %q", got) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatal("private output changed") + } +} + +func TestTaskWorkspaceTransactionCancellationBeforeCommitDecisionRollsBack(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + head := gitOut(t, source, "rev-parse", "HEAD") + hooks := t.TempDir() + mustGit(t, source, "config", "core.hooksPath", hooks) + script := "#!/bin/sh\nif [ \"$1\" = committed ] && [ ! -e hook-ran ]; then\n printf happened > hook-ran\n n=0\n while [ ! -e hook-released ] && [ $n -lt 200 ]; do n=$((n+1)); sleep 0.01; done\nfi\n" + if err := os.WriteFile(filepath.Join(hooks, "reference-transaction"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { _, err := agent.ApplyTaskWorkspaceRoot(ctx, id, "work"); finished <- err }() + deadline := time.After(10 * time.Second) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline: + t.Fatal("publication hook was never reached") + case err := <-finished: + t.Fatalf("apply returned before cancellation boundary: %v", err) + case <-ticker.C: + if _, err := os.Stat(filepath.Join(source, "hook-ran")); err == nil { + cancel() + writeFile(t, filepath.Join(source, "hook-released"), "release\n") + if err := <-finished; !errors.Is(err, context.Canceled) { + t.Fatalf("cancel before COMMITTED did not roll back: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatalf("cancel left applied bytes: %q", got) + } + if got := gitOut(t, source, "rev-parse", "HEAD"); got != head { + t.Fatal("cancel left applied branch") + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatal("private output changed") + } + return + } + } + } +} + +func TestTaskWorkspaceTransactionRefusesSplitIndexBeforeMutation(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + mustGit(t, source, "update-index", "--split-index") + head := gitOut(t, source, "rev-parse", "HEAD") + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "split index") { + t.Fatalf("split index did not refuse before mutation: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatalf("unsupported Git storage changed bytes: %q", got) + } + if got := gitOut(t, source, "rev-parse", "HEAD"); got != head { + t.Fatal("unsupported Git storage moved HEAD") + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatal("unsupported Git storage changed private copy") + } +} + +func TestTaskWorkspaceTransactionRefusesConfiguredSplitPreference(t *testing.T) { + agent, id, _, source, _ := completedWorkspaceApplyFixture(t, true) + mustGit(t, source, "config", "core.splitIndex", "true") + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err == nil || !strings.Contains(err.Error(), "split index") { + t.Fatalf("configured split index did not refuse: %v", err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "the original line\n" { + t.Fatalf("unsupported configuration changed output: %q", got) + } + if value := strings.TrimSpace(gitOut(t, source, "config", "--get", "core.splitIndex")); value != "true" { + t.Fatal("person's Git configuration changed") + } +} + +func TestTaskWorkspaceTransactionKeepsRootGroupAndNormalizesNewDirectory(t *testing.T) { + groups, err := os.Getgroups() + if err != nil { + t.Fatal(err) + } + secondary := -1 + for _, group := range groups { + if group != os.Getgid() { + secondary = group + break + } + } + if secondary < 0 { + t.Skip("requires a second supplementary group for inheritance fixture") + } + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, false, func(root string) { + if err := os.Chown(root, -1, secondary); err != nil { + t.Fatal(err) + } + }) + if err := os.Mkdir(filepath.Join(copyDir, "new-directory"), 0750); err != nil { + t.Fatal(err) + } + if _, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if err := taskcopy.CheckPortableMutationMetadata(source, []string{"new-directory"}); err != nil { + t.Fatalf("new directory group is not supported primary group: %v", err) + } + if err := taskcopy.CheckPortableMutationMetadata(source, []string{"."}); err == nil || !strings.Contains(err.Error(), "different owner or group") { + t.Fatalf("existing project root group changed: %v", err) + } +} diff --git a/internal/session/workspace_set_prepare.go b/internal/session/workspace_set_prepare.go index 02b2aaf40b..eab4cc42df 100644 --- a/internal/session/workspace_set_prepare.go +++ b/internal/session/workspace_set_prepare.go @@ -5,15 +5,16 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/taskcopy" - "github.com/Agent-Field/codeaf/internal/workspaceidentity" "io" "os" "path/filepath" "sort" "strconv" "strings" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/taskcopy" + "github.com/Agent-Field/codeaf/internal/workspaceidentity" ) func validateWorkspaceSetRequest(request *WorkspaceSetRequest) error { @@ -138,13 +139,23 @@ func prepareWorkspaceSet(ctx context.Context, place Place, id uint64, title stri if root, ok := repositoryRoot(use.Source); ok && canonicalPath(root) == canonicalPath(use.Source) && hasCommit(root) { order = worktreeOrder(place, root, dir, 0700, taskBranchName(title+"-"+use.Slot), title, "") } + inputBaseline, err := taskcopy.InspectPortableState(use.Source) + if order.promise == TaskModeMirror { + inputBaseline, err = taskcopy.InspectPortableFamilyInput(use.Source) + } + if err != nil { + return primary, fmt.Errorf("inspect workspace root %s source metadata: %w", use.Slot, err) + } tree, err := carveGround(ctx, order) if err != nil { return taskTree{}, fmt.Errorf("prepare workspace root %s: %w", use.Slot, err) } if order.promise == TaskModeMirror { rememberGroundBaseline(tree.dir) - tree = openFamilyTree(tree) + tree, err = openPortableWorkspaceFamilyTree(tree, inputBaseline) + if err != nil { + return primary, preserveFailedWorkspaceCreation(tree, err) + } // The native mirror is opened as Git only here, after the carve. Record // its immutable admission commit before any worker can change this copy. baseline, baseErr := git(tree.dir, "rev-parse", "--verify", "HEAD") @@ -157,6 +168,10 @@ func prepareWorkspaceSet(ctx context.Context, place Place, id uint64, title stri if err != nil { return taskTree{}, err } + tree, err = finishWorkspaceRootInput(use.Source, tree, inputBaseline) + if err != nil { + return primary, preserveFailedWorkspaceCreation(tree, err) + } tree.bashBelt = true landingURI := "" if use.Landing != "" { diff --git a/internal/syncsetup/workspace_transfer.go b/internal/syncsetup/workspace_transfer.go index 890efc9cb5..57ed25a897 100644 --- a/internal/syncsetup/workspace_transfer.go +++ b/internal/syncsetup/workspace_transfer.go @@ -70,6 +70,11 @@ func recordForkArtifacts(c cell.Cell, place session.Place, outputs map[string]st if entry.IsDir() { return nil } + // Literal links remain in the historical result and complete carrier. + // The deliverables picker cites regular files, never a link's target. + if entry.Type()&os.ModeSymlink != 0 { + return nil + } if !entry.Type().IsRegular() { return errors.New("historical artifact is not a regular output file") } diff --git a/internal/syncsetup/workspace_transfer_test.go b/internal/syncsetup/workspace_transfer_test.go index 3ec6491ba5..cc15a3f1b6 100644 --- a/internal/syncsetup/workspace_transfer_test.go +++ b/internal/syncsetup/workspace_transfer_test.go @@ -1,6 +1,7 @@ package syncsetup import ( + "bytes" "context" "encoding/json" "errors" @@ -23,6 +24,14 @@ import ( // This protocol fixture intentionally seeds mock structured metadata. The live // acceptance test separately requires the real model/task door to create it. func TestPublicTakeRestoresEveryWorkspaceSetRoot(t *testing.T) { + testPublicWorkspaceTakeAndFork(t, false) +} + +func TestPublicForkKeepsLiteralLinksWithoutCitingTheirTargets(t *testing.T) { + testPublicWorkspaceTakeAndFork(t, true) +} + +func testPublicWorkspaceTakeAndFork(t *testing.T, literalLinks bool) { h := newTwoHomes(t) t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv(home.EnvVar, h.homeA) @@ -51,6 +60,13 @@ func TestPublicTakeRestoresEveryWorkspaceSetRoot(t *testing.T) { plan := &taskcopy.WorkspacePlan{Version: 2, ChatURI: owner.URI} group := taskcopy.WorkspaceGroup{TaskID: 1, Primary: "app"} receiverBindings := map[string]string{} + outside := filepath.Join(t.TempDir(), "outside.txt") + if literalLinks { + if err := os.WriteFile(outside, []byte("mock outside bytes must not become a deliverable\n"), 0000); err != nil { + t.Fatal(err) + } + } + for _, slot := range []string{"app", "library"} { source := t.TempDir() project, err := registry.Register(workspaceidentity.Project, source) @@ -81,6 +97,13 @@ func TestPublicTakeRestoresEveryWorkspaceSetRoot(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "result.txt"), []byte("private output "+slot+"\n"), 0750); err != nil { t.Fatal(err) } + if literalLinks { + for name, target := range map[string]string{"dangling-link": "../missing-fixture-target", "outside-link": outside} { + if err := os.Symlink(target, filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + } receiverBindings[project.URI] = t.TempDir() } plan.Groups = append(plan.Groups, group) @@ -196,5 +219,32 @@ func TestPublicTakeRestoresEveryWorkspaceSetRoot(t *testing.T) { if _, err := os.Stat(filepath.Join(history, "results", "1", slot, "baseline.txt")); !os.IsNotExist(err) { t.Fatalf("fork copied unchanged input as output: %v", err) } + if literalLinks { + for name, want := range map[string]string{"dangling-link": "../missing-fixture-target", "outside-link": outside} { + got, err := os.Readlink(filepath.Join(history, "results", "1", slot, name)) + if err != nil || got != want { + t.Fatalf("fork lost literal result %s: %q %v", name, got, err) + } + } + } } + if literalLinks { + ledger, err := os.ReadFile((session.Place{Dir: fork.Taken.Cell.Root}).ArtifactLedger()) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(ledger, []byte("dangling-link")) || bytes.Contains(ledger, []byte("outside-link")) { + t.Fatal("literal link was registered as a regular document citation") + } + for _, artifact := range session.SealedArtifacts(fork.Taken.Cell.Root) { + info, err := os.Lstat(artifact.Path) + if err != nil || !info.Mode().IsRegular() { + t.Fatal("artifact registry exposed a link target") + } + } + if info, err := os.Stat(filepath.Join(history, filepath.Base(taskcopy.WorkspaceCarrierPath))); err != nil || !info.IsDir() { + t.Fatal("fork did not retain its complete historical carrier") + } + } + } diff --git a/internal/taskcopy/compose.go b/internal/taskcopy/compose.go index d1fe7bf3dc..e2d2a72089 100644 --- a/internal/taskcopy/compose.go +++ b/internal/taskcopy/compose.go @@ -119,6 +119,14 @@ func carryWithHistory(tree, dest string, full bool) error { return err } changed = append(changed, mixed...) + tracked, err := portableInspectionGit(tree, "ls-files", "--cached", "-z") + if err != nil { + return err + } + links, err := capturePortableLinks(tree, append(append([]string(nil), changed...), strings.Split(tracked, "\x00")...)) + if err != nil { + return err + } present, deleted := partition(tree, withoutSecrets(tree, changed)) if _, err := mirror.Sync(tree, filepath.Join(dest, filesDir), present); err != nil { return err @@ -127,7 +135,20 @@ func carryWithHistory(tree, dest string, full bool) error { if err := carryCommits(tree, branch, strings.TrimSpace(head), dest, full); err != nil { return err } - return writeRecord(dest, record{FullHistory: full, Branch: branch, Head: strings.TrimSpace(head), Deleted: deleted, Linked: isLinkedWorktree(tree), Modes: portableModes(tree, dest), EOL: styles}) + baseline, err := readWorkspaceFile(filepath.Join(tree, PortableBaselinePath), WorkspaceCheckpointLimit) + if errors.Is(err, os.ErrNotExist) { + baseline = nil + err = nil + } + if err != nil { + return err + } + if len(baseline) > 0 { + if _, _, err = decodePortableBaseline(baseline); err != nil { + return err + } + } + return writeRecord(dest, record{Symlinks: links, PortableBaseline: baseline, FullHistory: full, Branch: branch, Head: strings.TrimSpace(head), Deleted: deleted, Linked: isLinkedWorktree(tree), Modes: portableModes(tree, dest), EOL: styles}) } // carryCommits keeps dest's bundle of the commits only this copy holds in step @@ -247,3 +268,42 @@ func skipEntry(d fs.DirEntry) error { } return nil } + +func capturePortableLinks(root string, paths []string) (*PortableState, error) { + state := PortableState{Version: 1, Entries: map[string]PortableEntry{}} + for _, path := range paths { + if path == "" { + continue + } + info, err := os.Lstat(filepath.Join(root, path)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink == 0 { + continue + } + entry, err := inspectPortableEntry(filepath.Join(root, path), info) + if err != nil { + return nil, err + } + scanner := keys.Scanner{} + finding, err := scanner.Reader(path, strings.NewReader(entry.Link)) + if err != nil { + return nil, err + } + if finding.Rule != "" { + return nil, fmt.Errorf("withheld symlink %q cannot be carried", path) + } + state.Entries[filepath.ToSlash(path)] = entry + } + if len(state.Entries) == 0 { + return nil, nil + } + if _, err := state.Digest(); err != nil { + return nil, err + } + return &state, nil +} diff --git a/internal/taskcopy/record.go b/internal/taskcopy/record.go index 34ef17cd6b..ebc5379033 100644 --- a/internal/taskcopy/record.go +++ b/internal/taskcopy/record.go @@ -31,6 +31,8 @@ const ( // through is written by whatever umask the machine has and a copy must come back // with the modes it left with. type record struct { + Symlinks *PortableState `json:"symlinks,omitempty"` + PortableBaseline json.RawMessage `json:"portableBaseline,omitempty"` // FullHistory means HEAD has no prerequisite in the borrowed project. FullHistory bool `json:"fullHistory,omitempty"` Branch string `json:"branch,omitempty"` diff --git a/internal/taskcopy/restore.go b/internal/taskcopy/restore.go index 2d2d82bfff..a5dcf5e6f6 100644 --- a/internal/taskcopy/restore.go +++ b/internal/taskcopy/restore.go @@ -92,7 +92,7 @@ func (k Carry) restoreOne(c cell.Cell, project, name string) error { } } cutErr := k.cutAgain(project, dest, name, from, rec) - return errors.Join(cutErr, applyEOL(dest, rec.EOL), overlay(filepath.Join(from, filesDir), dest, rec), applyModes(dest, rec.Modes)) + return errors.Join(cutErr, applyEOL(dest, rec.EOL), overlay(filepath.Join(from, filesDir), dest, rec), applyModes(dest, rec.Modes), restorePortableBaseline(dest, rec)) } // applyModes gives each file of the copy the bits it left with, including the @@ -219,7 +219,10 @@ func overlay(files, dest string, rec record) error { if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } - return removeAll(dest, rec.Deleted) + if err := removeAll(dest, rec.Deleted); err != nil { + return err + } + return restorePortableLinks(dest, rec.Symlinks) } func removeAll(dest string, rels []string) error { @@ -233,3 +236,53 @@ func removeAll(dest string, rels []string) error { } return nil } + +func validatePortableLinks(state *PortableState) error { + if state == nil { + return nil + } + if _, err := state.Digest(); err != nil { + return err + } + if len(state.Withheld) > 0 { + return errors.New("carried symlink metadata has unsupported disposition") + } + for _, entry := range state.Entries { + if entry.Kind != "symlink" { + return errors.New("carried symlink metadata contains another kind") + } + } + return nil +} +func restorePortableLinks(dest string, state *PortableState) error { + if err := validatePortableLinks(state); err != nil { + return err + } + if state == nil { + return nil + } + root, err := os.OpenRoot(dest) + if err != nil { + return err + } + defer root.Close() + for path, entry := range state.Entries { + parent := filepath.Dir(filepath.FromSlash(path)) + if _, err = workspacePath(dest, parent); err != nil { + return err + } + if err = root.MkdirAll(parent, 0700); err != nil { + return err + } + if err = root.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err = root.Symlink(entry.Link, path); err != nil { + return err + } + if err = setPortableSymlinkMode(root, path, entry.Mode); err != nil { + return err + } + } + return nil +} diff --git a/internal/taskcopy/results.go b/internal/taskcopy/results.go index 864d6ff177..9eadfaec19 100644 --- a/internal/taskcopy/results.go +++ b/internal/taskcopy/results.go @@ -110,6 +110,11 @@ func copySelectedResult(checkout, from, output, relative string, record record) return nil } } + if record.Symlinks != nil { + if entry, ok := record.Symlinks.Entries[filepath.ToSlash(relative)]; ok { + return restorePortableLinks(output, &PortableState{Version: 1, Entries: map[string]PortableEntry{filepath.ToSlash(relative): entry}}) + } + } source := filepath.Join(from, filesDir, relative) if _, err := os.Lstat(source); errors.Is(err, fs.ErrNotExist) { source = filepath.Join(checkout, relative) @@ -123,6 +128,16 @@ func copySelectedResult(checkout, from, output, relative string, record record) if err != nil { return err } + if info.Mode()&os.ModeSymlink != 0 { + if _, err := workspacePath(checkout, filepath.Dir(relative)); err != nil { + return err + } + entry, err := inspectPortableEntry(source, info) + if err != nil { + return err + } + return restorePortableLinks(output, &PortableState{Version: 1, Entries: map[string]PortableEntry{filepath.ToSlash(relative): entry}}) + } if !info.Mode().IsRegular() { return fmt.Errorf("historical output %q is not a regular file; its archive is preserved", relative) } diff --git a/internal/taskcopy/results_test.go b/internal/taskcopy/results_test.go index 586b7f09d1..bdc0967ad3 100644 --- a/internal/taskcopy/results_test.go +++ b/internal/taskcopy/results_test.go @@ -72,3 +72,23 @@ func TestHistoricalOutputSelectionCannotLeaveItsFolder(t *testing.T) { } } } + +func TestCompletedResultsPreserveLiteralDanglingSymlinks(t *testing.T) { + m := newMachine(t) + tree := m.task("1") + if err := os.Symlink("missing-result-target", filepath.Join(tree, "result-link")); err != nil { + t.Fatal(err) + } + m.seal() + to := newMachineNoRepo(t) + m.moveTo(to) + dest := filepath.Join(to.cell.Root, ".cell", "fork-history", "fork", "results") + results, err := RestoreCompletedResults(to.cell, to.project, dest, []ResultSelection{{ID: "1", Paths: []string{"result-link"}}}) + if err != nil { + t.Fatal(err) + } + got, err := os.Readlink(filepath.Join(results["1"], "result-link")) + if err != nil || got != "missing-result-target" { + t.Fatalf("retained link=%q: %v", got, err) + } +} diff --git a/internal/taskcopy/review_metadata_darwin.go b/internal/taskcopy/review_metadata_darwin.go new file mode 100644 index 0000000000..e1c2a1003c --- /dev/null +++ b/internal/taskcopy/review_metadata_darwin.go @@ -0,0 +1,81 @@ +//go:build darwin + +package taskcopy + +import ( + "bytes" + "context" + "errors" + "golang.org/x/sys/unix" + "io/fs" + "os/exec" + "strings" + "syscall" + "time" +) + +func portableFilesystemFlags(path string, info fs.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Flags != 0 { + return errors.New("portable apply cannot preserve native filesystem flags") + } + return nil +} + +func portablePlatformMetadataBatch(ctx context.Context, paths []string) error { + for len(paths) > 0 { + n, size := 0, 0 + for n < len(paths) && n < 128 && size+len(paths[n]) < 32<<10 { + size += len(paths[n]) + 1 + n++ + } + if n == 0 { + return errors.New("native metadata path exceeds its bound") + } + if err := portableDarwinACLBatch(ctx, paths[:n]); err != nil { + return err + } + paths = paths[n:] + } + return ctx.Err() +} + +func portableDarwinACLBatch(ctx context.Context, paths []string) error { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + args := append([]string{"-ldben"}, paths...) + cmd := exec.CommandContext(ctx, "/bin/ls", args...) + output := &portableACLListing{} + cmd.Stdout = output + if err := cmd.Run(); err != nil { + return errors.New("portable apply cannot verify native access-control metadata") + } + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + if len(lines) != len(paths) { + return errors.New("portable apply cannot preserve native access-control metadata") + } + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) == 0 || strings.HasSuffix(fields[0], "+") { + return errors.New("portable apply cannot preserve native access-control metadata") + } + } + return nil +} + +type portableACLListing struct{ bytes.Buffer } + +func (b *portableACLListing) Write(raw []byte) (int, error) { + if b.Len()+len(raw) > 1<<20 { + return 0, errors.New("native ACL listing exceeds its bound") + } + return b.Buffer.Write(raw) +} + +// SetPortableSymlinkModeAt sets only the link inode through an owned parent descriptor. +func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error { + if mode != mode.Perm() { + return errors.New("symlink permission bits are unsupported") + } + return unix.Fchmodat(parentFD, name, uint32(mode.Perm()), unix.AT_SYMLINK_NOFOLLOW) +} diff --git a/internal/taskcopy/review_metadata_darwin_test.go b/internal/taskcopy/review_metadata_darwin_test.go new file mode 100644 index 0000000000..aad0c9db2e --- /dev/null +++ b/internal/taskcopy/review_metadata_darwin_test.go @@ -0,0 +1,72 @@ +//go:build darwin + +package taskcopy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPortableMutationRefusesNativeDarwinMetadata(t *testing.T) { + for _, kind := range []string{"acl", "flags"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "result.txt") + write(t, path, "result\n") + if err := os.Chown(path, -1, os.Getgid()); err != nil { + t.Fatal(err) + } + if err := CheckPortableMutationMetadata(root, []string{"result.txt"}); err != nil { + t.Fatalf("unadorned fixture must be supported: %v", err) + } + if kind == "acl" { + if out, err := exec.Command("/bin/chmod", "+a", "everyone deny delete", path).CombinedOutput(); err != nil { + t.Skipf("fixture ACL unavailable: %v %s", err, out) + } + defer exec.Command("/bin/chmod", "-N", path).Run() + } else { + if out, err := exec.Command("/usr/bin/chflags", "nodump", path).CombinedOutput(); err != nil { + t.Skipf("fixture flags unavailable: %v %s", err, out) + } + defer exec.Command("/usr/bin/chflags", "nonodump", path).Run() + } + if err := CheckPortableMutationMetadata(root, []string{"result.txt"}); err == nil { + t.Fatalf("native %s would be dropped by replacement", kind) + } else { + want := "native filesystem flags" + if kind == "acl" { + want = "access-control metadata" + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("native %s refused for wrong reason: %v", kind, err) + } + } + }) + } +} + +func TestPortableMutationPreflightBatchesDarwinPaths(t *testing.T) { + for _, count := range []int{100, 1000} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + root := t.TempDir() + paths := make([]string, count) + for i := range paths { + paths[i] = fmt.Sprintf("file-%04d.txt", i) + write(t, filepath.Join(root, paths[i]), "result\n") + if err := os.Chown(filepath.Join(root, paths[i]), -1, os.Getgid()); err != nil { + t.Fatal(err) + } + } + started := time.Now() + if err := CheckPortableMutationMetadata(root, paths); err != nil { + t.Fatal(err) + } + t.Logf("native metadata preflight: %d paths in %s", count, time.Since(started)) + }) + } +} diff --git a/internal/taskcopy/review_metadata_linux.go b/internal/taskcopy/review_metadata_linux.go new file mode 100644 index 0000000000..ca37614f16 --- /dev/null +++ b/internal/taskcopy/review_metadata_linux.go @@ -0,0 +1,52 @@ +//go:build linux + +package taskcopy + +import ( + "context" + "errors" + "io/fs" + "os" + + "golang.org/x/sys/unix" +) + +func portableFilesystemFlags(path string, info fs.FileInfo) error { + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + opened, err := file.Stat() + if err != nil { + return err + } + if !os.SameFile(info, opened) { + return errors.New("filesystem metadata identity changed") + } + flags, err := unix.IoctlGetInt(fd, unix.FS_IOC_GETFLAGS) + if err != nil { + return errors.New("portable apply cannot verify filesystem flags") + } + // Extents and directory indexing describe filesystem implementation, not + // portable user metadata. All other native flags require a retained case. + const implementationFlags = 0x80000 | 0x1000 + if flags & ^implementationFlags != 0 { + return errors.New("portable apply cannot preserve native filesystem flags") + } + return nil +} + +func portablePlatformMetadataBatch(ctx context.Context, paths []string) error { return ctx.Err() } + +// SetPortableSymlinkModeAt refuses modes Linux cannot represent on link inodes. +func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error { + if mode != 0777 { + return errors.New("this platform cannot preserve the reviewed symlink permissions") + } + return nil +} diff --git a/internal/taskcopy/review_metadata_linux_test.go b/internal/taskcopy/review_metadata_linux_test.go new file mode 100644 index 0000000000..47d02f2402 --- /dev/null +++ b/internal/taskcopy/review_metadata_linux_test.go @@ -0,0 +1,43 @@ +//go:build linux + +package taskcopy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestPortableMutationRefusesNativeFileFlags(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "result.txt") + write(t, path, "result\n") + if err := os.Chown(path, -1, os.Getgid()); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + flags, err := unix.IoctlGetInt(int(file.Fd()), unix.FS_IOC_GETFLAGS) + if err != nil { + t.Skipf("filesystem flags unavailable: %v", err) + } + if err := CheckPortableMutationMetadata(root, []string{"result.txt"}); err != nil { + t.Fatalf("unadorned fixture must be supported: %v", err) + } + const nodump = 0x40 + if err = unix.IoctlSetPointerInt(int(file.Fd()), unix.FS_IOC_SETFLAGS, flags|nodump); err != nil { + t.Skipf("fixture cannot set nodump: %v", err) + } + defer unix.IoctlSetPointerInt(int(file.Fd()), unix.FS_IOC_SETFLAGS, flags) + if err = CheckPortableMutationMetadata(root, []string{"result.txt"}); err == nil { + t.Fatal("native file flags would be dropped by replacement") + } else if !strings.Contains(err.Error(), "native filesystem flags") { + t.Fatalf("native flags refused for wrong reason: %v", err) + } +} diff --git a/internal/taskcopy/review_metadata_other.go b/internal/taskcopy/review_metadata_other.go new file mode 100644 index 0000000000..015e963506 --- /dev/null +++ b/internal/taskcopy/review_metadata_other.go @@ -0,0 +1,23 @@ +//go:build !linux && !darwin + +package taskcopy + +import ( + "context" + "errors" + "io/fs" +) + +func portablePlatformMetadata(path string, info fs.FileInfo) error { + return errors.New("portable apply cannot verify this platform's extended metadata; private copy retained") +} + +func portablePlatformMetadataBatch(ctx context.Context, paths []string) error { return ctx.Err() } + +func portableUnsupportedNativeMetadata(path string, info fs.FileInfo) error { + return portablePlatformMetadata(path, info) +} + +func SetPortableSymlinkModeAt(parentFD int, name string, mode fs.FileMode) error { + return errors.New("this platform cannot preserve symlink permissions") +} diff --git a/internal/taskcopy/review_metadata_unix.go b/internal/taskcopy/review_metadata_unix.go new file mode 100644 index 0000000000..ed21ed96cd --- /dev/null +++ b/internal/taskcopy/review_metadata_unix.go @@ -0,0 +1,37 @@ +//go:build linux || darwin + +package taskcopy + +import ( + "errors" + "io/fs" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func portablePlatformMetadata(path string, info fs.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("filesystem ownership metadata cannot be verified") + } + if stat.Uid != uint32(os.Getuid()) || stat.Gid != uint32(os.Getgid()) { + return errors.New("portable apply cannot preserve a different owner or group") + } + if !info.IsDir() && stat.Nlink > 1 { + return errors.New("portable apply cannot preserve hardlink topology") + } + return portableUnsupportedNativeMetadata(path, info) +} + +func portableUnsupportedNativeMetadata(path string, info fs.FileInfo) error { + size, err := unix.Llistxattr(path, nil) + if err != nil && !errors.Is(err, unix.ENOTSUP) { + return err + } + if size > 0 { + return errors.New("portable apply cannot preserve extended attributes or access-control metadata") + } + return portableFilesystemFlags(path, info) +} diff --git a/internal/taskcopy/review_snapshot.go b/internal/taskcopy/review_snapshot.go new file mode 100644 index 0000000000..d77013ec23 --- /dev/null +++ b/internal/taskcopy/review_snapshot.go @@ -0,0 +1,840 @@ +package taskcopy + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// PortableEntry records the supported working representation, independently of Git filters. +type PortableEntry struct { + Kind string `json:"kind"` + Mode fs.FileMode `json:"mode"` + Size int64 `json:"size,omitempty"` + Digest string `json:"digest,omitempty"` + Link string `json:"link,omitempty"` +} + +// PortableState excludes repository machinery and the isolated root's permissions. +// Timestamps are not part of the portable apply contract. +type PortableState struct { + Version int `json:"version"` + Entries map[string]PortableEntry `json:"entries"` + Withheld map[string]PortableEntry `json:"withheld,omitempty"` +} + +// Digest binds exact raw bytes, file kinds and supported permission metadata. +func (s PortableState) Digest() (string, error) { + if s.Version != 1 || s.Entries == nil { + return "", errors.New("portable metadata version is unsupported") + } + if err := validatePortableEntries(s.Withheld); err != nil { + return "", err + } + if len(s.Entries)+len(s.Withheld) > workspacePathLimit { + return "", errors.New("portable path metadata exceeds its bound") + } + if err := validatePortableEntries(s.Entries); err != nil { + return "", err + } + raw, err := json.Marshal(s) + if err != nil { + return "", err + } + if len(raw) > WorkspaceCheckpointLimit { + return "", errors.New("portable metadata exceeds its bound") + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +// InspectPortableState reads the representation without following symbolic links. +func InspectPortableState(root string) (PortableState, error) { + return inspectPortableState(root, false) +} + +// InspectPortableFamilyInput records the bounded whole-folder inputs mirrored +// into a plain family's local history, including ignored runtime dependencies. +func InspectPortableFamilyInput(root string) (PortableState, error) { + return inspectPortableState(root, true) +} + +func inspectPortableState(root string, wholeFamily bool) (PortableState, error) { + canonical, err := canonicalPortableRoot(root) + if err != nil { + return PortableState{}, err + } + root = canonical + indexRoot, cleanup, err := portableInspectionIndex(root) + if err != nil { + return PortableState{}, err + } + defer cleanup() + raw, err := portableInspectionGit(indexRoot, "--work-tree="+root, "ls-files", "--cached", "--others", "--exclude-standard", "-z") + if err != nil { + return PortableState{}, err + } + if len(raw) > WorkspaceCheckpointLimit { + return PortableState{}, errors.New("portable enumeration exceeds its bound") + } + if wholeFamily { + extra, extraErr := portableInspectionGit(indexRoot, "--work-tree="+root, "ls-files", "--others", "--ignored", "--exclude-standard", "-z") + if extraErr != nil { + return PortableState{}, extraErr + } + raw += extra + } + dirs, err := captureWorkspaceDirectoriesScope(root, indexRoot, wholeFamily || independentBaseline(root)) + if err != nil { + return PortableState{}, err + } + paths := strings.Split(raw, "\x00") + for path := range dirs { + paths = append(paths, path) + } + state, err := InspectPortablePaths(root, paths) + if err != nil { + return state, err + } + state.Withheld = portableWithheldEntries(root, state.Entries) + return state, nil +} + +func portableInspectionIndex(root string) (string, func(), error) { + if isRepository(root) { + return root, func() {}, nil + } + temporary, err := os.MkdirTemp("", "codeaf-portable-index-") + if err != nil { + return "", func() {}, err + } + cleanup := func() { _ = os.RemoveAll(temporary) } + if _, err = portableInspectionGit(temporary, "init", "-q"); err != nil { + cleanup() + return "", func() {}, err + } + if _, err = portableInspectionGit(temporary, "config", "--local", "core.fsmonitor", "false"); err != nil { + cleanup() + return "", func() {}, err + } + return temporary, cleanup, nil +} + +func inspectPortableEntry(path string, info fs.FileInfo) (PortableEntry, error) { + entry := PortableEntry{Mode: info.Mode().Perm()} + if info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 { + return entry, errors.New("portable apply does not support special permission bits") + } + switch { + case info.IsDir(): + entry.Kind = "dir" + case info.Mode()&os.ModeSymlink != 0: + entry.Kind = "symlink" + link, err := os.Readlink(path) + entry.Link = link + return entry, err + case info.Mode().IsRegular(): + entry.Kind = "file" + entry.Size = info.Size() + file, err := os.Open(path) + if err != nil { + return entry, err + } + opened, statErr := file.Stat() + if statErr != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + file.Close() + return entry, errors.New("portable file identity changed before reading") + } + hash := sha256.New() + n, err := io.Copy(hash, file) + err = errors.Join(err, file.Close()) + if err != nil { + return entry, err + } + if n != entry.Size { + return entry, errors.New("portable file changed while being read") + } + entry.Digest = hex.EncodeToString(hash.Sum(nil)) + default: + return entry, errors.New("portable apply encountered an unsupported file type") + } + return entry, nil +} + +// VerifyPortableState refuses changes to a previously reviewed representation. +func VerifyPortableState(root string, expected PortableState) error { + if _, err := expected.Digest(); err != nil { + return err + } + actual, err := InspectPortableState(root) + if err != nil { + return err + } + if actual.Version != expected.Version || !reflect.DeepEqual(actual.Entries, expected.Entries) { + return errors.New("the reviewed portable representation changed; review this root again") + } + return nil +} + +// CapturePortableSnapshot reconstructs an independent reviewed representation +// through the same carrier machinery used by portable checkpoints. Both output +// paths must be separately owned and absent; the original copy is never edited. +func CapturePortableSnapshot(source, carrier, destination string) (PortableState, error) { + var err error + source, err = canonicalPortableRoot(source) + if err != nil { + return PortableState{}, err + } + carrier, err = canonicalPortableOutput(carrier) + if err != nil { + return PortableState{}, err + } + destination, err = canonicalPortableOutput(destination) + if err != nil { + return PortableState{}, err + } + + before, err := InspectPortableState(source) + if err != nil { + return PortableState{}, err + } + if err = absentPortableOutput(carrier); err != nil { + return PortableState{}, err + } + if err = absentPortableOutput(destination); err != nil { + return PortableState{}, err + } + directories, err := captureWorkspaceDirectories(source) + if err != nil { + return PortableState{}, err + } + if err = carryWithHistory(source, carrier, true); err != nil { + return PortableState{}, err + } + rec, err := readWorkspaceRecord(carrier) + if err != nil { + return PortableState{}, err + } + if err = validateWorkspaceRecord(carrier, rec); err != nil { + return PortableState{}, err + } + if err = materializePortableSnapshot(carrier, destination, rec, directories); err != nil { + return PortableState{}, err + } + if err := materializePortableSnapshotLinks(source, destination, before); err != nil { + return PortableState{}, err + } + if independentBaseline(source) { + if err := MarkIndependentBaseline(destination); err != nil { + return PortableState{}, err + } + } + state, err := InspectPortableState(destination) + if err != nil { + return PortableState{}, err + } + if err = verifyCapturedPortableSource(source, before, state); err != nil { + return PortableState{}, err + } + state.Withheld = before.Withheld + return state, nil +} + +func absentPortableOutput(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return errors.New("portable output must be an owned absolute path") + } + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return errors.New("portable output already exists or cannot be inspected") + } + return nil +} + +func materializePortableSnapshot(carrier, destination string, rec record, directories map[string]fs.FileMode) error { + if err := prepareResultCheckout("", destination, carrier, rec); err != nil { + return err + } + if err := validateWorkspaceOverlay(destination, carrier, rec); err != nil { + return err + } + if err := prepareWorkspaceDirectories(destination, directories); err != nil { + return err + } + if err := applyEOL(destination, rec.EOL); err != nil { + return err + } + if err := overlay(filepath.Join(carrier, filesDir), destination, rec); err != nil { + return err + } + if err := applyModes(destination, rec.Modes); err != nil { + return err + } + if err := finishWorkspaceDirectories(destination, directories); err != nil { + return err + } + return restorePortableBaseline(destination, rec) +} + +func verifyCapturedPortableSource(source string, before, captured PortableState) error { + after, err := inspectPortableCoverage(source, before) + if err != nil { + return err + } + if !reflect.DeepEqual(before, after) { + return errors.New("private copy changed during review; review it again") + } + for path := range before.Entries { + if _, present := captured.Entries[path]; !present { + if _, withheld := before.Withheld[path]; !withheld { + return fmt.Errorf("portable snapshot omitted supported input %s; private copy retained", path) + } + } + } + for path, entry := range captured.Entries { + if before.Entries[path] != entry { + return fmt.Errorf("portable snapshot could not preserve %s; private copy retained", path) + } + } + return nil +} + +// CheckPortableMutationMetadata refuses unsupported metadata only on paths an +// apply intends to change. Unrelated project metadata grants no write authority. +func CheckPortableMutationMetadata(root string, paths []string) error { + return CheckPortableMutationMetadataContext(context.Background(), root, paths) +} + +// CheckPortableMutationMetadataContext bounds platform probes to this operation. +func CheckPortableMutationMetadataContext(ctx context.Context, root string, paths []string) error { + canonical, err := canonicalPortableRoot(root) + if err != nil { + return err + } + root = canonical + seen := map[string]bool{} + var existing []string + for _, rel := range paths { + if err = ctx.Err(); err != nil { + return err + } + if seen[rel] { + continue + } + seen[rel] = true + if !filepath.IsLocal(filepath.FromSlash(rel)) { + return errors.New("portable mutation path escapes its root") + } + parent, err := workspacePath(root, filepath.Dir(filepath.FromSlash(rel))) + if err != nil { + return err + } + path := filepath.Join(parent, filepath.Base(filepath.FromSlash(rel))) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if err = portablePlatformMetadata(path, info); err != nil { + return fmt.Errorf("portable metadata for %s: %w", rel, err) + } + existing = append(existing, path) + } + return portablePlatformMetadataBatch(ctx, existing) +} + +// MaterializePortableInput restores source representation into a newly owned +// private Git copy before any worker can use it. +func MaterializePortableInput(source, destination string, baseline PortableState) error { + var err error + source, err = canonicalPortableRoot(source) + if err != nil { + return err + } + destination, err = canonicalPortableRoot(destination) + if err != nil { + return err + } + + if err := verifyCapturedPortableSource(source, baseline, baseline); err != nil { + return err + } + directories, err := captureWorkspaceDirectoriesWithIndex(source, destination) + if err != nil { + return err + } + if err = prepareWorkspaceDirectories(destination, directories); err != nil { + return err + } + paths, err := portableInspectionGit(destination, "ls-files", "--cached", "--others", "--exclude-standard", "-z") + if err != nil { + return err + } + sourceRoot, err := os.OpenRoot(source) + if err != nil { + return err + } + defer sourceRoot.Close() + targetRoot, err := os.OpenRoot(destination) + if err != nil { + return err + } + defer targetRoot.Close() + selected := map[string]bool{} + for _, path := range strings.Split(paths, "\x00") { + selected[path] = true + } + for path, entry := range baseline.Entries { + if entry.Kind != "dir" { + selected[path] = true + } + } + for path := range selected { + if path == "" || cell.IsTaskDropping(path) { + continue + } + entry, present := baseline.Entries[path] + if !present { + if err = targetRoot.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + continue + } + if err = materializePortableInputEntry(sourceRoot, targetRoot, path, entry); err != nil { + return err + } + } + if err = finishWorkspaceDirectories(destination, directories); err != nil { + return err + } + represented, err := InspectPortableState(destination) + if err != nil { + return err + } + return verifyCapturedPortableSource(source, baseline, represented) +} + +func materializePortableInputEntry(source, target *os.Root, path string, entry PortableEntry) error { + if entry.Kind == "dir" { + return nil + } + if !filepath.IsLocal(filepath.FromSlash(path)) { + return errors.New("portable input path is unsafe") + } + if err := target.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if entry.Kind == "symlink" { + if err := target.Symlink(entry.Link, path); err != nil { + return err + } + return setPortableSymlinkMode(target, path, entry.Mode) + } + if entry.Kind != "file" { + return errors.New("portable input kind is unsupported") + } + file, err := source.Open(path) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + named, statErr := source.Lstat(path) + if statErr != nil || !info.Mode().IsRegular() || !os.SameFile(info, named) { + return errors.New("portable source changed its file identity") + } + out, err := target.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + hash := sha256.New() + n, err := io.Copy(io.MultiWriter(out, hash), file) + err = errors.Join(err, out.Close()) + if err != nil { + return err + } + if n != entry.Size || hex.EncodeToString(hash.Sum(nil)) != entry.Digest { + return errors.New("portable source changed during input materialization") + } + return target.Chmod(path, entry.Mode) +} + +// PortableBaselinePath holds historical source metadata, never landing permission. +const PortableBaselinePath = cell.TaskDropping + "/portable-baseline.json" + +// WritePortableBaseline creates historical metadata exactly once. +func WritePortableBaseline(root string, state PortableState) (string, error) { + digest, err := state.Digest() + if err != nil { + return "", err + } + raw, err := json.Marshal(state) + if err != nil { + return "", err + } + if err = os.MkdirAll(filepath.Join(root, cell.TaskDropping), 0700); err != nil { + return "", err + } + boundary, err := os.OpenRoot(root) + if err != nil { + return "", err + } + defer boundary.Close() + file, err := boundary.OpenFile(PortableBaselinePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return "", err + } + _, err = file.Write(raw) + if err == nil { + err = file.Sync() + } + err = errors.Join(err, file.Close()) + if err != nil { + return digest, err + } + directory, err := boundary.Open(cell.TaskDropping) + if err != nil { + return digest, err + } + err = errors.Join(directory.Sync(), directory.Close()) + return digest, err +} + +// ReadPortableBaseline never refreshes historical metadata from today's source. +func ReadPortableBaseline(root string) (PortableState, string, error) { + raw, err := readWorkspaceFile(filepath.Join(root, PortableBaselinePath), WorkspaceCheckpointLimit) + if err != nil { + return PortableState{}, "", err + } + return decodePortableBaseline(raw) +} + +func decodePortableBaseline(raw []byte) (PortableState, string, error) { + var state PortableState + if len(raw) > WorkspaceCheckpointLimit { + return state, "", errors.New("portable baseline exceeds its bound") + } + if err := json.Unmarshal(raw, &state); err != nil { + return state, "", err + } + digest, err := state.Digest() + return state, digest, err +} + +func restorePortableBaseline(root string, rec record) error { + if len(rec.PortableBaseline) == 0 { + return nil + } + state, digest, err := decodePortableBaseline(rec.PortableBaseline) + if err != nil { + return err + } + _, existing, err := ReadPortableBaseline(root) + if err == nil { + if existing != digest { + return errors.New("existing creation baseline differs; preserved both copies") + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + _, err = WritePortableBaseline(root, state) + return err +} + +func validatePortableEntries(entries map[string]PortableEntry) error { + if len(entries) > workspacePathLimit { + return errors.New("portable path count exceeds its bound") + } + for path, entry := range entries { + if !filepath.IsLocal(filepath.FromSlash(path)) || path == "." || len(path) > 4096 || strings.Contains(path, "\\") || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path { + return errors.New("portable entry path is unsafe") + } + for _, part := range strings.Split(path, "/") { + if part == ".git" || cell.IsTaskDropping(part) { + return errors.New("portable entry names administrative state") + } + } + if err := validatePortableEntry(entry); err != nil { + return err + } + } + return nil +} + +func validatePortableEntry(entry PortableEntry) error { + if entry.Mode != entry.Mode.Perm() { + return errors.New("portable permission metadata is unsupported") + } + switch entry.Kind { + case "file": + raw, err := hex.DecodeString(entry.Digest) + if err != nil || len(raw) != sha256.Size || entry.Size < 0 || entry.Link != "" { + return errors.New("portable file metadata is malformed") + } + case "dir": + if entry.Digest != "" || entry.Link != "" || entry.Size != 0 { + return errors.New("portable directory metadata is malformed") + } + case "symlink": + if entry.Digest != "" || entry.Size != 0 || entry.Link == "" { + return errors.New("portable symlink metadata is malformed") + } + default: + return errors.New("portable file kind is unsupported") + } + return nil +} + +// InspectPortablePaths includes explicit collisions even when ignored. Directories +// are inspected as entries; unrelated descendant bytes are never read. +func InspectPortablePaths(root string, paths []string) (PortableState, error) { + canonical, err := canonicalPortableRoot(root) + if err != nil { + return PortableState{}, err + } + root = canonical + state := PortableState{Version: 1, Entries: map[string]PortableEntry{}} + for _, rel := range paths { + if rel == "" || rel == "." || cell.IsTaskDropping(rel) { + continue + } + if _, ok := state.Entries[rel]; ok { + continue + } + if len(state.Entries) >= workspacePathLimit { + return state, errors.New("portable path count exceeds its bound") + } + if !filepath.IsLocal(filepath.FromSlash(rel)) { + return state, errors.New("portable inspection path is unsafe") + } + parent, err := workspacePath(root, filepath.Dir(filepath.FromSlash(rel))) + if err != nil { + return state, err + } + path := filepath.Join(parent, filepath.Base(filepath.FromSlash(rel))) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return state, err + } + entry, err := inspectPortableEntry(path, info) + if err != nil { + return state, err + } + state.Entries[filepath.ToSlash(rel)] = entry + } + _, err = state.Digest() + return state, err +} + +// Resolve the caller-supplied anchor once. Descendant symlinks still refuse in +// workspacePath; a platform alias such as Darwin /var is not a child redirect. +func canonicalPortableRoot(root string) (string, error) { + if !filepath.IsAbs(root) { + return "", errors.New("portable root must be absolute") + } + return filepath.EvalSymlinks(root) +} +func canonicalPortableOutput(path string) (string, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return "", errors.New("portable output must be absolute") + } + parent, err := canonicalPortableRoot(filepath.Dir(path)) + if err != nil { + return "", err + } + return filepath.Join(parent, filepath.Base(path)), nil +} + +func portableInspectionGit(dir string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + args = append([]string{"-c", "core.fsmonitor=false"}, args...) + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Env = append(portableInspectionEnvironment(), "GIT_OPTIONAL_LOCKS=0") + stderr := portableBoundedOutput{Limit: 64 << 10} + stdout := portableBoundedOutput{Limit: WorkspaceCheckpointLimit} + cmd.Stderr = &stderr + cmd.Stdout = &stdout + err := cmd.Run() + raw := stdout.Bytes() + if ctx.Err() != nil { + return "", fmt.Errorf("portable Git enumeration timed out: %w", ctx.Err()) + } + if err != nil { + return "", fmt.Errorf("portable Git enumeration failed: %w: %s", err, stderr.String()) + } + if len(raw) > WorkspaceCheckpointLimit { + return "", errors.New("portable Git enumeration exceeds its bound") + } + return string(raw), nil +} + +type portableBoundedOutput struct { + bytes.Buffer + Limit int +} + +func (b *portableBoundedOutput) Write(raw []byte) (int, error) { + if b.Len()+len(raw) > b.Limit { + return 0, errors.New("portable Git output exceeds its bound") + } + return b.Buffer.Write(raw) +} + +func verifyRestoredPortableBaseline(root string, rec record) error { + if len(rec.PortableBaseline) == 0 { + return nil + } + _, expected, err := decodePortableBaseline(rec.PortableBaseline) + if err != nil { + return err + } + _, actual, err := ReadPortableBaseline(root) + if err != nil { + return err + } + if actual != expected { + return errors.New("restored creation baseline changed; preserved the independent carrier") + } + return nil +} + +func portableWithheldEntries(root string, entries map[string]PortableEntry) map[string]PortableEntry { + var files []string + for path, entry := range entries { + if entry.Kind == "file" { + files = append(files, path) + } + } + allowed := map[string]bool{} + for _, path := range withoutSecrets(root, files) { + allowed[path] = true + } + var withheld map[string]PortableEntry + for _, path := range files { + if !allowed[path] { + if withheld == nil { + withheld = map[string]PortableEntry{} + } + withheld[path] = entries[path] + } + } + return withheld +} + +func portableInspectionEnvironment() []string { + var result []string + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + name = strings.ToUpper(name) + if strings.HasPrefix(name, "GIT_") && name != "GIT_CONFIG_GLOBAL" && name != "GIT_CONFIG_SYSTEM" && name != "GIT_CONFIG_NOSYSTEM" { + continue + } + result = append(result, entry) + } + return result +} + +func materializePortableSnapshotLinks(source, destination string, state PortableState) error { + input, err := os.OpenRoot(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenRoot(destination) + if err != nil { + return err + } + defer output.Close() + for path, entry := range state.Entries { + if entry.Kind != "symlink" { + continue + } + if err = materializePortableInputEntry(input, output, path, entry); err != nil { + return err + } + } + return nil +} + +// CheckPortableRootInheritance checks native metadata inherited by new children, +// without changing or requiring ownership of the existing project root. +func CheckPortableRootInheritance(ctx context.Context, root string) error { + canonical, err := canonicalPortableRoot(root) + if err != nil { + return err + } + info, err := os.Lstat(canonical) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("portable destination root is not a directory") + } + if err = portableUnsupportedNativeMetadata(canonical, info); err != nil { + return err + } + return portablePlatformMetadataBatch(ctx, []string{canonical}) +} + +func inspectPortableCoverage(root string, expected PortableState) (PortableState, error) { + state, err := InspectPortableState(root) + if err != nil { + return state, err + } + var missing []string + for path := range expected.Entries { + if _, ok := state.Entries[path]; !ok { + missing = append(missing, path) + } + } + explicit, err := InspectPortablePaths(root, missing) + if err != nil { + return state, err + } + for path, entry := range explicit.Entries { + state.Entries[path] = entry + } + for path := range expected.Withheld { + if entry, ok := state.Entries[path]; ok { + if state.Withheld == nil { + state.Withheld = map[string]PortableEntry{} + } + state.Withheld[path] = entry + } + } + return state, nil +} + +func setPortableSymlinkMode(root *os.Root, path string, mode fs.FileMode) error { + parent, err := root.Open(filepath.Dir(filepath.FromSlash(path))) + if err != nil { + return err + } + defer parent.Close() + return SetPortableSymlinkModeAt(int(parent.Fd()), filepath.Base(filepath.FromSlash(path)), mode) +} diff --git a/internal/taskcopy/review_snapshot_test.go b/internal/taskcopy/review_snapshot_test.go new file mode 100644 index 0000000000..fe5695038f --- /dev/null +++ b/internal/taskcopy/review_snapshot_test.go @@ -0,0 +1,396 @@ +package taskcopy + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func TestCapturePortableSnapshotPreservesReviewedRepresentation(t *testing.T) { + source := t.TempDir() + write(t, filepath.Join(source, "shared.txt"), "original\n") + run(t, source, "git", "init", "-q", "-b", "main") + run(t, source, "git", "add", ".") + run(t, source, "git", "commit", "-qm", "original") + write(t, filepath.Join(source, ".gitattributes"), "shared.txt text eol=lf\n") + write(t, filepath.Join(source, "shared.txt"), "reviewed\r\n") + if err := os.Chmod(filepath.Join(source, "shared.txt"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(source, "empty"), 0750); err != nil { + t.Fatal(err) + } + root := t.TempDir() + output := filepath.Join(root, "snapshot") + state, err := CapturePortableSnapshot(source, filepath.Join(root, "carrier"), output) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(output, "shared.txt")) + if err != nil { + t.Fatal(err) + } + if string(raw) != "reviewed\r\n" { + t.Fatalf("raw bytes changed: %q", raw) + } + for name, want := range map[string]os.FileMode{"shared.txt": 0640, "empty": 0750} { + info, err := os.Stat(filepath.Join(output, name)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != want { + t.Fatalf("%s mode=%04o want%04o", name, info.Mode().Perm(), want) + } + } + if err := VerifyPortableState(output, state); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(output, "shared.txt"), "tampered\n") + if err := VerifyPortableState(output, state); err == nil { + t.Fatal("modified reviewed representation accepted") + } +} + +func TestMaterializePortableInputPreservesSourceRepresentation(t *testing.T) { + source := t.TempDir() + write(t, filepath.Join(source, "shared.txt"), "input\r\n") + write(t, filepath.Join(source, "new-input.txt"), "supported input absent from checkout\n") + if err := os.Chmod(filepath.Join(source, "shared.txt"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(source, "empty"), 0750); err != nil { + t.Fatal(err) + } + destination := t.TempDir() + write(t, filepath.Join(destination, "shared.txt"), "input\n") + write(t, filepath.Join(destination, "deleted-in-source.txt"), "stale checkout bytes\n") + run(t, destination, "git", "init", "-q", "-b", "main") + run(t, destination, "git", "add", ".") + run(t, destination, "git", "commit", "-qm", "input") + baseline, err := InspectPortableState(source) + if err != nil { + t.Fatal(err) + } + if err = MaterializePortableInput(source, destination, baseline); err != nil { + t.Fatal(err) + } + if err = VerifyPortableState(destination, baseline); err != nil { + t.Fatal(err) + } +} + +func TestPortableSnapshotCarriesImmutableCreationBaseline(t *testing.T) { + source := t.TempDir() + write(t, filepath.Join(source, "shared.txt"), "original\n") + run(t, source, "git", "init", "-q", "-b", "main") + run(t, source, "git", "add", ".") + run(t, source, "git", "commit", "-qm", "original") + baseline, err := InspectPortableState(source) + if err != nil { + t.Fatal(err) + } + digest, err := WritePortableBaseline(source, baseline) + if err != nil { + t.Fatal(err) + } + write(t, filepath.Join(source, "shared.txt"), "reviewed\n") + root := t.TempDir() + output := filepath.Join(root, "snapshot") + if _, err = CapturePortableSnapshot(source, filepath.Join(root, "carrier"), output); err != nil { + t.Fatal(err) + } + restored, got, err := ReadPortableBaseline(output) + if err != nil { + t.Fatal(err) + } + if got != digest || restored.Entries["shared.txt"] != baseline.Entries["shared.txt"] { + t.Fatal("historical creation baseline changed in portable snapshot") + } + changed, err := InspectPortableState(source) + if err != nil { + t.Fatal(err) + } + if _, err = WritePortableBaseline(source, changed); err == nil { + t.Fatal("creation baseline was overwritten") + } +} + +func TestInspectPortableStateScopesIgnoredDependencies(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, ".gitignore"), "cache/\n") + write(t, filepath.Join(root, "input.txt"), "input\n") + run(t, root, "git", "init", "-q", "-b", "main") + run(t, root, "git", "add", ".") + run(t, root, "git", "commit", "-qm", "input") + write(t, filepath.Join(root, "cache", "collision.txt"), "unrelated cache\n") + state, err := InspectPortableState(root) + if err != nil { + t.Fatal(err) + } + if _, ok := state.Entries["cache/collision.txt"]; ok { + t.Fatal("ignored dependency was included in portable census") + } + selected, err := InspectPortablePaths(root, []string{"cache/collision.txt"}) + if err != nil { + t.Fatal(err) + } + if selected.Entries["cache/collision.txt"].Kind != "file" { + t.Fatal("explicit ignored destination collision was hidden") + } +} + +func TestInspectPortableStateDoesNotRunGlobalFSMonitor(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "input.txt"), "input\n") + settings := t.TempDir() + marker := filepath.Join(settings, "fsmonitor-ran") + hook := filepath.Join(settings, "fsmonitor-hook") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nprintf ran > '"+marker+"'\nexit 0\n"), 0700); err != nil { + t.Fatal(err) + } + config := filepath.Join(settings, "global.gitconfig") + run(t, settings, "git", "config", "--file", config, "core.fsmonitor", hook) + t.Setenv("GIT_CONFIG_GLOBAL", config) + if _, err := InspectPortableState(root); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("synthetic portable index executed user's global fsmonitor") + } +} + +func TestWorkspaceCarrierPreservesAndVerifiesCreationBaseline(t *testing.T) { + c, plan := workspaceFixture(t) + tree := filepath.Join(liveRoot(c), "7", "main") + baseline, err := InspectPortableState(tree) + if err != nil { + t.Fatal(err) + } + digest, err := WritePortableBaseline(tree, baseline) + if err != nil { + t.Fatal(err) + } + if err = (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + receiver := cell.Cell{ID: "receiver", Root: filepath.Join(t.TempDir(), "chat")} + if err = os.MkdirAll(filepath.Join(receiver.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + run(t, c.Root, "cp", "-R", workspaceCarrierRoot(c), workspaceCarrierRoot(receiver)) + writeWorkspaceCheckpoint(t, receiver, plan) + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = prepared.Promote(); err != nil { + t.Fatal(err) + } + restored := filepath.Join(liveRoot(receiver), "7", "main") + _, got, err := ReadPortableBaseline(restored) + if err != nil || got != digest { + t.Fatalf("creation baseline lost: %s %v", got, err) + } + if err = os.WriteFile(filepath.Join(restored, PortableBaselinePath), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + if err = prepared.Acknowledge(); err == nil { + t.Fatal("tampered historical metadata admitted after portable restore") + } +} + +func TestInspectPortableStateIgnoresInheritedGitRouting(t *testing.T) { + for _, name := range []string{"GIT_DIR", "GIT_CONFIG"} { + t.Run(name, func(t *testing.T) { + foreign := t.TempDir() + write(t, filepath.Join(foreign, "foreign.txt"), "foreign\n") + run(t, foreign, "git", "init", "-q", "-b", "main") + run(t, foreign, "git", "add", ".") + run(t, foreign, "git", "commit", "-qm", "foreign") + run(t, foreign, "git", "config", "core.fsmonitor", "original-monitor") + config := filepath.Join(foreign, ".git", "config") + before, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + target := t.TempDir() + write(t, filepath.Join(target, "input.txt"), "intended input\n") + value := filepath.Join(foreign, ".git") + if name == "GIT_CONFIG" { + value = config + } + t.Setenv(name, value) + state, err := InspectPortableState(target) + if err != nil { + t.Fatal(err) + } + if state.Entries["input.txt"].Kind != "file" { + t.Fatal("inherited routing hid intended source input") + } + after, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("read-only portable inspection changed a foreign Git config") + } + }) + } +} + +func TestPortableSnapshotRecordsOnlyExplicitlyWithheldOmissions(t *testing.T) { + source := t.TempDir() + write(t, filepath.Join(source, "input.txt"), "input\n") + run(t, source, "git", "init", "-q", "-b", "main") + run(t, source, "git", "add", ".") + run(t, source, "git", "commit", "-qm", "input") + write(t, filepath.Join(source, ".env"), "FIXTURE_VALUE=private-fixture-only\n") + root := t.TempDir() + output := filepath.Join(root, "snapshot") + state, err := CapturePortableSnapshot(source, filepath.Join(root, "carrier"), output) + if err != nil { + t.Fatal(err) + } + if state.Withheld[".env"].Kind != "file" { + t.Fatal("omission has no bounded withholding disposition") + } + if _, exists := state.Entries[".env"]; exists { + t.Fatal("withheld payload represented as applied output") + } + if _, err = os.Stat(filepath.Join(output, ".env")); !os.IsNotExist(err) { + t.Fatal("withheld payload copied into portable snapshot") + } + if err = VerifyPortableState(output, state); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(source, ".env")); err != nil || string(got) != "FIXTURE_VALUE=private-fixture-only\n" { + t.Fatal("original private input changed") + } +} + +func TestWorkspaceCarrierRestoresChangedAndNewDanglingLinks(t *testing.T) { + c, plan := workspaceFixture(t) + tree := filepath.Join(liveRoot(c), "7", "main") + if err := os.Symlink("tracked.txt", filepath.Join(tree, "pointer")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("tracked.txt", filepath.Join(tree, "clean-link")); err != nil { + t.Fatal(err) + } + run(t, tree, "git", "add", "pointer", "clean-link") + run(t, tree, "git", "commit", "-qm", "link input") + if err := os.Remove(filepath.Join(tree, "pointer")); err != nil { + t.Fatal(err) + } + for name, target := range map[string]string{"pointer": "missing existing target", "new-link": "../missing new target"} { + if err := os.Symlink(target, filepath.Join(tree, name)); err != nil { + t.Fatal(err) + } + } + if err := (Carry{}).Compose(c); err != nil { + t.Fatal(err) + } + if export := os.Getenv("CODEAF_TEST_EXPORT_LINK_CARRIER"); export != "" { + exported := cell.Cell{ID: "receiver", Root: filepath.Join(export, "chat")} + if err := os.MkdirAll(filepath.Join(exported.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + run(t, c.Root, "cp", "-R", workspaceCarrierRoot(c), workspaceCarrierRoot(exported)) + writeWorkspaceCheckpoint(t, exported, plan) + raw, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(export, "plan.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + receiver := cell.Cell{ID: "receiver", Root: filepath.Join(t.TempDir(), "chat")} + if err := os.MkdirAll(filepath.Join(receiver.Root, cell.StateDir), 0700); err != nil { + t.Fatal(err) + } + run(t, c.Root, "cp", "-R", workspaceCarrierRoot(c), workspaceCarrierRoot(receiver)) + writeWorkspaceCheckpoint(t, receiver, plan) + manifestPath := filepath.Join(workspaceCarrierRoot(receiver), "manifest.json") + manifestRaw, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + for _, unsupported := range []string{"1", "99"} { + altered := strings.Replace(string(manifestRaw), `"directory_version":2`, `"directory_version":`+unsupported, 1) + if altered == string(manifestRaw) { + t.Fatal("typed-link carrier omitted mandatory capability version") + } + if err = os.WriteFile(manifestPath, []byte(altered), 0600); err != nil { + t.Fatal(err) + } + if _, err = (Carry{}).PrepareWorkspace(receiver, plan); err == nil { + t.Fatalf("unsupported typed-link version %s admitted", unsupported) + } + if _, err = os.Stat(filepath.Join(liveRoot(receiver), "7", "main")); !os.IsNotExist(err) { + t.Fatal("unsupported carrier was promoted") + } + } + if err = os.WriteFile(manifestPath, manifestRaw, 0600); err != nil { + t.Fatal(err) + } + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = prepared.Promote(); err != nil { + t.Fatal(err) + } + restored := filepath.Join(liveRoot(receiver), "7", "main") + for name, target := range map[string]string{"pointer": "missing existing target", "new-link": "../missing new target", "clean-link": "tracked.txt"} { + got, err := os.Readlink(filepath.Join(restored, name)) + if err != nil || got != target { + t.Fatalf("link %s got %q want %q: %v", name, got, target, err) + } + } + if err = prepared.Acknowledge(); err != nil { + t.Fatal(err) + } +} + +// This external-fixture test runs the exact Linux-produced carrier on Darwin. +func TestWorkspaceCarrierImportsLinuxLinkFixture(t *testing.T) { + fixture := os.Getenv("CODEAF_TEST_IMPORT_LINK_CARRIER") + if fixture == "" { + t.Skip("external Linux-produced carrier not supplied") + } + raw, err := os.ReadFile(filepath.Join(fixture, "plan.json")) + if err != nil { + t.Fatal(err) + } + var plan WorkspacePlan + if err = json.Unmarshal(raw, &plan); err != nil { + t.Fatal(err) + } + receiver := cell.Cell{ID: "receiver", Root: filepath.Join(fixture, "chat")} + prepared, err := (Carry{}).PrepareWorkspace(receiver, plan) + if err != nil { + t.Fatal(err) + } + if _, err = prepared.Promote(); err != nil { + t.Fatal(err) + } + restored := filepath.Join(liveRoot(receiver), "7", "main") + for name, target := range map[string]string{"pointer": "missing existing target", "new-link": "../missing new target", "clean-link": "tracked.txt"} { + got, err := os.Readlink(filepath.Join(restored, name)) + if err != nil || got != target { + t.Fatalf("imported link %s=%q: %v", name, got, err) + } + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode().Perm() != 0777 { + t.Fatalf("Linux link mode lost: %v %v", info, err) + } + } + if err = prepared.Acknowledge(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/taskcopy/workspace_carrier.go b/internal/taskcopy/workspace_carrier.go index 96af6b706d..29a1e58e61 100644 --- a/internal/taskcopy/workspace_carrier.go +++ b/internal/taskcopy/workspace_carrier.go @@ -161,7 +161,11 @@ func (k Carry) composeWorkspacePlan(c cell.Cell, plan WorkspacePlan, digest stri if err != nil { return fmt.Errorf("carry workspace directories %s: %w", e.Layout, err) } - portable := workspaceRoot{Entry: e, Token: token, Head: rec.Head, DirectoryVersion: 1, Directories: directories} + directoryVersion := 1 + if rec.Symlinks != nil { + directoryVersion = 2 + } + portable := workspaceRoot{Entry: e, Token: token, Head: rec.Head, DirectoryVersion: directoryVersion, Directories: directories} if _, err = validateWorkspaceDirectories(portable, rec); err != nil { return err } @@ -312,11 +316,19 @@ func prepareWorkspaceCapture(c cell.Cell, previous workspaceManifest) (string, e // captureWorkspaceDirectories carries only the visible working tree. Git names // ignored subtrees once, so large caches are not walked just to omit their dirs. func captureWorkspaceDirectories(tree string) (map[string]fs.FileMode, error) { - ignored, err := git(tree, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") + return captureWorkspaceDirectoriesScope(tree, tree, independentBaseline(tree)) +} + +func captureWorkspaceDirectoriesWithIndex(tree, indexRoot string) (map[string]fs.FileMode, error) { + return captureWorkspaceDirectoriesScope(tree, indexRoot, false) +} + +func captureWorkspaceDirectoriesScope(tree, indexRoot string, wholeFamily bool) (map[string]fs.FileMode, error) { + ignored, err := portableInspectionGit(indexRoot, "--work-tree="+tree, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") if err != nil { return nil, err } - tracked, err := git(tree, "ls-files", "--cached", "-z") + tracked, err := portableInspectionGit(indexRoot, "ls-files", "--cached", "-z") if err != nil { return nil, err } @@ -365,7 +377,7 @@ func captureWorkspaceDirectories(tree string) (map[string]fs.FileMode, error) { break } } - if ignored && !trackedParents[rel] { + if ignored && !trackedParents[rel] && !wholeFamily { return filepath.SkipDir } finding, err := scanner.Reader(rel, strings.NewReader("")) diff --git a/internal/taskcopy/workspace_restore.go b/internal/taskcopy/workspace_restore.go index 08fd8392d9..24bdfb8b2a 100644 --- a/internal/taskcopy/workspace_restore.go +++ b/internal/taskcopy/workspace_restore.go @@ -7,8 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/text/cases" - "golang.org/x/text/unicode/norm" "io" "io/fs" "os" @@ -18,6 +16,9 @@ import ( "strings" "unicode/utf8" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + "github.com/Agent-Field/codeaf/internal/cell" ) @@ -176,6 +177,9 @@ func (Carry) PrepareWorkspace(c cell.Cell, plan WorkspacePlan) (*WorkspaceRestor if err = applyModes(destination, rec.Modes); err != nil { return nil, err } + if err = restorePortableBaseline(destination, rec); err != nil { + return nil, err + } if err = finishWorkspaceDirectories(destination, root.Directories); err != nil { return nil, err } @@ -325,6 +329,9 @@ func (r *WorkspaceRestore) validateRoot(destination string, root workspaceRoot) if err != nil { return err } + if err = verifyRestoredPortableBaseline(destination, rec); err != nil { + return err + } if err = validateWorkspaceOverlay(destination, from, rec); err != nil { return err } @@ -454,6 +461,14 @@ func validateWorkspaceCarrier(c cell.Cell, plan WorkspacePlan, digest string) (w return manifest, nil } func validateWorkspaceRecord(from string, rec record) error { + if err := validatePortableLinks(rec.Symlinks); err != nil { + return err + } + if len(rec.PortableBaseline) > 0 { + if _, _, err := decodePortableBaseline(rec.PortableBaseline); err != nil { + return err + } + } if _, err := hex.DecodeString(rec.Head); err != nil || len(rec.Head) != 40 && len(rec.Head) != 64 { return errors.New("workspace commit identity is malformed") } @@ -462,7 +477,7 @@ func validateWorkspaceRecord(from string, rec record) error { return errors.New("workspace branch identity is invalid") } } - if len(rec.Deleted)+len(rec.Modes)+len(rec.EOL) > workspacePathLimit { + if len(rec.Deleted)+len(rec.Modes)+len(rec.EOL)+portableLinkCount(rec) > workspacePathLimit { return errors.New("workspace path metadata exceeds its bound") } check := func(rel string) error { @@ -559,21 +574,7 @@ func validateWorkspaceOverlay(destination, from string, rec record) error { // are excluded; the recorded HEAD is checked independently. func workspaceTreeDigest(root string) (string, error) { hash := sha256.New() - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - if rel == ".git" || cell.IsTaskDropping(filepath.ToSlash(rel)) { - return skipEntry(d) - } - info, err := os.Lstat(path) - if err != nil { - return err - } + err := walkWorkspaceTree(root, func(path, rel string, info fs.FileInfo) error { fmt.Fprintf(hash, "%d:%s:%o:%d:", len(rel), filepath.ToSlash(rel), info.Mode(), info.Size()) if info.Mode()&os.ModeSymlink != 0 { link, err := os.Readlink(path) @@ -606,6 +607,26 @@ func workspaceTreeDigest(root string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } +func walkWorkspaceTree(root string, visit func(string, string, fs.FileInfo) error) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == ".git" || cell.IsTaskDropping(filepath.ToSlash(rel)) { + return skipEntry(d) + } + info, err := os.Lstat(path) + if err != nil { + return err + } + return visit(path, rel, info) + }) +} + func readWorkspaceRecord(from string) (record, error) { var rec record raw, err := readWorkspaceFile(filepath.Join(from, recordName), workspaceManifestLimit) @@ -866,7 +887,7 @@ func RestoreCompletedWorkspaceResults(c cell.Cell, plan WorkspacePlan, destinati } sort.Strings(ordered) for _, path := range ordered { - if _, err = workspacePath(checkout, path); err != nil { + if _, err = workspacePath(checkout, filepath.Dir(path)); err != nil { return nil, err } if _, err = os.Lstat(filepath.Join(checkout, path)); errors.Is(err, os.ErrNotExist) { @@ -1021,10 +1042,14 @@ func validWorkspaceResultStage(name string) bool { } func validateWorkspaceDirectories(root workspaceRoot, rec record) (int, error) { - if root.DirectoryVersion != 1 || root.Directories == nil { + expectedVersion := 1 + if rec.Symlinks != nil { + expectedVersion = 2 + } + if root.DirectoryVersion != expectedVersion || root.Directories == nil { return 0, errors.New("workspace carrier lacks supported directory metadata; preserve it and recapture with the current prototype") } - if len(root.Directories)+len(rec.Modes)+len(rec.EOL)+len(rec.Deleted) > workspacePathLimit { + if len(root.Directories)+len(rec.Modes)+len(rec.EOL)+len(rec.Deleted)+portableLinkCount(rec) > workspacePathLimit { return 0, errors.New("workspace path metadata exceeds its bound") } bytes := 0 @@ -1253,3 +1278,10 @@ func workspaceOutputDirectories(checkout, base string, directories map[string]fs } return out, nil } + +func portableLinkCount(rec record) int { + if rec.Symlinks == nil { + return 0 + } + return len(rec.Symlinks.Entries) +} From e5789c8ae4b94b08af3b4b2a270f9a655fdfff0e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:06:53 -0400 Subject: [PATCH 09/35] Explain portable apply transactions and compatibility limits --- docs/architecture/workspaces-storage-relay.md | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index ae7203a436..5458590f9b 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -33,6 +33,15 @@ maps identities to the actual device paths. Project caches are project-owned, so finishing one task does not authorize deleting a cache shared with another. Original folders and unrelated caches never become task cleanup targets. +Plain-folder family history preserves the bounded nonsecret inputs needed to +reconstruct a child's complete working world, including ignored runtime inputs. +Ignore rules do not silently remove those family inputs to claim space savings. +Git-backed sources retain their existing copy eligibility. Credentials withheld +by the existing secret policy remain local inputs; their values do not enter new +transferable family history. Their explicit disposition is bound to the creation +metadata. New or changed withheld outputs refuse review, and existing history +containing tracked secrets refuses full export instead of being rewritten. + ```mermaid flowchart TD Chat[Chat identity] --> Layout[Declared execution layout] @@ -77,7 +86,8 @@ receiving a root does not authorize applying changes to it. Use `/task review ` after the task and its cooperating writers/checkers finish. The terminal shows the private copy, receiver-local application destination, exact snapshot identity, and diff. Large previews show -a command for inspecting the complete immutable diff. Review does not change +a command for inspecting the complete immutable Git/file diff; that command +does not describe all directory or permission metadata. Review does not change the original project working files or the retained private working copy. `/task apply ` applies that reviewed snapshot only. @@ -94,6 +104,45 @@ baseline, or snapshot bindings refuse before application. Uncertain writers stil prevent destructive retirement; they do not authorize deleting the retained original during a snapshot application. +Private task creation records eligible raw bytes, file kinds, symlink targets, +permission bits, and empty directories before Git can normalize them. That +immutable baseline travels with the task carrier; it grants no destination +write authority. Existing tasks without a verified metadata baseline retain +their readable results, but application refuses instead of inventing history. + +Application uses a journaled destination transaction. Independently copied +preimages and verified raw postimages remain available until the final task +checkpoint is durable. Built-in Git text conversion produces canonical history +while working files retain their exact reviewed bytes. External clean filters +and unsupported working-tree encodings refuse before publication. Affected +paths with pre-existing staged or unstaged changes refuse; unrelated changes +remain untouched. Ignored or withheld content never becomes an inferred deletion. + +```mermaid +flowchart LR + Review[Immutable review] --> Compare[Check affected paths and ancestors] + Compare --> Prepare[Journal and independent preimages] + Prepare --> Publish[Publish raw files and Git candidate] + Publish --> Verify[Verify resulting state] + Verify --> Commit[Durable application receipt] + Commit --> Checkpoint[Persist task outcome] + Checkpoint --> Release[Release transaction staging] + Publish --> Recovery[Interrupted operation retains recovery data] +``` + +The portable contract covers raw bytes, supported file and symlink kinds, +POSIX permissions, and eligible directories. It excludes timestamps. Affected +ownership, hardlink, xattr, native ACL, filesystem-flag, and special-file state +that cannot be represented must refuse before mutation. Split indexes and +unsupported Git reference-storage formats also refuse before mutation until +independent recovery is supported. The original project +root's permissions are preserved. This transaction does not give unrelated +readers atomic visibility across all files and Git metadata, or fence unmanaged +external writers. Outside changes that prevent safe recovery retain the +preimages and produce an actionable error. Cancellation before the durable +application receipt rolls back when safe; after that boundary the task outcome +is finalized. Hardware power-cut validation has not been performed. + The terminal enters these operations through the resident engine, with the conversation's driver/session binding and cancellable operation deadline. An older engine without the capability asks for an engine update and reconnect. A @@ -175,10 +224,11 @@ use. Deduplicated compressed chunks may serve several manifests without combining their ownership. The archive implementation is a separate native dependency: -[Furrow #13](https://github.com/Agent-Field/furrow/pull/13). Its local archive -database is not a relay wire format. Release artifacts and the embedded pin must -be updated before ordinary installation can claim archive support. A custom -build used for acceptance is not equivalent to a released dependency. +[Furrow v0.3.1](https://github.com/Agent-Field/furrow/releases/tag/v0.3.1). +Its local archive database is not a relay wire format. Ordinary builds use the +published release with six platform checksums recorded in the embedded pin; +acceptance does not depend on an unpublished local native artifact. Windows +builds do not expose the Unix archive command. Uncertain writers stay resident. Multi-root layout retirement remains excluded until whole-layout quiescence and recovery are verified. Unsupported ACLs, @@ -193,6 +243,7 @@ from the archive experiment. | Legacy conversations | Preserve existing placement and readers; no silent historical rewrite | | New owned conversations | Persist versioned placement policy so restart does not depend on a shell flag | | Multi-root carriers | Validate every required root, directory map and digest before execution | +| Creation metadata | Preserve verified baseline bytes and digest through relay; missing legacy metadata refuses Apply while retaining readable results | | Mixed or incomplete history | Refuse unsupported combinations without dropping records | | Receiver paths | Re-admit per device; remote paths never become write authority | | Older readers | Require supported readers; guaranteed early capability refusal is still unfinished | @@ -214,8 +265,9 @@ percentage yet. Focused tests cover ordinary owned task admission, exact cooperation, directory metadata, root mapping, cancellation, stale holders, preflight refusal and retry. -Final real-terminal actual-provider and cross-machine acceptance is still -pending on this implementation candidate. The earlier terminal run used an -incorrect record-card oracle and cannot establish either a full pass or a -confirmed task-status defect. Draft status remains appropriate until the -complete acceptance chain and dependency integration are verified. +The scripted actual-terminal chain passes with the published native dependency: +private tasks, restart, receiver root selection, Take, completed results, public +fork, and a second Take. This verifies the application flow with scripted +responses. Actual-provider and cross-machine acceptance of the final portable +application candidate remains pending; draft status remains appropriate until +that complete chain is verified. From 134c5fd12bc51609c4f5212e3e66e7fc7cd3d593 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:28:26 -0400 Subject: [PATCH 10/35] Keep native workspace metadata outside portable project state --- .../session/task_workspace_transaction.go | 11 +-- internal/taskcopy/compose.go | 4 +- internal/taskcopy/eol.go | 5 ++ internal/taskcopy/review_snapshot.go | 32 ++++++++- internal/taskcopy/review_snapshot_test.go | 71 +++++++++++++++++++ internal/taskcopy/workspace_carrier.go | 5 +- internal/taskcopy/workspace_restore.go | 11 +-- internal/taskcopy/workspace_restore_test.go | 26 ++++++- 8 files changed, 150 insertions(+), 15 deletions(-) diff --git a/internal/session/task_workspace_transaction.go b/internal/session/task_workspace_transaction.go index 9b1c935156..a30dbfc3b9 100644 --- a/internal/session/task_workspace_transaction.go +++ b/internal/session/task_workspace_transaction.go @@ -58,10 +58,10 @@ func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir return taskWorkspaceApplyReceipt{}, err } unlock, err := lockWorkspaceApplication(root, destination) + defer unlock() if err != nil { return taskWorkspaceApplyReceipt{}, err } - defer unlock() expected, err := workspaceApplicationTransactionDelta(baseline, reviewed, wrote, original.mode != TaskModeMirror) if err != nil { return taskWorkspaceApplyReceipt{}, err @@ -157,19 +157,22 @@ func workspaceApplicationDirectory(identity, destination string) (string, error) } return root, syncWorkspaceApplicationAncestors(root) } + +// The closer is always callable, including when acquisition fails, so callers +// can defer it before examining the error. func lockWorkspaceApplication(root, destination string) (func(), error) { sum := sha256.Sum256([]byte(destination)) path := filepath.Join(filepath.Dir(root), hex.EncodeToString(sum[:])+".lock") if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { - return nil, errors.New("application lock is not a regular file") + return func() {}, errors.New("application lock is not a regular file") } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) if err != nil { - return nil, err + return func() {}, err } if err := filelock.Lock(f, true, true); err != nil { f.Close() - return nil, fmt.Errorf("another workspace application holds this destination: %w", err) + return func() {}, fmt.Errorf("another workspace application holds this destination: %w", err) } return func() { _ = filelock.Unlock(f); _ = f.Close() }, nil } diff --git a/internal/taskcopy/compose.go b/internal/taskcopy/compose.go index e2d2a72089..64bda4c6ff 100644 --- a/internal/taskcopy/compose.go +++ b/internal/taskcopy/compose.go @@ -198,7 +198,7 @@ func changedPaths(tree string) ([]string, error) { var paths []string for _, entry := range strings.Split(out, "\x00") { // An entry is two status letters, a space and the path. - if len(entry) > 3 && !cell.IsTaskDropping(entry[3:]) { + if len(entry) > 3 && !cell.IsTaskDropping(entry[3:]) && !portableNativeMetadataPath(entry[3:]) { paths = append(paths, entry[3:]) } } @@ -272,7 +272,7 @@ func skipEntry(d fs.DirEntry) error { func capturePortableLinks(root string, paths []string) (*PortableState, error) { state := PortableState{Version: 1, Entries: map[string]PortableEntry{}} for _, path := range paths { - if path == "" { + if path == "" || portableNativeMetadataPath(path) { continue } info, err := os.Lstat(filepath.Join(root, path)) diff --git a/internal/taskcopy/eol.go b/internal/taskcopy/eol.go index e500310f9e..6193185048 100644 --- a/internal/taskcopy/eol.go +++ b/internal/taskcopy/eol.go @@ -109,6 +109,11 @@ func applyEOL(dest string, styles map[string]string) error { // cannot represent them; a new Windows path gets 0644 (K3/K4). func portableModes(tree, dest string) map[string]fs.FileMode { modes := modesOfTree(tree) + for path := range modes { + if portableNativeMetadataPath(path) { + delete(modes, path) + } + } if runtime.GOOS != "windows" { return modes } diff --git a/internal/taskcopy/review_snapshot.go b/internal/taskcopy/review_snapshot.go index d77013ec23..cd72726efb 100644 --- a/internal/taskcopy/review_snapshot.go +++ b/internal/taskcopy/review_snapshot.go @@ -79,6 +79,9 @@ func inspectPortableState(root string, wholeFamily bool) (PortableState, error) return PortableState{}, err } root = canonical + if err := portableNativeMetadataDirectory(root); err != nil { + return PortableState{}, err + } indexRoot, cleanup, err := portableInspectionIndex(root) if err != nil { return PortableState{}, err @@ -405,7 +408,7 @@ func MaterializePortableInput(source, destination string, baseline PortableState } } for path := range selected { - if path == "" || cell.IsTaskDropping(path) { + if path == "" || cell.IsTaskDropping(path) || portableNativeMetadataPath(path) { continue } entry, present := baseline.Entries[path] @@ -566,6 +569,9 @@ func validatePortableEntries(entries map[string]PortableEntry) error { return errors.New("portable path count exceeds its bound") } for path, entry := range entries { + if portableNativeMetadataPath(path) { + return errors.New("portable entry names native control metadata") + } if !filepath.IsLocal(filepath.FromSlash(path)) || path == "." || len(path) > 4096 || strings.Contains(path, "\\") || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path { return errors.New("portable entry path is unsafe") } @@ -613,9 +619,12 @@ func InspectPortablePaths(root string, paths []string) (PortableState, error) { return PortableState{}, err } root = canonical + if err := portableNativeMetadataDirectory(root); err != nil { + return PortableState{}, err + } state := PortableState{Version: 1, Entries: map[string]PortableEntry{}} for _, rel := range paths { - if rel == "" || rel == "." || cell.IsTaskDropping(rel) { + if rel == "" || rel == "." || cell.IsTaskDropping(rel) || portableNativeMetadataPath(rel) { continue } if _, ok := state.Entries[rel]; ok { @@ -838,3 +847,22 @@ func setPortableSymlinkMode(root *os.Root, path string, mode fs.FileMode) error defer parent.Close() return SetPortableSymlinkModeAt(int(parent.Fd()), filepath.Base(filepath.FromSlash(path)), mode) } + +// The native engine reserves this top-level directory for its workspace IDs. +// Nested directories with the same spelling remain ordinary project inputs. +func portableNativeMetadataPath(path string) bool { + return path == ".furrow" || strings.HasPrefix(path, ".furrow/") +} +func portableNativeMetadataDirectory(root string) error { + info, err := os.Lstat(filepath.Join(root, ".furrow")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("native workspace metadata is not a directory; preserve this private copy") + } + return nil +} diff --git a/internal/taskcopy/review_snapshot_test.go b/internal/taskcopy/review_snapshot_test.go index fe5695038f..56fc429e5c 100644 --- a/internal/taskcopy/review_snapshot_test.go +++ b/internal/taskcopy/review_snapshot_test.go @@ -394,3 +394,74 @@ func TestWorkspaceCarrierImportsLinuxLinkFixture(t *testing.T) { t.Fatal(err) } } + +func TestMaterializePortableInputKeepsNativeMetadataOutsideUserState(t *testing.T) { + for _, repository := range []bool{false, true} { + t.Run(map[bool]string{false: "plain", true: "git"}[repository], func(t *testing.T) { + source := t.TempDir() + write(t, filepath.Join(source, "input.txt"), "raw input\r\n") + write(t, filepath.Join(source, "docs", ".furrow", "user.txt"), "nested user input\n") + if err := os.Mkdir(filepath.Join(source, "empty"), 0750); err != nil { + t.Fatal(err) + } + if repository { + run(t, source, "git", "init", "-q") + run(t, source, "git", "add", ".") + run(t, source, "git", "commit", "-qm", "input") + } + var baseline PortableState + var err error + if repository { + baseline, err = InspectPortableState(source) + } else { + baseline, err = InspectPortableFamilyInput(source) + } + if err != nil { + t.Fatal(err) + } + write(t, filepath.Join(source, ".furrow", "workspace-id"), "source native identity\n") + destination := t.TempDir() + write(t, filepath.Join(destination, "input.txt"), "normalized input\n") + write(t, filepath.Join(destination, "docs", ".furrow", "user.txt"), "nested user input\n") + write(t, filepath.Join(destination, ".furrow", "workspace-id"), "private native identity\n") + run(t, destination, "git", "init", "-q") + run(t, destination, "git", "add", "--all", "--force", "--", ".", ":(exclude,literal).furrow") + run(t, destination, "git", "commit", "-qm", "private input") + if err = MaterializePortableInput(source, destination, baseline); err != nil { + t.Fatal(err) + } + for path, want := range map[string]string{"input.txt": "raw input\r\n", "docs/.furrow/user.txt": "nested user input\n", ".furrow/workspace-id": "private native identity\n"} { + raw, err := os.ReadFile(filepath.Join(destination, path)) + if err != nil || string(raw) != want { + t.Fatalf("input/control %s changed: %v", path, err) + } + } + state, err := InspectPortableFamilyInput(source) + if err != nil { + t.Fatal(err) + } + if _, ok := state.Entries[".furrow/workspace-id"]; ok { + t.Fatal("native control metadata entered project state") + } + if state.Entries["docs/.furrow/user.txt"].Kind != "file" { + t.Fatal("nested user directory excluded") + } + }) + } +} + +func TestPortableCaptureRefusesRedirectedNativeMetadata(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + write(t, filepath.Join(outside, "user.txt"), "outside user input\n") + if err := os.Symlink(outside, filepath.Join(root, ".furrow")); err != nil { + t.Fatal(err) + } + if _, err := InspectPortableFamilyInput(root); err == nil { + t.Fatal("redirected native metadata accepted") + } + raw, err := os.ReadFile(filepath.Join(outside, "user.txt")) + if err != nil || string(raw) != "outside user input\n" { + t.Fatal("outside file changed") + } +} diff --git a/internal/taskcopy/workspace_carrier.go b/internal/taskcopy/workspace_carrier.go index 29a1e58e61..ac48398015 100644 --- a/internal/taskcopy/workspace_carrier.go +++ b/internal/taskcopy/workspace_carrier.go @@ -324,6 +324,9 @@ func captureWorkspaceDirectoriesWithIndex(tree, indexRoot string) (map[string]fs } func captureWorkspaceDirectoriesScope(tree, indexRoot string, wholeFamily bool) (map[string]fs.FileMode, error) { + if err := portableNativeMetadataDirectory(tree); err != nil { + return nil, err + } ignored, err := portableInspectionGit(indexRoot, "--work-tree="+tree, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") if err != nil { return nil, err @@ -367,7 +370,7 @@ func captureWorkspaceDirectoriesScope(tree, indexRoot string, wholeFamily bool) if !d.IsDir() { return nil } - if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) || portableNativeMetadataPath(rel) { return filepath.SkipDir } ignored := false diff --git a/internal/taskcopy/workspace_restore.go b/internal/taskcopy/workspace_restore.go index 24bdfb8b2a..b198eaf5fe 100644 --- a/internal/taskcopy/workspace_restore.go +++ b/internal/taskcopy/workspace_restore.go @@ -481,7 +481,7 @@ func validateWorkspaceRecord(from string, rec record) error { return errors.New("workspace path metadata exceeds its bound") } check := func(rel string) error { - if !filepath.IsLocal(filepath.FromSlash(rel)) || len(rel) > 4096 || strings.Contains(rel, "\\") || rel == ".git" || strings.HasPrefix(rel, ".git/") { + if rel == "." || filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) != rel || portableNativeMetadataPath(rel) || !filepath.IsLocal(filepath.FromSlash(rel)) || len(rel) > 4096 || strings.Contains(rel, "\\") || rel == ".git" || strings.HasPrefix(rel, ".git/") { return errors.New("workspace record path is unsafe") } return nil @@ -1067,7 +1067,7 @@ func validateWorkspaceDirectories(root workspaceRoot, rec record) (int, error) { } folded[alias] = true - if !utf8.ValidString(path) || path == "." || !filepath.IsLocal(filepath.FromSlash(path)) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path || strings.Contains(path, "\\") || len(path) > 4096 { + if portableNativeMetadataPath(path) || !utf8.ValidString(path) || path == "." || !filepath.IsLocal(filepath.FromSlash(path)) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) != path || strings.Contains(path, "\\") || len(path) > 4096 { return 0, errors.New("workspace directory path is unsafe") } for _, part := range strings.Split(path, "/") { @@ -1135,6 +1135,9 @@ func prepareWorkspaceDirectories(destination string, directories map[string]fs.F return nil } func finishWorkspaceDirectories(destination string, directories map[string]fs.FileMode) error { + if err := portableNativeMetadataDirectory(destination); err != nil { + return err + } var absent []string err := filepath.WalkDir(destination, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -1150,7 +1153,7 @@ func finishWorkspaceDirectories(destination string, directories map[string]fs.Fi if rel == "." { return nil } - if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) || portableNativeMetadataPath(filepath.ToSlash(rel)) { return filepath.SkipDir } rel = filepath.ToSlash(rel) @@ -1202,7 +1205,7 @@ func verifyWorkspaceDirectories(destination string, directories map[string]fs.Fi if rel == "." { return nil } - if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) { + if d.Name() == ".git" || cell.IsTaskDropping(d.Name()) || portableNativeMetadataPath(filepath.ToSlash(rel)) { return filepath.SkipDir } if _, ok := directories[filepath.ToSlash(rel)]; !ok { diff --git a/internal/taskcopy/workspace_restore_test.go b/internal/taskcopy/workspace_restore_test.go index 925faf099c..b53ac7ddd5 100644 --- a/internal/taskcopy/workspace_restore_test.go +++ b/internal/taskcopy/workspace_restore_test.go @@ -62,7 +62,7 @@ func TestWorkspaceRestoreReopensSameWholeLayout(t *testing.T) { } } func TestWorkspaceRestoreRejectsDamagedClosureBeforeMutations(t *testing.T) { - for _, kind := range []string{"missing-root", "wrong-checkpoint", "traversal-record", "symlink-carrier", "occupied-destination"} { + for _, kind := range []string{"missing-root", "wrong-checkpoint", "traversal-record", "native-deletion", "native-mode", "native-mode-alias", "native-deletion-alias", "native-overlay", "symlink-carrier", "occupied-destination"} { t.Run(kind, func(t *testing.T) { c, plan := workspaceFixture(t) if err := (Carry{}).Compose(c); err != nil { @@ -90,6 +90,26 @@ func TestWorkspaceRestoreRejectsDamagedClosureBeforeMutations(t *testing.T) { if err = writeRecord(from, rec); err != nil { t.Fatal(err) } + case "native-deletion", "native-mode", "native-mode-alias", "native-deletion-alias", "native-overlay": + rec, err := readRecord(from) + if err != nil { + t.Fatal(err) + } + switch kind { + case "native-deletion": + rec.Deleted = []string{".furrow/workspace-id"} + case "native-mode-alias": + rec.Modes = map[string]os.FileMode{"./.furrow/workspace-id": 0600} + case "native-deletion-alias": + rec.Deleted = []string{"x/../.furrow/workspace-id"} + case "native-mode": + rec.Modes = map[string]os.FileMode{".furrow/workspace-id": 0600} + case "native-overlay": + write(t, filepath.Join(from, filesDir, ".furrow", "workspace-id"), "forged native identity\n") + } + if err = writeRecord(from, rec); err != nil { + t.Fatal(err) + } case "symlink-carrier": replacement := t.TempDir() if err = os.Rename(from, filepath.Join(replacement, "root")); err != nil { @@ -482,7 +502,7 @@ func TestWorkspaceSecondRootFailurePreservesStageAndStartsNothing(t *testing.T) // Directory metadata refusals are exercised through public carrier preparation. func TestWorkspaceDirectoryMetadataRefusesBeforePreparation(t *testing.T) { - for _, kind := range []string{"legacy", "nil-map", "traversal", "git-private", "special-mode", "owner-permission", "missing-parent", "file-parent", "case-alias", "unicode-alias"} { + for _, kind := range []string{"legacy", "nil-map", "traversal", "git-private", "native-private", "special-mode", "owner-permission", "missing-parent", "file-parent", "case-alias", "unicode-alias"} { t.Run(kind, func(t *testing.T) { c, plan := workspaceFixture(t) if err := (Carry{}).Compose(c); err != nil { @@ -504,6 +524,8 @@ func TestWorkspaceDirectoryMetadataRefusesBeforePreparation(t *testing.T) { root.Directories = map[string]os.FileMode{"../outside": 0750} case "git-private": root.Directories = map[string]os.FileMode{".git": 0700} + case "native-private": + root.Directories = map[string]os.FileMode{".furrow": 0700} case "special-mode": root.Directories = map[string]os.FileMode{"unsafe": os.ModeSymlink | 0750} case "owner-permission": From 0e4ed86dab23941855147cdcd77fdcbd7b9b8eec Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:28:33 -0400 Subject: [PATCH 11/35] Clarify native control metadata ownership --- docs/architecture/workspaces-storage-relay.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index 5458590f9b..b4682fcdb2 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -110,6 +110,11 @@ immutable baseline travels with the task carrier; it grants no destination write authority. Existing tasks without a verified metadata baseline retain their readable results, but application refuses instead of inventing history. +Native control metadata at the workspace root is owned by the local engine, +separate from project inputs. Capture and restore agree on that boundary; +incoming records cannot overwrite it. Nested project folders with the same name +remain ordinary user content. This distinction does not expand cleanup authority. + Application uses a journaled destination transaction. Independently copied preimages and verified raw postimages remain available until the final task checkpoint is durable. Built-in Git text conversion produces canonical history From 31a3359e5ff7ddbf4a5fd7bfda0d10c59c1e4577 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:37:01 -0400 Subject: [PATCH 12/35] Persist review workspace identity without prototype overrides --- internal/session/task_workspace_apply.go | 2 +- internal/session/task_workspace_apply_test.go | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 86bca284ec..2d02596625 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -338,7 +338,7 @@ func stageTaskWorkspaceReview(ctx context.Context, id uint64, use WorkspaceRootU return fail(err) } place := Place{Dir: owned, Workspace: original.ground} - if err := SaveMeta(owned, Meta{WorkspacePolicy: DefaultWorkspacePolicy(), Workspace: original.ground}); err != nil { + if err := SaveMeta(owned, Meta{ID: place.ID(), WorkspacePolicy: DefaultWorkspacePolicy(), Workspace: original.ground}); err != nil { _ = os.RemoveAll(owned) return fail(err) } diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go index d97c820956..c33ebeafc7 100644 --- a/internal/session/task_workspace_apply_test.go +++ b/internal/session/task_workspace_apply_test.go @@ -63,6 +63,11 @@ func TestTaskWorkspaceExplicitReviewAndApply(t *testing.T) { if !taskStorageHasUncertainWriter() { t.Fatal("ordinary shell did not retain writer uncertainty") } + // The shipped policy must work without the retired prototype override. + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), Workspace: source, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") review, err := agent.ReviewTaskWorkspaceRoot(context.Background(), id, "work") if err != nil { t.Fatal(err) @@ -152,7 +157,7 @@ func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setup func(string)) (*Agent, uint64, Place, string, string) { t.Helper() t.Setenv("CODEAF_HOME", t.TempDir()) - t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "unavailable-native-engine")) source := t.TempDir() @@ -165,6 +170,9 @@ func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setu setup(source) } place := Place{Dir: t.TempDir(), Workspace: source} + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), Workspace: source, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { + t.Fatal(err) + } engine := newBeltRunDouble("done") engine.real = true registerBeltRunEngine(t, engine) @@ -210,6 +218,9 @@ func TestTaskWorkspaceApplyRejectsAlteredStageReceipt(t *testing.T) { foreign := newTestRepo(t) // A second authentic review can be transplanted without inventing any URI. otherPlace := Place{Dir: t.TempDir(), Workspace: foreign} + if err := SaveMeta(otherPlace.Dir, Meta{ID: otherPlace.ID(), Workspace: foreign, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { + t.Fatal(err) + } engine := newBeltRunDouble("done") engine.real = true registerBeltRunEngine(t, engine) From 14d225c89c165aaacef9ee4a459021734f6ed08d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 21:54:42 -0400 Subject: [PATCH 13/35] Isolate memory close test from background route networking --- internal/session/memory_close_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/session/memory_close_test.go b/internal/session/memory_close_test.go index f3c9dacb4e..40aac1d1e6 100644 --- a/internal/session/memory_close_test.go +++ b/internal/session/memory_close_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" ) // ── NO MEMORY PASS OUTLIVES THE SESSION ───────────────────────────────────── @@ -81,7 +82,11 @@ func TestClosedSessionHasNoMemoryPassStillWritingTheStore(t *testing.T) { usageWriterFor(UsageLedgerPath()) synctest.Test(t, func(t *testing.T) { reflexes := newHeldReflex() - agent, brain := brainAgent(t, reflexes, nil) + agent, brain := brainAgent(t, reflexes, func(config *Config) { + // Lane refreshes use process-global networking, outside this + // memory-lifetime test and its controlled synctest bubble. + config.Routing = provider.RoutingOff + }) collect(t, mustSubmit(t, agent, "the release train leaves on Thursdays")) <-reflexes.entered From 287ca5997e416008257d5ad73121e1fd591fed11 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 22:05:00 -0400 Subject: [PATCH 14/35] Route folder picker paste without changing the chat draft --- internal/tui3/app.go | 10 ++++++++++ internal/tui3/contextmodal_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 259e947b17..97e83df106 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -9519,6 +9519,16 @@ func (a *app) paste(text string) tea.Cmd { a.touch() return nil } + // The folder sheet owns its path box and suspends the conversation draft. + if a.folder.open { + a.folder.filter.insert(strings.ReplaceAll(text, "\n", " ")) + a.folder.rank() + if a.folder.browsing { + a.folder.browseSync(a.resolvePath) + } + a.touch() + return a.folderWork() + } // A KEY BOX IS THE PASTE THIS SURFACE MOST EXPECTS, and it reads first. A // key is a thing nobody types — it comes out of a clipboard — so the two // boxes that collect one take the clipboard before anything else does: the diff --git a/internal/tui3/contextmodal_test.go b/internal/tui3/contextmodal_test.go index 8e1ea5c11a..1371345407 100644 --- a/internal/tui3/contextmodal_test.go +++ b/internal/tui3/contextmodal_test.go @@ -917,3 +917,29 @@ func TestContextModalLateralWheelAndHoverStayOnBackdrop(t *testing.T) { }) } } + +func TestFolderPickerOwnsPastedPathsWithoutChangingTheDraft(t *testing.T) { + for _, bracketed := range []bool{false, true} { + t.Run(map[bool]string{false: "coalesced", true: "bracketed"}[bracketed], func(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "pasted-target") + if err := os.MkdirAll(target, 0755); err != nil { + t.Fatal(err) + } + a := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m"}, Workspace: root, ProfileDir: t.TempDir(), UsageLedger: labLedger(), Env: envOf(map[string]string{"TERM": "xterm-256color"})}) + drive(t, a, tea.WindowSizeMsg{Width: 200, Height: 46}, tea.PasteMsg{Content: "/folder"}, key("enter")) + if bracketed { + drive(t, a, tea.PasteStartMsg{}, tea.PasteMsg{Content: target + "/"}, tea.PasteEndMsg{}) + } else { + drive(t, a, tea.PasteMsg{Content: target + "/"}) + } + if !strings.Contains(plain(a.View().Content), "pasted-target") { + t.Fatal("pasted path did not reach the visible folder picker") + } + drive(t, a, key("esc")) + if strings.Contains(plain(a.View().Content), "pasted-target") { + t.Fatal("canceling the picker exposed its pasted path in the original empty draft") + } + }) + } +} From 661605b5855add7a09a0d39cd675df5db2574348 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 22:22:24 -0400 Subject: [PATCH 15/35] Allow explicitly reviewed apply on the chosen branch --- internal/manual/chat/task-controls.md | 3 +++ internal/session/task_branch_protection.go | 8 ++++++- internal/session/task_workspace_apply_test.go | 21 +++++++++++++++++++ .../session/task_workspace_transaction.go | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 8dda085aa6..9856eee770 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -247,6 +247,9 @@ private copy and its identity remain available after a successful apply too. Uncertain writers continue to prevent destructive retirement of that copy. Applying one root does not authorize another root or give later tasks automatic landing permission. A received root stays private until this explicit local action. +Explicit reviewed apply may update the chosen branch, including `main`. Automatic +task landing still refuses protected branches. Checkout movement, conflicts and +ref/index checks continue to protect the destination. The portable apply contract preserves raw file bytes and line endings, file permissions, empty directories and directory permissions. Changed paths with diff --git a/internal/session/task_branch_protection.go b/internal/session/task_branch_protection.go index 6d1d6ae140..d7d6867007 100644 --- a/internal/session/task_branch_protection.go +++ b/internal/session/task_branch_protection.go @@ -167,13 +167,19 @@ func (t taskTree) landsInThePersonsRepository() bool { // when the checkout is not a safe destination. It only reads the checkout; the // caller has already put the task branch in the root repository before asking. func (t taskTree) keptLandingSentence() string { + return t.landingSafetySentence(true) +} + +// Explicit reviewed apply grants consent to the chosen protected branch, while +// retaining the checkout and historical branch-movement checks. +func (t taskTree) landingSafetySentence(automatic bool) string { current := currentBranch(t.root) switch { case current == "": return "its branch " + t.branch + " was kept: your checkout is not on a branch — inspect the retained task branch without changing this checkout" case t.home != "" && current != t.home: return "its branch " + t.branch + " was kept: your checkout has moved from " + t.home + " to " + current + " since the work was cut — inspect the retained task branch before choosing a destination" - case protectedBranch(t.root, current): + case automatic && protectedBranch(t.root, current): return "its branch " + t.branch + " was kept: your checkout is on " + current + ", which tasks do not merge into automatically" case branchMovedByPerson(t.root, current, t.homeSha): return "its branch " + t.branch + " was kept: " + current + " has moved on since the work was cut — inspect the retained task branch before choosing a destination" diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go index c33ebeafc7..6ea28fc30f 100644 --- a/internal/session/task_workspace_apply_test.go +++ b/internal/session/task_workspace_apply_test.go @@ -635,3 +635,24 @@ func TestTaskWorkspaceFamilyRetainsIgnoredRuntimeInputs(t *testing.T) { t.Fatal("ignored original input changed") } } + +func TestTaskWorkspaceExplicitApplyToMain(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixtureWithInput(t, true, func(source string) { + mustGit(t, source, "branch", "-m", "main") + }) + if _, err := agent.ReviewTaskWorkspaceRoot(t.Context(), id, "work"); err != nil { + t.Fatal(err) + } + if _, err := agent.ApplyTaskWorkspaceRoot(t.Context(), id, "work"); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(source, "shared.txt")); got != "private result\n" { + t.Fatalf("explicit main apply did not publish reviewed bytes: %q", got) + } + if got := gitOut(t, source, "branch", "--show-current"); strings.TrimSpace(got) != "main" { + t.Fatalf("explicit apply changed the chosen branch: %q", got) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatalf("explicit apply did not retain private result: %q", got) + } +} diff --git a/internal/session/task_workspace_transaction.go b/internal/session/task_workspace_transaction.go index a30dbfc3b9..5c8f10872b 100644 --- a/internal/session/task_workspace_transaction.go +++ b/internal/session/task_workspace_transaction.go @@ -74,7 +74,7 @@ func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir return receipt, err } if original.mode != TaskModeMirror && original.landsInThePersonsRepository() { - if reason := original.keptLandingSentence(); reason != "" { + if reason := original.landingSafetySentence(false); reason != "" { return taskWorkspaceApplyReceipt{}, errors.New(reason) } } From 69af064c63acb8986f181f7e0d1e97e877055cf9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 22:22:24 -0400 Subject: [PATCH 16/35] Join stopped-result fixture work before checking new runs --- internal/session/route_judge_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/session/route_judge_test.go b/internal/session/route_judge_test.go index 78f1bce7f4..4610f95dc5 100644 --- a/internal/session/route_judge_test.go +++ b/internal/session/route_judge_test.go @@ -1976,6 +1976,10 @@ func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { graph := agent.graph() id := graph.reserve() graph.admit(id, taskSpec{title: "the requested audit", request: tc.request, brief: "audit", acceptance: "done"}) + // Admission starts the original stub runner. Join setup before asking + // whether reporting its result starts any additional work. + graph.runners.Wait() + runsBefore := nodes.count() if tc.stopped { graph.mu.Lock() graph.nodes[id].state = TaskFailed @@ -2006,9 +2010,14 @@ func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { if completer.asked() != wantCalls || completer.confirms() != wantCalls { t.Fatalf("screen calls = %d, confirms = %d, want %d each", completer.asked(), completer.confirms(), wantCalls) } - if !tc.want { - agent.applyRouteJudge(newEventHub(), ruling) - if nodes.count() != 0 || admitted(graph) != 1 { + agent.applyRouteJudge(newEventHub(), ruling) + graph.runners.Wait() + if tc.want { + if nodes.count() != runsBefore+1 || admitted(graph) != 2 { + t.Fatal("authorized work did not admit exactly one additional task") + } + } else { + if nodes.count() != runsBefore || admitted(graph) != 1 { t.Fatal("reporting a stopped result admitted or ran another task") } // A person can repeat the same request after the stopped wake. @@ -2017,6 +2026,11 @@ func TestAStoppedTaskResultCannotAutoStartItsRequestAgain(t *testing.T) { if !fresh.start || completer.asked() != 1 || completer.confirms() != 1 { t.Fatal("a fresh human request was suppressed after the stopped result") } + agent.applyRouteJudge(newEventHub(), fresh) + graph.runners.Wait() + if nodes.count() != runsBefore+1 || admitted(graph) != 2 { + t.Fatal("the repeated human request did not admit exactly one additional task") + } } }) } From fbeeddebe88f48edb0f114d182b04cb63eb01d19 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 22:22:34 -0400 Subject: [PATCH 17/35] Document explicit apply consent and automatic branch protection --- docs/architecture/workspaces-storage-relay.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index b4682fcdb2..9b983d9634 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -97,6 +97,11 @@ roots cannot be applied. Conflicts preserve both versions. Successful applicatio records its outcome and prevents a duplicate apply. The original private copy and its URI remain available after restart. +Explicit reviewed Apply can update the chosen branch, including `main`. +Automatic task landing retains its protected-branch refusal. Both paths retain +checkout-movement checks; reviewed Apply also retains its ref/index transaction +and affected-path conflict checks. + Reviewed staging and its receipt are local profile assets, separate from the portable conversation. A replacement review retires only its verified previous staging after the new receipt is durable. Invalid ownership, destination, From df95fac3cbaafa26f617d9156c5900c0da0c90bf Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 23:04:43 -0400 Subject: [PATCH 18/35] Pin released non-mutating SQLite capture engine --- internal/furrowbin/pin.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/furrowbin/pin.json b/internal/furrowbin/pin.json index 60bfb2ee7e..484a95f99d 100644 --- a/internal/furrowbin/pin.json +++ b/internal/furrowbin/pin.json @@ -1,31 +1,31 @@ { "repository": "Agent-Field/furrow", - "tag": "v0.3.1", - "version": "0.3.1", + "tag": "v0.3.2", + "version": "0.3.2", "artifacts": { "darwin-amd64": { "asset": "furrow-darwin-amd64", - "sha256": "0bad130abb4193221bf00b0ff077f2a0fa9f0ccb294fcd696e4b790e77fa4f43" + "sha256": "d7fa1cacf282ced26b1a7a04a1cc1efc0cf2c35373dfe98f4a8ee6f6c00f3ada" }, "darwin-arm64": { "asset": "furrow-darwin-arm64", - "sha256": "d74e47b76fc208ebab5ddc6047fa6efc37c4392e8ac8ee1c931f744d620c2a6f" + "sha256": "fbafec1747f83ec93e0a4794996b96b4c980973d5d344d40e79da2072d3b0cac" }, "linux-amd64": { "asset": "furrow-linux-amd64", - "sha256": "95c59f81432dcadf5e32f9762adac321dffbd5cb756517b956c30cb0cad75e3c" + "sha256": "2afcfe220c0815ed9b3336b17c5fcb75bfb1f57b9d13b48999b9efc0dba6c54b" }, "linux-arm64": { "asset": "furrow-linux-arm64", - "sha256": "69bba30afaa1127edcf04bbe336e2a90c1cfff45ff654eec65915c20cdd117bc" + "sha256": "cf2daadfd34feb45cba1ecc3f9600d1bef3c6ad90b475d64a2343a4a98e75680" }, "windows-amd64": { "asset": "furrow-windows-amd64.exe", - "sha256": "a6d92449962c5e7890021b63e6424258d7540c339130851d72dde69cbf5834f0" + "sha256": "5188d62b13978b90f8e0b352cdb433ef4c5621b3e97e674aaa6973db532945de" }, "windows-arm64": { "asset": "furrow-windows-arm64.exe", - "sha256": "66323c167c5985d44fb69806293c27ad6755214e58dbaeecd43cac66e15a7525" + "sha256": "9afe193c5de4ed411ba942204caa97dd465518d2bdd48085a429210901b51531" } } } From 3ea4f7434a37ef9e73a7b7ce46315ad558cabb0d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 23:05:15 -0400 Subject: [PATCH 19/35] Document non-mutating database capture contract --- docs/architecture/workspaces-storage-relay.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index 9b983d9634..58008adf79 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -234,7 +234,7 @@ use. Deduplicated compressed chunks may serve several manifests without combining their ownership. The archive implementation is a separate native dependency: -[Furrow v0.3.1](https://github.com/Agent-Field/furrow/releases/tag/v0.3.1). +[Furrow v0.3.2](https://github.com/Agent-Field/furrow/releases/tag/v0.3.2). Its local archive database is not a relay wire format. Ordinary builds use the published release with six platform checksums recorded in the embedded pin; acceptance does not depend on an unpublished local native artifact. Windows @@ -263,6 +263,8 @@ an older executable is not a sufficient rollback for records it cannot read. Fast routine key rewrapping, if introduced separately, cannot replace full compromise recovery or revoke content exposed by an old key. +SQLite backup capture stages verified database and journal bytes privately before opening them through SQLite. It does not SQLite-open original working roots, avoiding source WAL/shared-memory mutations. Writers must remain quiescent during capture; sequential file capture is not an atomic live-database snapshot. + ## Validation and measurement limits Separate experiments measured **43.34% lower allocated use** inside an isolated From 059976637fa97aa56c7191363034ea43aa0d2858 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 23:31:20 -0400 Subject: [PATCH 20/35] Keep typed folder targets through asynchronous listings --- internal/tui3/contextmodal_test.go | 35 +++++++++++++++++++++++++ internal/tui3/folderact.go | 4 +++ internal/tui3/folderplace.go | 5 ++++ internal/tui3/workspace_binding_test.go | 4 ++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/tui3/contextmodal_test.go b/internal/tui3/contextmodal_test.go index 1371345407..c4d33122cc 100644 --- a/internal/tui3/contextmodal_test.go +++ b/internal/tui3/contextmodal_test.go @@ -943,3 +943,38 @@ func TestFolderPickerOwnsPastedPathsWithoutChangingTheDraft(t *testing.T) { }) } } + +func TestFolderPickerWaitsForTheTypedDirectoryBeforeConfirming(t *testing.T) { + for _, immediate := range []bool{false, true} { + t.Run(map[bool]string{false: "listing-arrives", true: "enter-before-listing"}[immediate], func(t *testing.T) { + parent := t.TempDir() + for _, name := range []string{"app", "library"} { + if err := os.Mkdir(filepath.Join(parent, name), 0755); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(parent, "library") + var anchored string + a := newApp(t.Context(), Options{Agent: &fakeAgent{model: "m"}, Workspace: t.TempDir(), ProfileDir: t.TempDir(), UsageLedger: labLedger(), Env: envOf(map[string]string{"TERM": "xterm-256color"}), AnchorWorkspace: func(path string) (string, error) { anchored = path; return path, nil }}) + drive(t, a, tea.WindowSizeMsg{Width: 200, Height: 46}, tea.PasteMsg{Content: "/workspace"}, key("enter")) + _, pending := a.Update(tea.PasteMsg{Content: target}) + if !strings.Contains(plain(a.View().Content), "library") { + t.Fatal("typed destination was not visible") + } + if immediate { + a.Update(key("enter")) + if anchored != "" { + t.Fatalf("pending folder query admitted %q before its directory listing arrived", anchored) + } + } + if pending == nil { + t.Fatal("new directory query did not request its listing") + } + spend(t, a, pending) + drive(t, a, key("enter")) + if anchored != target { + t.Fatalf("typed destination %q anchored %q", target, anchored) + } + }) + } +} diff --git a/internal/tui3/folderact.go b/internal/tui3/folderact.go index 4896f36ec4..9d34099c4e 100644 --- a/internal/tui3/folderact.go +++ b/internal/tui3/folderact.go @@ -150,6 +150,10 @@ func (f *folderPick) hereKind() (string, bool, bool) { } return f.all[f.hits[f.cursor]].path, true, true } + // A pending read is not an empty directory and cannot authorize its parent. + if !f.cols.here.done { + return "", false, false + } at := f.cols.cursor if at >= 0 && at < f.cols.here.rows() { return filepath.Join(f.cols.dir, f.cols.here.rowName(at)), f.cols.here.isDir(at), true diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index 9a29a46019..bcac06a013 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -923,6 +923,11 @@ func (a *app) tookFolderKids(msg folderKidsMsg) tea.Cmd { if !a.folder.took(msg.path, msg.read) { return nil } + // A typed leaf can arrive after the parent listing was requested. Walking + // clears the filter, so explicit navigation keeps its own cursor. + if a.folder.browsing && msg.path == a.folder.cols.dir { + a.folder.browseSync(a.resolvePath) + } a.touch() // A LEVEL ARRIVING CAN MAKE THE NEXT ONE WORTH ASKING FOR — the parent of // the level that just landed is not known until the level is, and neither is diff --git a/internal/tui3/workspace_binding_test.go b/internal/tui3/workspace_binding_test.go index 6c8c77f0ea..c59582dde5 100644 --- a/internal/tui3/workspace_binding_test.go +++ b/internal/tui3/workspace_binding_test.go @@ -67,8 +67,10 @@ func TestTakeRootPickerCollectsExplicitFoldersThroughKeys(t *testing.T) { for _, char := range name { msgs = append(msgs, key(string(char))) } - msgs = append(msgs, key("enter")) + // A typed path requests directory rows asynchronously. Confirm only + // after the choice is visible; pending Enter is tested separately. drive(t, a, msgs...) + drive(t, a, key("enter")) } choose(app + string(filepath.Separator)) if got := plain(a.View().Content); !strings.Contains(got, "local folder for 1/library") { From c3f986af85252f0cfdb4adb18b10d4a066c76ee5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 23:31:38 -0400 Subject: [PATCH 21/35] Document safe asynchronous receiver folder selection --- docs/architecture/workspaces-storage-relay.md | 2 ++ docs/changes/unreleased/1944-workspace-relay-architecture.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index 58008adf79..f49afe3184 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -265,6 +265,8 @@ compromise recovery or revoke content exposed by an old key. SQLite backup capture stages verified database and journal bytes privately before opening them through SQLite. It does not SQLite-open original working roots, avoiding source WAL/shared-memory mutations. Writers must remain quiescent during capture; sequential file capture is not an atomic live-database snapshot. +The receiver folder picker keeps the typed destination through asynchronous directory reads. An unfinished listing cannot authorize the parent as an empty folder. The current directory's answer reselects the typed leaf; unrelated results preserve navigation and stale picker generations are rejected. Completed empty directories remain valid choices. Listing work stays outside the UI event loop. + ## Validation and measurement limits Separate experiments measured **43.34% lower allocated use** inside an isolated diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 1ccd8128ed..84b149c73a 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -11,6 +11,8 @@ exact cooperating work. Carry multiple working roots with stable identities, versioned directory metadata, restart receipts and independent fork history. Ask for receiver-local folders before acquiring the source lease; cancellation and invalid mappings preserve the existing holder and allow a valid retry. +Typed folder targets survive asynchronous listings; confirmation waits for the +listing instead of choosing an unloaded parent or the first sibling. Review and apply each writable root explicitly through the terminal. Application uses the exact reviewed snapshot and retains the original private copy; edits From a4e25d6f73318bf7e5fa234658850fdcd127f9b6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 00:25:35 -0400 Subject: [PATCH 22/35] Resolve plan worker transcripts from their owned local history --- internal/session/task_index_rebuild.go | 7 +- internal/session/task_journal_test.go | 82 ++++++++++++++++++++++ internal/session/task_room.go | 12 +++- internal/session/task_run_history.go | 78 ++++++++++++++++++++ internal/session/task_run_history_other.go | 34 +++++++++ internal/session/task_run_history_unix.go | 33 +++++++++ internal/session/task_run_index.go | 2 +- 7 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 internal/session/task_run_history.go create mode 100644 internal/session/task_run_history_other.go create mode 100644 internal/session/task_run_history_unix.go diff --git a/internal/session/task_index_rebuild.go b/internal/session/task_index_rebuild.go index 16bef27f6f..bb8ed58da6 100644 --- a/internal/session/task_index_rebuild.go +++ b/internal/session/task_index_rebuild.go @@ -19,8 +19,9 @@ import ( // closed by the session's own reopen. A background job never had a row. // // A rebuilt row names no folder of the machine that ran the work (Where, -// Ground, the artifact and transcript URIs), because a moved chat reads this on -// another machine where those paths are false. +// Ground and the artifact URI), because a moved chat reads this on +// another machine where those paths are false. A worker transcript is cited only +// when its transferred local task directory contains a verified worker journal. // // Fields the checkpoint never carried stay absent, as they do in a row written // live for such a record: EndedAt is zero for a record that predates the @@ -43,7 +44,7 @@ func RebuiltTaskRows(dir string) []TaskIndexEntry { for _, record := range document.Runs { if row, ok := runIndexRow(record, id); ok && !filed[row.ID] { filed[row.ID] = true - rows = append(rows, row) + rows = append(rows, withLocalWorkerHistory(row, Place{Dir: dir})) } } return rows diff --git a/internal/session/task_journal_test.go b/internal/session/task_journal_test.go index 920f03eac0..51d15f3d5c 100644 --- a/internal/session/task_journal_test.go +++ b/internal/session/task_journal_test.go @@ -9,6 +9,7 @@ package session import ( "os" "path/filepath" + "strings" "testing" "github.com/Agent-Field/codeaf/internal/home" @@ -186,3 +187,84 @@ func TestNodeJournalNamesSortIntoMintingOrder(t *testing.T) { last = name } } + +func TestPlanBackedTaskReopensItsNestedWorkerHistory(t *testing.T) { + agent, id, place, _, _ := completedWorkspaceApplyFixture(t, false) + dir := filepath.Join(place.NodeJournals(), itoa64(id)) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, "20261026-000305.763019_worker.jsonl") + journal := `{"type":"session","version":1,"id":"worker","model":"deepseek/deepseek-v4.1-flash"}` + "\n" + `{"type":"message","role":"assistant","content":"the actual worker history"}` + "\n" + for name, body := range map[string]string{want: journal, filepath.Join(dir, "20261025-000305.763019_worker.jsonl"): journal, filepath.Join(dir, "20261027-000305.763019_worker.jsonl"): strings.Repeat("x", 65536), filepath.Join(dir, "20261026-000400.000000_helper.jsonl"): `{"type":"session","version":1,"id":"helper"}` + "\n"} { + if err := os.WriteFile(name, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + other := filepath.Join(place.NodeJournals(), itoa64(id+1)) + if err := os.MkdirAll(other, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(other, "20261026-010000.000000_worker.jsonl"), []byte(journal), 0600); err != nil { + t.Fatal(err) + } + t.Run("journal", func(t *testing.T) { + if got := agent.TaskJournal(id + 1); got != "" { + t.Fatalf("unknown task exposed foreign worker: %q", got) + } + if got := agent.TaskJournal(id); got != want { + t.Fatalf("plan-backed TaskJournal(%d)=%q, want its worker %q", id, got, want) + } + if report, ok := PeekReport(want); !ok || report != "the actual worker history" { + t.Fatalf("resolved worker history is unreadable: %q", report) + } + }) + t.Run("rebuilt-index", func(t *testing.T) { + for _, row := range RebuiltTaskRows(place.Dir) { + if row.ID == itoa64(id) { + if row.TranscriptURI != taskURI(want) || row.Model != "deepseek/deepseek-v4.1-flash" { + t.Fatalf("reopened task has wrong history/model: transcript=%q model=%q", row.TranscriptURI, row.Model) + } + return + } + } + t.Fatal("completed plan-backed task missing from rebuilt index") + }) +} + +func TestPlanBackedHistoryRefusesRedirectedTaskAndWorker(t *testing.T) { + for _, redirectDirectory := range []bool{false, true} { + t.Run(map[bool]string{false: "worker", true: "task-directory"}[redirectDirectory], func(t *testing.T) { + agent, id, place, _, _ := completedWorkspaceApplyFixture(t, false) + outside := t.TempDir() + name := "20261026-000305.763019_worker.jsonl" + if err := os.WriteFile(filepath.Join(outside, name), []byte(`{"type":"session","version":1,"id":"foreign"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(place.NodeJournals(), itoa64(id)) + if err := os.MkdirAll(filepath.Dir(dir), 0700); err != nil { + t.Fatal(err) + } + if redirectDirectory { + if err := os.Symlink(outside, dir); err != nil { + t.Fatal(err) + } + } else { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, name), filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + if got := agent.TaskJournal(id); got != "" { + t.Fatalf("redirected worker was exposed: %q", got) + } + for _, row := range RebuiltTaskRows(place.Dir) { + if row.ID == itoa64(id) && row.TranscriptURI != "" { + t.Fatalf("redirected worker indexed: %q", row.TranscriptURI) + } + } + }) + } +} diff --git a/internal/session/task_room.go b/internal/session/task_room.go index 13e2f058d0..7a300e4c43 100644 --- a/internal/session/task_room.go +++ b/internal/session/task_room.go @@ -872,7 +872,17 @@ func (a *Agent) WatchTaskRoom(id uint64) (<-chan Event, func(), error) { func (a *Agent) TaskJournal(id uint64) string { node := a.taskNode(id) if node == nil { - return "" + graph := a.tasker() + if graph == nil { + return "" + } + if _, known := runRowOf(graph, id); !known { + return "" + } + a.mu.Lock() + place, session := a.familyPlace(nil), a.sessionID() + a.mu.Unlock() + return findWorkerHistory(taskJournalDir(place, session), id).journal } if path := node.journalPath(); path != "" { return path diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go new file mode 100644 index 0000000000..cfb092e0a1 --- /dev/null +++ b/internal/session/task_run_history.go @@ -0,0 +1,78 @@ +package session + +import ( + "bufio" + "encoding/json" + "io" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type localWorkerHistory struct{ journal, model string } + +// Plan workers have their own numeric task directory. Never search another +// task, accept helper logs, or reuse an absolute path from a moved checkpoint. +func findWorkerHistory(base string, id uint64) localWorkerHistory { + relative := strconv.FormatUint(id, 10) + directory, err := openTaskHistoryPath(base, relative, true) + if err != nil { + return localWorkerHistory{} + } + defer directory.Close() + entries, err := directory.ReadDir(-1) + if err != nil { + return localWorkerHistory{} + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() > entries[j].Name() }) + for _, entry := range entries { + name := entry.Name() + stamp, worker := strings.CutSuffix(name, "_worker.jsonl") + if !worker || !entry.Type().IsRegular() { + continue + } + if _, err := time.Parse("20060102-150405.000000", stamp); err != nil { + continue + } + header, ok := readWorkerHeader(base, filepath.Join(relative, name)) + if ok { + return localWorkerHistory{journal: filepath.Join(base, relative, name), model: header.Model} + } + } + return localWorkerHistory{} +} + +// Only the first bounded header line is needed to identify a worker and the +// model its journal recorded; index rebuild never replays whole transcripts. +func readWorkerHeader(base, relative string) (sessionHeader, bool) { + file, err := openTaskHistoryPath(base, relative, false) + if err != nil { + return sessionHeader{}, false + } + defer file.Close() + const limit = 64 << 10 + line, err := bufio.NewReader(io.LimitReader(file, limit)).ReadString('\n') + if err != nil && err != io.EOF || len(line) >= limit { + return sessionHeader{}, false + } + var header sessionHeader + if json.Unmarshal([]byte(line), &header) != nil || header.Type != "session" || header.Version != sessionFileVersion || header.ID == "" { + return sessionHeader{}, false + } + return header, true +} + +func withLocalWorkerHistory(entry TaskIndexEntry, place Place) TaskIndexEntry { + id, err := strconv.ParseUint(entry.ID, 10, 64) + if err != nil || id == 0 { + return entry + } + history := findWorkerHistory(taskJournalDir(place, entry.SessionID), id) + entry.TranscriptURI = taskURI(history.journal) + if entry.Model == "" { + entry.Model = history.model + } + return entry +} diff --git a/internal/session/task_run_history_other.go b/internal/session/task_run_history_other.go new file mode 100644 index 0000000000..652643fd91 --- /dev/null +++ b/internal/session/task_run_history_other.go @@ -0,0 +1,34 @@ +//go:build !linux && !darwin + +package session + +import ( + "errors" + "os" + "syscall" +) + +func openTaskHistoryPath(root, relative string, directory bool) (*os.File, error) { + if !noSymlinkAncestors(root) { + return nil, errors.New("task history root is redirected") + } + base, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + defer base.Close() + info, err := base.Lstat(relative) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("task history is redirected or absent") + } + file, err := base.OpenFile(relative, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) || directory && !opened.IsDir() || !directory && !opened.Mode().IsRegular() { + _ = file.Close() + return nil, errors.New("task history is not an ordinary local file or directory") + } + return file, nil +} diff --git a/internal/session/task_run_history_unix.go b/internal/session/task_run_history_unix.go new file mode 100644 index 0000000000..b6f6ae2551 --- /dev/null +++ b/internal/session/task_run_history_unix.go @@ -0,0 +1,33 @@ +//go:build linux || darwin + +package session + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func openTaskHistoryPath(root, relative string, directory bool) (*os.File, error) { + parent, name, err := workspaceApplicationParent(root, relative) + if err != nil { + return nil, err + } + defer unix.Close(parent) + flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_CLOEXEC + if directory { + flags |= unix.O_DIRECTORY + } + fd, err := unix.Openat(parent, name, flags, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), name) + info, err := file.Stat() + if err != nil || directory && !info.IsDir() || !directory && !info.Mode().IsRegular() { + _ = file.Close() + return nil, errors.New("task history is not an ordinary local file or directory") + } + return file, nil +} diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index de15f774b3..376c30bd2c 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -42,7 +42,7 @@ func (a *Agent) indexRunRow(notice TaskNotice) { a.mu.Lock() session := a.sessionID() a.mu.Unlock() - entry := crewRunEntry(notice, session) + entry := withLocalWorkerHistory(crewRunEntry(notice, session), a.config.Place) worktree := "" if where := notice.Copy; where != nil { worktree = where.Dir From c543991ad92b05c2b7c27975cc71988768dd2ba9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 00:25:57 -0400 Subject: [PATCH 23/35] Document local task history and adopted checkpoint semantics --- docs/architecture/workspaces-storage-relay.md | 2 ++ docs/changes/unreleased/1944-workspace-relay-architecture.md | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index f49afe3184..96dbf813b3 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -267,6 +267,8 @@ SQLite backup capture stages verified database and journal bytes privately befor The receiver folder picker keeps the typed destination through asynchronous directory reads. An unfinished listing cannot authorize the parent as an empty folder. The current directory's answer reselects the typed leaf; unrelated results preserve navigation and stale picker generations are rejected. Completed empty directories remain valid choices. Listing work stays outside the UI event loop. +A received task's transcript references are rebuilt from its owned local journals. Plan-backed workers and legacy graph tasks keep their respective layouts; history readers accept the current worker journal and bounded recorded metadata rather than another computer's absolute index paths. Incoming carrier validation binds immutable provenance before adoption. Local reopening uses the admitted restore receipt and current URI bindings: receiver-local checkpoint paths can change without rewriting that incoming provenance. + ## Validation and measurement limits Separate experiments measured **43.34% lower allocated use** inside an isolated diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 84b149c73a..08a9215ecb 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -16,7 +16,9 @@ listing instead of choosing an unloaded parent or the first sibling. Review and apply each writable root explicitly through the terminal. Application uses the exact reviewed snapshot and retains the original private copy; edits -after review do not land. Conflicts preserve both versions. +after review do not land. Conflicts preserve both versions. Completed plan workers +reopen their owned transcripts after restart or relay; local index rebuilding +recovers their recorded model without retaining another computer's file paths. Local archive recovery requires the separately reviewed native dependency. Uncertain writers and multi-root reclamation remain excluded. Existing From 6989f72ea59c96ae320b93a45c052d664345aca4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 00:34:39 -0400 Subject: [PATCH 24/35] Recover recorded worker model on plan task pages --- internal/session/plandb_tasks.go | 3 +++ internal/session/task_journal_test.go | 6 ++++++ internal/session/task_run_history.go | 13 +++++++++++++ 3 files changed, 22 insertions(+) diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 7c436dcd2c..6d20a95d0a 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -411,6 +411,9 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { if usage, ok := planUsageByTask(store.Path())[task.ID]; ok { pageRow.Model, pageRow.Tokens = usage.model, usage.tokens } + if pageRow.Model == "" { + pageRow.Model = a.planWorkerRecordedModel(task.ID) + } return PlanTaskPage{ Row: pageRow, Models: a.planTaskModels(store.RootID()), diff --git a/internal/session/task_journal_test.go b/internal/session/task_journal_test.go index 51d15f3d5c..c3f84e5fea 100644 --- a/internal/session/task_journal_test.go +++ b/internal/session/task_journal_test.go @@ -219,6 +219,12 @@ func TestPlanBackedTaskReopensItsNestedWorkerHistory(t *testing.T) { t.Fatalf("resolved worker history is unreadable: %q", report) } }) + t.Run("task-page", func(t *testing.T) { + page, ok := agent.PlanTaskPage("t-" + itoa64(id)) + if !ok || page.Row.Model != "deepseek/deepseek-v4.1-flash" { + t.Fatalf("worker page lacks its recorded model: found=%v model=%q", ok, page.Row.Model) + } + }) t.Run("rebuilt-index", func(t *testing.T) { for _, row := range RebuiltTaskRows(place.Dir) { if row.ID == itoa64(id) { diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index cfb092e0a1..de2f75ba4e 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -76,3 +76,16 @@ func withLocalWorkerHistory(entry TaskIndexEntry, place Place) TaskIndexEntry { } return entry } + +// A plan page prefers its usage ledger. Older runs without usage rows may +// still name their worker model in the locally retained journal header. +func (a *Agent) planWorkerRecordedModel(taskID string) string { + id, err := strconv.ParseUint(taskID, 10, 64) + if err != nil || id == 0 { + return "" + } + a.mu.Lock() + place, session := a.familyPlace(nil), a.sessionID() + a.mu.Unlock() + return findWorkerHistory(taskJournalDir(place, session), id).model +} From 1f088f26f70a033201a9ec597317a73a8cfaa276 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 00:43:01 -0400 Subject: [PATCH 25/35] Stop owned sealing test daemon before temporary store cleanup --- internal/session/truth_seal_test.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/internal/session/truth_seal_test.go b/internal/session/truth_seal_test.go index cfbaa07916..d39b5ed123 100644 --- a/internal/session/truth_seal_test.go +++ b/internal/session/truth_seal_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" @@ -101,7 +102,25 @@ func sealAndMaterialize(t *testing.T, bin, dir string, c cell.Cell, from, to sea t.Helper() workspace := from.workspace putFile(t, filepath.Join(workspace, "main.go"), "package main\n") - e := cellstore.Engine{Binary: bin, DataRoot: t.TempDir(), Workspace: workspace} + freshDir = filepath.Join(t.TempDir(), c.ID) + dataRoot := t.TempDir() + // A short owned socket path also fits macOS AF_UNIX's path limit. + socketDir, err := os.MkdirTemp("", "seal-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketDir, "engine.sock")} + // This helper owns the native store and daemon. Join its final flush before + // TempDir removes the store; package-wide shutdown happens after test cleanup. + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := daemon.Stop(ctx); err != nil { + t.Errorf("stop owned sealing daemon: %v", err) + } + }) + e := cellstore.Engine{Binary: bin, DataRoot: dataRoot, Workspace: workspace, Transport: daemon} sealed, err := e.Seal(context.Background(), c, cellstore.TurnInfo{}) if err != nil { t.Fatal(err) @@ -110,7 +129,6 @@ func sealAndMaterialize(t *testing.T, bin, dir string, c cell.Cell, from, to sea // A fresh machine: an empty workspace and an empty cell, attached to the // same store, then restored to the sealed turn. freshWorkspace = to.workspace - freshDir = filepath.Join(t.TempDir(), c.ID) if err := os.MkdirAll(filepath.Join(freshDir, cellStateDir), 0o700); err != nil { t.Fatal(err) } @@ -120,7 +138,7 @@ func sealAndMaterialize(t *testing.T, bin, dir string, c cell.Cell, from, to sea if err != nil { t.Fatal(err) } - there := cellstore.Engine{Binary: bin, DataRoot: e.DataRoot, Workspace: freshWorkspace} + there := cellstore.Engine{Binary: bin, DataRoot: e.DataRoot, Workspace: freshWorkspace, Transport: daemon} if _, err := there.Seal(context.Background(), target, cellstore.TurnInfo{}); err != nil { t.Fatal(err) } From 2234733e9395c8d14694efadaf89af407e0582e3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 00:49:46 -0400 Subject: [PATCH 26/35] Restore recorded models before replaying transferred task cards --- internal/session/task_journal_test.go | 48 +++++++++++++++++++++++++++ internal/session/task_run_history.go | 15 +++++++++ internal/session/task_store.go | 1 + 3 files changed, 64 insertions(+) diff --git a/internal/session/task_journal_test.go b/internal/session/task_journal_test.go index c3f84e5fea..fae856563a 100644 --- a/internal/session/task_journal_test.go +++ b/internal/session/task_journal_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/home" ) @@ -274,3 +275,50 @@ func TestPlanBackedHistoryRefusesRedirectedTaskAndWorker(t *testing.T) { }) } } + +// A taken chat may retain its completed task and journal without a plan store. +// Its ordinary task room reads the replayed notice, not a PlanTaskPage or index. +func TestTakenWorkerRosterCarriesRecordedModelWithoutPlanStore(t *testing.T) { + for _, saved := range []string{"", "recorded/prior-model"} { + t.Run(saved, func(t *testing.T) { + want := saved + if want == "" { + want = "deepseek/deepseek-v4.1-flash" + } + place := newPlace(t, false) + writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 2, Runs: []runRecord{{ID: 2, Title: "Extend two-root formatting helpers", State: TaskDone, PlanTask: "t-2", Report: "finished", Model: saved}}}) + dir := filepath.Join(place.NodeJournals(), "2") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + journal := filepath.Join(dir, "20261026-000305.763019_worker.jsonl") + if err := os.WriteFile(journal, []byte(`{"type":"session","version":1,"id":"worker","model":"deepseek/deepseek-v4.1-flash"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + agent := journalAgent(t, place) + if _, found := agent.PlanTaskPage("t-2"); found { + t.Fatal("fixture unexpectedly has a plan page; it must use the generic task room") + } + if got := agent.TaskJournal(2); got != journal { + t.Fatalf("worker journal=%q, want %q", got, journal) + } + events, stop := agent.WatchTaskUpdates() + defer stop() + deadline := time.After(3 * time.Second) + for { + select { + case event := <-events: + if event.Task != nil && event.Task.ID == 2 { + if event.Task.Model != want { + t.Fatalf("generic task room receives model %q instead of recorded worker model", event.Task.Model) + } + return + } + case <-deadline: + t.Fatal("completed worker missing from replayed roster") + } + } + + }) + } +} diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index de2f75ba4e..79e414fd9e 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -89,3 +89,18 @@ func (a *Agent) planWorkerRecordedModel(taskID string) string { a.mu.Unlock() return findWorkerHistory(taskJournalDir(place, session), id).model } + +// Resolve missing recorded models once before graph recovery takes its lock. +// Roster watchers then replay ordinary notices without filesystem reads. +func (a *Agent) restoreWorkerHistoryModels(document *taskDocument) { + a.mu.Lock() + place, session := a.familyPlace(nil), a.sessionID() + a.mu.Unlock() + base := taskJournalDir(place, session) + for i := range document.Runs { + record := &document.Runs[i] + if record.Model == "" && record.Kind == "" && record.ID != 0 { + record.Model = findWorkerHistory(base, record.ID).model + } + } +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 51fcc8d0f7..87c51e1816 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1800,6 +1800,7 @@ func (a *Agent) recoverTasks() { // one already on the column — and would write its journal into that run's // folder (orchestrate.go's orchestrateJournalPath). a.reserveRunNames(document.Runs) + a.restoreWorkerHistoryModels(&document) graph := a.graph() recovery := graph.rehydrate(document, a.config.Workspace, a.settlePolicy()) From d689b1621f781924070bef0fac6bfb576a0ecab0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 01:16:55 -0400 Subject: [PATCH 27/35] Pin local task directory before reading fallback worker headers --- internal/session/task_run_history_other.go | 38 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/session/task_run_history_other.go b/internal/session/task_run_history_other.go index 652643fd91..ef9a6a72ab 100644 --- a/internal/session/task_run_history_other.go +++ b/internal/session/task_run_history_other.go @@ -5,6 +5,8 @@ package session import ( "errors" "os" + "path/filepath" + "strconv" "syscall" ) @@ -17,11 +19,19 @@ func openTaskHistoryPath(root, relative string, directory bool) (*os.File, error return nil, err } defer base.Close() - info, err := base.Lstat(relative) + target, name := base, relative + if !directory { + target, name, err = pinTaskHistoryParent(base, relative) + if err != nil { + return nil, err + } + defer target.Close() + } + info, err := target.Lstat(name) if err != nil || info.Mode()&os.ModeSymlink != 0 { return nil, errors.New("task history is redirected or absent") } - file, err := base.OpenFile(relative, os.O_RDONLY|syscall.O_NONBLOCK, 0) + file, err := target.OpenFile(name, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, err } @@ -32,3 +42,27 @@ func openTaskHistoryPath(root, relative string, directory bool) (*os.File, error } return file, nil } + +// Pin the single task directory before opening its header. A symlink swapped +// between listing and reading must not redirect a known task to another task. +func pinTaskHistoryParent(base *os.Root, relative string) (*os.Root, string, error) { + parent := filepath.Dir(relative) + id, err := strconv.ParseUint(parent, 10, 64) + if err != nil || id == 0 || strconv.FormatUint(id, 10) != parent { + return nil, "", errors.New("task history parent is not a task directory") + } + before, err := base.Lstat(parent) + if err != nil || !before.IsDir() || before.Mode()&os.ModeSymlink != 0 { + return nil, "", errors.New("task history parent is redirected or absent") + } + pinned, err := base.OpenRoot(parent) + if err != nil { + return nil, "", err + } + opened, err := pinned.Stat(".") + if err != nil || !os.SameFile(before, opened) { + _ = pinned.Close() + return nil, "", errors.New("task history parent changed while opening") + } + return pinned, filepath.Base(relative), nil +} From b1d3caf340ca735e215936663b2e5fc6cf0e12cc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 26 Oct 2026 01:58:03 -0400 Subject: [PATCH 28/35] docs: clarify workspace and relay agent contract --- docs/ARCHITECTURE.md | 5 +- docs/architecture/workspaces-storage-relay.md | 400 ++++++------------ .../1944-workspace-relay-architecture.md | 5 + 3 files changed, 139 insertions(+), 271 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0aa15fcfee..764c064e29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,8 +33,9 @@ specific action rather than reading either large package from beginning to end. | Provider calls and model configuration | [provider](../internal/provider/), [config](../internal/config/) | [Usage accounting](model-usage-accounting.md), [limits](LIMITS.md) | | Remote sessions and movable chats | [remote](../internal/remote/), [enginehost](../internal/enginehost/), [cell](../internal/cell/) | [Remote architecture](REMOTE.md), [remote testing](testing-anywhere.md) | -For owned task folders, cooperation, root mapping, and reviewed result application, -see [workspace storage and relay](architecture/workspaces-storage-relay.md). +Before delegating across folders, cooperating on private copies, receiving a chat, +applying results or cleaning task storage, read the +[task workspace and relay agent contract](architecture/workspaces-storage-relay.md). ## Find the right storage and knowledge owner diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index 96dbf813b3..84b2623348 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -1,289 +1,151 @@ -# Workspace storage and relay architecture - -This implementation separates working-root identity, private task placement, -explicit result application, and encrypted transfer. A working root is a -selected folder, not necessarily a repository or everything a chat works on. -One chat may use several roots. The [architecture map](../ARCHITECTURE.md) distinguishes this chat storage -from the separate resident surface. - -## Folders, ownership, and cooperation - -The harness chooses delegated task placement. A model names inputs and relative -layout; its brief cannot grant permission to write an original project. An -execution has one primary root and explicit read/write sibling roots. Root names -are distinct portable directory names; overlapping or unsupported sources are -refused rather than silently renamed. - -```text -profile/ - workspace-identities/ local opaque-URI bindings and ownership - workspace-runtime// owned temporary storage - workspace-caches// explicitly shared project cache - workspace-apply// local reviewed snapshots and operation receipts -chat/ - .cell/ portable chat truth and task carriers - trees// - app/ private primary copy - library/ private sibling copy - logs/ chat-owned logs +# Task workspaces and relay: agent contract + +Read this contract when delegating work across folders, cooperating on a task, +receiving a chat, applying task results, or cleaning task storage. Use the +[shipped worker manual](../../internal/manual/chat/worker-harness.md) for the +complete user-facing command reference. Current acceptance evidence and limits +belong in [PR #1944](https://github.com/Agent-Field/CodeAF/pull/1944). + +## Choose the execution roots before delegating + +A **working root** is one selected folder. It may be a repository or a plain +folder. A chat can reference several roots; a root is not the whole chat or a +user's entire product. Registry records named `Project` identify local roots. +Matching folder names or Git remotes do not combine their identities. + +New managed conversations persist their private-task placement policy. Supported +Bash tasks receive private copies by default; legacy conversations keep their +recorded placement and existing readers. An explicit user-selected in-place mode +keeps its existing meaning. A model's brief or proposed folder does not grant +in-place write permission. Home/reference work uses scratch space rather than +copying the person's home directory. Foreground commands retain the semantics of +the folder the person opened. + +For multiple roots, include `workspace_set` in the **`propose_task` tool +arguments**, alongside its required task fields. For example: + +```json +{ + "workspace_set": { + "primary": "app", + "roots": [ + {"source": "/selected/app", "slot": "app", "access": "write"}, + {"source": "/selected/library", "slot": "library", "access": "read"} + ] + } +} ``` -Paths above describe roles, not portable absolute locations. The local registry -maps identities to the actual device paths. Project caches are project-owned, -so finishing one task does not authorize deleting a cache shared with another. -Original folders and unrelated caches never become task cleanup targets. +Use 1–64 roots. `primary` must name a declared slot. Slots are distinct portable +ASCII directory names, including case-insensitive uniqueness. `access` is +exactly `read` or `write`. Each `source` is an explicitly selected existing +absolute folder without symlink ancestors; an admitted opaque `source_uri` can +resolve that source instead. Omit `ground`, `where` and `via` when declaring this +layout. Landing permission is not a model tool argument. -Plain-folder family history preserves the bounded nonsecret inputs needed to -reconstruct a child's complete working world, including ignored runtime inputs. -Ignore rules do not silently remove those family inputs to claim space savings. -Git-backed sources retain their existing copy eligibility. Credentials withheld -by the existing secret policy remain local inputs; their values do not enter new -transferable family history. Their explicit disposition is bound to the creation -metadata. New or changed withheld outputs refuse review, and existing history -containing tracked secrets refuses full export instead of being rewritten. +Before editing, read the worker's admitted root map. Confirm its primary folder, +each sibling slot, and each access mode; write to those private routes. If a root +is missing or the layout is refused, stop that execution and correct the request. +Do not substitute an original folder. Shell placement is not an operating-system +sandbox: unrestricted commands and remote commands need their own explicit scope. ```mermaid -flowchart TD - Chat[Chat identity] --> Layout[Declared execution layout] - Layout --> App[Primary private copy] - Layout --> Library[Sibling private copy] - App --> A[Working root A identity] - Library --> B[Working root B identity] - Layout --> Cooperation[Exact cooperating tasks share the layout] - Chat --> Independent[Independent work gets a separate layout] - Layout --> Runtime[Owned temporary storage and logs] +flowchart LR + Request[Explicit root request] --> Admission[Harness admits layout] + Admission --> App[Private app: write] + Admission --> Library[Private library: read] + App --> Review[Separate reviewed Apply] + Review --> Original[Chosen original destination] ``` -Cooperation reuses an already admitted layout; it does not broaden a read-only -root or silently add a sibling. Independent conversations retain independent -execution. Foreground commands keep the semantics of the project the person -explicitly opened. Home/reference tasks keep scratch space rather than copying -an entire home directory. Shell placement and environment variables are not an -operating-system sandbox for detached writers or remote commands. - -Source seams: [placement](../../internal/session/workspace_ownership.go), -[layout preparation](../../internal/session/workspace_set_prepare.go), -[identity registry](../../internal/workspaceidentity), and -[conversation storage](../../internal/session/workspace_chat.go). - -## Identity does not grant write permission - -| Resource | Contract | -| --- | --- | -| Working root | Opaque identity for one selected folder; each receiver association is explicit | -| Chat | Own history and execution frontier; references several roots | -| Execution layout | Primary copy plus explicit read/write sibling uses | -| Copy | One physical copy with local ownership and placement | -| Landing destination | Separate user permission; never inferred from a source identity | - -Equal folder names and Git remotes do not merge identities. URI records are -local authority and are not copied between devices as a path database. Result -application must be explicit for each writable root, with conflicts retained; -receiving a root does not authorize applying changes to it. +## Cooperate within the admitted layout -## Review and apply one root +Exact cooperating tasks reuse the admitted private layout. Keep the same root +identities, slots and access modes; cooperation cannot add a sibling or broaden +`read` to `write`. Independent work receives an independent layout. A new chat +or fork does not acquire another chat's active execution authority. -Use `/task review ` after the task and its cooperating -writers/checkers finish. The terminal shows the private copy, receiver-local -application destination, exact snapshot identity, and diff. Large previews show -a command for inspecting the complete immutable Git/file diff; that command -does not describe all directory or permission metadata. Review does not change -the original project working files or the retained private working copy. +Wait for cooperating writers and checkers to finish before reviewing results. +Retain their private copies while completion or writer ownership is uncertain. -`/task apply ` applies that reviewed snapshot only. -Changes arriving in the private copy after review are retained there and do not -enter this application. Every writable root has a separate operation; read-only -roots cannot be applied. Conflicts preserve both versions. Successful application -records its outcome and prevents a duplicate apply. The original private copy -and its URI remain available after restart. +## Review and apply each writable root -Explicit reviewed Apply can update the chosen branch, including `main`. -Automatic task landing retains its protected-branch refusal. Both paths retain -checkout-movement checks; reviewed Apply also retains its ref/index transaction -and affected-path conflict checks. +1. Run `/task review `. Check the displayed private copy, + Apply destination, snapshot and diff. Large previews provide a command to + inspect the complete file diff; that diff does not describe every directory + or permission change. +2. After the user reviews it, run `/task apply ` for that + root. Apply uses the reviewed snapshot. Later edits remain in the private copy. +3. Read the recorded outcome before continuing. Repeat review and Apply separately + for every writable root; a read-only root cannot be applied. -Reviewed staging and its receipt are local profile assets, separate from the -portable conversation. A replacement review retires only its verified previous -staging after the new receipt is durable. Invalid ownership, destination, -baseline, or snapshot bindings refuse before application. Uncertain writers still -prevent destructive retirement; they do not authorize deleting the retained -original during a snapshot application. +Explicit reviewed Apply permits the chosen branch, including `main`. Automatic +landing remains blocked on protected branches. Changed checkouts, affected-path +conflicts and unsupported state refuse rather than overwrite work. Unrelated +staged and unstaged changes remain intact. Successful Apply retains the private +copy and records its outcome across restart; it does not grant general source +write permission. -Private task creation records eligible raw bytes, file kinds, symlink targets, -permission bits, and empty directories before Git can normalize them. That -immutable baseline travels with the task carrier; it grants no destination -write authority. Existing tasks without a verified metadata baseline retain -their readable results, but application refuses instead of inventing history. +If the connection drops after Apply begins, treat the outcome as **unknown**. +Reconnect and inspect the task's recorded result before retrying. Preserve any +recovery data named by the error. An older engine without the operation requires +an update and reconnect to the updated engine; restarting a window alone may +leave its existing engine running. -Native control metadata at the workspace root is owned by the local engine, -separate from project inputs. Capture and restore agree on that boundary; -incoming records cannot overwrite it. Nested project folders with the same name -remain ordinary user content. This distinction does not expand cleanup authority. +## Receive and reopen a chat -Application uses a journaled destination transaction. Independently copied -preimages and verified raw postimages remain available until the final task -checkpoint is durable. Built-in Git text conversion produces canonical history -while working files retain their exact reviewed bytes. External clean filters -and unsupported working-tree encodings refuse before publication. Affected -paths with pre-existing staged or unstaged changes refuse; unrelated changes -remain untouched. Ignored or withheld content never becomes an inferred deletion. +Use a managed profile and compatible workspace-aware builds at both ends. Fetch +and Take use the harness's private staging and identity registry. Do not restore +into an arbitrary absolute destination or hand-edit registry/checkpoint files. ```mermaid flowchart LR - Review[Immutable review] --> Compare[Check affected paths and ancestors] - Compare --> Prepare[Journal and independent preimages] - Prepare --> Publish[Publish raw files and Git candidate] - Publish --> Verify[Verify resulting state] - Verify --> Commit[Durable application receipt] - Commit --> Checkpoint[Persist task outcome] - Checkpoint --> Release[Release transaction staging] - Publish --> Recovery[Interrupted operation retains recovery data] + Fetch[Fetch incoming chat] --> Map[Admit each missing local root] + Map --> Take[Take displayed chat] + Take --> Ready[Verify private layout is ready] + Ready --> Work[Reopen task history or execute] ``` -The portable contract covers raw bytes, supported file and symlink kinds, -POSIX permissions, and eligible directories. It excludes timestamps. Affected -ownership, hardlink, xattr, native ACL, filesystem-flag, and special-file state -that cannot be represented must refuse before mutation. Split indexes and -unsupported Git reference-storage formats also refuse before mutation until -independent recovery is supported. The original project -root's permissions are preserved. This transaction does not give unrelated -readers atomic visibility across all files and Git metadata, or fence unmanaged -external writers. Outside changes that prevent safe recovery retain the -preimages and produce an actionable error. Cancellation before the durable -application receipt rolls back when safe; after that boundary the task outcome -is finalized. Hardware power-cut validation has not been performed. - -The terminal enters these operations through the resident engine, with the -conversation's driver/session binding and cancellable operation deadline. An -older engine without the capability asks for an engine update and reconnect. A -disconnect after application begins can leave an unknown outcome: inspect the -recorded result before retrying. - -## Relay: fetch, map roots, acquire, restore - -Furrow owns byte capture, cryptographic verification and materialization. -`cellsync` schedules transport. The relay stores encrypted objects and an -authenticated directory of published heads and leases. The harness owns -execution, local folder admission, and result application. A lease elects the -chat holder; it does not authorize writing arbitrary folders. - -```mermaid -sequenceDiagram - participant S as Source harness - participant F as Furrow - participant R as Relay - participant D as Receiver harness - participant U as User - S->>F: Capture portable chat and task state - F-->>S: Sealed head and exportable objects - S->>R: Upload objects before publishing head - D->>R: Fetch displayed head into private staging - D->>D: Verify carrier and required root closure - D->>U: Choose missing local root folders - U-->>D: Explicit per-root associations - D->>D: Validate staging and final destination - D->>R: Acquire against displayed holder and fence - D->>D: Stage and verify complete private layout - D->>D: Promote, bind identities, finalize checkpoint - D->>D: Permit execution only after readiness checks -``` - -The receiver's existing folder picker asks for each missing root. Escape cancels -without taking the lease. A retry retains the holder/fence the person saw, so a -changed holder requires a fresh decision. Bindings overlapping private receiver -storage are rejected before takeover. If staged admission fails, the original -native timeline binding is restored so a valid retry remains possible. - -Publishing uploads before advancing the directory head; stale fences cannot -overwrite the holder. Bounded existing-frame advertisements may prime fetching, -but Furrow's authoritative `Want`/import loop proves completeness. Hints cannot -substitute for verified objects. - -Source seams: [publish](../../internal/cellsync/publish.go), -[fetch](../../internal/cellsync/fetch.go), -[handoff](../../internal/handoff/handoff.go), -[receiver orchestration](../../internal/syncsetup/take.go), and -[task transfer](../../internal/session/workspace_transfer.go). - -## Reopen, restart, and fork - -Task workspaces are carried separately from ordinary sealed chat files. -Portable commits, uncommitted changes, and explicit versioned directory metadata -preserve supported state. Git alone cannot preserve empty directories and -directory permissions. Missing metadata, invalid digest bindings, or an unknown -recorded baseline refuse rather than guessing a replacement. - -Restore uses private staging and full history when no verified Git base is -available. It never unbundles into a borrowed original repository. A durable -receipt spans promotion, registry admission and checkpoint finalization; retries -recognize the same operation rather than creating a second copy. This recovery -protocol spans several stores, not one atomic database transaction. - -A fork retains completed results and portable history, with inherited active -execution detached. It does not duplicate ongoing workers or spending. A later -Take must preserve those results without reviving the original frontier. -Successful chat transfer does not guarantee that arbitrary external commands or -dependencies can resume on another device. - -## Local archives and storage safety - -The local lifecycle is `resident → retiring → cold → restoring → resident`. -An eligible owned copy must be captured, independently verified and recorded -durably before retirement. Restore verifies supported bytes and metadata before -use. Deduplicated compressed chunks may serve several manifests without -combining their ownership. - -The archive implementation is a separate native dependency: -[Furrow v0.3.2](https://github.com/Agent-Field/furrow/releases/tag/v0.3.2). -Its local archive database is not a relay wire format. Ordinary builds use the -published release with six platform checksums recorded in the embedded pin; -acceptance does not depend on an unpublished local native artifact. Windows -builds do not expose the Unix archive command. - -Uncertain writers stay resident. Multi-root layout retirement remains excluded -until whole-layout quiescence and recovery are verified. Unsupported ACLs, -ownership, filesystem flags, special files, or other unverified metadata must -retain the resident copy. No four-month automatic reclamation claim follows -from the archive experiment. - -## Compatibility and migration - -| Boundary | Required behavior | -| --- | --- | -| Legacy conversations | Preserve existing placement and readers; no silent historical rewrite | -| New owned conversations | Persist versioned placement policy so restart does not depend on a shell flag | -| Multi-root carriers | Validate every required root, directory map and digest before execution | -| Creation metadata | Preserve verified baseline bytes and digest through relay; missing legacy metadata refuses Apply while retaining readable results | -| Mixed or incomplete history | Refuse unsupported combinations without dropping records | -| Receiver paths | Re-admit per device; remote paths never become write authority | -| Older readers | Require supported readers; guaranteed early capability refusal is still unfinished | - -Do not retire the last readable copy while compatibility is uncertain. Installing -an older executable is not a sufficient rollback for records it cannot read. -Fast routine key rewrapping, if introduced separately, cannot replace full -compromise recovery or revoke content exposed by an old key. - -SQLite backup capture stages verified database and journal bytes privately before opening them through SQLite. It does not SQLite-open original working roots, avoiding source WAL/shared-memory mutations. Writers must remain quiescent during capture; sequential file capture is not an atomic live-database snapshot. - -The receiver folder picker keeps the typed destination through asynchronous directory reads. An unfinished listing cannot authorize the parent as an empty folder. The current directory's answer reselects the typed leaf; unrelated results preserve navigation and stale picker generations are rejected. Completed empty directories remain valid choices. Listing work stays outside the UI event loop. - -A received task's transcript references are rebuilt from its owned local journals. Plan-backed workers and legacy graph tasks keep their respective layouts; history readers accept the current worker journal and bounded recorded metadata rather than another computer's absolute index paths. Incoming carrier validation binds immutable provenance before adoption. Local reopening uses the admitted restore receipt and current URI bindings: receiver-local checkpoint paths can change without rewriting that incoming provenance. - -## Validation and measurement limits - -Separate experiments measured **43.34% lower allocated use** inside an isolated -volume containing copies of a real-worktree corpus, and **55.17% lower warm -materialization latency** in a matched transfer test. The transfer experiment -reduced HTTP body bytes by **5.33%**. These are different experiments; latency -reduction is not byte reduction, and isolated-volume reduction is not original -host free-space reclamation. Multi-root normal usage has no matched savings -percentage yet. - -Focused tests cover ordinary owned task admission, exact cooperation, directory -metadata, root mapping, cancellation, stale holders, preflight refusal and retry. -The scripted actual-terminal chain passes with the published native dependency: -private tasks, restart, receiver root selection, Take, completed results, public -fork, and a second Take. This verifies the application flow with scripted -responses. Actual-provider and cross-machine acceptance of the final portable -application candidate remains pending; draft status remains appropriate until -that complete chain is verified. +Choose an existing receiver-local folder for **each** missing root in the picker. +Confirm the selected folder is the intended root, not its parent or sibling. +The picker waits for directory listings before confirmation. Mappings overlapping +private receiver storage refuse. Escape cancels without taking the lease; a +changed holder requires a fresh Take decision. An association already admitted +to another local folder cannot be silently rebound. + +Treat every URI as opaque local authority. A remote path, shared name or Git +remote is not a receiving association and does not authorize Apply. Foreign +owned copies, missing required roots, unsupported versions, incomplete metadata +and invalid bindings must refuse before execution. Fix the missing association +or use a supported build; preserve the last readable copy while compatibility is +uncertain. Guaranteed early refusal by every older reader is not established. + +Restart requires the complete recorded layout. Completed worker transcripts and +recorded models reopen from owned local history, rather than another device's +absolute index paths. Receiver-local paths may differ from the incoming paths; +use the admitted local layout rather than comparing or rewriting raw checkpoint +paths. A fork retains settled results and history while inherited active tasks +remain detached, avoiding duplicate execution and spending. Restored files alone +do not establish that an active external command or dependency can resume. + +## Preserve supported state and clean only owned fixtures + +Supported task capture and Apply preserve raw file bytes, eligible regular files, +literal symlink targets, POSIX permission bits and empty directories. Timestamps +are excluded. Unsupported ownership, hardlinks, extended attributes, native ACLs, +filesystem flags, special files or Git storage/filter configurations refuse when +they cannot be preserved safely. Missing verified legacy creation metadata keeps +results readable but blocks Apply. Secret screening remains in force; withheld +content is not an inferred deletion. SQLite writers must be quiescent during +capture; this is not an atomic live-database snapshot. + +Preserve originals and uncertain worktrees. Multi-root layouts remain resident; +this contract does not promise automatic long-term reclamation. Archive support +uses the pinned published [Furrow v0.3.2](https://github.com/Agent-Field/furrow/releases/tag/v0.3.2), +with platform-specific capability limits. + +For test cleanup, stop owned workers, daemons and tunnels first. Remove only the +fixture folders, temporary copies and task-specific caches whose ownership was +recorded for that test. Preserve shared project caches, shared build caches, +original projects, unrelated worktrees and retained user results. Task completion +alone does not authorize deleting those assets. diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 08a9215ecb..0e4107116f 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -24,3 +24,8 @@ Local archive recovery requires the separately reviewed native dependency. Uncertain writers and multi-root reclamation remain excluded. Existing conversations require compatibility safeguards; this change does not silently rewrite legacy task histories or promise long-term storage savings. + +Document the agent contract for declaring tool roots, checking admitted worker +routes, mapping received roots, reviewing results and cleaning owned fixtures. +Current acceptance evidence is maintained in the pull request rather than as a +pending status in the architecture reference. From dfefbe3cd421424520ff0c54b31bd2a0bedec07f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 18:54:25 -0400 Subject: [PATCH 29/35] fix(chat): keep automatic task landing unless private copies are opted in New conversations persisted the private-task policy unconditionally, so every delegated task ran in a private copy that only /task apply could bring home, Apply refused on Windows, and older builds that took such a chat dropped its tasks. A new conversation now persists the policy only with CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1, an absolute CODEAF_HOME and a platform whose Apply works; a saved workspace identity alone no longer enables private placement; and a saved policy keeps working on Linux and macOS. In a chat with no private copies, /task review and /task apply say so and change nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_layout.go | 6 +- cmd/codeaf/chatv3_layout_test.go | 42 +++- internal/session/task_workspace_apply.go | 15 +- internal/session/task_workspace_apply_test.go | 2 +- internal/session/task_workspace_fs_other.go | 2 + internal/session/task_workspace_fs_unix.go | 2 + internal/session/workspace_chat_test.go | 2 +- internal/session/workspace_defaults_test.go | 223 ++++++++++++++++++ internal/session/workspace_ownership.go | 10 +- .../workspace_ownership_public_test.go | 4 +- internal/session/workspace_policy.go | 11 +- internal/session/workspace_policy_test.go | 2 +- internal/tui3/task_workspace_apply.go | 4 + internal/tui3/taskcommand_test.go | 17 ++ 14 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 internal/session/workspace_defaults_test.go diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 4ae9c538f0..7db6e79615 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -487,6 +487,10 @@ func v3MintSessionAs(class cell.Class, bucket, workspace, launchDir string, owne return session.Place{}, err } place := v3PlaceFor(dir, workspace, owned) + var policy *session.WorkspacePolicy + if session.WorkspaceOwnershipPrototypeEnabled() { + policy = session.DefaultWorkspacePolicy() + } // Placement policy is durable conversation truth, so a failed creation // write must not silently open the conversation with legacy task placement. if err := session.SaveMeta(dir, session.Meta{ @@ -495,7 +499,7 @@ func v3MintSessionAs(class cell.Class, bucket, workspace, launchDir string, owne LaunchDir: launchDir, Owned: owned, Created: time.Now(), - WorkspacePolicy: session.DefaultWorkspacePolicy(), + WorkspacePolicy: policy, }); err != nil { return session.Place{}, fmt.Errorf("save new conversation policy: %w", err) } diff --git a/cmd/codeaf/chatv3_layout_test.go b/cmd/codeaf/chatv3_layout_test.go index ed6998446f..593a9c6c38 100644 --- a/cmd/codeaf/chatv3_layout_test.go +++ b/cmd/codeaf/chatv3_layout_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -140,6 +142,7 @@ func TestTheGroomLeavesEveryConversationAlone(t *testing.T) { // own id with meta.json already in it — so a second window sees the session // before anybody has spoken in it. func TestAFirstLaunchMintsAFolderThatNamesItself(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) workspace := t.TempDir() @@ -154,8 +157,19 @@ func TestAFirstLaunchMintsAFolderThatNamesItself(t *testing.T) { if err != nil { t.Fatal(err) } - if meta.WorkspacePolicy == nil || *meta.WorkspacePolicy != *session.DefaultWorkspacePolicy() { - t.Fatalf("new conversation has no private task policy: %+v", meta.WorkspacePolicy) + raw, err := os.ReadFile(found.Place.MetaPath()) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, exists := fields["workspace_policy"]; exists { + t.Fatalf("ordinary conversation persisted a workspace policy: %s", raw) + } + if meta.WorkspacePolicy != nil { + t.Fatal("ordinary conversation enabled private task copies") } if meta.ID != filepath.Base(found.Place.Dir) { t.Fatalf("the folder is called %s and the session %s", filepath.Base(found.Place.Dir), meta.ID) @@ -480,3 +494,27 @@ func TestUnreadableTeamsNeverAuthorizeEmptyCleanupOrReuse(t *testing.T) { t.Fatal("unknown membership was removed") } } + +func TestNewConversationPersistsPrivateTaskPolicyOnlyWithExperiment(t *testing.T) { + for _, flag := range []string{"", "1"} { + t.Run("switch="+flag, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", flag) + place, err := v3MintSession(t.TempDir(), t.TempDir(), "", false) + if err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(place.Dir) + if err != nil { + t.Fatal(err) + } + want := flag == "1" && (runtime.GOOS == "linux" || runtime.GOOS == "darwin") + if (meta.WorkspacePolicy != nil) != want { + t.Fatalf("policy=%+v, want enabled=%v", meta.WorkspacePolicy, want) + } + if want && (meta.WorkspacePolicy.Version != 1 || meta.WorkspacePolicy.Mode != "private-tasks") { + t.Fatalf("wrong policy: %+v", meta.WorkspacePolicy) + } + }) + } +} diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 2d02596625..3b24373ff7 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -27,9 +27,15 @@ type TaskWorkspaceReview struct { Changed []string } +// TaskWorkspaceUnavailable explains the scope of the private result commands. +const TaskWorkspaceUnavailable = "review and apply are for private task copies; this chat doesn't have them" + // ReviewTaskWorkspaceRoot reads actual committed and uncommitted changes. This // person-facing door is intentionally absent from model tools. func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (TaskWorkspaceReview, error) { + if !WorkspaceOwnershipEnabled(a.config.Place) { + return TaskWorkspaceReview{}, errors.New(TaskWorkspaceUnavailable) + } if err := taskWorkspaceOperationEnvironment(ctx); err != nil { return TaskWorkspaceReview{}, err } @@ -169,7 +175,7 @@ func reviewTaskWorkspaceRoot(ctx context.Context, use WorkspaceRootUse, tree tas if err := ctx.Err(); err != nil { return review, err } - //codeaf:plumbing git compares one local untracked path without external diff helpers. + //codeaf:plumbing compares one local untracked path without external diff helpers. cmd := exec.CommandContext(ctx, "git", "diff", "--no-ext-diff", "--no-textconv", "--no-index", "--", "/dev/null", path) cmd.Dir = tree.dir out, err := cmd.Output() @@ -193,6 +199,9 @@ func reviewTaskWorkspaceRoot(ctx context.Context, use WorkspaceRootUse, tree tas // ApplyTaskWorkspaceRoot grants permission only for this selected operation. // It never records Landing, starts a worker, or broadens another root's access. func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { + if !WorkspaceOwnershipEnabled(a.config.Place) { + return "", errors.New(TaskWorkspaceUnavailable) + } if err := taskWorkspaceOperationEnvironment(ctx); err != nil { return "", err } @@ -460,7 +469,7 @@ func cleanupTaskWorkspaceReview(ticket taskWorkspaceReviewTicket) { func taskWorkspaceSnapshotMessage(copyURI, destination, base, baselineDigest, portableDigest, creationDigest string) string { sum := sha256.Sum256([]byte(copyURI + "\x00" + destination + "\x00" + base + "\x00" + baselineDigest + "\x00" + portableDigest + "\x00" + creationDigest)) - return "CodeAF reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) + return "codeaf reviewed workspace snapshot\n\nReview-binding: " + hex.EncodeToString(sum[:]) } func adoptTaskWorkspaceRoot(use WorkspaceRootUse, place Place) (taskTree, error) { @@ -823,7 +832,7 @@ func taskWorkspaceOperationEnvironment(ctx context.Context) error { name, _, _ := strings.Cut(entry, "=") upper := strings.ToUpper(name) if taskWorkspaceGitRoutingVariable(upper) { - return fmt.Errorf("unset %s and relaunch CodeAF before reviewing or applying a task root", name) + return fmt.Errorf("unset %s and relaunch codeaf before reviewing or applying a task root", name) } } return nil diff --git a/internal/session/task_workspace_apply_test.go b/internal/session/task_workspace_apply_test.go index 6ea28fc30f..ae040353e5 100644 --- a/internal/session/task_workspace_apply_test.go +++ b/internal/session/task_workspace_apply_test.go @@ -157,7 +157,7 @@ func completedWorkspaceApplyFixture(t *testing.T, repository bool) (*Agent, uint func completedWorkspaceApplyFixtureWithInput(t *testing.T, repository bool, setup func(string)) (*Agent, uint64, Place, string, string) { t.Helper() t.Setenv("CODEAF_HOME", t.TempDir()) - t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "unavailable-native-engine")) source := t.TempDir() diff --git a/internal/session/task_workspace_fs_other.go b/internal/session/task_workspace_fs_other.go index 6c81de392d..6ae0c3804b 100644 --- a/internal/session/task_workspace_fs_other.go +++ b/internal/session/task_workspace_fs_other.go @@ -7,6 +7,8 @@ import ( "os" ) +const workspaceApplicationSupported = false + func workspaceApplicationMoveOut(root, relative, staging, held string) error { return errors.New("transactional portable apply requires Linux or Darwin") } diff --git a/internal/session/task_workspace_fs_unix.go b/internal/session/task_workspace_fs_unix.go index 872defdf5f..2a157172a6 100644 --- a/internal/session/task_workspace_fs_unix.go +++ b/internal/session/task_workspace_fs_unix.go @@ -14,6 +14,8 @@ import ( "golang.org/x/sys/unix" ) +const workspaceApplicationSupported = true + // Resolve every directory through held descriptors with O_NOFOLLOW. No mutation // follows an ancestor swapped for a symlink between admission and publication. func workspaceApplicationParent(root, relative string) (int, string, error) { diff --git a/internal/session/workspace_chat_test.go b/internal/session/workspace_chat_test.go index b3b5fdbab8..31073263b4 100644 --- a/internal/session/workspace_chat_test.go +++ b/internal/session/workspace_chat_test.go @@ -79,7 +79,7 @@ func TestOwnershipPrototypeChatReopensWithoutProcessFlag(t *testing.T) { if err != nil { t.Fatal(err) } - if err := SaveMeta(place.Dir, Meta{ID: place.ID(), ChatURI: chat.URI, Workspace: project}); err != nil { + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), ChatURI: chat.URI, Workspace: project, WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { t.Fatal(err) } a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Workspace, c.Place = project, place }) diff --git a/internal/session/workspace_defaults_test.go b/internal/session/workspace_defaults_test.go new file mode 100644 index 0000000000..5082a13983 --- /dev/null +++ b/internal/session/workspace_defaults_test.go @@ -0,0 +1,223 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +func ordinaryTaskFixture(t *testing.T) (*Agent, *beltRunDouble, Place, cell.Cell) { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_TASK_BELT", "bash") + project := newTestRepo(t) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + place := Place{Dir: c.Root, Workspace: project} + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), Workspace: project}); err != nil { + t.Fatal(err) + } + engine := newBeltRunDouble("done") + engine.real = true + registerBeltRunEngine(t, engine) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = project, place, place.Transcript() + }) + if _, _, _, err := agent.StartTask(context.Background(), "write result.txt", true); err != nil { + t.Fatal(err) + } + <-engine.entered + t.Cleanup(func() { + select { + case <-engine.release: + default: + endBeltRun(t, agent, engine) + } + }) + return agent, engine, place, c +} + +func TestOrdinaryDelegatedEditComesHomeWithoutACommand(t *testing.T) { + agent, engine, place, _ := ordinaryTaskFixture(t) + engine.mu.Lock() + spec := engine.spec + engine.mu.Unlock() + if spec.WorkspaceSet != nil { + t.Fatal("ordinary task received a private workspace set") + } + writeFile(t, filepath.Join(spec.Workspace, "result.txt"), "delivered\n") + endBeltRun(t, agent, engine) + got, err := os.ReadFile(filepath.Join(place.Workspace, "result.txt")) + if err != nil || string(got) != "delivered\n" { + t.Fatalf("task did not come home: %q, %v", got, err) + } + g := agent.graph() + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + if len(rows) != 1 || rows[0].Merge != mergeMerged || rows[0].Branch != currentBranch(place.Workspace) { + t.Fatalf("landing receipt: %+v", rows) + } + meta, err := LoadMeta(place.Dir) + if err != nil || meta.WorkspacePolicy != nil || meta.ChatURI != "" { + t.Fatalf("ordinary chat gained ownership: %+v, %v", meta, err) + } +} + +func TestOrdinaryTaskCheckpointAndCarrierRemainReadableByOlderBuilds(t *testing.T) { + _, engine, place, c := ordinaryTaskFixture(t) + engine.mu.Lock() + workspace := engine.spec.Workspace + engine.mu.Unlock() + writeFile(t, filepath.Join(workspace, "result.txt"), "carried\n") + if err := (taskcopy.Carry{Project: place.Workspace}).Compose(c); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(place.Tasks()) + if err != nil { + t.Fatal(err) + } + var checkpoint struct { + Version int `json:"version"` + } + if err := json.Unmarshal(raw, &checkpoint); err != nil { + t.Fatal(err) + } + if checkpoint.Version != 1 { + t.Fatalf("older reader requires version 1: %s", raw) + } + records, err := filepath.Glob(filepath.Join(c.Root, ".cell", "trees", "*", "tree.json")) + if err != nil || len(records) != 1 { + t.Fatalf("older reader cannot find task carrier: %v, %v", records, err) + } + if _, err := os.Stat(filepath.Join(c.Root, ".cell", "workspaces-v2")); !os.IsNotExist(err) { + t.Fatalf("ordinary task created a workspace carrier: %v", err) + } +} + +type ordinarySealTransport struct{} + +func (ordinarySealTransport) Do(_ context.Context, _ cellstore.Target, op cellstore.Op) ([]byte, error) { + if op.Verb() == "capture-plan" { + return []byte(`{"token":"ordinary-seal","full":true,"paths":[]}`), nil + } + return []byte(`{"snapshot":"` + strings.Repeat("a", 64) + `","durable":true}`), nil +} + +func TestOrdinaryFinishedTaskSealsCreateNoWorkspaceGeneration(t *testing.T) { + agent, engine, place, c := ordinaryTaskFixture(t) + endBeltRun(t, agent, engine) + store := cellstore.Engine{DataRoot: t.TempDir(), Workspace: place.Workspace, Compose: taskcopy.Carry{}, Transport: ordinarySealTransport{}} + for range 2 { + if _, err := store.Seal(context.Background(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(c.Root, ".cell", "workspaces-v2")); !os.IsNotExist(err) { + t.Fatalf("idle seal created workspace carrier work: %v", err) + } + } +} + +func TestPlatformWithoutApplyKeepsOwnershipOff(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + place := Place{Dir: t.TempDir()} + if err := SaveMeta(place.Dir, Meta{ID: place.ID(), WorkspacePolicy: DefaultWorkspacePolicy()}); err != nil { + t.Fatal(err) + } + if workspaceOwnershipEnabled(place, false) { + t.Fatal("unsupported platform selected private placement for a persisted policy") + } + if !workspaceOwnershipEnabled(place, true) { + t.Fatal("supported platform lost the persisted policy") + } + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + if !workspaceOwnershipEnabled(place, true) { + t.Fatal("persisted policy depends on the receiver's switch") + } +} + +func TestTakenConversationWithoutPolicyKeepsLegacyPlacement(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + source := Place{Dir: t.TempDir(), Workspace: t.TempDir()} + if err := os.Mkdir(filepath.Join(source.Dir, ".cell"), 0700); err != nil { + t.Fatal(err) + } + if err := SaveMeta(source.Dir, Meta{ID: source.ID(), Workspace: source.Workspace}); err != nil { + t.Fatal(err) + } + receiver := Place{Dir: t.TempDir(), Workspace: t.TempDir()} + if err := os.CopyFS(filepath.Join(receiver.Dir, ".cell"), os.DirFS(filepath.Join(source.Dir, ".cell"))); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + if err := AdoptChatWorkspaceIdentity(receiver); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place, c.Workspace, c.SessionFile = receiver, receiver.Workspace, receiver.Transcript() + }) + if WorkspaceOwnershipEnabled(receiver) || agent.config.WorkspaceURI != "" { + t.Fatal("taking an ordinary chat enabled private placement") + } + stand := taskStand{dir: receiver.Workspace, mode: TaskModeMirror} + got, err := agent.ownedRunPlacement(context.Background(), stand) + if err != nil || got.workspaceSet != nil { + t.Fatalf("receiver changed task placement: %+v, %v", got, err) + } +} + +func TestOrdinaryChatReviewAndApplyExplainPrivateCopies(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Place = Place{Dir: t.TempDir()} }) + _, reviewErr := agent.ReviewTaskWorkspaceRoot(context.Background(), 1, "work") + _, applyErr := agent.ApplyTaskWorkspaceRoot(context.Background(), 1, "work") + for _, err := range []error{reviewErr, applyErr} { + if err == nil || err.Error() != "review and apply are for private task copies; this chat doesn't have them" { + t.Fatalf("ordinary chat explanation: %v", err) + } + } +} + +func TestOrdinaryChatKeepsItsTemporaryAndCacheEnvironment(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + scratch, cache := t.TempDir(), t.TempDir() + t.Setenv("TMPDIR", scratch) + t.Setenv("XDG_CACHE_HOME", cache) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Place = Place{Dir: t.TempDir()} }) + result, err := agent.config.seat().In(agent.config.Workspace).Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", `printf '%s\n' "$TMPDIR" "$XDG_CACHE_HOME"`}}, nil) + if err != nil || result.Exit != 0 || string(result.Stdout) != scratch+"\n"+cache+"\n" { + t.Fatalf("ordinary environment changed: %+v, %v", result, err) + } +} + +func TestOrdinaryChatOmitsAndRefusesModelWorkspaceRoots(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_TASK_BELT", "bash") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Place = Place{Dir: t.TempDir()} }) + tools := agent.taskTools() + if len(tools) != 1 || strings.Contains(string(tools[0].Schema), "workspace_set") { + t.Fatal("unavailable workspace roots were advertised") + } + args := json.RawMessage(`{"title":"Use roots","summary":"Use private roots","brief":"Write output","deliverable":"output","acceptance":"output exists","workspace_set":{"primary":"work","roots":[{"source":"` + filepath.ToSlash(t.TempDir()) + `","slot":"work","access":"write"}]}}`) + line, refused, err := bare.RunStaged(context.Background(), agent.stageTask(context.Background(), args)) + if err != nil || !refused || !strings.Contains(line, "requires private task placement") { + t.Fatalf("unavailable roots were not refused: %q, %v, %v", line, refused, err) + } +} diff --git a/internal/session/workspace_ownership.go b/internal/session/workspace_ownership.go index 8aa173f650..1943ef2434 100644 --- a/internal/session/workspace_ownership.go +++ b/internal/session/workspace_ownership.go @@ -10,13 +10,13 @@ import ( "github.com/Agent-Field/codeaf/internal/home" ) -// WorkspaceOwnershipPrototypeEnabled retains the explicit legacy experiment -// switch. Production placement reads each conversation's persisted policy. +// WorkspaceOwnershipPrototypeEnabled requires an explicit experiment switch, +// an absolute profile, and support for applying the retained output. func WorkspaceOwnershipPrototypeEnabled() bool { - return env.Get("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP") == "1" && filepath.IsAbs(strings.TrimSpace(env.Get(home.EnvVar))) + return workspaceApplicationSupported && env.Get("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP") == "1" && filepath.IsAbs(strings.TrimSpace(env.Get(home.EnvVar))) } -// ownedRunPlacement puts ordinary Bash tasks on the same durable layout road +// ownedRunPlacement puts delegated tasks on the same durable layout road // as explicitly selected roots. A placement names input, never landing consent. // Reference tasks retain their empty scratch and borrowed context; in particular // opening a conversation at home must not copy the person's home directory. @@ -63,7 +63,7 @@ func (a *Agent) ownershipPlacement(spec taskSpec, workspace string) (taskStand, dir, mode = root, TaskModeWorktree } return taskStand{dir: dir, mode: mode, rung: taskGroundNamed, - redirect: "The task works in its own copy of " + dir + "; its results come back through the task's normal landing."}, true + redirect: "The task works in its own private copy of " + dir + "; review and apply its results with /task review and /task apply."}, true } // Home is a reference, not a project snapshot. A general task must not copy a diff --git a/internal/session/workspace_ownership_public_test.go b/internal/session/workspace_ownership_public_test.go index 1f40f571f0..3078b022a1 100644 --- a/internal/session/workspace_ownership_public_test.go +++ b/internal/session/workspace_ownership_public_test.go @@ -11,9 +11,9 @@ import ( "github.com/Agent-Field/codeaf/internal/taskcopy" ) -func TestOwnershipStartTaskUsesPrivateWorkspaceSetWithoutPrototypeFlag(t *testing.T) { +func TestOwnershipExperimentKeepsOrdinaryAndExplicitRootsPrivate(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) - t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") t.Setenv("CODEAF_TASK_BELT", "bash") project := t.TempDir() if err := os.WriteFile(filepath.Join(project, "input.txt"), []byte("input\n"), 0600); err != nil { diff --git a/internal/session/workspace_policy.go b/internal/session/workspace_policy.go index e6480bb168..760104c077 100644 --- a/internal/session/workspace_policy.go +++ b/internal/session/workspace_policy.go @@ -3,7 +3,7 @@ package session import "fmt" // WorkspacePolicy records a conversation's task placement contract. Its absence -// preserves legacy placement; new conversation creation explicitly selects v1. +// preserves legacy placement; experimental conversations explicitly select v1. type WorkspacePolicy struct { Version int `json:"version"` Mode string `json:"mode"` @@ -24,12 +24,19 @@ func validateWorkspacePolicy(policy *WorkspacePolicy) error { // WorkspaceOwnershipEnabled reports this conversation's task placement policy. func WorkspaceOwnershipEnabled(place Place) bool { + return workspaceOwnershipEnabled(place, workspaceApplicationSupported) +} + +func workspaceOwnershipEnabled(place Place, supportsApply bool) bool { + if !supportsApply { + return false + } if place.Dir != "" { meta, err := LoadMeta(place.Dir) if err != nil || validateWorkspacePolicy(meta.WorkspacePolicy) != nil { return false } - if meta.WorkspacePolicy != nil || meta.ChatURI != "" { + if meta.WorkspacePolicy != nil { return true } } diff --git a/internal/session/workspace_policy_test.go b/internal/session/workspace_policy_test.go index de54a62095..7f70cafe3f 100644 --- a/internal/session/workspace_policy_test.go +++ b/internal/session/workspace_policy_test.go @@ -51,7 +51,7 @@ func TestWorkspacePolicyActivatesOnlyRecordedConversations(t *testing.T) { }{ {"new conversation", session.DefaultWorkspacePolicy(), "", true}, {"legacy conversation", nil, "", false}, - {"prototype identity", nil, "codeaf-workspace://test/chat", true}, + {"identity without policy", nil, "codeaf-workspace://test/chat", false}, } { t.Run(check.name, func(t *testing.T) { place := session.Place{Dir: t.TempDir()} diff --git a/internal/tui3/task_workspace_apply.go b/internal/tui3/task_workspace_apply.go index 4327ef3cc3..084f503060 100644 --- a/internal/tui3/task_workspace_apply.go +++ b/internal/tui3/task_workspace_apply.go @@ -22,6 +22,10 @@ func (a *app) runTaskWorkspaceCommand(arg string) (tea.Cmd, bool) { if len(words) == 0 || words[0] != "review" && words[0] != "apply" { return nil, false } + if !session.WorkspaceOwnershipEnabled(session.Place{Dir: session.DirOf(a.file)}) { + a.note(session.TaskWorkspaceUnavailable) + return nil, true + } if len(words) != 3 { a.note("usage: /task " + words[0] + " ") return nil, true diff --git a/internal/tui3/taskcommand_test.go b/internal/tui3/taskcommand_test.go index eb7a680719..8864dfe787 100644 --- a/internal/tui3/taskcommand_test.go +++ b/internal/tui3/taskcommand_test.go @@ -28,6 +28,23 @@ type taskCommandFake struct { err error } +func TestOrdinaryChatTaskReviewAndApplyExplainPrivateCopies(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + for _, arg := range []string{"review", "apply", "review 1 work", "apply 1 work"} { + t.Run(arg, func(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + cmd, handled := a.runTaskWorkspaceCommand(arg) + if !handled || cmd != nil { + t.Fatal("ordinary chat dispatched a private workspace operation") + } + if got := lastNote(t, a); got != session.TaskWorkspaceUnavailable { + t.Fatalf("command explanation: %q", got) + } + }) + } +} + func TestTaskStartLateAnswerDoesNotSayTheTaskFailedToStart(t *testing.T) { note := taskStartFailureNote(session.ErrSendUnanswered) if note != taskStartLateNote { From 369b31c7a82a8fcd2dd5964d68cf19739afe6eb3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 18:54:35 -0400 Subject: [PATCH 30/35] fix(tasks): refuse Apply across profile and project disks before any write Apply hard-links its owned Git index from the profile's transaction folder into the project's .git, which fails as a busy index when the two live on different filesystems. Apply now compares the devices first and refuses with a plain sentence before staging, locking or touching the destination. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/task_workspace_transaction.go | 30 ++++++++- .../task_workspace_transaction_test.go | 61 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/internal/session/task_workspace_transaction.go b/internal/session/task_workspace_transaction.go index 5c8f10872b..d3b53fa8ec 100644 --- a/internal/session/task_workspace_transaction.go +++ b/internal/session/task_workspace_transaction.go @@ -43,9 +43,13 @@ type workspaceApplicationJournal struct { Receipt taskWorkspaceApplyReceipt } -// One transaction owns raw filesystem publication, Git publication, and retained +// One transaction owns raw filesystem publication, revision publication, and retained // preimages. A committed receipt is replayed until its task checkpoint is durable. func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir string, baseline, reviewed taskcopy.PortableState, identity string, wrote []string) (taskWorkspaceApplyReceipt, error) { + return applyReviewedWorkspaceOnDevice(ctx, original, reviewedDir, baseline, reviewed, identity, wrote, workspaceApplicationDevice) +} + +func applyReviewedWorkspaceOnDevice(ctx context.Context, original taskTree, reviewedDir string, baseline, reviewed taskcopy.PortableState, identity string, wrote []string, device func(string) (uint64, error)) (taskWorkspaceApplyReceipt, error) { destination := original.ground if original.mode != TaskModeMirror { destination = original.root @@ -57,6 +61,11 @@ func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir if err != nil { return taskWorkspaceApplyReceipt{}, err } + if original.mode != TaskModeMirror { + if err := workspaceApplicationProfileDevice(ctx, root, destination, device); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + } unlock, err := lockWorkspaceApplication(root, destination) defer unlock() if err != nil { @@ -81,6 +90,25 @@ func applyReviewedWorkspace(ctx context.Context, original taskTree, reviewedDir return startWorkspaceApplication(ctx, root, original, reviewedDir, baseline, reviewed, identity, expected) } +func workspaceApplicationProfileDevice(ctx context.Context, root, destination string, device func(string) (uint64, error)) error { + index, err := gitContext(ctx, destination, nil, "rev-parse", "--path-format=absolute", "--git-path", "index") + if err != nil { + return err + } + profileDevice, err := device(root) + if err != nil { + return err + } + projectDevice, err := device(filepath.Dir(strings.TrimSpace(index))) + if err != nil { + return err + } + if profileDevice != projectDevice { + return errors.New("the codeaf profile and this project are on different disks; Apply needs them on the same disk") + } + return nil +} + func startWorkspaceApplication(ctx context.Context, root string, original taskTree, reviewedDir string, baseline, reviewed taskcopy.PortableState, identity string, expected []workspaceApplicationEntry) (taskWorkspaceApplyReceipt, error) { destination := original.root if original.mode == TaskModeMirror { diff --git a/internal/session/task_workspace_transaction_test.go b/internal/session/task_workspace_transaction_test.go index 28a1de3bff..ca8f7f3425 100644 --- a/internal/session/task_workspace_transaction_test.go +++ b/internal/session/task_workspace_transaction_test.go @@ -15,6 +15,67 @@ import ( "github.com/Agent-Field/codeaf/internal/taskcopy" ) +func TestTaskWorkspaceApplyRefusesDifferentDisksBeforeDestinationWrites(t *testing.T) { + agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, true) + _, _, use, tree, err := agent.taskWorkspaceRoot(id, "work") + if err != nil { + t.Fatal(err) + } + ticket, _, err := loadTaskWorkspaceReview(id, use, tree) + if err != nil { + t.Fatal(err) + } + baseline, err := taskWorkspaceHistoricalBaseline(tree) + if err != nil { + t.Fatal(err) + } + beforeHead := gitOut(t, source, "rev-parse", "HEAD") + beforeStatus := gitOut(t, source, "status", "--porcelain=v1", "--untracked-files=all") + beforeContents := readFile(t, filepath.Join(source, "shared.txt")) + indexPath := strings.TrimSpace(gitOut(t, source, "rev-parse", "--path-format=absolute", "--git-path", "index")) + indexBefore, err := os.ReadFile(indexPath) + if err != nil { + t.Fatal(err) + } + lookups := 0 + device := func(path string) (uint64, error) { + lookups++ + if path == filepath.Dir(indexPath) { + return 2, nil + } + return 1, nil + } + _, err = applyReviewedWorkspaceOnDevice(context.Background(), tree, filepath.Join(ticket.Place, "portable"), baseline, ticket.Portable, ticket.Snapshot, []string{"shared.txt"}, device) + if err == nil || err.Error() != "the codeaf profile and this project are on different disks; Apply needs them on the same disk" { + t.Fatalf("cross-device refusal: %v", err) + } + if lookups != 2 { + t.Fatalf("different devices were not checked: %d lookups", lookups) + } + if after := gitOut(t, source, "rev-parse", "HEAD"); after != beforeHead { + t.Fatalf("destination history changed: %s", after) + } + if after := gitOut(t, source, "status", "--porcelain=v1", "--untracked-files=all"); after != beforeStatus { + t.Fatalf("destination status changed: %s", after) + } + if after := readFile(t, filepath.Join(source, "shared.txt")); after != beforeContents { + t.Fatalf("destination contents changed: %q", after) + } + if after, err := os.ReadFile(indexPath); err != nil || string(after) != string(indexBefore) { + t.Fatalf("destination index changed: %v", err) + } + if _, err := os.Stat(indexPath + ".lock"); !os.IsNotExist(err) { + t.Fatalf("destination index lock created: %v", err) + } + staging, err := filepath.Glob(filepath.Join(filepath.Dir(source), ".codeaf-applying-*")) + if err != nil || len(staging) != 0 { + t.Fatalf("destination staging created: %v, %v", staging, err) + } + if got := readFile(t, filepath.Join(copyDir, "shared.txt")); got != "private result\n" { + t.Fatalf("private copy changed: %q", got) + } +} + func TestTaskWorkspaceTransactionRejectsChangedPermissions(t *testing.T) { agent, id, _, source, copyDir := completedWorkspaceApplyFixture(t, false) path := filepath.Join(source, "shared.txt") From 3a7b0cbd734c6df45fe243f3dab3fcfeb8143d1e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 18:54:35 -0400 Subject: [PATCH 31/35] docs(tasks): scope private workspaces to the opt-in experiment Every manual section about private task copies, Review/Apply, workspace roots and the folder mapping on a move now opens by saying it applies only with the experiment switch on Linux or macOS, so the ordinary landing pages stay the answer for ordinary chats. The relay and architecture references agree that private workspaces are implemented and opt-in, and name their compatibility, cost and platform limits. Source comments describe behaviour, not tools. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture/workspaces-storage-relay.md | 21 +++++++++-- docs/relay.md | 10 ++++- internal/cellsync/trace_engine.go | 2 +- internal/e2e/storage_lifecycle_e2e_test.go | 4 +- internal/manual/chat/commands.md | 3 +- internal/manual/chat/how-tasks-run.md | 6 ++- internal/manual/chat/task-controls.md | 29 ++++++++++++++- internal/manual/chat/worker-harness.md | 37 ++++++++++++++++--- internal/manual/chat_test.go | 1 + internal/session/task_run.go | 8 ++-- internal/session/task_storage_git.go | 4 +- internal/session/task_store.go | 4 +- internal/session/task_workspace_recovery.go | 2 +- internal/session/taskoutside.go | 2 +- internal/session/workspace_set.go | 2 +- internal/session/workspace_set_prepare.go | 2 +- internal/session/workspace_set_test.go | 2 +- internal/taskcopy/baseline.go | 2 +- internal/taskcopy/compose.go | 2 +- internal/taskcopy/git.go | 2 +- internal/taskcopy/history_screen.go | 10 ++--- internal/taskcopy/results.go | 2 +- internal/taskcopy/review_snapshot.go | 6 +-- internal/taskcopy/workspace_carrier.go | 2 +- internal/taskcopy/workspace_restore.go | 6 +-- 25 files changed, 124 insertions(+), 47 deletions(-) diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md index aa26038c71..eac288c288 100644 --- a/docs/architecture/workspaces-storage-relay.md +++ b/docs/architecture/workspaces-storage-relay.md @@ -4,7 +4,7 @@ Read this contract when delegating work across folders, cooperating on a task, receiving a chat, applying task results, or cleaning task storage. Use the [shipped worker manual](../../internal/manual/chat/worker-harness.md) for the complete user-facing command reference. Current acceptance evidence and limits -belong in [PR #1944](https://github.com/Agent-Field/CodeAF/pull/1944). +belong in [PR #1944](https://github.com/Agent-Field/codeaf/pull/1944). ## Choose the execution roots before delegating @@ -13,9 +13,12 @@ folder. A chat can reference several roots; a root is not the whole chat or a user's entire product. Registry records named `Project` identify local roots. Matching folder names or Git remotes do not combine their identities. -New managed conversations persist their private-task placement policy. Supported -Bash tasks receive private copies by default; legacy conversations keep their -recorded placement and existing readers. An explicit user-selected in-place mode +Private task copies, multi-root workspaces and Review/Apply are implemented behind +`CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME` on Linux and +macOS. Only opted-in new conversations persist their private-task placement policy. +Ordinary conversations keep automatic task landing and version-1 checkpoints. +A saved policy travels with the chat and remains enabled on Linux and macOS even +without the switch. Windows cannot enable ownership, including for a saved policy. An explicit user-selected in-place mode keeps its existing meaning. A model's brief or proposed folder does not grant in-place write permission. Home/reference work uses scratch space rather than copying the person's home directory. Foreground commands retain the semantics of @@ -79,6 +82,9 @@ Retain their private copies while completion or writer ownership is uncertain. 3. Read the recorded outcome before continuing. Repeat review and Apply separately for every writable root; a read-only root cannot be applied. +For Git projects, Apply requires the profile and destination Git index on the +same disk and refuses before destination writes if they differ. + Explicit reviewed Apply permits the chosen branch, including `main`. Automatic landing remains blocked on protected branches. Changed checkouts, affected-path conflicts and unsupported state refuse rather than overwrite work. Unrelated @@ -94,6 +100,13 @@ leave its existing engine running. ## Receive and reopen a chat +Every computer receiving a private-task chat needs a build that understands its +checkpoint and carrier format; older builds can take it and drop its tasks. +Sealing captures and screens complete histories again, so cost grows with retained +private roots. Ordinary chats without a workspace plan use the existing transfer +route and remain unowned on the receiver. Optional frame hints ride the Raft relay +and stay off by default. + Use a managed profile and compatible workspace-aware builds at both ends. Fetch and Take use the harness's private staging and identity registry. Do not restore into an arbitrary absolute destination or hand-edit registry/checkpoint files. diff --git a/docs/relay.md b/docs/relay.md index 864722414e..1821d6de32 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -171,5 +171,11 @@ HTTP 404 `not_found`. ## Architecture and workspace boundaries See [workspace storage and relay architecture](architecture/workspaces-storage-relay.md) -for the shipped transport, the proposed multi-root contract, and the compatibility -requirements. The proposed lifecycle is not enabled by this documentation. +for the shipped transport and the implemented, opt-in private-task and multi-root +contract. Private task copies and Review/Apply require +`CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME` on Linux or +macOS when creating the chat. Saved policies travel with compatible builds; +ordinary chats keep automatic task landing. Windows cannot enable this policy. +Every receiving computer needs a build that understands the private-task format: +an older build can take the chat and drop its tasks. Seal cost grows with retained +private roots. Warm-transfer frame hints use the Raft relay and stay off by default. diff --git a/internal/cellsync/trace_engine.go b/internal/cellsync/trace_engine.go index a29f5995c9..fb71d8a767 100644 --- a/internal/cellsync/trace_engine.go +++ b/internal/cellsync/trace_engine.go @@ -8,7 +8,7 @@ import ( ) // ObserveEngine times native operations without changing the completeness gate. -// Native materialization is not proof of an application's G1/G2 UI readiness. +// Native materialization does not prove that the application's interface is ready. func ObserveEngine(inner Engine, observer Observer) Engine { if observer == nil { return inner diff --git a/internal/e2e/storage_lifecycle_e2e_test.go b/internal/e2e/storage_lifecycle_e2e_test.go index 4a156dff53..abe92b44b4 100644 --- a/internal/e2e/storage_lifecycle_e2e_test.go +++ b/internal/e2e/storage_lifecycle_e2e_test.go @@ -795,7 +795,7 @@ func lifecycleInjectRealCopy(t *testing.T, corpus, destination string) (map[stri for _, entry := range entries { if entry.Name() == ".git" { continue - } // Copied original Git metadata remains outside the test workspace. + } // Copied original repository metadata remains outside the test workspace. cmd := exec.Command("cp", "-a", "--reflink=auto", "--", filepath.Join(corpus, entry.Name()), destination) if err := cmd.Run(); err != nil { t.Fatalf("copy owned real workspace entry: %v", err) @@ -1022,7 +1022,7 @@ func lifecycleHome(t *testing.T, model string) string { func lifecycleDaemonCleanup(t *testing.T, home, archive string) { t.Helper() // Register before engine/window cleanup so those writers finish before the - // private Furrow daemon flushes and releases this fixture's store handles. + // private storage process flushes and releases this fixture's store handles. t.Cleanup(func() { socket := filepath.Join(home, "v3", "engine-"+strings.TrimPrefix(filepath.Base(archive), "furrow-")+".sock") if len(socket) >= 100 { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 16b14f24b5..d1c0fdfa4c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1034,7 +1034,8 @@ build — go modules and build outputs, npm, pip, cargo — shared across sessio module is downloaded once instead of per task. It can quietly grow to hundreds of megabytes; that growth is this cache, not your conversations. -Project caches also live beneath this cache directory and are reused by tasks +With the experimental private-task switch on Linux and macOS, project caches +also live beneath this cache directory and are reused by tasks using the same working root. Older admitted cache locations remain in use; `/cache` includes them in its size and lists them before an explicit clean. Temporary files and saved work are outside the cache cleanup. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 829bd0c85b..55f775694d 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -475,7 +475,11 @@ You will see one exact reason: - `its branch task/x was kept: your checkout is not on a branch — inspect the retained task branch without changing this checkout` - `its branch task/… was kept: feat/x has moved on since the work was cut — inspect the retained task branch before choosing a destination` -## How do I take the work — why did the work not land in my checkout, why didn't my task merge, I committed, amended, rebased or reset my branch while it ran, my checkout is on main or dev, tasks do not merge into a protected branch automatically +## How do I take the work — why didn't my task's changes show up in my folder, why did the work not land in my checkout, why didn't my task merge, I committed, amended, rebased or reset my branch while it ran, my checkout is on main or dev, tasks do not merge into a protected branch automatically + +Ordinary chats use the automatic landing described here. Experimental private-task +chats on Linux and macOS instead keep results for `/task review` and `/task apply`; +see “Review and apply an owned task root” in Task controls. An ordinary task keeps its work on its own branch when your checkout is protected, detached, on a different branch or on a commit you moved after the task started. diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 9856eee770..512e31c75c 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -229,6 +229,11 @@ actual commit and which record could not be saved. Inspect that outcome before r ## Review and apply an owned task root +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + Completed tasks keep their output in their private working copies. Starting a copy, sharing it or mapping a received copy to a local folder does not give it permission to write the original folder. @@ -247,6 +252,21 @@ private copy and its identity remain available after a successful apply too. Uncertain writers continue to prevent destructive retirement of that copy. Applying one root does not authorize another root or give later tasks automatic landing permission. A received root stays private until this explicit local action. + +For Git projects, the codeaf profile and the destination Git index must be on +the same disk. Otherwise Apply refuses before writing the destination: +`the codeaf profile and this project are on different disks; Apply needs them on the same disk`. + +On a chat without private task copies, `/task review` and `/task apply` reply: +`review and apply are for private task copies; this chat doesn't have them`. + +## Experimental Apply metadata and recovery + +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + Explicit reviewed apply may update the chosen branch, including `main`. Automatic task landing still refuses protected branches. Checkout movement, conflicts and ref/index checks continue to protect the destination. @@ -263,6 +283,13 @@ the same root. The retained receipt records work that already arrived, so recove does not apply it twice. Recover that pending operation before requesting a new review of the root. +## Experimental private task inputs and portable carriers + +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + New owned plain-folder task copies retain their actual runtime inputs, including ignored files. Bash task cooperators share that admitted private copy. Files screened as secrets stay readable there but are excluded from its initial history @@ -272,7 +299,7 @@ history. Receiving a task cannot restore inputs that were deliberately withheld. The legacy family initializer and its separate child-copy behavior are unchanged. Portable task carriers preserve tracked, changed and new symlink targets and -supported link permissions using typed metadata. Both sending and receiving CodeAF versions must support this format; +supported link permissions using typed metadata. Both sending and receiving codeaf versions must support this format; workspace receivers without its required capability refuse before promotion. Older standalone receivers do not provide this capability check and must be updated before taking these carriers. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index cef911098f..3fd9b76bf6 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -2,11 +2,13 @@ ## Private task workspaces and saved workspace identities -New conversations record a versioned private-task policy when they are created. -Existing conversations without that policy keep their previous placement and -history. Earlier private-workspace conversations retain their saved identity -and reopen without requiring the old experiment flag. The explicit -`CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` switch remains a compatibility opt-in. +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + +Ordinary chats keep their existing automatic task landing. New chats record the +private-task policy only with this opt-in; an identity alone does not enable it. Foreground chat editing still uses the project you opened. Delegated tasks get private copies, and cooperating tasks share the execution's declared layout. @@ -24,6 +26,13 @@ Your home and account configuration stay where they are. These locations are not a shell sandbox: commands can still use external paths and other caches. The experiment does not promise automatic removal of those files. +## Experimental private task history and folder mapping on Take + +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + A plain folder's private mirror needs its complete recorded Git history to reopen on another device. Before transfer, the prototype screens that entire portable history, including deleted historical files and metadata, using the @@ -1090,6 +1099,11 @@ start: ` and nothing is started on the older engine in its place. ## Private tasks across several working roots +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + In a conversation using private-task policy, a `propose_task` request may declare a `workspace_set` of 1–64 roots: its primary slot and explicit source folders, slots and `read`/`write` uses. The harness makes private sibling copies @@ -1104,6 +1118,13 @@ relocated or incompletely admitted roots block the whole execution. Failed preparation keeps its completed copies and ownership receipt; it does not retry the missing work automatically. +## Experimental multi-root relay and retained copies + +This section applies only to chats opted into the experimental private-task +switch, `CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME`, +on Linux and macOS. A saved private-task policy travels with the chat and remains +active there even after the switch is off. Windows is not supported. + Relay requires compatible workspace-aware builds at both ends. Each source root needs its own explicitly admitted receiving association. Complete screened histories and edits are staged privately before execution can reopen; source landing @@ -1115,11 +1136,17 @@ older prototype V2 snapshots need a new capture before transfer. Folders must let their owner read, write and enter them. Special permission bits are unsupported; the prototype refuses them rather than changing permissions. +Every receiving computer needs a build that understands private-task checkpoints; +an older build can take the chat and drop its tasks. Seal cost grows with retained +private roots because their complete histories are captured and screened again. + This remains a prototype. Mixed older singleton copies and workspace sets refuse transfer, and active cross-device plan recovery is not established by restoring files alone. Workspace sets stay resident rather than being partially compacted. Ordinary profiles retain their existing task behavior. +## Saving tasks and the checkpoint fence + Workspace captures and checkpoint writes take a private conversation lock in every profile, including legacy profiles, so policy activation cannot bypass the checkpoint fence. This creates local control files during capture and diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8f0197cfa4..96d611c5f0 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -33,6 +33,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { question string page string }{ + {"why didn't my task's changes show up in my folder", "how-tasks-run"}, // R6/R7/R8/R15/R30 keep leadership, fallback and worker limits reachable. {"can my phone lead", "coordination-and-workers"}, {"can my phone be the leader", "coordination-and-workers"}, diff --git a/internal/session/task_run.go b/internal/session/task_run.go index cfd548d9ba..3e6fc11595 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -9632,11 +9632,9 @@ func gitWith(dir string, environment []string, args ...string) (string, error) { return gitContext(context.Background(), dir, environment, args...) } -// gitContext is [gitWith] under a context, for a command somebody may have to -// stop before git answers ([worktreeDirtIn]'s watch). The refusal of an empty -// directory and the pinned environment live here, so there is one copy of -// each; the usual git helpers wrap it. The workspace transaction protocol runner -// separates bounded stdout from stderr so Git warnings cannot become object IDs. +// gitContext keeps cancellation, directory admission and the pinned environment +// together. Bounded standard output is separate from diagnostics so warnings +// cannot become object identities. func gitContext(ctx context.Context, dir string, environment []string, args ...string) (string, error) { // A COMMAND WITH NO DIRECTORY RUNS WHEREVER THE PROCESS HAPPENS TO BE. // diff --git a/internal/session/task_storage_git.go b/internal/session/task_storage_git.go index de66f99614..7469c7a884 100644 --- a/internal/session/task_storage_git.go +++ b/internal/session/task_storage_git.go @@ -87,7 +87,7 @@ func taskIndexDigest(admin string) (string, error) { return hex.EncodeToString(sum[:]), nil } -// pinTaskGit keeps Git's external administration alive while a linked working +// pinTaskGit keeps external repository administration alive while a linked working // directory is cold. Its compact backup preserves the index if normal pruning // or an administrative repair removes it; shared repository objects stay shared. func pinTaskGit(ctx context.Context, manager taskstorage.Manager, place Place, path string) (retErr error) { @@ -202,7 +202,7 @@ func restoreTaskGit(ctx context.Context, manager taskstorage.Manager, path strin return errors.New("invalid task Git administration receipt") } // A local task archive depends on the shared repository's objects. Missing - // repositories are refused rather than replaced by a different project's Git. + // repositories are refused rather than replaced by another project's objects. if info, e := os.Stat(filepath.Join(pin.Root, "objects")); e != nil || !info.IsDir() { return errors.New("the shared Git repository needed by this task is unavailable") } diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 87c51e1816..99ecafc4d7 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -85,8 +85,8 @@ import ( ) const ( - // taskFileVersion is the schema of .tasks.json. A file carrying any - // unsupported version is IGNORED rather than migrated, for state.go's reason: the + // taskFileVersion names the checkpoint schema. Unsupported versions are + // ignored rather than migrated because the // graph is a record of work that has already happened, and guessing at an // older shape risks rehydrating a report into a field that no longer means // what it meant — which here would mean briefing a dependent with it. diff --git a/internal/session/task_workspace_recovery.go b/internal/session/task_workspace_recovery.go index d1255e5e30..051dc44bee 100644 --- a/internal/session/task_workspace_recovery.go +++ b/internal/session/task_workspace_recovery.go @@ -1375,7 +1375,7 @@ func workspaceApplicationGitInput(ctx context.Context, dir string, environment [ if dir == "" { return "", errors.New("Git application command has no working directory") } - //codeaf:plumbing the transaction uses bounded local Git protocol streams. + //codeaf:plumbing the transaction uses bounded local revision protocol streams. command := exec.CommandContext(ctx, "git", args...) command.Dir = dir command.Env = append(os.Environ(), "GIT_PAGER=cat", "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0") diff --git a/internal/session/taskoutside.go b/internal/session/taskoutside.go index 28897be1e4..e977318ce9 100644 --- a/internal/session/taskoutside.go +++ b/internal/session/taskoutside.go @@ -1064,7 +1064,7 @@ func environmentAssignment(word string) (string, string, bool) { } // Numeric descriptor duplication/closure does not open a filesystem target. -// Bash's >& also accepts a filename, unlike its numeric 2>&1 spelling. +// A combined stream redirection also accepts a filename; a numeric target duplicates a descriptor. func fileWriteRedirection(operator, target string) bool { switch operator { case ">", ">>", ">|", "<>": diff --git a/internal/session/workspace_set.go b/internal/session/workspace_set.go index 879c2ac7ab..de4bc3c50b 100644 --- a/internal/session/workspace_set.go +++ b/internal/session/workspace_set.go @@ -28,7 +28,7 @@ const ( // Slots preserve supported ASCII spelling; case-fold aliases, Unicode and // reserved filesystem names refuse rather than being renamed. // Landing is empty to retain work privately, or the explicitly admitted source -// folder. Matching names or Git remotes never supply landing permission. +// folder. Matching names or repository remotes never supply landing permission. type WorkspaceRootRequest struct { // SourceURI is authoritative once admitted; Source is a local path hint. SourceURI string `json:"source_uri,omitempty"` diff --git a/internal/session/workspace_set_prepare.go b/internal/session/workspace_set_prepare.go index eab4cc42df..21dac3cead 100644 --- a/internal/session/workspace_set_prepare.go +++ b/internal/session/workspace_set_prepare.go @@ -156,7 +156,7 @@ func prepareWorkspaceSet(ctx context.Context, place Place, id uint64, title stri if err != nil { return primary, preserveFailedWorkspaceCreation(tree, err) } - // The native mirror is opened as Git only here, after the carve. Record + // The native mirror receives its repository only after the carve. Record // its immutable admission commit before any worker can change this copy. baseline, baseErr := git(tree.dir, "rev-parse", "--verify", "HEAD") if baseErr != nil { diff --git a/internal/session/workspace_set_test.go b/internal/session/workspace_set_test.go index cc10bd490d..8f7e8a78e0 100644 --- a/internal/session/workspace_set_test.go +++ b/internal/session/workspace_set_test.go @@ -683,7 +683,7 @@ func TestWorkspaceSetUnchangedRootCompletesWithoutLandingFailure(t *testing.T) { } } -// The live path creates its mirror's Git repository after the native carve. +// The live path creates its mirror's repository after the native carve. // Baselines must come from that admission, not from fixture-seeded task rows. func TestPlainWorkspaceSetPersistsItsPreWorkerGitBaselines(t *testing.T) { t.Setenv("CODEAF_HOME", t.TempDir()) diff --git a/internal/taskcopy/baseline.go b/internal/taskcopy/baseline.go index 9f12b15647..946d7ba5e4 100644 --- a/internal/taskcopy/baseline.go +++ b/internal/taskcopy/baseline.go @@ -9,7 +9,7 @@ import ( const independentBaselineFile = "codeaf-independent-baseline" // MarkIndependentBaseline records the harness-created baseline of an owned -// mirror. Its original plain folder cannot supply Git prerequisites on relay. +// mirror. Its original plain folder cannot supply history prerequisites on relay. // Callers use this only while creating their private mirror repository. func MarkIndependentBaseline(tree string) error { info, err := os.Lstat(filepath.Join(tree, ".git")) diff --git a/internal/taskcopy/compose.go b/internal/taskcopy/compose.go index 64bda4c6ff..bc9f5d4259 100644 --- a/internal/taskcopy/compose.go +++ b/internal/taskcopy/compose.go @@ -31,7 +31,7 @@ type Carry struct { func (k Carry) Compose(c cell.Cell) error { return k.ComposeWorkspace(c, k.Project) } // ComposeWorkspace receives the engine's current project, including recovery. -// A plain folder has no Git baseline on the receiving machine, so its private +// A plain folder has no repository baseline on the receiving machine, so its private // mirror roots must carry their entire reachable HEAD, even when child refs // already contain the parent's commit before its first capture. func (k Carry) ComposeWorkspace(c cell.Cell, project string) error { diff --git a/internal/taskcopy/git.go b/internal/taskcopy/git.go index 215c3f3757..0d86319e57 100644 --- a/internal/taskcopy/git.go +++ b/internal/taskcopy/git.go @@ -17,7 +17,7 @@ func git(dir string, args ...string) (string, error) { cmd := exec.Command("git", append([]string{"--no-optional-locks"}, args...)...) cmd.Dir = dir // A cold capture must not be invalidated by an optional index refresh in - // a read-only Git query while its portable relay artifact is prepared. + // a read-only repository query while its portable relay artifact is prepared. if env.Get("CODEAF_PROTOTYPE_TASK_STORAGE") == "1" { cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") } diff --git a/internal/taskcopy/history_screen.go b/internal/taskcopy/history_screen.go index a7fdbae72c..1a421713a3 100644 --- a/internal/taskcopy/history_screen.go +++ b/internal/taskcopy/history_screen.go @@ -79,7 +79,7 @@ type fullBundleHeader struct { } // The full-history producer writes exactly HEAD, with no prerequisites or -// arbitrary references. Parse that original wire contract before any Git import: +// arbitrary references. Parse that original wire contract before any history import: // cloning can silently discard references and is not a container validator. func readFullBundleHeader(file *os.File) (fullBundleHeader, error) { input := bufio.NewReaderSize(file, 256) @@ -142,7 +142,7 @@ func fullBundleHead(reference, format string) (string, error) { // index-pack validates and stores the original pack. It accepts trailing input // but echoes it after its digest line, so require exactly that bounded line and -// EOF. Never capture or echo those extra bytes or Git's diagnostic text. +// EOF. Never capture or echo those extra bytes or the process's diagnostic text. func importFullBundlePack(file *os.File, repository string, header fullBundleHeader) error { if _, err := file.Seek(header.offset, io.SeekStart); err != nil { return errFullHistoryContainer @@ -180,9 +180,9 @@ func validPackReceipt(line []byte, hexSize int) bool { return err == nil } -// Inspection must stay in its owned object store even when the caller has Git -// routing/configuration variables for another checkout. PATH remains available -// for the installed executable and the public process/failure regression tests. +// Inspection stays in its owned object store even when inherited routing or +// configuration variables name another checkout. Executable lookup remains +// available for the installed process and failure regression tests. func historyGitCommand(repository string, args ...string) *exec.Cmd { cmd := exec.Command("git", append([]string{"-C", repository}, args...)...) for _, entry := range os.Environ() { diff --git a/internal/taskcopy/results.go b/internal/taskcopy/results.go index 9eadfaec19..57810b6a0c 100644 --- a/internal/taskcopy/results.go +++ b/internal/taskcopy/results.go @@ -20,7 +20,7 @@ type ResultSelection struct { } // RestoreCompletedResults opens recorded outputs as ordinary historical files, -// never working copies. The caller selects only settled tasks. Git reconstruction +// never working copies. The caller selects only settled tasks. Repository reconstruction // is temporary; unchanged project inputs and execution metadata are not retained. func RestoreCompletedResults(c cell.Cell, project, destination string, selections []ResultSelection) (map[string]string, error) { return RestoreCompletedResultsWithParents(c, project, destination, selections, nil) diff --git a/internal/taskcopy/review_snapshot.go b/internal/taskcopy/review_snapshot.go index cd72726efb..1ea08479a6 100644 --- a/internal/taskcopy/review_snapshot.go +++ b/internal/taskcopy/review_snapshot.go @@ -20,7 +20,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" ) -// PortableEntry records the supported working representation, independently of Git filters. +// PortableEntry records the supported working representation independently of repository filters. type PortableEntry struct { Kind string `json:"kind"` Mode fs.FileMode `json:"mode"` @@ -362,7 +362,7 @@ func CheckPortableMutationMetadataContext(ctx context.Context, root string, path } // MaterializePortableInput restores source representation into a newly owned -// private Git copy before any worker can use it. +// private repository copy before any worker can use it. func MaterializePortableInput(source, destination string, baseline PortableState) error { var err error source, err = canonicalPortableRoot(source) @@ -659,7 +659,7 @@ func InspectPortablePaths(root string, paths []string) (PortableState, error) { } // Resolve the caller-supplied anchor once. Descendant symlinks still refuse in -// workspacePath; a platform alias such as Darwin /var is not a child redirect. +// workspacePath; a platform root alias is not a child redirect. func canonicalPortableRoot(root string) (string, error) { if !filepath.IsAbs(root) { return "", errors.New("portable root must be absolute") diff --git a/internal/taskcopy/workspace_carrier.go b/internal/taskcopy/workspace_carrier.go index ac48398015..300656af4d 100644 --- a/internal/taskcopy/workspace_carrier.go +++ b/internal/taskcopy/workspace_carrier.go @@ -313,7 +313,7 @@ func prepareWorkspaceCapture(c cell.Cell, previous workspaceManifest) (string, e return stage, nil } -// captureWorkspaceDirectories carries only the visible working tree. Git names +// captureWorkspaceDirectories carries only the visible working tree. The index names // ignored subtrees once, so large caches are not walked just to omit their dirs. func captureWorkspaceDirectories(tree string) (map[string]fs.FileMode, error) { return captureWorkspaceDirectoriesScope(tree, tree, independentBaseline(tree)) diff --git a/internal/taskcopy/workspace_restore.go b/internal/taskcopy/workspace_restore.go index b198eaf5fe..58ceaab074 100644 --- a/internal/taskcopy/workspace_restore.go +++ b/internal/taskcopy/workspace_restore.go @@ -570,7 +570,7 @@ func validateWorkspaceOverlay(destination, from string, rec record) error { } // workspaceTreeDigest seals the prepared working files, including clean files -// that an overlay comparison cannot judge. Git's mutable administrative files +// that an overlay comparison cannot judge. Mutable repository administration files // are excluded; the recorded HEAD is checked independently. func workspaceTreeDigest(root string) (string, error) { hash := sha256.New() @@ -767,7 +767,7 @@ type WorkspaceResultSelection struct { BaseCommit string `json:"base_commit"` } -// RestoreCompletedWorkspaceResults retains changed outputs without runnable Git +// RestoreCompletedWorkspaceResults retains changed outputs without runnable repository // copies. Full history remains in the independent portable carrier namespace. func RestoreCompletedWorkspaceResults(c cell.Cell, plan WorkspacePlan, destination string, selections []WorkspaceResultSelection) (map[string]string, error) { manifest, err := readWorkspaceManifest(c) @@ -1233,7 +1233,7 @@ func verifyWorkspaceDirectories(destination string, directories map[string]fs.Fi } // workspaceOutputDirectories retains output parents and empty directories not -// represented by the Git baseline. Git does not record directory permissions, +// represented by the repository baseline. History does not record directory permissions, // so mode-only changes to an otherwise unchanged input directory remain in the // full carrier rather than being attributed to this changed-output subset. func workspaceOutputDirectories(checkout, base string, directories map[string]fs.FileMode, files []string) (map[string]fs.FileMode, error) { From 5942519bd6a1921e97fa717c6e67c52e912d0c5c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 18:54:42 -0400 Subject: [PATCH 32/35] fix(two computers): a removed computer keeps working on its own chats A window that learned its computer had been removed refused every send before looking at the chat, so even a new chat opened with /new could not reach the model. Removal now decides like an unreachable service: a chat this window held, or one never shared, goes on here only; a chat another computer took stays read-only with the removal sentence. A move that failed because the other machines could not be reached said only "other machines unreachable". It now says the chat could not be continued here and that the words are still in the box, as every other failed move does. Also rewrites the #1944 change entry against dev rather than against the branch's own earlier default. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1944-workspace-relay-architecture.md | 60 ++++++++++--------- .../continuing-a-chat-on-another-computer.md | 1 + internal/tui3/homepanel_continue.go | 9 ++- internal/tui3/homepanel_heldelsewhere.go | 17 ++++-- internal/tui3/manual_devices_test.go | 2 +- internal/tui3/removed_continuation_test.go | 43 +++++++++++++ internal/tui3/take_failure_draft_test.go | 7 +++ 7 files changed, 102 insertions(+), 37 deletions(-) diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index fd42663be3..8f626ce183 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -1,38 +1,42 @@ --- kind: added -title: owned task layouts and explicit multi-root relay mapping +title: Private task copies and multi-root workspaces behind an experiment switch, and a removed computer keeps working on its own chats pr: 1944 surface: [chat, engine, remote, docs] -invalidates: [] +invalidates: + - "A delegated task could only work in, and land into, the project the chat opened. With CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1 and an absolute CODEAF_HOME on Linux or macOS, a new chat's tasks work in private copies, may span several admitted folders, and come home only through /task review and /task apply. Without the switch, and always on Windows, tasks land exactly as before." + - "/task had no review or apply step. /task review and /task apply now exist; in a chat without private task copies they say so in one sentence and change nothing." + - "The Raft relay refused a publish whose signed body carried frame_hints. It now carries them as bounded, optional metadata; no shipped client sends them yet." + - "Since #1967 a window that learned its computer had been removed refused every send, even in a new chat. A removed computer now keeps working on chats it held or never shared; only a chat another computer took stays read-only there." + - "A move that failed because the other machines could not be reached said only other machines unreachable. It now says the chat could not be continued here and that the words are still in the box." --- -Give supported delegated tasks private layouts and reuse the admitted layout for -exact cooperating work. Carry multiple working roots with stable identities, -versioned directory metadata, restart receipts and independent fork history. -Ask for receiver-local folders before acquiring the source lease; cancellation -and invalid mappings preserve the existing holder and allow a valid retry. -Typed folder targets survive asynchronous listings; confirmation waits for the -listing instead of choosing an unloaded parent or the first sibling. +Private task copies, multi-root workspaces and explicit Review/Apply exist behind +`CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1` with an absolute `CODEAF_HOME` on Linux and +macOS. Without the switch, a new chat persists no workspace policy, its tasks land +as before, and its task checkpoint and carrier keep the formats older builds read. +A saved private-task policy travels with its chat and needs no switch on a +compatible receiving computer. Windows has no private-task Apply, so it never +enables ownership, even for a saved policy. -Review and apply each writable root explicitly through the terminal. Application -uses the exact reviewed snapshot and retains the original private copy; edits -after review do not land. Conflicts preserve both versions. Completed plan workers -reopen their owned transcripts after restart or relay; local index rebuilding -recovers their recorded model without retaining another computer's file paths. +The experiment's limits: +- every computer a private-task chat moves to needs a build that understands its + checkpoint and carrier, because an older build can take it and drop its tasks; +- seal cost grows with retained private roots, because complete histories are + captured and screened again; +- workspace sets keep their private copies, so there is no automatic storage + reclamation yet; +- for Git projects, the profile and the destination's Git index must be on the + same disk; Apply refuses before writing anything otherwise. -Local archive recovery requires the separately reviewed native dependency. -Uncertain writers and multi-root reclamation remain excluded. Existing -conversations require compatibility safeguards; this change does not silently -rewrite legacy task histories or promise long-term storage savings. +Apply uses the exact reviewed snapshot, preserves conflicts and unrelated staged or +dirty work, and keeps the private copy. -Document the agent contract for declaring tool roots, checking admitted worker -routes, mapping received roots, reviewing results and cleaning owned fixtures. -Current acceptance evidence is maintained in the pull request rather than as a -pending status in the architecture reference. +For every chat, task cards recover their worker transcript and recorded model after +a restart or a move. Typed folder targets survive asynchronous listings, and a +failed move keeps the typed words and never sends them. -Optional warm-transfer frame hints now travel through the shared Raft rules. -Invalid hints are omitted without blocking a saved head; Home listings omit them. -The prototype remains off by default, and every cluster member must understand -these fields before a device sends them. Receiving multiple task roots preserves -the captured draft and pending send through folder selection, records the acquired -send fence, and keeps cancellation and failed moves from sending on an older copy. +Warm-transfer frame hints ride the Raft relay and stay off by default: +- an invalid set is dropped rather than refusing the publish; +- Home listings omit them; +- every cluster member must understand them before a device sends them. diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 68d4c1d433..5d58ccf214 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -142,6 +142,7 @@ If nothing happens when you press `Continue here`, the cause is one of these: - `another device continued this chat first`: another device was faster. The row goes back to `running on `. Nothing changed. - `other machines unreachable`: this device cannot reach the service. Nothing can be moved until it can. - `could not continue this chat here`: the move failed. The row stays where it was; try again. +- `could not continue this chat here because other machines are unreachable; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. - A sentence starting `the sync service must be updated before chats can move` means the service is too old to move chats safely. The operators update the hosted service; for your own service, follow the update command in that sentence on the terminal. Pairing, listing and viewing still work. Reopen codeaf after the service is updated. - With no way to continue set up, an off row has no `continue here`: enter says why. diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 1f0f669238..ef1d3b9a5c 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -89,9 +89,12 @@ const ( lostRaceWord = chatlist.LostRace continuing = "continuing here…" continueFails = "could not continue this chat here" - branchAsk = "What should happen to these turns?" - branchLeave = "leave them" - branchFails = "could not change those turns" + // continueUnreachable names what did not happen, not only why, because + // the box still holds words the person meant to send in the moved chat. + continueUnreachable = continueFails + " because other machines are unreachable; your words are still in the box" + branchAsk = "What should happen to these turns?" + branchLeave = "leave them" + branchFails = "could not change those turns" ) // homeContinueKind and homeBranchKind are the lanes the two questions travel diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 3accbf6e0a..08999ee7f5 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -168,15 +168,22 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { // resumeOwnedSend spends only the submitted gesture after its fresh ownership // reading. A free chat can resume silently only from the same saved turn. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { - if a.machineRead.computerRemoved { - a.note(chatlist.Removed) - return nil - } id := filepath.Base(session.DirOf(msg.file)) remote := id if msg.leaseCell != "" { remote = msg.leaseCell } + if a.machineRead.computerRemoved { + // A REMOVED COMPUTER KEEPS WORKING ON ITS OWN CHATS. It can no longer + // sync, so a chat this window held, or one that was never shared, goes on + // here only. A chat another computer took stays as this window last saw + // it, because this computer can never take it back. + if a.offlineSendHeld(id, remote, msg) { + return msg.resume() + } + a.note(chatlist.Removed) + return nil + } if msg.reading.err != nil { // AN UNREACHABLE SERVICE DOES NOT TAKE A HELD CHAT AWAY. The window // keeps working on a chat whose hold it recorded and last saw as its @@ -289,7 +296,7 @@ func (a *app) failedTake(msg homeTakenMsg) string { case errors.Is(msg.err, directory.ErrLeaseHeld): return lostRaceWord case errors.Is(msg.err, directory.ErrUnreachable): - return chatlist.Unreachable + return continueUnreachable case errors.Is(msg.err, directory.ErrConditionalTake): return directory.ErrConditionalTake.Error() case errors.Is(msg.err, directory.ErrRevoked): diff --git a/internal/tui3/manual_devices_test.go b/internal/tui3/manual_devices_test.go index 746779609c..365fbdc002 100644 --- a/internal/tui3/manual_devices_test.go +++ b/internal/tui3/manual_devices_test.go @@ -28,7 +28,7 @@ func TestEverySentenceOfTheDeviceScreensIsOnItsPage(t *testing.T) { }, "naming-a-device": {devicesNameKeys, devicesNameAsk + "atlas▏", devicesNameOwn}, "continuing-a-chat-on-another-computer": { - continueAsk, continueStay, continueFails, lostRaceWord, chatlist.Unreachable, + continueAsk, continueStay, continueFails, continueUnreachable, lostRaceWord, chatlist.Unreachable, fmt.Sprintf(resumeAskFormat, "tower"), resumeYes, resumeNo, chatlist.Moved("tower", 3200e6, true), chatlist.Moved("", 3200e6, false), chatlist.ArrivedHead("tower"), chatlist.OfferSetUp, chatlist.OfferNotNow, diff --git a/internal/tui3/removed_continuation_test.go b/internal/tui3/removed_continuation_test.go index 4b2c3d02c9..92aa60534e 100644 --- a/internal/tui3/removed_continuation_test.go +++ b/internal/tui3/removed_continuation_test.go @@ -9,6 +9,49 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" ) +// TestRemovedComputerStillSendsOnItsOwnChats proves that removal ends syncing, +// not local work: a chat held here, or never shared, still sends, while a chat +// another computer took keeps the words and says why. +func TestRemovedComputerStillSendsOnItsOwnChats(t *testing.T) { + for _, tc := range []struct { + name string + row chatlist.Row + mine bool + want bool + }{ + {name: "held here", row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute), mine: true, want: true}, + {name: "never shared", row: leftOffRow("0000000000000009", "tower", chatlist.Running, time.Minute), want: true}, + {name: "taken elsewhere", row: leftOffRow(heldCell, "tower", chatlist.Running, time.Minute), want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + src := &continuationListing{row: tc.row} + if tc.mine { + src.row.Mine, src.row.Status, src.row.Fence = true, chatlist.Here, 3 + } + a, agent := heldLab(t, src, &fakeTaker{}) + a.closeHome() + src.err = directory.ErrRevoked + typeText(t, a, "keep working here") + drive(t, a, key("enter")) + if !a.machineRead.computerRemoved { + t.Fatal("the fresh reading did not mark this computer removed") + } + if tc.want { + if len(agent.sent) != 1 { + t.Fatalf("removed computer refused its own chat: sent=%v draft=%q", agent.sent, a.input.String()) + } + return + } + if len(agent.sent) != 0 || a.input.String() != "keep working here" { + t.Fatalf("removed computer sent on a chat it does not hold: sent=%v draft=%q", agent.sent, a.input.String()) + } + if notes := strings.Join(noteTexts(a), "\n"); !strings.Contains(notes, "this device was removed") { + t.Errorf("refusal did not say why: %q", notes) + } + }) + } +} + func TestRemovedComputerOffersNoContinuation(t *testing.T) { for _, notice := range []bool{false, true} { a := continueHome(t, &fakeTaker{}, chatlist.Static{leftOffRow("away", "other", chatlist.Off, time.Minute)}, 200) diff --git a/internal/tui3/take_failure_draft_test.go b/internal/tui3/take_failure_draft_test.go index bd3a4e16ae..0e41116402 100644 --- a/internal/tui3/take_failure_draft_test.go +++ b/internal/tui3/take_failure_draft_test.go @@ -3,6 +3,7 @@ package tui3 import ( "errors" "reflect" + "strings" "testing" "time" @@ -42,6 +43,12 @@ func TestEveryFailedTakeReturnsTheCapturedComposer(t *testing.T) { if len(old.sent) != 0 { t.Errorf("failure sent words: %v", old.sent) } + if failure.err == directory.ErrUnreachable { + notes := strings.Join(noteTexts(a), "\n") + if !strings.Contains(notes, "could not continue this chat here") || !strings.Contains(notes, "your words are still in the box") { + t.Errorf("an unreachable move did not say the chat stayed and the words were kept: %q", notes) + } + } if a.at(pageHome) { a.closeHome() } From cb0fedda5bb2d3b71a7c030b8027f0070c085828 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 19:04:20 -0400 Subject: [PATCH 33/35] refactor(tasks): split Apply's door and applicability checks out of Apply The ordinary-chat explanation pushed ApplyTaskWorkspaceRoot past the task engine's decision ceiling. Review and Apply now share one door check, and the read-root and already-applied refusals live in their own function. Also shortens the change entry's title to one line. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1944-workspace-relay-architecture.md | 2 +- internal/session/task_workspace_apply.go | 38 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 8f626ce183..38a7860215 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -1,6 +1,6 @@ --- kind: added -title: Private task copies and multi-root workspaces behind an experiment switch, and a removed computer keeps working on its own chats +title: Private task copies behind an experiment switch, and removed computers keep working locally pr: 1944 surface: [chat, engine, remote, docs] invalidates: diff --git a/internal/session/task_workspace_apply.go b/internal/session/task_workspace_apply.go index 3b24373ff7..45a0109f21 100644 --- a/internal/session/task_workspace_apply.go +++ b/internal/session/task_workspace_apply.go @@ -33,10 +33,7 @@ const TaskWorkspaceUnavailable = "review and apply are for private task copies; // ReviewTaskWorkspaceRoot reads actual committed and uncommitted changes. This // person-facing door is intentionally absent from model tools. func (a *Agent) ReviewTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (TaskWorkspaceReview, error) { - if !WorkspaceOwnershipEnabled(a.config.Place) { - return TaskWorkspaceReview{}, errors.New(TaskWorkspaceUnavailable) - } - if err := taskWorkspaceOperationEnvironment(ctx); err != nil { + if err := a.taskWorkspaceDoor(ctx); err != nil { return TaskWorkspaceReview{}, err } a.lockBeltStart() @@ -196,13 +193,31 @@ func reviewTaskWorkspaceRoot(ctx context.Context, use WorkspaceRootUse, tree tas return review, nil } +// taskWorkspaceDoor admits Review and Apply only in a chat that has private task +// copies, and only where the person's environment lets the operation run safely. +func (a *Agent) taskWorkspaceDoor(ctx context.Context) error { + if !WorkspaceOwnershipEnabled(a.config.Place) { + return errors.New(TaskWorkspaceUnavailable) + } + return taskWorkspaceOperationEnvironment(ctx) +} + +// taskWorkspaceApplicable refuses a root whose Apply would write nothing new: a +// read root, or one already applied with no newer review waiting. +func taskWorkspaceApplicable(id uint64, use WorkspaceRootUse) error { + if use.Access != WorkspaceWrite { + return errors.New("a read root cannot be applied") + } + if (use.Outcome == mergeMerged || use.Outcome == mergeInPlace) && !taskWorkspaceReviewPending(id, use) { + return errors.New("this root was already applied; no work was repeated") + } + return nil +} + // ApplyTaskWorkspaceRoot grants permission only for this selected operation. // It never records Landing, starts a worker, or broadens another root's access. func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot string) (string, error) { - if !WorkspaceOwnershipEnabled(a.config.Place) { - return "", errors.New(TaskWorkspaceUnavailable) - } - if err := taskWorkspaceOperationEnvironment(ctx); err != nil { + if err := a.taskWorkspaceDoor(ctx); err != nil { return "", err } epoch, err := a.beltAdmissionEpoch() @@ -215,11 +230,8 @@ func (a *Agent) ApplyTaskWorkspaceRoot(ctx context.Context, id uint64, slot stri if err != nil { return "", err } - if use.Access != WorkspaceWrite { - return "", errors.New("a read root cannot be applied") - } - if (use.Outcome == mergeMerged || use.Outcome == mergeInPlace) && !taskWorkspaceReviewPending(id, use) { - return "", errors.New("this root was already applied; no work was repeated") + if err := taskWorkspaceApplicable(id, use); err != nil { + return "", err } if err := a.taskWorkspaceApplyIdle(g, kept, tree); err != nil { return "", err From 152c2d8ce75cc283d4d7ed221586a58b03d23772 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 19:25:43 -0400 Subject: [PATCH 34/35] fix(two computers): Home says the whole sentence when a move fails unreachable A move that failed because the other machines could not be reached put the full sentence in the chat's own tab, but the window lands on Home, and Home said only "other machines unreachable". Home now carries the same sentence: the chat stayed where it was and the words are still in the box. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/homepanel_continue.go | 4 +++- internal/tui3/take_failure_draft_test.go | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index ef1d3b9a5c..b4c078a2a5 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -327,8 +327,10 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { case errors.Is(msg.err, directory.ErrLeaseHeld): return a.lostRace(msg.row) case errors.Is(msg.err, directory.ErrUnreachable): + // Home is where the failed move lands, so it carries the whole sentence: + // the reason alone does not say the chat stayed and the words were kept. a.machineRead.down = true - a.home.say(chatlist.Unreachable, "") + a.home.say(failure, "") case errors.Is(msg.err, directory.ErrConditionalTake): a.home.say(directory.ErrConditionalTake.Error(), "") case msg.err != nil: diff --git a/internal/tui3/take_failure_draft_test.go b/internal/tui3/take_failure_draft_test.go index 0e41116402..3d8a73837d 100644 --- a/internal/tui3/take_failure_draft_test.go +++ b/internal/tui3/take_failure_draft_test.go @@ -45,9 +45,12 @@ func TestEveryFailedTakeReturnsTheCapturedComposer(t *testing.T) { } if failure.err == directory.ErrUnreachable { notes := strings.Join(noteTexts(a), "\n") - if !strings.Contains(notes, "could not continue this chat here") || !strings.Contains(notes, "your words are still in the box") { + if !strings.Contains(notes, continueUnreachable) { t.Errorf("an unreachable move did not say the chat stayed and the words were kept: %q", notes) } + if !a.at(pageHome) || !strings.Contains(homeText(a), "your words are still in the box") { + t.Errorf("Home, where the failed move lands, gave only the reason:\n%s", homeText(a)) + } } if a.at(pageHome) { a.closeHome() From 364f30d4e22a4e69ac070b2e74bafc14129f7b20 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 19:43:25 -0400 Subject: [PATCH 35/35] fix(chat): one activity row per chat, and moved task journals reopen The activity page took a chat's own row identity from the folder holding its transcript, which in the cell layout is the chat's internal state folder, so a chat with a finished task appeared twice and one row read the stale index as "incomplete". The row now uses the chat's own folder. A transferred run whose worker journal kept the fixed worker name could not reopen its transcript; history lookup now accepts that verified journal too. The worker manual no longer claims finished runs stay on the sidebar: they live in Tasks. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../1944-workspace-relay-architecture.md | 9 ++ internal/manual/chat/tasks.md | 5 + internal/manual/chat/worker-harness.md | 10 +- internal/session/moved_run_history_test.go | 116 ++++++++++++++++++ internal/session/task_run_history.go | 4 + internal/tui3/activity_finished_task_test.go | 60 +++++++++ internal/tui3/place_sessions.go | 5 +- internal/tui3/taskowner.go | 3 + 8 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 internal/session/moved_run_history_test.go create mode 100644 internal/tui3/activity_finished_task_test.go diff --git a/docs/changes/unreleased/1944-workspace-relay-architecture.md b/docs/changes/unreleased/1944-workspace-relay-architecture.md index 38a7860215..a79bae9684 100644 --- a/docs/changes/unreleased/1944-workspace-relay-architecture.md +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -9,6 +9,8 @@ invalidates: - "The Raft relay refused a publish whose signed body carried frame_hints. It now carries them as bounded, optional metadata; no shipped client sends them yet." - "Since #1967 a window that learned its computer had been removed refused every send, even in a new chat. A removed computer now keeps working on chats it held or never shared; only a chat another computer took stays read-only there." - "A move that failed because the other machines could not be reached said only other machines unreachable. It now says the chat could not be continued here and that the words are still in the box." + - "A native chat could appear twice in activity and label finished work incomplete because its internal state folder was mistaken for its identity. Activity now joins the live and saved rows under the chat's own identity." + - "Transferred task history with a fixed worker journal name had no transcript citation. Completed runs now reopen that verified local journal even without a plan database." --- Private task copies, multi-root workspaces and explicit Review/Apply exist behind @@ -36,6 +38,13 @@ For every chat, task cards recover their worker transcript and recorded model af a restart or a move. Typed folder targets survive asynchronous listings, and a failed move keeps the typed words and never sends them. +Native chat activity uses the chat's identity rather than its internal state +folder, so one completed task appears once and says done even while an older +index reading still says running. This also repairs the duplicate activity rows +on dev. Transferred worker journals with either a timestamped name or the fixed +worker name reopen locally without a plan database. Finished tasks remain in +Tasks; the active sidebar deliberately leaves them out. + Warm-transfer frame hints ride the Raft relay and stay off by default: - an invalid set is dropped rather than refusing the publish; - Home listings omit them; diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 55239069c0..02457a88b5 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5282,6 +5282,11 @@ fold. Click its `Tasks` heading to open results and history on demand. Finished keep their results, reports, files, branch and run details; hiding routine completion receipts never deletes their records. +A moved chat keeps the same finished task in Tasks even when its checkpoint has +only completed runs and no plan nodes. The activity page lists the chat once and +uses the task's settled state. When the transferred local worker journal is +present, its transcript remains readable without the original plan database. + ## What does queued behind it mean? — open tasks are waiting on this one `· 2 queued behind it` on a row means two open tasks directly wait for that task diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 3fd9b76bf6..aca49aa0db 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -236,12 +236,14 @@ started. ## Can I still read a task from an earlier run? -Yes. Every run this conversation has made stays on the rail, oldest first. You -can open any task from an earlier run and read its description, notes, steps and -spend. An ended run is there to read, not to steer: a note, pause, resume, cancel, +Yes. Open Tasks to find earlier runs. The sidebar keeps active work and unresolved +decisions; completed runs remain in activity history. You can open an earlier +task to read its saved result and local worker transcript. Description, notes, +steps and spend remain available when the run's plan database is present. +An ended run is there to read, not to steer: a note, pause, resume, cancel, amend or priority on one of its tasks answers `that task's run has ended` and changes nothing. Only the run that is underway takes those. A run that has finished is ended -from that moment, even while it is still the newest run on the rail and nothing has +from that moment, even while it is still the newest run and nothing has started after it: steering one of its tasks answers the same sentence. ## Does a new task pick up a run that did not finish? A new /task starts fresh diff --git a/internal/session/moved_run_history_test.go b/internal/session/moved_run_history_test.go new file mode 100644 index 0000000000..6cd90bf64b --- /dev/null +++ b/internal/session/moved_run_history_test.go @@ -0,0 +1,116 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAMovedRunKeepsItsRosterAndWorkerTranscript(t *testing.T) { + for _, name := range []string{"worker.jsonl", "20261026-120000.000000_worker.jsonl"} { + t.Run(name, func(t *testing.T) { + place := newPlace(t, false) + if err := os.MkdirAll(filepath.Join(place.Dir, ".cell"), 0700); err != nil { + t.Fatal(err) + } + writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Create hello-task.txt", State: TaskDone, PlanTask: "t-1", Model: "z-ai/glm-5.3-flash", Merge: "merged", Changed: []string{"hello-task.txt"}}}}) + dir := filepath.Join(place.NodeJournals(), "1") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + journal := filepath.Join(dir, name) + body := `{"type":"session","version":1,"id":"worker","model":"z-ai/glm-5.3-flash"}` + "\n" + `{"type":"message","role":"assistant","content":"created hello-task.txt"}` + "\n" + if err := os.WriteFile(journal, []byte(body), 0600); err != nil { + t.Fatal(err) + } + trajectory := []byte(`{"kind":"step","step":1,"command":"printf hello","observation":"hello"}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "trajectory.jsonl"), trajectory, 0600); err != nil { + t.Fatal(err) + } + agent := journalAgent(t, place) + if len(agent.PlanTasks()) != 0 { + t.Fatal("fixture unexpectedly contains a plan database") + } + if _, found := agent.PlanTaskPage("t-1"); found { + t.Fatal("a trajectory file unexpectedly recreated the missing plan page") + } + t.Log("trajectory is present; a plan page still requires the plan database") + events, stop := agent.WatchTaskUpdates() + defer stop() + select { + case event := <-events: + if event.Task == nil || event.Task.ID != 1 || event.Task.State != TaskDone || event.Task.Model != "z-ai/glm-5.3-flash" { + t.Fatalf("finished run lost from roster: %+v", event.Task) + } + t.Logf("roster: id=%d state=%s model=%s; plan rows=0", event.Task.ID, event.Task.State, event.Task.Model) + case <-time.After(2 * time.Second): + t.Fatal("finished run missing from roster replay") + } + if got := agent.TaskJournal(1); got != journal { + t.Errorf("TaskJournal(1)=%q, want transferred local worker", got) + } else if record := ReadTranscript(got); len(record.Entries) != 1 || record.Entries[0].Text != "created hello-task.txt" { + t.Errorf("worker room cannot replay its transcript: %+v", record) + } + rows := RebuiltTaskRows(place.Dir) + if len(rows) != 1 || rows[0].TranscriptURI != taskURI(journal) || rows[0].Status != "done" { + t.Errorf("rebuilt activity row lacks worker transcript: %+v", rows) + } + if got := agent.TaskJournal(99); got != "" { + t.Errorf("unknown run exposed another task's journal: %q", got) + } + }) + } +} + +func TestWorkerHistoryPrefersTheNewestAndRefusesRedirects(t *testing.T) { + place := newPlace(t, false) + writeCheckpoint(t, place.Tasks(), taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Runs: []runRecord{{ID: 1, Title: "Create hello-task.txt", State: TaskDone, PlanTask: "t-1"}}}) + dir := filepath.Join(place.NodeJournals(), "1") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + body := []byte(`{"type":"session","version":1,"id":"worker","model":"recorded/model"}` + "\n") + stable := filepath.Join(dir, "worker.jsonl") + newest := filepath.Join(dir, "20261026-120000.000000_worker.jsonl") + for _, path := range []string{stable, newest} { + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + } + agent := journalAgent(t, place) + if got := agent.TaskJournal(1); got != newest { + t.Fatalf("newest worker journal=%q", got) + } + if err := os.Remove(newest); err != nil { + t.Fatal(err) + } + if err := os.Remove(stable); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "worker.jsonl") + if err := os.WriteFile(outside, body, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, stable); err != nil { + t.Fatal(err) + } + if got := agent.TaskJournal(1); got != "" { + t.Fatalf("redirected worker journal=%q", got) + } + if rows := RebuiltTaskRows(place.Dir); len(rows) != 1 || rows[0].TranscriptURI != "" { + t.Fatalf("redirected worker indexed: %+v", rows) + } + if err := os.Remove(stable); err != nil { + t.Fatal(err) + } + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Dir(outside), dir); err != nil { + t.Fatal(err) + } + if got := agent.TaskJournal(1); got != "" { + t.Fatalf("redirected task directory=%q", got) + } +} diff --git a/internal/session/task_run_history.go b/internal/session/task_run_history.go index 79e414fd9e..93b27d5bb8 100644 --- a/internal/session/task_run_history.go +++ b/internal/session/task_run_history.go @@ -41,6 +41,10 @@ func findWorkerHistory(base string, id uint64) localWorkerHistory { return localWorkerHistory{journal: filepath.Join(base, relative, name), model: header.Model} } } + worker := filepath.Join(relative, "worker.jsonl") + if header, ok := readWorkerHeader(base, worker); ok { + return localWorkerHistory{journal: filepath.Join(base, worker), model: header.Model} + } return localWorkerHistory{} } diff --git a/internal/tui3/activity_finished_task_test.go b/internal/tui3/activity_finished_task_test.go new file mode 100644 index 0000000000..03ae048b68 --- /dev/null +++ b/internal/tui3/activity_finished_task_test.go @@ -0,0 +1,60 @@ +package tui3 + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestAFinishedTaskAppearsOnceInActivity(t *testing.T) { + for _, native := range []bool{false, true} { + t.Run(map[bool]string{false: "legacy", true: "native"}[native], func(t *testing.T) { + now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC) + root := filepath.Join(t.TempDir(), "chat-one") + file := filepath.Join(root, "transcript.jsonl") + if native { + file = filepath.Join(root, ".cell", "transcript.jsonl") + } + a := newTestApp(&fakeAgent{}) + a.file, a.title = file, "background task creating hello file" + a.clock = func() time.Time { return now } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 1, Title: "Create hello-task.txt", State: session.TaskDone, + PlanTask: "t-1", EndedAt: now, + }}) + entry := session.TaskIndexEntry{SessionID: "chat-one", ID: "1", Title: "Create hello-task.txt", Label: "Create hello-task.txt", Status: string(session.TaskDone), EndedAt: now} + row := session.SessionRow{ID: "chat-one", Transcript: file, Title: a.title, Project: "ws9", ProjectDir: "/project/ws9", At: now, Tasks: session.TaskRollup{Rows: []session.TaskIndexEntry{entry}, Done: 1}} + world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}} + entry.Status = string(session.TaskRunning) + a.comp.tasks = []session.TaskIndexEntry{entry} + mine := a.taskSheetMine() + mine.plan = []session.PlanTaskRow{{ID: "t-1", Title: entry.Title, Status: "done", Ended: now, Steps: 2, USD: .0013}} + reading := readTasks(world, mine, session.LastDays(now, 14), tasksSort{}, time.Time{}, now) + page := tasksPage(reading, 140) + t.Logf("chats=%d tasks=%d\n%s", len(reading.tree().groups), len(reading.items), page) + if len(reading.tree().groups) != 1 || len(reading.items) != 1 || reading.items[0].status().Word != "done" || strings.Contains(page, "incomplete") { + t.Fatal("one finished task must appear under one chat and say done") + } + }) + } +} + +func TestTheRailKeepsOnlyActiveWork(t *testing.T) { + a := newTestApp(&fakeAgent{}) + a.width, a.height = 120, 40 + a.file = "/chats/chat-one/.cell/transcript.jsonl" + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 1, Title: "Create hello-task.txt", State: session.TaskDone, PlanTask: "t-1", + }}) + view := strings.Join(railText(a, a.viewHeight()), "\n") + if strings.Contains(view, "hello-task") { + t.Fatalf("finished run occupies the active sidebar: %s", view) + } + if len(a.tasks) != 1 || a.tasks[1].state != session.TaskDone { + t.Fatal("finished run was discarded rather than kept as history") + } + t.Log("finished run is replayed and retained; the active sidebar omits it") +} diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index ee0c29382f..6a23275f5b 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -1,7 +1,6 @@ package tui3 import ( - "path/filepath" "strconv" "strings" "time" @@ -531,9 +530,7 @@ func (a *app) taskSheetSelfRow() session.SessionRow { Model: a.model, Workspace: a.workspace, } - if file := row.Transcript; file != "" { - row.ID = filepath.Base(filepath.Dir(file)) - } + row.ID = taskSessionOf(row.Transcript) row.Open, row.Live = true, true row.Presence.State = a.taskSheetSelfState() return row diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 7f4d9e6993..9cb4051399 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -75,6 +75,9 @@ func taskSessionOf(file string) string { if file = strings.TrimSpace(file); file == "" { return "" } + if dir, ok := session.FolderOf(file); ok { + return filepath.Base(dir) + } return filepath.Base(filepath.Dir(file)) }