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 8c65effd40..7db6e79615 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -487,16 +487,22 @@ 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(), - }) + 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{ + ID: id, + Workspace: place.Workspace, + LaunchDir: launchDir, + Owned: owned, + Created: time.Now(), + WorkspacePolicy: policy, + }); err != nil { + return session.Place{}, fmt.Errorf("save new conversation policy: %w", err) + } return place, nil } @@ -830,6 +836,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..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,6 +157,20 @@ func TestAFirstLaunchMintsAFolderThatNamesItself(t *testing.T) { if err != nil { t.Fatal(err) } + 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) } @@ -477,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/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index e883495c66..1cf3f16c23 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.md b/docs/ARCHITECTURE.md index ca67adf983..33528ce3ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,6 +34,10 @@ specific action rather than reading either large package from beginning to end. | Remote sessions and movable chats | [remote](../internal/remote/), [enginehost](../internal/enginehost/), [cell](../internal/cell/) | [Remote architecture](REMOTE.md), [remote testing](testing-anywhere.md) | | Relay coordination across computers and workers | [relay server](../internal/relayserve/), [committed directory](../internal/raftdirectory/), [consensus core](../internal/raftcore/), [hosted relay](../relay/hosted/), [cluster command](../cmd/codeaf/cluster.go) | [Run a relay](relay.md), [coordination and workers](../internal/manual/chat/coordination-and-workers.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 There is no single Markdown file that acts as every kind of memory. Keep these diff --git a/docs/README.md b/docs/README.md index c26f8b91b8..6963fc53b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,8 @@ notes and users' learned chat knowledge are separate; see [documentation ownersh Start with the [current architecture map](ARCHITECTURE.md). It identifies the chat and resident surfaces, entry points, implementation owners and storage roles. +The [workspace storage and relay reference](architecture/workspaces-storage-relay.md) +explains task folders, cooperating layouts, multi-root transfer, and result application. | Category | References | | --- | --- | diff --git a/docs/architecture/workspaces-storage-relay.md b/docs/architecture/workspaces-storage-relay.md new file mode 100644 index 0000000000..eac288c288 --- /dev/null +++ b/docs/architecture/workspaces-storage-relay.md @@ -0,0 +1,164 @@ +# 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. + +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 +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"} + ] + } +} +``` + +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. + +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 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] +``` + +## Cooperate within the admitted layout + +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. + +Wait for cooperating writers and checkers to finish before reviewing results. +Retain their private copies while completion or writer ownership is uncertain. + +## Review and apply each writable root + +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. + +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 +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. + +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. + +## 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. + +```mermaid +flowchart LR + 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] +``` + +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.4](https://github.com/Agent-Field/furrow/releases/tag/v0.3.4), +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 new file mode 100644 index 0000000000..a79bae9684 --- /dev/null +++ b/docs/changes/unreleased/1944-workspace-relay-architecture.md @@ -0,0 +1,51 @@ +--- +kind: added +title: Private task copies behind an experiment switch, and removed computers keep working locally +pr: 1944 +surface: [chat, engine, remote, docs] +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." + - "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 +`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. + +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. + +Apply uses the exact reviewed snapshot, preserves conflicts and unrelated staged or +dirty work, and keeps the private copy. + +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. + +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; +- every cluster member must understand them before a device sends them. diff --git a/docs/relay.md b/docs/relay.md index c09c6f3422..1821d6de32 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -167,3 +167,15 @@ segments such as `/a/relay`. Use that complete address when pairing. Signatures the prefix, and directory operations and computer enrollment keep working there when a joined computer leads. Requests outside the configured prefix return HTTP 404 `not_found`. + +## Architecture and workspace boundaries + +See [workspace storage and relay architecture](architecture/workspaces-storage-relay.md) +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/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 b7351cfdbc..c7d492d831 100644 --- a/internal/cellstore/engine.go +++ b/internal/cellstore/engine.go @@ -12,6 +12,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 @@ -83,6 +84,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) } @@ -111,6 +129,11 @@ func (e Engine) Seal(ctx context.Context, c cell.Cell, info TurnInfo) (Sealed, e if err := e.markOwnFolder(c); err != nil { return Sealed{}, fmt.Errorf("seal: compose: %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_native_test.go b/internal/cellsync/frame_hints_native_test.go new file mode 100644 index 0000000000..d70dfb4851 --- /dev/null +++ b/internal/cellsync/frame_hints_native_test.go @@ -0,0 +1,150 @@ +package cellsync + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + cs "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/raftstore" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +type frameHintSigner struct { + identity identity.Identity + device identity.Dev +} + +func (s frameHintSigner) IdentityKey() ed25519.PublicKey { return s.identity.PublicKey() } +func (s frameHintSigner) Cert() identity.Cert { return s.device.Cert } +func (s frameHintSigner) Sign(raw []byte) []byte { return s.device.Sign(raw) } + +type frameHintReads struct { + blobstore.Store + bounded int +} + +func (s *frameHintReads) GetFrameBounded(ctx context.Context, id string, limit int64) ([]byte, error) { + s.bounded++ + return s.Store.(blobstore.BoundedFrameStore).GetFrameBounded(ctx, id, limit) +} + +func TestFrameHintsWarmAndPlainFetchThroughNativeRaftRelay(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + storage := t.TempDir() + svc := relayserve.New(relayserve.Config{Store: storage}) + defer svc.Close() + server := httptest.NewServer(svc.Handler) + defer server.Close() + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + device, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + sign := reqsign.SignFor(frameHintSigner{identity: id, device: device}, time.Now) + dir := directory.NewHTTP(server.URL, sign, server.Client()) + store := blobstore.NewHTTP(server.URL, sign, server.Client()) + if err := dir.PutDevice(ctx, device.ID(), directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + r := newRig(t) + r.pub.Dir, r.pub.Store, r.pub.WarmFrameHints = dir, store, true + first := r.publishFirst(map[string]string{"a": "first"}) + warmReads, plainReads := &frameHintReads{Store: store}, &frameHintReads{Store: store} + warm, warmCell := fetcherFor(t, warmReads) + plain, plainCell := fetcherFor(t, plainReads) + warm.Dir, plain.Dir = dir, dir + warm.WarmFrameHints = true + if err := warm.Fetch(ctx, warmCell, first); err != nil { + t.Fatal(err) + } + if err := plain.Fetch(ctx, plainCell, first); err != nil { + t.Fatal(err) + } + next := r.seal(map[string]string{"a": "second", "b": "new output"}) + if err := r.pub.Publish(ctx, r.drv, next, r.info()); err != nil { + t.Fatal(err) + } + view, err := dir.Cell(ctx, cellID) + if err != nil || len(view.Cell.FrameHints) == 0 { + t.Fatalf("Raft publish lost hints: %+v, %v", view, err) + } + listing, err := dir.List(ctx) + if err != nil || len(listing.Cells[cellID].FrameHints) != 0 || len(listing.Cells[cellID].Frames) != 0 { + t.Fatalf("listing carried transfer metadata: %+v, %v", listing, err) + } + for _, hint := range view.Cell.FrameHints { + raw, err := store.GetFrame(ctx, hint.ID) + if err != nil || int64(len(raw)) != hint.Bytes { + t.Fatalf("hint does not describe uploaded frame: %v", err) + } + } + if err := warm.Fetch(ctx, warmCell, next); err != nil { + t.Fatal(err) + } + if warmReads.bounded != len(view.Cell.FrameHints) { + t.Fatalf("warm fetch did not prime hints: %d reads", warmReads.bounded) + } + if err := plain.Fetch(ctx, plainCell, next); err != nil { + t.Fatal(err) + } + if plainReads.bounded != 0 { + t.Fatal("plain fetch used optional hints") + } + for _, root := range []string{warmCell.Root, plainCell.Root} { + for name, want := range map[string]string{"a": "second", "b": "new output"} { + got, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || string(got) != want { + t.Fatalf("fetch materialization %s = %q, %v", name, got, err) + } + } + } + var provision struct{ Cluster string } + raw, err := os.ReadFile(filepath.Join(storage, "raft", id.ID(), "provision.json")) + if err != nil { + t.Fatalf("native Raft group was not provisioned: %v", err) + } + if err := json.Unmarshal(raw, &provision); err != nil { + t.Fatal(err) + } + for node := uint64(1); node <= 3; node++ { + anchor, err := raftstore.Open(filepath.Join(storage, "raft", id.ID(), string(rune('0'+node))+".db"), raftstore.Identity{ClusterUUID: provision.Cluster, NodeID: node, Protocol: cs.ClusterProtocol}) + if err != nil { + t.Fatal(err) + } + func() { + defer anchor.Close() + app, _, err := anchor.ApplicationView(ctx) + if err != nil { + t.Fatal(err) + } + key := raftstore.Key{Namespace: raftstore.State, Key: cs.CellKey(cellID).Key} + rows, _, err := anchor.Read(ctx, app.Applied, []raftstore.Key{key}) + if err != nil { + t.Fatal(err) + } + var committed cs.Cell + if err := cs.Decode(rows[key], &committed); err != nil { + t.Fatal(err) + } + if app.Applied <= 1 || committed.Head != next || !reflect.DeepEqual(committed.FrameHints, view.Cell.FrameHints) { + t.Fatalf("anchor %d did not apply hinted head: %+v", node, committed) + } + }() + } +} 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..fb71d8a767 --- /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 does not prove that the application's interface is ready. +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/clusterstate/apply.go b/internal/clusterstate/apply.go index 543218577e..18b1389543 100644 --- a/internal/clusterstate/apply.go +++ b/internal/clusterstate/apply.go @@ -950,6 +950,7 @@ func applyMutation(c Command, now int64, identity IdentityRec, rows map[RecordKe // Visible versions follow the existing projection: renewed expiry within the // same held state and advisory last-seen timestamps do not repaint Home. func visibleCellChange(a, b Cell, now int64) bool { + a.FrameHints, b.FrameHints = nil, nil if a.Lease.Expires > now { a.Lease.Expires = 1 } else { diff --git a/internal/clusterstate/frame_hints_test.go b/internal/clusterstate/frame_hints_test.go new file mode 100644 index 0000000000..cdcb4d93b1 --- /dev/null +++ b/internal/clusterstate/frame_hints_test.go @@ -0,0 +1,129 @@ +package clusterstate_test + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "math" + "reflect" + "strconv" + "strings" + "testing" + + cs "github.com/Agent-Field/codeaf/internal/clusterstate" +) + +func frameHintCommand(t *testing.T, body []byte, now int64) cs.Command { + t.Helper() + proof := cs.V1Request{Method: "POST", URI: "/v1/dir/cells/chat/publish", IdentityKey: actorKey("root").Public().(ed25519.PublicKey), Certificate: joinCert(t, "a"), At: strconv.FormatInt(now, 10), Body: body} + proof.Signature = ed25519.Sign(actorKey("a"), cs.V1SigningMessage(proof.Method, proof.URI, proof.At, body)) + command, err := cs.V1Command("test", now, proof) + if err != nil { + t.Fatal(err) + } + return command +} + +func TestFrameHintsSignedV1PublishAppliesAndKeepsStrictFields(t *testing.T) { + hints := []cs.FrameHint{{ID: strings.Repeat("a", 64), Bytes: 42}} + body, err := json.Marshal(cs.Publish{Fence: 1, OldHead: "old", Head: "new", FrameHints: hints}) + if err != nil { + t.Fatal(err) + } + command := frameHintCommand(t, body, 1000) + a := newApplication(t) + a.put(cs.DeviceKey(command.Actor), cs.Device{V: 1}) + a.put(cs.CellKey("chat"), cs.Created(cs.CellInit{Head: "old"}, command.Actor, 999)) + result := a.evaluate(command).Result + if result.Code != cs.OK || result.View == nil || result.View.Cell.Head != "new" || !reflect.DeepEqual(result.View.Cell.FrameHints, hints) { + t.Fatalf("publish = %+v", result) + } + if got := a.cell(); !reflect.DeepEqual(got.FrameHints, hints) { + t.Fatalf("persisted hints = %v", got.FrameHints) + } + proof := *command.V1 + proof.Body = append(append([]byte(nil), body[:len(body)-1]...), []byte(`,"unknown":true}`)...) + proof.Signature = ed25519.Sign(actorKey("a"), cs.V1SigningMessage(proof.Method, proof.URI, proof.At, proof.Body)) + if _, err := cs.V1Command("test", 1000, proof); !errors.Is(err, cs.ErrCodec) { + t.Fatalf("unknown field = %v", err) + } + clear := []byte(`{"fence":1,"old_head":"new","head":"last"}`) + result = a.evaluate(frameHintCommand(t, clear, 1001)).Result + if result.Code != cs.OK || result.View.Cell.Head != "last" || len(result.View.Cell.FrameHints) != 0 { + t.Fatalf("absent hints kept stale metadata: %+v", result) + } +} + +func TestFrameHintsInvalidSetsAreOmittedWhilePublishMovesHead(t *testing.T) { + one := cs.FrameHint{ID: strings.Repeat("a", 64), Bytes: 42} + for name, hints := range map[string][]cs.FrameHint{ + "too many": {one, {ID: strings.Repeat("b", 64), Bytes: 1}, {ID: strings.Repeat("c", 64), Bytes: 1}}, + "non hex": {{ID: strings.Repeat("g", 64), Bytes: 1}}, + "duplicate": {one, one}, + "unsafe integer bytes": {{ID: one.ID, Bytes: math.MaxInt64}}, + "over budget": {{ID: one.ID, Bytes: cs.FrameHintBudget}}, + "probe exceeds budget": {{ID: one.ID, Bytes: cs.FrameHintBudget - 2}, {ID: strings.Repeat("b", 64), Bytes: 1}}, + "zero bytes": {{ID: one.ID, Bytes: 0}}, + "negative bytes": {{ID: one.ID, Bytes: -1}}, + } { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(cs.Publish{Fence: 1, OldHead: "old", Head: "new", FrameHints: hints}) + if err != nil { + t.Fatal(err) + } + command := frameHintCommand(t, body, 1000) + a := newApplication(t) + a.put(cs.DeviceKey(command.Actor), cs.Device{V: 1}) + a.put(cs.CellKey("chat"), cs.Created(cs.CellInit{Head: "old", FrameHints: []cs.FrameHint{one}}, command.Actor, 999)) + result := a.evaluate(command).Result + if result.Code != cs.OK || result.View == nil || result.View.Cell.Head != "new" || len(result.View.Cell.FrameHints) != 0 || len(a.cell().FrameHints) != 0 { + t.Fatalf("invalid metadata refused head or survived: %+v", result) + } + }) + } +} + +func TestFrameHintsCommandAndRowRoundTrip(t *testing.T) { + hints := []cs.FrameHint{{ID: strings.Repeat("a", 64), Bytes: cs.FrameHintBudget - 1}} + for _, change := range []cs.Mutation{ + {Create: &cs.CreateCell{Cell: "chat", Init: cs.CellInit{Head: "new", FrameHints: hints}}}, + {Publish: &cs.PublishCell{Cell: "chat", Publish: cs.Publish{Head: "new", FrameHints: hints}}}, + } { + command := cs.Command{Schema: cs.SchemaVersion, Cluster: "test", Actor: "a", Session: "s", Sequence: 1, RequestDigest: "digest", Change: change} + raw, err := cs.EncodeCommand(command) + if err != nil { + t.Fatal(err) + } + got, err := cs.DecodeCommand(raw) + if err != nil || !reflect.DeepEqual(command, got) { + t.Fatalf("command round trip = %+v, %v", got, err) + } + } + source := cs.Created(cs.CellInit{Head: "new", FrameHints: hints}, "a", 1000) + hints[0].Bytes = 1 + if source.FrameHints[0].Bytes != cs.FrameHintBudget-1 { + t.Fatal("create aliased hint input") + } + row := cs.Row{Schema: cs.SchemaVersion, Key: "chat", Cell: &source} + raw, err := cs.EncodeRow(row) + if err != nil { + t.Fatal(err) + } + got, err := cs.DecodeRow(raw) + if err != nil || !reflect.DeepEqual(row, got) { + t.Fatalf("row round trip = %+v, %v", got, err) + } + got.Cell.FrameHints[0].Bytes = 2 + if source.FrameHints[0].Bytes != cs.FrameHintBudget-1 { + t.Fatal("decoded row aliased source") + } +} + +func TestFrameHintsAloneDoNotMoveVisibleVersion(t *testing.T) { + a := newApplication(t) + first := a.run("a", 1000, cs.Mutation{Create: &cs.CreateCell{Cell: "chat", Init: cs.CellInit{Head: "head"}}}) + next := a.run("a", 1000, cs.Mutation{Publish: &cs.PublishCell{Cell: "chat", Publish: cs.Publish{Fence: 1, OldHead: "head", Head: "head", FrameHints: []cs.FrameHint{{ID: strings.Repeat("a", 64), Bytes: 42}}}}}) + if next.Code != cs.OK || next.Version != first.Version || len(a.cell().FrameHints) != 1 { + t.Fatalf("advisory hints changed listing version: first=%+v next=%+v", first, next) + } +} diff --git a/internal/clusterstate/records.go b/internal/clusterstate/records.go index 6cccec31b1..7c3a01c712 100644 --- a/internal/clusterstate/records.go +++ b/internal/clusterstate/records.go @@ -58,8 +58,21 @@ 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 is optional and absent by default; every cluster member must understand it before a device sends it. + 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 @@ -93,6 +106,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"` @@ -111,14 +125,15 @@ 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 } // Rotation is what an identity's record says about its replacement. diff --git a/internal/clusterstate/rules.go b/internal/clusterstate/rules.go index d3e3163a22..885d11a245 100644 --- a/internal/clusterstate/rules.go +++ b/internal/clusterstate/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}, } } @@ -141,3 +142,31 @@ func PlatformOf(p string) string { return "other" } } + +// 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/clusterstate/testdata/canonical.json b/internal/clusterstate/testdata/canonical.json index cf1e8b8b07..5c27cf023f 100644 --- a/internal/clusterstate/testdata/canonical.json +++ b/internal/clusterstate/testdata/canonical.json @@ -23,5 +23,10 @@ "name": "R13 rotated identity", "kind": "row", "canonical": "{\"identity\":{\"V\":1,\"identity\":\"id\",\"rotation\":{\"at\":1699999999000,\"by\":\"dev_a\",\"retire_at\":1700000090000,\"state\":\"retired\"},\"vault\":\"vault\"},\"key\":\"id\",\"schema\":1}" + }, + { + "name": "Optional frame hints on a cell row", + "kind": "row", + "canonical": "{\"cell\":{\"V\":1,\"archived\":true,\"class\":\"host-bound\",\"durable_at\":1700000000000,\"frame_hints\":[{\"bytes\":42,\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"frames\":[\"f1\",\"f2\"],\"head\":\"head\",\"keys\":{\"key\":{\"10\":\"ten\",\"2\":\"two\",\"fence\":\"9007199254740993\",\"id\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==\",\"x\\u003c\\u003e\\u0026\\u2028\\u2029\":\"escaped\",\"\":\"bmp\",\"𐀀\":\"astral\"}},\"lease\":{\"device\":\"dev_a\",\"expires\":1700000090000,\"fence\":\"9007199254740991\",\"pending\":4294967295,\"released_at\":1699999999000},\"orphan_turns\":3,\"parent_cell\":\"parent\",\"size\":\"18446744073709551615\",\"title\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==\"},\"key\":\"chat\",\"schema\":1}" } ] diff --git a/internal/clusterstate/v1.go b/internal/clusterstate/v1.go index e15d05ef4b..38a033c43e 100644 --- a/internal/clusterstate/v1.go +++ b/internal/clusterstate/v1.go @@ -383,19 +383,26 @@ func (p V1Request) route() (string, error) { // V1MutationEqual keeps route and actor binding while treating absent, null // and empty frame plans as the same instruction to clear the previous hint. +// Invalid optional hints are omitted before canonical encoding so saving still succeeds. func V1MutationEqual(a, b Mutation) bool { return reflect.DeepEqual(normalizeV1Mutation(a), normalizeV1Mutation(b)) } func normalizeV1Mutation(m Mutation) Mutation { - if m.Create != nil && m.Create.Init.Frames != nil && len(m.Create.Init.Frames) == 0 { + if m.Create != nil && (m.Create.Init.FrameHints != nil || m.Create.Init.Frames != nil && len(m.Create.Init.Frames) == 0) { v := *m.Create - v.Init.Frames = nil + if len(v.Init.Frames) == 0 { + v.Init.Frames = nil + } + v.Init.FrameHints = boundedFrameHints(v.Init.FrameHints) m.Create = &v } - if m.Publish != nil && m.Publish.Publish.Frames != nil && len(m.Publish.Publish.Frames) == 0 { + if m.Publish != nil && (m.Publish.Publish.FrameHints != nil || m.Publish.Publish.Frames != nil && len(m.Publish.Publish.Frames) == 0) { v := *m.Publish - v.Publish.Frames = nil + if len(v.Publish.Frames) == 0 { + v.Publish.Frames = nil + } + v.Publish.FrameHints = boundedFrameHints(v.Publish.FrameHints) m.Publish = &v } return m diff --git a/internal/config/settings.go b/internal/config/settings.go index 2e8143d3f5..f0476c1abf 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1041,6 +1041,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. diff --git a/internal/directory/feed.go b/internal/directory/feed.go index 0bfef460a0..b36e9dd786 100644 --- a/internal/directory/feed.go +++ b/internal/directory/feed.go @@ -406,6 +406,7 @@ func fingerprint(v any, now int64) []byte { return fingerprint(*r, now) } case Cell: + r.FrameHints = nil if r.Lease.Expires > now { r.Lease.Expires = 1 } else { 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 7727c328e6..a87fcb115f 100644 --- a/internal/directory/records.go +++ b/internal/directory/records.go @@ -41,6 +41,11 @@ type IdentityRec = clusterstate.IdentityRec type Caps = clusterstate.Caps type Device = clusterstate.Device type Lease = clusterstate.Lease +type FrameHint = clusterstate.FrameHint + +const MaxFrameHints = clusterstate.MaxFrameHints +const FrameHintBudget = clusterstate.FrameHintBudget + type Cell = clusterstate.Cell type Listing = clusterstate.Listing type CellView = clusterstate.CellView @@ -53,6 +58,7 @@ type Publish = clusterstate.Publish func withoutFrames(l Listing) Listing { for id, c := range l.Cells { c.Frames = nil + c.FrameHints = nil l.Cells[id] = c } return l diff --git a/internal/e2e/pairjourney_e2e_test.go b/internal/e2e/pairjourney_e2e_test.go index b7a1101024..2bdf9aa1c5 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..abe92b44b4 --- /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 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) + } + } + 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 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 { + 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 39841de21a..d1c0fdfa4c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1028,12 +1028,18 @@ 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. +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. + 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/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/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 326b8f4e13..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. @@ -3390,6 +3394,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/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md index dd851248c3..e6f75d87c8 100644 --- a/internal/manual/chat/relay-what-it-stores-and-sees.md +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -36,3 +36,16 @@ No. Turns, tool output and files are sealed on your computer before they are sen Your saved keys are sealed too, as said above. Continuing a chat on another computer brings its saved workspace files with its conversation, tool output and task journals. Rebuildable install folders and withheld files are listed on the arrival card. Secret files travel separately in the sealed vault between paired devices. See *Continuing a chat that one of your machines holds* and "What comes with a continued chat" on *Home*. It does not keep two arbitrary project folders mirrored while you edit them. + +## Optional warm-transfer frame hints and relay compatibility + +The warm-transfer prototype can attach up to two encrypted chunk ids and their +encoded byte sizes to a saved chat head. The total, including one extra read byte +per chunk, is at most 64 KiB. These are readable transfer hints, never proof that +all content arrived. Invalid hints are ignored; saving the chat still succeeds. +They appear on a single-chat read and stay off Home's listing. Each publish +replaces them, and a publish without hints clears the previous hints. + +The prototype is off by default. Every member of the relay cluster must run a +build that understands these fields before any computer sends them. Older members +refuse unknown fields; existing requests omit the fields and keep their encoding. diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index f71be2c054..512e31c75c 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -226,3 +226,80 @@ 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 + +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. + +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 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. +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. + +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. + +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. + +## 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 +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/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 b372d9b31a..aca49aa0db 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -1,5 +1,58 @@ # The worker harness +## Private task workspaces and saved workspace identities + +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. +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. + +## 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 +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 @@ -183,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 @@ -1043,3 +1098,58 @@ 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 + +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 +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. + +## 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 +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. + +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 +checkpoint writes; it does not change legacy task placement or rewrite history. 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/raftabi/hosted.go b/internal/raftabi/hosted.go index 4fe58527d7..9902a5ee36 100644 --- a/internal/raftabi/hosted.go +++ b/internal/raftabi/hosted.go @@ -72,7 +72,8 @@ func hostedProjectionBytes(d *decoder) ([]byte, error) { return nil, err } if kind == "list_cell" { - value.(*cs.Cell).Frames = nil + cell := value.(*cs.Cell) + cell.Frames, cell.FrameHints = nil, nil } if kind == "view" { value = value.(*cs.Result).View diff --git a/internal/raftdirectory/display.go b/internal/raftdirectory/display.go index be084a1932..28eb17673b 100644 --- a/internal/raftdirectory/display.go +++ b/internal/raftdirectory/display.go @@ -51,6 +51,7 @@ func DisplayListing(ctx context.Context, s *raftstore.SQLite, app raftstore.Appl if e = clusterstate.Decode(r.Value, &v); e != nil { return l, e } + v.Frames, v.FrameHints = nil, nil l.Cells[string(id)] = v } } diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 79dd2790f1..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: + case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodCompact, MethodOpenPlanTaskWork: return classWork default: return classOrdered diff --git a/internal/remote/client.go b/internal/remote/client.go index c67fd6e908..a72998cfbf 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 @@ -2263,6 +2264,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 254edbea30..b7381c4884 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1268,9 +1268,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 @@ -1714,8 +1715,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 @@ -2101,13 +2103,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 { @@ -2979,6 +2987,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/taskworkspace.go b/internal/remote/taskworkspace.go new file mode 100644 index 0000000000..735a776a98 --- /dev/null +++ b/internal/remote/taskworkspace.go @@ -0,0 +1,146 @@ +package remote + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "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) +} +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(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, taskWorkspacePatience) + if !answered { + // Cancellation travels independently of the operation and its ordered UI + // gesture. After durable commit, the engine finishes recording its actual receipt. + 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, patience time.Duration) *taskWorkspacePending { + deadline := args.Deadline + limit := time.Now().Add(patience) + 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(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") + } + patience := taskWorkspacePatience + if method == MethodTaskWorkspaceCancel { + patience = callDeadline + } + pending := s.taskWorkspacePending(args, patience) + 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..f15947ccdf --- /dev/null +++ b/internal/remote/taskworkspace_test.go @@ -0,0 +1,168 @@ +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") + } +} + +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/remote/wire.go b/internal/remote/wire.go index 7a9881dd1a..82422942ea 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 @@ -1188,8 +1189,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/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 3c7efa05a5..54ef9324c0 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -115,6 +115,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 } @@ -133,6 +136,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 f9ff0cdd2d..301e18a42d 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3612,6 +3612,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/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 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/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/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_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/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/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") + } } }) } 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 35daffdd76..1ba176d278 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1237,6 +1237,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_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_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_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..fae856563a 100644 --- a/internal/session/task_journal_test.go +++ b/internal/session/task_journal_test.go @@ -9,7 +9,9 @@ package session import ( "os" "path/filepath" + "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/home" ) @@ -186,3 +188,137 @@ 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("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) { + 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) + } + } + }) + } +} + +// 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_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_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.go b/internal/session/task_run.go index c707d621b7..3e6fc11595 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,10 @@ func taskLog(listed *job) io.Writer { // taskTree is where one node works and how its work comes home. type taskTree struct { + 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 // when there is no repository. dir string @@ -8196,8 +8332,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 +8540,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 +8585,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 +8614,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 +8641,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 +8657,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 { @@ -9454,10 +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; every other spelling of a git call in this package is a wrapper of it. +// 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. // @@ -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..57f1c0bc7d 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,14 @@ 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"` + 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 +91,8 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { return nil } return &TaskCopyRecord{ + 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, @@ -136,7 +147,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 +165,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 +194,15 @@ 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{ + portableBaselineDigest: record.PortableBaselineDigest, + 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 +213,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_history.go b/internal/session/task_run_history.go new file mode 100644 index 0000000000..93b27d5bb8 --- /dev/null +++ b/internal/session/task_run_history.go @@ -0,0 +1,110 @@ +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} + } + } + 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{} +} + +// 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 +} + +// 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 +} + +// 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_run_history_other.go b/internal/session/task_run_history_other.go new file mode 100644 index 0000000000..ef9a6a72ab --- /dev/null +++ b/internal/session/task_run_history_other.go @@ -0,0 +1,68 @@ +//go:build !linux && !darwin + +package session + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "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() + 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 := target.OpenFile(name, 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 +} + +// 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 +} 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 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..7469c7a884 --- /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 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) { + 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 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") + } + 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..99ecafc4d7 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" @@ -82,12 +85,14 @@ 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 + // 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. 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 { @@ -1758,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()) @@ -2186,30 +2229,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 +2515,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..4467392e9d 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. @@ -108,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) } @@ -118,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) } @@ -142,6 +154,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/task_workspace_apply.go b/internal/session/task_workspace_apply.go new file mode 100644 index 0000000000..45a0109f21 --- /dev/null +++ b/internal/session/task_workspace_apply.go @@ -0,0 +1,923 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "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 +// 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 +} + +// 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 err := a.taskWorkspaceDoor(ctx); 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 := priorTaskWorkspaceReview(id, use, tree) + if priorErr != nil { + return TaskWorkspaceReview{}, priorErr + } + stage, ticket, err := stageTaskWorkspaceReview(ctx, id, use, tree) + if err != nil { + return TaskWorkspaceReview{}, err + } + published := false + defer func() { + if !published { + cleanupTaskWorkspaceReview(ticket) + } + }() + 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(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 prior.Snapshot != "" { + 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 err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".review-") + if err != nil { + return 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 err + } + return 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 + } + tree, err := adoptTaskWorkspaceRoot(use, a.config.Place) + if 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 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 +} + +// 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 err := a.taskWorkspaceDoor(ctx); err != nil { + return "", err + } + 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 err := taskWorkspaceApplicable(id, use); err != nil { + return "", err + } + 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 := reviewTaskWorkspacePortableRoot(ctx, use, originalPortableReview{tree: tree, ticket: ticket, stage: 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 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 { + 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 + } + 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 { + 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) + } + 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() + } + if taskWorkspaceNodesBusy(g, tree.dir) { + return blocked() + } + 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() + } + 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)) { + 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 + Portable taskcopy.PortableState + DeletedDirectories []string +} + +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 + } + if err := checkTaskWorkspaceWithheldOutputs(original); err != nil { + return fail(err) + } + 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{ID: place.ID(), 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 = "" + 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) + } + snapshot = bound.commit + baseline, baselineDigest, destination := bound.baseline, bound.baselineDigest, bound.destination + ready := false + defer func() { + if !ready { + _, _ = git(repository, "worktree", "remove", "--force", stage.dir) + _, _ = git(repository, "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, 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 = registerTaskWorkspaceReviewStage(stage, place) + if err != nil { + return fail(err) + } + 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 := 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") + } + if err := json.Unmarshal(raw, &ticket); err != nil { + return ticket, taskTree{}, err + } + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + 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 + } + 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, 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[:]) +} + +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 errors.New("the reviewed staging identity or destination changed; review this root again") + } + 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, portableDigest, original.portableBaselineDigest) { + 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 errors.New("the reviewed comparison baseline is missing") + } + sum := sha256.Sum256(baseline) + if hex.EncodeToString(sum[:]) != ticket.BaselineDigest { + 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 errors.New("the reviewed snapshot changed; review this root again") + } + dirty, err := git(stage.dir, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return 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 errors.New("the reviewed staging copy changed; review this root again") + } + } + return nil +} + +type taskWorkspaceSnapshot struct { + commit, baselineDigest, destination string + baseline []byte +} + +func bindTaskWorkspaceSnapshot(ctx context.Context, use WorkspaceRootUse, original taskTree, snapshot, portableDigest, repository 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[:]) + } + destination := original.ground + if original.mode != TaskModeMirror { + destination = original.root + } + 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, 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 + } + snapshot = strings.TrimSpace(commit) + return taskWorkspaceSnapshot{commit: snapshot, baseline: baseline, baselineDigest: baselineDigest, destination: destination}, nil +} + +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 +} + +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 new file mode 100644 index 0000000000..ae040353e5 --- /dev/null +++ b/internal/session/task_workspace_apply_test.go @@ -0,0 +1,658 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +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") + } + // 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) + } + 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() + 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") + 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") + } + if setup != nil { + 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) + 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} + 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) + 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") + } + _, 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") + } +} + +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_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..6ae0c3804b --- /dev/null +++ b/internal/session/task_workspace_fs_other.go @@ -0,0 +1,38 @@ +//go:build !linux && !darwin + +package session + +import ( + "errors" + "os" +) + +const workspaceApplicationSupported = false + +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..2a157172a6 --- /dev/null +++ b/internal/session/task_workspace_fs_unix.go @@ -0,0 +1,177 @@ +//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" +) + +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) { + 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..051dc44bee --- /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 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") + 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..d3b53fa8ec --- /dev/null +++ b/internal/session/task_workspace_transaction.go @@ -0,0 +1,801 @@ +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, 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 + } + if err := workspaceApplicationInputs(destination, identity, baseline, reviewed); err != nil { + return taskWorkspaceApplyReceipt{}, err + } + root, err := workspaceApplicationDirectory(identity, destination) + 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 { + return taskWorkspaceApplyReceipt{}, err + } + 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.landingSafetySentence(false); reason != "" { + return taskWorkspaceApplyReceipt{}, errors.New(reason) + } + } + 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 { + 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) +} + +// 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 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 func() {}, err + } + if err := filelock.Lock(f, true, true); err != nil { + f.Close() + return func() {}, 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..ca8f7f3425 --- /dev/null +++ b/internal/session/task_workspace_transaction_test.go @@ -0,0 +1,450 @@ +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 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") + 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/taskoutside.go b/internal/session/taskoutside.go index d38d8fbcb4..e977318ce9 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. +// A combined stream redirection also accepts a filename; a numeric target duplicates a descriptor. +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 3f6ad6ce67..86926e11cd 100644 --- a/internal/session/tools_anchor_workspace.go +++ b/internal/session/tools_anchor_workspace.go @@ -148,7 +148,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/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) } 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..31073263b4 --- /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, WorkspacePolicy: DefaultWorkspacePolicy()}); 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_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_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..1943ef2434 --- /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 requires an explicit experiment switch, +// an absolute profile, and support for applying the retained output. +func WorkspaceOwnershipPrototypeEnabled() bool { + return workspaceApplicationSupported && env.Get("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP") == "1" && filepath.IsAbs(strings.TrimSpace(env.Get(home.EnvVar))) +} + +// 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. +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 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 +// 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..3078b022a1 --- /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 TestOwnershipExperimentKeepsOrdinaryAndExplicitRootsPrivate(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 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..760104c077 --- /dev/null +++ b/internal/session/workspace_policy.go @@ -0,0 +1,44 @@ +package session + +import "fmt" + +// WorkspacePolicy records a conversation's task placement contract. Its absence +// preserves legacy placement; experimental conversations explicitly select 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 { + 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 { + 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..7f70cafe3f --- /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}, + {"identity without policy", nil, "codeaf-workspace://test/chat", false}, + } { + 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..de4bc3c50b --- /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 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"` + 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..21dac3cead --- /dev/null +++ b/internal/session/workspace_set_prepare.go @@ -0,0 +1,561 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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 { + 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, "") + } + 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, err = openPortableWorkspaceFamilyTree(tree, inputBaseline) + if err != nil { + return primary, preserveFailedWorkspaceCreation(tree, err) + } + // 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 { + 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, err = finishWorkspaceRootInput(use.Source, tree, inputBaseline) + if err != nil { + return primary, preserveFailedWorkspaceCreation(tree, 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..8f7e8a78e0 --- /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 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/continue_ownership_test.go b/internal/syncsetup/continue_ownership_test.go index 20928c8439..8b8f2f5e57 100644 --- a/internal/syncsetup/continue_ownership_test.go +++ b/internal/syncsetup/continue_ownership_test.go @@ -19,80 +19,93 @@ import ( // continuing never releases this computer's own hold, and that a different holder // is refused before any local session is retired. func TestContinueChecksOwnershipBeforeRetiring(t *testing.T) { - for _, own := range []bool{true, false} { - t.Run(map[bool]string{true: "already held here", false: "different holder won"}[own], func(t *testing.T) { - var releases, acquires atomic.Int32 - h := newTwoHomes(t, func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/release") { - releases.Add(1) - } - if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { - acquires.Add(1) - } - next.ServeHTTP(w, r) + for _, roots := range []bool{false, true} { + for _, own := range []bool{true, false} { + t.Run(map[bool]string{false: "ordinary/", true: "workspace roots/"}[roots]+map[bool]string{true: "already held here", false: "different holder won"}[own], func(t *testing.T) { + var releases, acquires atomic.Int32 + h := newTwoHomes(t, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/release") { + releases.Add(1) + } + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { + acquires.Add(1) + } + next.ServeHTTP(w, r) + }) }) - }) - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - a := h.openA() - a.mustSay("saved before continuing") - h.durable(h.cell.ID, h.cell) - view, err := h.a.Dir.Cell(ctx, h.cell.ID) - mustNil(t, err) - seen := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: view.Cell.Lease.Fence - 1} - if !own { - seen = &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence} - _, err = h.b.Dir.Acquire(ctx, h.cell.ID, directory.AcquireOpts{Force: true, Expect: seen, RequireConditional: true}) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + a := h.openA() + a.mustSay("saved before continuing") + h.durable(h.cell.ID, h.cell) + view, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + seen := &directory.LeaseExpectation{Device: h.b.Device.ID(), Fence: view.Cell.Lease.Fence - 1} + if !own { + seen = &directory.LeaseExpectation{Device: view.Cell.Lease.Device, Fence: view.Cell.Lease.Fence} + _, err = h.b.Dir.Acquire(ctx, h.cell.ID, directory.AcquireOpts{Force: true, Expect: seen, RequireConditional: true}) + mustNil(t, err) + } + before, err := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, err) + journal, err := os.Open(transcriptOf(h.cell)) mustNil(t, err) - } - before, err := h.a.Dir.Cell(ctx, h.cell.ID) - mustNil(t, err) - journal, err := os.Open(transcriptOf(h.cell)) - mustNil(t, err) - defer journal.Close() - mustNil(t, filelock.Lock(journal, true, true)) - defer filelock.Unlock(journal) - var retired atomic.Bool - stop, done := make(chan struct{}), make(chan struct{}) - go func() { - defer close(done) - beat := time.NewTicker(time.Millisecond) - defer beat.Stop() - for { - select { - case <-stop: - return - case <-beat.C: - if _, err := os.Stat(session.TakeoverPath(h.cell.Root)); err == nil { - retired.Store(true) - _ = a.drive.Close(ctx) - _ = filelock.Unlock(journal) + defer journal.Close() + mustNil(t, filelock.Lock(journal, true, true)) + defer filelock.Unlock(journal) + var retired atomic.Bool + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + beat := time.NewTicker(time.Millisecond) + defer beat.Stop() + for { + select { + case <-stop: return + case <-beat.C: + if _, err := os.Stat(session.TakeoverPath(h.cell.Root)); err == nil { + retired.Store(true) + _ = a.drive.Close(ctx) + _ = filelock.Unlock(journal) + return + } } } + }() + defer func() { close(stop); <-done }() + r0, a0 := releases.Load(), acquires.Load() + var got Continued + if roots { + got, err = h.continuerA().TakeSeenWithWorkspaceRoots(ctx, h.cell.ID, seen, map[string]string{"root": t.TempDir()}) + } else { + got, err = h.continuerA().TakeSeen(ctx, h.cell.ID, seen) } - }() - defer func() { close(stop); <-done }() - r0, a0 := releases.Load(), acquires.Load() - got, err := h.continuerA().TakeSeen(ctx, h.cell.ID, seen) - if own && err != nil { - t.Errorf("already held here refused: %v", err) - } - if !own && !errors.Is(err, directory.ErrLeaseHeld) { - t.Errorf("different holder was not refused: %v", err) - } - after, readErr := h.a.Dir.Cell(ctx, h.cell.ID) - mustNil(t, readErr) - if retired.Load() || releases.Load() != r0 || acquires.Load() != a0 || !session.InUse(transcriptOf(h.cell)) { - t.Errorf("continuation retired the session or changed ownership: retired=%v releases=%d acquires=%d inUse=%v", retired.Load(), releases.Load()-r0, acquires.Load()-a0, session.InUse(transcriptOf(h.cell))) - } - if after.Cell.Lease != before.Cell.Lease { - t.Errorf("lease changed: before=%+v after=%+v", before.Cell.Lease, after.Cell.Lease) - } - if own && (got.Taken.Driving.Fence != before.Cell.Lease.Fence || got.Taken.Cell.Root != h.cell.Root) { - t.Errorf("no-op lost the local chat: %+v", got) - } - }) + if own && err != nil { + t.Errorf("already held here refused: %v", err) + } + if !own && !errors.Is(err, directory.ErrLeaseHeld) { + t.Errorf("different holder was not refused: %v", err) + } + after, readErr := h.a.Dir.Cell(ctx, h.cell.ID) + mustNil(t, readErr) + if retired.Load() || releases.Load() != r0 || acquires.Load() != a0 || !session.InUse(transcriptOf(h.cell)) { + t.Errorf("continuation retired the session or changed ownership: retired=%v releases=%d acquires=%d inUse=%v", retired.Load(), releases.Load()-r0, acquires.Load()-a0, session.InUse(transcriptOf(h.cell))) + } + if after.Cell.Lease != before.Cell.Lease { + t.Errorf("lease changed: before=%+v after=%+v", before.Cell.Lease, after.Cell.Lease) + } + if own { + cell, fence, readErr := h.a.SendLease(h.cell.ID) + if readErr != nil || cell != h.cell.ID || fence != before.Cell.Lease.Fence { + t.Errorf("successful take did not record send lease: %s %d %v", cell, fence, readErr) + } + } + if own && (got.Taken.Driving.Fence != before.Cell.Lease.Fence || got.Taken.Cell.Root != h.cell.Root) { + t.Errorf("no-op lost the local chat: %+v", got) + } + }) + } } } 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 461a7579ef..ebc91f3155 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 @@ -111,6 +116,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) { @@ -141,6 +160,12 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err != nil { return Continued{}, err } + // This computer held the chat before the take began, possibly for a session + // that is running now, so a failed record keeps that hold rather than + // releasing a lease this call never acquired. + if err := c.sync.recordSendLease(opened.ID, sendLease{Cell: id, Fence: lease.Fence}); err != nil { + return Continued{}, err + } return Continued{AlreadyHere: true, Device: c.opt.DeviceName, Taken: handoff.Taken{ Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames}, }}, nil @@ -155,7 +180,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(ctx, 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. @@ -178,6 +223,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{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } @@ -194,10 +256,22 @@ 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 + 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) + } if err := c.sync.recordSendLease(taken.Cell.ID, sendLease{Cell: id, Fence: taken.Driving.Fence}); err != nil { return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, taken.Driving.Fence)) } - out := Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: c.restoreCopies(taken.Cell), Resume: c.arrive(taken.Cell, from)} + 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) } @@ -259,10 +333,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..57ed25a897 --- /dev/null +++ b/internal/syncsetup/workspace_transfer.go @@ -0,0 +1,96 @@ +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 + } + // 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") + } + 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..4938b7a0b6 --- /dev/null +++ b/internal/syncsetup/workspace_transfer_test.go @@ -0,0 +1,254 @@ +package syncsetup + +import ( + "bytes" + "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) { + 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) + 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{} + 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) + 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) + } + 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) + 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(context.Background(), 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) + } + leaseCell, fence, err := h.b.SendLease(got.Taken.Cell.ID) + if err != nil || leaseCell != h.cell.ID || fence != got.Taken.Driving.Fence { + t.Fatalf("workspace take did not admit sending at its fence: %s %d %v", leaseCell, fence, 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) + } + 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/baseline.go b/internal/taskcopy/baseline.go new file mode 100644 index 0000000000..946d7ba5e4 --- /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 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")) + 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..bc9f5d4259 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 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 { + 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 @@ -91,28 +119,55 @@ func carry(tree, dest string) 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 } 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 + } + baseline, err := readWorkspaceFile(filepath.Join(tree, PortableBaselinePath), WorkspaceCheckpointLimit) + if errors.Is(err, os.ErrNotExist) { + baseline = nil + err = nil + } + if 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}) + 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 // 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 } @@ -143,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:]) } } @@ -213,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 == "" || portableNativeMetadataPath(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/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/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 a74d38a274..0d86319e57 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", 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 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") + } 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..1a421713a3 --- /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 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) + 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 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 + } + 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 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() { + 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..ebc5379033 100644 --- a/internal/taskcopy/record.go +++ b/internal/taskcopy/record.go @@ -31,11 +31,15 @@ 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"` + 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"` + 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..a5dcf5e6f6 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,8 +86,13 @@ 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)) + 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 @@ -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 } @@ -189,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 { @@ -203,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 new file mode 100644 index 0000000000..57810b6a0c --- /dev/null +++ b/internal/taskcopy/results.go @@ -0,0 +1,178 @@ +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. 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) +} + +// 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 + } + } + 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) + } 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()&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) + } + 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..bdc0967ad3 --- /dev/null +++ b/internal/taskcopy/results_test.go @@ -0,0 +1,94 @@ +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) + } + } +} + +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..1ea08479a6 --- /dev/null +++ b/internal/taskcopy/review_snapshot.go @@ -0,0 +1,868 @@ +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 repository 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 + if err := portableNativeMetadataDirectory(root); err != nil { + return PortableState{}, err + } + 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 repository 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) || portableNativeMetadataPath(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 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") + } + 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 + 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) || portableNativeMetadataPath(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 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") + } + 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) +} + +// 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 new file mode 100644 index 0000000000..56fc429e5c --- /dev/null +++ b/internal/taskcopy/review_snapshot_test.go @@ -0,0 +1,467 @@ +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) + } +} + +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/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..300656af4d --- /dev/null +++ b/internal/taskcopy/workspace_carrier.go @@ -0,0 +1,407 @@ +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) + } + 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 + } + 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. 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)) +} + +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) { + 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 + } + tracked, err := portableInspectionGit(indexRoot, "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()) || portableNativeMetadataPath(rel) { + 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] && !wholeFamily { + 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..58ceaab074 --- /dev/null +++ b/internal/taskcopy/workspace_restore.go @@ -0,0 +1,1290 @@ +package taskcopy + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + + "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 = restorePortableBaseline(destination, rec); 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 = verifyRestoredPortableBaseline(destination, rec); 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 := 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") + } + 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)+portableLinkCount(rec) > workspacePathLimit { + return errors.New("workspace path metadata exceeds its bound") + } + check := func(rel string) error { + 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 + } + 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. Mutable repository administration files +// are excluded; the recorded HEAD is checked independently. +func workspaceTreeDigest(root string) (string, error) { + hash := sha256.New() + 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) + 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 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) + 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 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) + 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, filepath.Dir(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) { + 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)+portableLinkCount(rec) > 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 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, "/") { + 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 { + 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 { + 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()) || portableNativeMetadataPath(filepath.ToSlash(rel)) { + 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()) || portableNativeMetadataPath(filepath.ToSlash(rel)) { + 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 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) { + 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 +} + +func portableLinkCount(rec record) int { + if rec.Symlinks == nil { + return 0 + } + return len(rec.Symlinks.Entries) +} diff --git a/internal/taskcopy/workspace_restore_test.go b/internal/taskcopy/workspace_restore_test.go new file mode 100644 index 0000000000..b53ac7ddd5 --- /dev/null +++ b/internal/taskcopy/workspace_restore_test.go @@ -0,0 +1,618 @@ +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", "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 { + 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 "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 { + 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", "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 { + 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 "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": + 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/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/app.go b/internal/tui3/app.go index 3cf665689b..56b816cd84 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -9586,6 +9586,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/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/contextmodal_test.go b/internal/tui3/contextmodal_test.go index 8e1ea5c11a..c4d33122cc 100644 --- a/internal/tui3/contextmodal_test.go +++ b/internal/tui3/contextmodal_test.go @@ -917,3 +917,64 @@ 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") + } + }) + } +} + +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 82a1e6e2d1..303ea48bca 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 @@ -226,6 +230,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, a.folder.cancelChoose = nil, 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..ff1cad1613 100644 --- a/internal/tui3/folderpick.go +++ b/internal/tui3/folderpick.go @@ -206,6 +206,12 @@ 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 + // cancelChoose restores the waiting draft when a folder choice is abandoned. + cancelChoose func() // 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 +1409,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..6b011375ce 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -290,8 +290,12 @@ 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 + cancelChoose := a.folder.cancelChoose a.folder.close() + if cancelChoose != nil { + cancelChoose() + } a.touch() if home { return tea.Batch(tea.ClearScreen, a.openHome()) @@ -923,6 +927,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 @@ -1181,6 +1190,8 @@ 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 + cancelChoose := a.folder.cancelChoose 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 +1204,8 @@ 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 + a.folder.cancelChoose = cancelChoose if !forTarget && !forWorkspace { a.markFolderHeld() } diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index f2faefcfc8..b4c078a2a5 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -70,6 +70,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) @@ -85,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 @@ -260,41 +267,56 @@ type homeTakenMsg struct { row chatlist.Row taken Taken err error + roots map[string]string draft *composerState send func() tea.Cmd } -// 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. +// takeRemote asks for the chat off the update loop because a move waits on the 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 { + return a.takeRemoteState(homeTakenMsg{row: row, roots: roots}) +} + +func (a *app) takeRemoteState(msg homeTakenMsg) tea.Cmd { if a.machineRead.computerRemoved { + a.restoreTakeDraft(msg) a.home.say(chatlist.Removed, "") return nil } a.home.say(continuing, "") taker, ctx := a.taker, a.ctx - var draft *composerState - if a.file != "" && filepath.Base(session.DirOf(a.file)) == row.Cell { - // Retirement can replace the displaced session while the move is in - // flight. Its unsent message belongs to the chat that is arriving. + if msg.draft == nil && a.file != "" && filepath.Base(session.DirOf(a.file)) == msg.row.Cell { + // The displaced session may retire during the move, so its unsent words travel with the answer. composer := a.mainComposer() if !composer.empty() { - draft = &composer + msg.draft = &composer } } return func() tea.Msg { - 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, draft: draft} + seen := &directory.LeaseExpectation{Device: msg.row.DeviceID, Fence: msg.row.Fence} + if mapped, ok := taker.(workspaceRootTaker); ok && len(msg.roots) > 0 { + msg.taken, msg.err = mapped.TakeSeenWithWorkspaceRoots(ctx, msg.row.Cell, seen, msg.roots) + } else if conditional, ok := taker.(conditionalTaker); ok { + msg.taken, msg.err = conditional.TakeSeen(ctx, msg.row.Cell, seen) + } else { + msg.taken, msg.err = taker.Take(ctx, msg.row.Cell) } - taken, err := taker.Take(ctx, row.Cell) - return homeTakenMsg{row: row, taken: taken, err: err, draft: draft} + return msg } } -// 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. +// tookTakeover restores unsent words on failure and opens the arrived chat on success. 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, missing.Roots) + } + } var failure string if msg.err != nil { failure = a.failedTake(msg) @@ -305,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: @@ -318,6 +342,28 @@ func (a *app) tookTakeover(msg homeTakenMsg) tea.Cmd { return nil } +// chooseTakeRoots keeps the original holder and unsent words while collecting local folders. +func (a *app) chooseTakeRoots(msg homeTakenMsg, missing []session.WorkspaceRootBinding) tea.Cmd { + if len(missing) == 0 { + return a.takeRemoteState(msg) + } + cmd := a.openContextPick("", false) + a.folder.forWorkspace = true + a.folder.held = nil + a.folder.chooseTitle = "local folder for " + missing[0].Label + a.folder.cancelChoose = func() { a.restoreTakeDraft(msg) } + a.folder.chooseFolder = func(path string) tea.Cmd { + roots := make(map[string]string, len(msg.roots)+1) + for uri, folder := range msg.roots { + roots[uri] = folder + } + roots[missing[0].URI] = path + msg.roots = roots + return a.chooseTakeRoots(msg, missing[1:]) + } + 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/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/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/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/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..3d8a73837d 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,15 @@ 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, 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() } diff --git a/internal/tui3/task_workspace_apply.go b/internal/tui3/task_workspace_apply.go new file mode 100644 index 0000000000..084f503060 --- /dev/null +++ b/internal/tui3/task_workspace_apply.go @@ -0,0 +1,74 @@ +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 !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 + } + 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 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 { 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)) } diff --git a/internal/tui3/workspace_binding_test.go b/internal/tui3/workspace_binding_test.go new file mode 100644 index 0000000000..c59582dde5 --- /dev/null +++ b/internal/tui3/workspace_binding_test.go @@ -0,0 +1,88 @@ +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))) + } + // 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") { + 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/tui3/workspace_take_draft_test.go b/internal/tui3/workspace_take_draft_test.go new file mode 100644 index 0000000000..4ee0dd77d6 --- /dev/null +++ b/internal/tui3/workspace_take_draft_test.go @@ -0,0 +1,128 @@ +package tui3 + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + 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 draftBindingTaker struct { + bindingTaker + taken Taken + err error +} + +func (f *draftBindingTaker) 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 f.taken, f.err +} + +func TestTakeRootPickerPreservesDraftAndSendsOnlyAfterSuccess(t *testing.T) { + for _, outcome := range []string{"success", "failure", "cancel first", "cancel second", "removed"} { + t.Run(outcome, func(t *testing.T) { + src := &continuationListing{row: leftOffRow(heldCell, "other", chatlist.Running, time.Minute)} + fake := &draftBindingTaker{} + a, _ := heldLab(t, src, fake) + a.closeHome() + a.input.setText(" waiting " + pasteToken(1, 2) + " words ") + a.input.cursor = 4 + a.pastes = []pasteChip{{n: 1, text: "whole\npaste"}} + a.chips = []chip{{path: filepath.Join(t.TempDir(), "image.png")}} + want := a.mainComposer() + fake.taken = Taken{AlreadyHere: true, Transcript: a.file, Fence: 18} + if outcome == "failure" { + fake.err = errors.New("restore failed") + } + first, second := t.TempDir(), t.TempDir() + a.home.world = session.World{Projects: []session.Project{{Path: first}, {Path: second}}} + cmd := a.takeRemote(src.row) + msg := cmd().(homeTakenMsg) + sends := 0 + msg.send = func() tea.Cmd { + sends++ + if !reflect.DeepEqual(a.mainComposer(), want) { + t.Error("send ran before the captured draft arrived") + } + return nil + } + a.putComposer(composerState{}) + spend(t, a, a.tookTakeover(msg)) + if noted(a, continueFails) { + t.Fatal("a folder choice was reported as a failed take") + } + if outcome == "cancel first" { + drive(t, a, key("esc")) + } else { + chooseTakeFolder(t, a, first) + if outcome == "cancel second" { + drive(t, a, key("esc")) + } else { + if outcome == "removed" { + a.machineRead.computerRemoved = true + } + chooseTakeFolder(t, a, second) + } + } + if !reflect.DeepEqual(a.mainComposer(), want) { + t.Fatalf("take chooser lost captured composer: got %+v want %+v", a.mainComposer(), want) + } + if outcome == "success" { + if sends != 1 || fake.calls != 2 { + t.Fatalf("successful retry: sends=%d calls=%d", sends, fake.calls) + } + } else if sends != 0 { + t.Fatal("unsuccessful move sent a message") + } + if (outcome == "cancel first" || outcome == "cancel second" || outcome == "removed") && fake.calls != 1 { + t.Fatal("cancelled or removed computer retried the take") + } + if (outcome == "cancel first" || outcome == "cancel second") && noted(a, continueFails) { + t.Fatal("cancellation reported a failed take") + } + if outcome == "failure" && !a.machineRead.sendHolds[heldCell].blocked { + t.Fatal("failed retry left sending enabled") + } + if outcome == "success" || outcome == "failure" { + if fake.roots["root-app"] != first || fake.roots["root-library"] != second { + t.Fatalf("root choices changed: %v", fake.roots) + } + } + }) + } +} + +func chooseTakeFolder(t *testing.T, a *app, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } + msgs := []tea.Msg{tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}} + for _, char := range path + string(filepath.Separator) { + msgs = append(msgs, key(string(char))) + } + drive(t, a, msgs...) + drive(t, a, key("enter")) +} + +func TestRemovedComputerCannotTakeWithWorkspaceRoots(t *testing.T) { + fake := &bindingTaker{} + a := continueHome(t, fake, chatlist.Static{}, 200) + a.machineRead.computerRemoved = true + row := chatlist.Row{Cell: "chat", DeviceID: "other", Fence: 17} + if a.takeRemote(row) != nil || a.takeRemoteWithRoots(row, map[string]string{"root": t.TempDir()}) != nil || fake.calls != 0 { + t.Fatal("removed computer offered a take") + } +} 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 c9048618ce..059209f5a9 100644 --- a/relay/hosted/src/directory.js +++ b/relay/hosted/src/directory.js @@ -199,7 +199,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); + } +});