From 9305f0236a4953469cf3d55c1ca5e2fe2d2f07e0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:46:26 -0400 Subject: [PATCH 1/5] Keep a copied older chat's copy-ready marker across rewind and take-back A rewind or a take onto a copied older chat removed .cell/copy-ready, so Home fell back to the untouched original, and opening that original ran CopyLegacy, which removed the copy and every turn written since. Restore now keeps this computer's own .cell bookkeeping, a take re-stamps the copy markers, and CopyLegacy refuses to remove a destination that already holds sealed turns. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/olderchats_test.go | 510 ++++++++++++++++++++++++++++++++ internal/cell/copy.go | 78 ++++- internal/cell/copy_test.go | 87 ++++++ internal/cellstore/localwork.go | 2 +- internal/cellstore/restate.go | 18 +- internal/cellstore/syncops.go | 23 +- internal/handoff/handoff.go | 33 ++- 7 files changed, 735 insertions(+), 16 deletions(-) diff --git a/cmd/codeaf/olderchats_test.go b/cmd/codeaf/olderchats_test.go index 6a93aa5d90..387e15b21b 100644 --- a/cmd/codeaf/olderchats_test.go +++ b/cmd/codeaf/olderchats_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "net/http/httptest" "os" "path/filepath" "strings" @@ -12,7 +14,9 @@ import ( "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // TestC29FirstSavedTurnIncludesTheEarlierConversation proves C29.1/C29.4 with @@ -70,3 +74,509 @@ func TestC29FirstSavedTurnIncludesTheEarlierConversation(t *testing.T) { // Retire only this fixture's engine routes before TempDir cleanup. _ = cellstore.Await(context.Background(), c) } + +func newShortOlderChatHome(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "cf29-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + sockets, _ := filepath.Glob(filepath.Join(dir, "v3", "engine-*.sock")) + for _, socket := range sockets { + _ = (cellstore.Daemon{Socket: socket}).Stop(context.Background()) + } + _ = os.RemoveAll(dir) + }) + return dir +} + +// TestCopiedLegacyChatKeepsCopyReadyAfterRewind verifies from the person's side +// that copying a legacy chat into `.moving` and rewinding to an earlier turn +// preserves `.cell/copy-ready` (along with local `.cell/` bookkeeping such as +// `copy-offered`, `published.json`, and `adopted.json`) and keeps Home listing +// the `.moving` copy rather than the stale original. +func TestCopiedLegacyChatKeepsCopyReadyAfterRewind(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + homeDir := newShortOlderChatHome(t) + t.Setenv(home.EnvVar, homeDir) + t.Setenv("CODEAF_PROFILE_DIR", homeDir) + if _, err := identity.Ensure(homeDir); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1") + + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn one"), 0o600); err != nil { + t.Fatal(err) + } + bucket, err := v3ProjectDir(work) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-rewind-chat") + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + t1Stamp := now.Add(-10 * time.Minute).Format(time.RFC3339) + t2Stamp := now.Add(-1 * time.Minute).Format(time.RFC3339) + origTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":\"legacy-rewind-chat\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply one before copy\",\"timestamp\":%q}\n", + t1Stamp, t1Stamp, t1Stamp, + ) + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-rewind-chat", + Title: "Stale Pre-Copy Original", + Workspace: work, + Created: now.Add(-10 * time.Minute), + LastUserAt: now.Add(-10 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + copied, err := copyOlderChat(t.Context(), original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + defer func() { _ = cellstore.Await(context.Background(), copied) }() + + firstHead, err := cellstore.Head(copied) + if err != nil || firstHead == nil { + t.Fatalf("Head after copyOlderChat: %v", err) + } + + // Update the `.moving` copy with a second turn and local `.cell/` bookkeeping. + copyTranscriptPath := filepath.Join(copied.Root, filepath.FromSlash(cell.TranscriptPath)) + turnTwoTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q,\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply one before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"turn two in copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"reply two in copy\",\"timestamp\":%q}\n", + copied.ID, t1Stamp, t1Stamp, t1Stamp, t2Stamp, t2Stamp, + ) + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn two"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copyTranscriptPath, []byte(turnTwoTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(copied.Root, session.Meta{ + ID: copied.ID, + Title: "Live Moving Copy", + Workspace: work, + Created: now.Add(-10 * time.Minute), + LastUserAt: now.Add(-1 * time.Minute), + }); err != nil { + t.Fatal(err) + } + engine := cellstore.EngineFor(work) + secondHead, err := engine.Seal(t.Context(), copied, cellstore.TurnInfo{}) + if err != nil { + t.Fatalf("Seal turn 2: %v", err) + } + if err := cellstore.Await(t.Context(), copied); err != nil { + t.Fatalf("Await turn 2: %v", err) + } + if err := cellstore.NotePublished(copied, secondHead.Turn.ID); err != nil { + t.Fatalf("NotePublished: %v", err) + } + if err := os.WriteFile(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath)), nil, 0o600); err != nil { + t.Fatal(err) + } + adoptedPath := filepath.Join(copied.Root, ".cell", "adopted.json") + if err := os.WriteFile(adoptedPath, []byte(fmt.Sprintf(`{"V":1,"head":%q,"turns_size":1,"transcript_end":1,"from":"laptop"}`+"\n", firstHead.Turn.ID)), 0o600); err != nil { + t.Fatal(err) + } + + // Rewind to the first turn (whose snapshot predates `copy-ready`, `copy-offered`, `published.json`, and `adopted.json`). + if _, err := engine.Rewind(t.Context(), copied, firstHead.Turn.ID); err != nil { + t.Fatalf("Rewind: %v", err) + } + + copyReadyFile := filepath.Join(copied.Root, ".cell", "copy-ready") + if _, err := os.Stat(copyReadyFile); err != nil { + t.Fatalf("rewind removed .cell/copy-ready: %v", err) + } + if _, err := os.Stat(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath))); err != nil { + t.Fatalf("rewind removed .cell/copy-offered: %v", err) + } + if shared, err := cellstore.Shared(copied); err != nil || !shared { + t.Fatalf("rewind lost local publication/adoption bookkeeping: shared=%v err=%v", shared, err) + } + rawPublished, err := os.ReadFile(filepath.Join(copied.Root, ".cell", "published.json")) + if err != nil || !strings.Contains(string(rawPublished), secondHead.Turn.ID) { + t.Fatalf("rewind altered .cell/published.json: err=%v body=%q", err, string(rawPublished)) + } + if _, err := os.Stat(adoptedPath); err != nil { + t.Fatalf("rewind removed .cell/adopted.json: %v", err) + } + if got := cell.Copied(original); filepath.Clean(got) != filepath.Clean(copied.Root) { + t.Fatalf("cell.Copied(original) after rewind = %s, want %s", got, copied.Root) + } + rewoundTranscript, err := os.ReadFile(copyTranscriptPath) + if err != nil || !strings.Contains(string(rewoundTranscript), "turn one before copy") || strings.Contains(string(rewoundTranscript), "turn two in copy") { + t.Fatalf("rewound transcript = %q, err = %v", string(rewoundTranscript), err) + } + + t.Setenv(syncsetup.URLVar, "off") + rows := session.ReadHome().Sessions() + if len(rows) != 1 { + t.Fatalf("ReadHome() after rewind returned %d chats, want 1: %+v", len(rows), rows) + } + if rows[0].ID != copied.ID || filepath.Clean(session.DirOf(rows[0].Transcript)) != filepath.Clean(copied.Root) { + t.Fatalf("Home listed stale original instead of rewound copy: got id=%q dir=%q, want id=%q dir=%q", + rows[0].ID, session.DirOf(rows[0].Transcript), copied.ID, copied.Root) + } +} + +// TestCopiedLegacyChatKeepsCopyReadyAfterTakeBack verifies from the person's +// side that copying a legacy chat into `.moving`, continuing it on another +// computer, and taking it back onto this computer's `.moving` copy root keeps +// `.cell/copy-ready` and lists the updated copy (never the stale original) on +// Home — both for borrowed-project (`InPlace`) and owned (`swap`) chats. +func TestCopiedLegacyChatKeepsCopyReadyAfterTakeBack(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + for _, tc := range []struct { + name string + owned bool + }{ + {name: "borrowed_project", owned: false}, + {name: "owned_workspace", owned: true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + srv := httptest.NewServer(relayserveForMove(t)) + t.Cleanup(srv.Close) + + home1 := newShortOlderChatHome(t) + home2 := newShortOlderChatHome(t) + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(home.EnvVar, home1) + t.Setenv("CODEAF_PROFILE_DIR", home1) + + id, err := identity.Ensure(home1) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(home2, id, false); err != nil { + t.Fatal(err) + } + sync1, ok, err := syncsetup.Open(home1) + if err != nil || !ok { + t.Fatalf("Open(home1) = (%v, %v)", ok, err) + } + sync2, ok, err := syncsetup.Open(home2) + if err != nil || !ok { + t.Fatalf("Open(home2) = (%v, %v)", ok, err) + } + if err := syncsetup.PutOwnDevice(ctx, sync1.Dir, sync1.Identity, sync1.Device, "desk"); err != nil { + t.Fatal(err) + } + if err := syncsetup.PutOwnDevice(ctx, sync2.Dir, sync2.Identity, sync2.Device, "laptop"); err != nil { + t.Fatal(err) + } + + projectWork := t.TempDir() + bucket, err := v3ProjectDir(projectWork) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-takeback-orig") + origWork := projectWork + if tc.owned { + origWork = filepath.Join(original, "work") + } + if err := os.MkdirAll(origWork, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + deskStamp := now.Add(-15 * time.Minute).Format(time.RFC3339) + laptopStamp := now.Add(-1 * time.Minute).Format(time.RFC3339) + origJournal := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":\"legacy-takeback-orig\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"started on desk before copy\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"desk initial answer\",\"timestamp\":%q}\n", + deskStamp, deskStamp, deskStamp, + ) + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origJournal), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-takeback-orig", + Title: "Stale Original On Desk", + Workspace: origWork, + Owned: tc.owned, + Created: now.Add(-15 * time.Minute), + LastUserAt: now.Add(-15 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 1. Copy the legacy chat into `.moving` on Computer 1 and publish it. + copied, err := copyOlderChat(ctx, original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + copiedMeta, err := session.LoadMeta(copied.Root) + if err != nil { + t.Fatal(err) + } + copyWork1 := copiedMeta.Workspace + if copiedMeta.Owned { + copyWork1 = filepath.Join(copied.Root, "work") + } + engine1 := cellstore.EngineFor(copyWork1) + engine1.DataRoot = filepath.Join(home1, "v3", "stores") + if err := sync1.PublishCopy(ctx, engine1, copied, "desk", "Copied Chat From Desk"); err != nil { + t.Fatalf("PublishCopy: %v", err) + } + if err := os.WriteFile(filepath.Join(copied.Root, filepath.FromSlash(cell.CopyOfferedPath)), nil, 0o600); err != nil { + t.Fatal(err) + } + + // 2. Continue the chat on Computer 2 (`home2`), add a turn, and publish it. + t.Setenv(home.EnvVar, home2) + t.Setenv("CODEAF_PROFILE_DIR", home2) + awayPath := projectWork + ".away" + if !tc.owned { + if err := os.Rename(projectWork, awayPath); err != nil { + t.Fatal(err) + } + } + restoreProjectWork := func() { + if !tc.owned { + if _, err := os.Stat(awayPath); err == nil { + _ = os.Rename(awayPath, projectWork) + } + } + } + defer restoreProjectWork() + + engine2 := cellstore.EngineFor("") + engine2.DataRoot = filepath.Join(home2, "v3", "stores") + cont2 := sync2.Continuer(engine2, syncsetup.TakeOptions{ + DeviceName: "laptop", + RootFor: func(cid string) string { return filepath.Join(home2, "v3", "projects", "copies", cid) }, + BucketFor: func(string) string { return filepath.Join(home2, "v3", "projects", "copy--project") }, + }) + on2, err := cont2.Take(ctx, copied.ID) + if err != nil { + t.Fatalf("Computer 2 Take: %v", err) + } + cell2, err := cell.OpenAt(on2.Taken.Cell.Root, copied.ID) + if err != nil { + t.Fatal(err) + } + transcript2 := session.Place{Dir: cell2.Root}.Transcript() + f2, err := os.OpenFile(transcript2, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, err = fmt.Fprintf(f2, + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"question on laptop\",\"timestamp\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"turn added on laptop\",\"timestamp\":%q}\n", + laptopStamp, laptopStamp, + ) + _ = f2.Close() + if err != nil { + t.Fatal(err) + } + meta2, err := session.LoadMeta(cell2.Root) + if err != nil { + t.Fatal(err) + } + meta2.Title = "Continued On Laptop" + meta2.LastUserAt = now.Add(-1 * time.Minute) + if err := session.SaveMeta(cell2.Root, meta2); err != nil { + t.Fatal(err) + } + work2 := meta2.Workspace + if meta2.Owned { + work2 = filepath.Join(cell2.Root, "work") + } + driveEngine2 := engine2 + driveEngine2.Workspace = work2 + title2 := meta2.Title + drive2, err := sync2.Drive(ctx, driveEngine2, cell2, syncsetup.DriveOptions{ + DeviceName: "laptop", + Title: func() string { return title2 }, + }) + if err != nil { + t.Fatalf("Computer 2 Drive: %v", err) + } + if _, err := drive2.Store(driveEngine2).Seal(ctx, cell2, cellstore.TurnInfo{}); err != nil { + _ = drive2.Close(ctx) + t.Fatalf("Computer 2 Seal: %v", err) + } + drive2.Idle() + if err := drive2.Close(ctx); err != nil { + t.Fatalf("Computer 2 Drive.Close: %v", err) + } + + // 3. Back on Computer 1 (`home1`), take the chat back onto `/.moving/`. + restoreProjectWork() + t.Setenv(home.EnvVar, home1) + t.Setenv("CODEAF_PROFILE_DIR", home1) + if gotRoot := takeRootFor(copied.ID); filepath.Clean(gotRoot) != filepath.Clean(copied.Root) { + t.Fatalf("takeRootFor(%s) = %s, want %s", copied.ID, gotRoot, copied.Root) + } + takeBackEngine1 := cellstore.EngineFor("") + takeBackEngine1.DataRoot = filepath.Join(home1, "v3", "stores") + cont1 := sync1.Continuer(takeBackEngine1, syncsetup.TakeOptions{ + DeviceName: "desk", + RootFor: takeRootFor, + BucketFor: takeCopyBucket, + }) + back1, err := cont1.Take(ctx, copied.ID) + if err != nil { + t.Fatalf("Computer 1 Take back: %v", err) + } + if filepath.Clean(back1.Taken.Cell.Root) != filepath.Clean(copied.Root) { + t.Fatalf("Take back landed at %s, want %s", back1.Taken.Cell.Root, copied.Root) + } + + if _, err := os.Stat(filepath.Join(copied.Root, ".cell", "copy-ready")); err != nil { + t.Fatalf("take-back lost .cell/copy-ready: %v", err) + } + if got := cell.Copied(original); filepath.Clean(got) != filepath.Clean(copied.Root) { + t.Fatalf("cell.Copied(original) after take-back = %s, want %s", got, copied.Root) + } + + // Verify Home (including with sync turned off) lists the updated `.moving` copy once, not the stale original. + t.Setenv(syncsetup.URLVar, "off") + rows := session.ReadHome().Sessions() + if len(rows) != 1 { + t.Fatalf("ReadHome() after take-back returned %d chats, want 1: %+v", len(rows), rows) + } + if rows[0].ID != copied.ID || filepath.Clean(session.DirOf(rows[0].Transcript)) != filepath.Clean(copied.Root) { + t.Fatalf("Home listed stale original instead of taken-back copy: got {ID:%q Transcript:%q}, want {ID:%q Root:%q}", + rows[0].ID, rows[0].Transcript, copied.ID, copied.Root) + } + homeRowBody, err := os.ReadFile(rows[0].Transcript) + if err != nil || !strings.Contains(string(homeRowBody), "turn added on laptop") { + t.Fatalf("Home row transcript missing laptop turn: err=%v body=%s", err, string(homeRowBody)) + } + + // Opening the stale original path resolves to the taken-back copy without losing Computer 2's turn. + opened, err := movablePlace(ctx, session.Place{Dir: original, Workspace: origWork, Owned: tc.owned}) + if err != nil { + t.Fatalf("movablePlace(original) after take-back: %v", err) + } + if filepath.Clean(opened.Dir) != filepath.Clean(copied.Root) { + t.Fatalf("movablePlace(original) = %s, want %s", opened.Dir, copied.Root) + } + rawTranscript, err := os.ReadFile(opened.Transcript()) + if err != nil || !strings.Contains(string(rawTranscript), "turn added on laptop") { + t.Fatalf("taken-back copy transcript lost laptop turn: err=%v body=%s", err, string(rawTranscript)) + } + }) + } +} + +// TestOpeningStaleOriginalNeverDeletesPopulatedCopy verifies the second line of +// defence from the person's side: even if `.cell/copy-ready` was missing on a +// `.moving` copy that already holds sealed turns, opening the stale original +// never wipes the populated copy. +func TestOpeningStaleOriginalNeverDeletesPopulatedCopy(t *testing.T) { + celltest.RequireEngine(t) + t.Cleanup(cellstore.StopSpawned) + + homeDir := newShortOlderChatHome(t) + t.Setenv(home.EnvVar, homeDir) + t.Setenv("CODEAF_PROFILE_DIR", homeDir) + if _, err := identity.Ensure(homeDir); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1") + + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "readme.txt"), []byte("turn one"), 0o600); err != nil { + t.Fatal(err) + } + bucket, err := v3ProjectDir(work) + if err != nil { + t.Fatal(err) + } + original := filepath.Join(bucket, "legacy-never-wipe") + if err := os.MkdirAll(original, 0o700); err != nil { + t.Fatal(err) + } + origTranscript := `{"type":"session","version":1,"id":"legacy-never-wipe"}` + "\n" + + `{"type":"message","role":"user","content":"pre-copy words"}` + "\n" + if err := os.WriteFile(filepath.Join(original, "transcript.jsonl"), []byte(origTranscript), 0o600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(original, session.Meta{ + ID: "legacy-never-wipe", + Title: "Stale Original", + Workspace: work, + LastUserAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + copied, err := copyOlderChat(t.Context(), original) + if err != nil { + t.Fatalf("copyOlderChat: %v", err) + } + defer func() { _ = cellstore.Await(context.Background(), copied) }() + + // Add a precious new turn to the `.moving` copy and seal it. + copyTranscriptPath := filepath.Join(copied.Root, filepath.FromSlash(cell.TranscriptPath)) + preciousTranscript := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"pre-copy words\"}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"precious turn only in moving copy\"}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"precious answer that must never be wiped\"}\n", + copied.ID, + ) + if err := os.WriteFile(copyTranscriptPath, []byte(preciousTranscript), 0o600); err != nil { + t.Fatal(err) + } + engine := cellstore.EngineFor(work) + if _, err := engine.Seal(t.Context(), copied, cellstore.TurnInfo{}); err != nil { + t.Fatalf("Seal precious turn: %v", err) + } + + // Simulate an installation where an earlier build already stripped `.cell/copy-ready`. + copyReadyFile := filepath.Join(copied.Root, ".cell", "copy-ready") + if err := os.Remove(copyReadyFile); err != nil { + t.Fatal(err) + } + if cell.Copied(original) != original { + t.Fatal("expected Copied(original) == original once copy-ready was removed") + } + + // Opening the stale original must NEVER os.RemoveAll(copied.Root) or wipe the precious turn. + opened, err := movablePlace(t.Context(), session.Place{Dir: original, Workspace: work}) + if err != nil { + t.Fatalf("movablePlace(original) failed: %v", err) + } + if filepath.Clean(opened.Dir) != filepath.Clean(copied.Root) { + t.Fatalf("movablePlace(original) = %s, want %s", opened.Dir, copied.Root) + } + afterRaw, err := os.ReadFile(copyTranscriptPath) + if err != nil { + t.Fatalf("copy transcript unreadable: %v", err) + } + if !strings.Contains(string(afterRaw), "precious answer that must never be wiped") { + t.Fatalf("opening stale original wiped the populated copy! transcript:\n%s", string(afterRaw)) + } + if _, err := os.Stat(copyReadyFile); err != nil { + t.Fatalf("expected CopyLegacy to restore .cell/copy-ready on populated copy: %v", err) + } +} diff --git a/internal/cell/copy.go b/internal/cell/copy.go index 2003d9fb2c..bf02b07d80 100644 --- a/internal/cell/copy.go +++ b/internal/cell/copy.go @@ -1,13 +1,18 @@ package cell import ( + "bufio" + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" + "fmt" "io" "io/fs" "os" "path/filepath" + "strings" "github.com/Agent-Field/codeaf/internal/filelock" ) @@ -15,6 +20,10 @@ import ( // copyCheckpoint is a process-death seam; production never pauses construction. var copyCheckpoint = func(string) {} +// CopyReadyPath marks a legacy copy whose initial turn is durable on this +// computer; snapshots never carry it because it is local bookkeeping. +const CopyReadyPath = StateDir + "/copy-ready" + // CopyOfferedPath is local bookkeeping inside the harness-owned state area. const CopyOfferedPath = StateDir + "/copy-offered" @@ -55,7 +64,7 @@ func explainCopyFolder(dir string) error { // completion marker leaves the original as the only visible conversation. func Copied(original string) string { root := CopyRoot(original) - if _, err := os.Stat(filepath.Join(root, StateDir, "copy-ready")); err == nil { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(CopyReadyPath))); err == nil { if _, err := OpenAt(root, filepath.Base(root)); err == nil { return root } @@ -63,6 +72,53 @@ func Copied(original string) string { return original } +// StampCopyReady records on disk that root is a completed `.moving` copy on +// this computer so Home and legacy-open paths resolve it instead of the +// untouched original. +func StampCopyReady(root string) error { + p := filepath.Join(root, filepath.FromSlash(CopyReadyPath)) + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return err + } + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return errors.Join(f.Sync(), f.Close()) +} + +// hasSealedTurns reports whether root already holds a cell with at least one +// sealed turn or adopted remote head. Engine.Seal appends to turns.jsonl only +// after composing meta.json and capturing the snapshot, and Materialize writes +// adopted.json only after restoring an incoming head, so either file proves +// root holds real conversation history rather than a half-copied scratch tree. +func hasSealedTurns(root string) bool { + if raw, err := os.ReadFile(filepath.Join(root, StateDir, "turns.jsonl")); err == nil { + sc := bufio.NewScanner(bytes.NewReader(raw)) + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + var turn struct { + ID string `json:"id"` + } + if json.Unmarshal(line, &turn) == nil && strings.TrimSpace(turn.ID) != "" { + return true + } + } + } + if raw, err := os.ReadFile(filepath.Join(root, StateDir, "adopted.json")); err == nil { + var adopted struct { + Head string `json:"head"` + } + if json.Unmarshal(raw, &adopted) == nil && strings.TrimSpace(adopted.Head) != "" { + return true + } + } + return false +} + // CopyLegacy makes a private byte copy and invokes prepare before publishing // it. It never links files to the original: later writes must stay independent. // The lock lives outside the original and serializes windows and crash retries. @@ -93,6 +149,20 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if Copied(original) != original { return OpenAt(root, filepath.Base(root)) } + // Never wipe a destination that already holds sealed turns even if an + // earlier restore or take-back lost its copy-ready marker: reopen and + // re-stamp it when its cell record is valid, or fail loudly so newer turns + // cannot be destroyed. + if hasSealedTurns(root) { + c, openErr := OpenAt(root, filepath.Base(root)) + if openErr != nil { + return Cell{}, fmt.Errorf("copy destination %s already holds sealed turns: %w", root, openErr) + } + if err := StampCopyReady(root); err != nil { + return Cell{}, err + } + return c, nil + } if err := ctx.Err(); err != nil { return Cell{}, err } @@ -135,11 +205,7 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte if err := ctx.Err(); err != nil { return Cell{}, err } - f, err := os.OpenFile(filepath.Join(root, StateDir, "copy-ready"), os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return Cell{}, err - } - if err := errors.Join(f.Sync(), f.Close()); err != nil { + if err := StampCopyReady(root); err != nil { return Cell{}, err } copyCheckpoint("published") diff --git a/internal/cell/copy_test.go b/internal/cell/copy_test.go index 16b7de617d..412ccd0a77 100644 --- a/internal/cell/copy_test.go +++ b/internal/cell/copy_test.go @@ -196,3 +196,90 @@ func TestTheCopyFolderSaysItIsTheLiveStore(t *testing.T) { t.Fatalf("the copy folder does not explain itself: %q, %v", raw, err) } } + +// TestCopyLegacyRefusesToWipePopulatedDestination verifies the second line of +// defence when a destination copy already holds sealed turns (either in +// turns.jsonl or adopted.json) without copy-ready: CopyLegacy opens and +// re-stamps a valid cell without rerunning prepare or removing the destination, +// and fails loudly without removing the destination if OpenAt fails. +func TestCopyLegacyRefusesToWipePopulatedDestination(t *testing.T) { + for _, marker := range []string{"turns.jsonl", "adopted.json"} { + t.Run(marker, func(t *testing.T) { + original := olderFixture(t) + c, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }) + if err != nil { + t.Fatal(err) + } + // Append precious post-copy content and record a sealed turn marker. + transcriptPath := filepath.Join(c.Root, filepath.FromSlash(TranscriptPath)) + if err := os.WriteFile(transcriptPath, []byte("whole conversation\nprecious turn\n"), 0o600); err != nil { + t.Fatal(err) + } + switch marker { + case "turns.jsonl": + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "turns.jsonl"), + []byte(`{"V":1,"id":"1111111111111111111111111111111111111111111111111111111111111111"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + case "adopted.json": + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "adopted.json"), + []byte(`{"V":1,"head":"2222222222222222222222222222222222222222222222222222222222222222"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + // Remove copy-ready to simulate an older build's restore or take-back stripping it. + readyFile := filepath.Join(c.Root, StateDir, "copy-ready") + if err := os.Remove(readyFile); err != nil { + t.Fatal(err) + } + + reopened, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { + t.Fatal("CopyLegacy re-ran prepare on an already-populated copy") + return nil + }) + if err != nil { + t.Fatalf("CopyLegacy failed instead of opening populated copy: %v", err) + } + if reopened.Root != c.Root { + t.Fatalf("CopyLegacy returned root %s, want %s", reopened.Root, c.Root) + } + after, err := os.ReadFile(transcriptPath) + if err != nil || !strings.Contains(string(after), "precious turn") { + t.Fatalf("populated copy transcript lost precious turn: err=%v body=%q", err, string(after)) + } + if Copied(original) != c.Root { + t.Fatalf("Copied(original) = %s, want %s", Copied(original), c.Root) + } + }) + } + + t.Run("unopenable_populated_destination_fails_loudly_without_removal", func(t *testing.T) { + original := olderFixture(t) + c, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }) + if err != nil { + t.Fatal(err) + } + transcriptPath := filepath.Join(c.Root, filepath.FromSlash(TranscriptPath)) + if err := os.WriteFile(transcriptPath, []byte("precious unopenable turn\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, StateDir, "turns.jsonl"), + []byte(`{"V":1,"id":"1111111111111111111111111111111111111111111111111111111111111111"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(c.Root, StateDir, "copy-ready")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, filepath.FromSlash(MetaPath)), []byte("{corrupt"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }); err == nil { + t.Fatal("expected CopyLegacy to fail loudly when populated destination cannot be opened") + } + after, err := os.ReadFile(transcriptPath) + if err != nil || !strings.Contains(string(after), "precious unopenable turn") { + t.Fatalf("CopyLegacy deleted or altered populated unopenable destination: err=%v body=%q", err, string(after)) + } + }) +} diff --git a/internal/cellstore/localwork.go b/internal/cellstore/localwork.go index 7ff97d4a38..8dfc6f5cd0 100644 --- a/internal/cellstore/localwork.go +++ b/internal/cellstore/localwork.go @@ -191,7 +191,7 @@ func localPlanWork(raw []byte) (bool, bool, error) { continue } switch name { - case TurnsPath, publishedPath, adoptedPath, cell.CopyOfferedPath, cell.StateDir + "/copy-ready": + case TurnsPath, publishedPath, adoptedPath, cell.CopyOfferedPath, cell.CopyReadyPath: continue case cell.TranscriptPath: // The shared prefix is checked before a changed journal is discarded. diff --git a/internal/cellstore/restate.go b/internal/cellstore/restate.go index 693a202ed3..538ecd2a3a 100644 --- a/internal/cellstore/restate.go +++ b/internal/cellstore/restate.go @@ -14,13 +14,19 @@ import ( ) // chainNames are the entries of .cell/ that a restore never writes: the chain -// describes the past rather than belongs to any turn's content (L12), and -// meta.json is the harness's own, composed afresh by every seal. +// describes the past rather than belongs to any turn's content (L12), meta.json +// is the harness's own composed afresh by every seal, and local publication, +// adoption, and legacy-copy markers are written after sealing or materializing +// so snapshots either omit them or hold an earlier turn's stale upload note. var chainNames = map[string]bool{ - path.Base(TurnsPath): true, - path.Base(ReceiptsDir): true, - path.Base(BlobsDir): true, - path.Base(cell.MetaPath): true, + path.Base(TurnsPath): true, + path.Base(ReceiptsDir): true, + path.Base(BlobsDir): true, + path.Base(cell.MetaPath): true, + path.Base(publishedPath): true, + path.Base(adoptedPath): true, + path.Base(cell.CopyReadyPath): true, + path.Base(cell.CopyOfferedPath): true, } // restoreKeepingChain puts the workspace and the rest of .cell/ (the diff --git a/internal/cellstore/syncops.go b/internal/cellstore/syncops.go index 3175b64bf0..44f4fdb4f7 100644 --- a/internal/cellstore/syncops.go +++ b/internal/cellstore/syncops.go @@ -260,12 +260,31 @@ func (e SyncEngine) Materialize(ctx context.Context, c cell.Cell, head string) e Held []heldName `json:"held"` } // The restore brings the sender's policy file and record over this machine's, - // which would forget the names an earlier take set apart: they are read first - // and written back with this take's. + // which would forget the names an earlier take set apart and any local + // legacy-copy markers snapshots never carry: they are read first and + // written back after the head is materialized. earlier := heldBefore(c, e.holdDir(c)) + _, readyErr := os.Stat(rel(c, cell.CopyReadyPath)) + _, lockErr := os.Stat(c.Root + ".lock") + keepCopyReady := readyErr == nil || (filepath.Base(filepath.Dir(c.Root)) == ".moving" && lockErr == nil) + _, offeredErr := os.Stat(rel(c, cell.CopyOfferedPath)) + keepCopyOffered := offeredErr == nil if err := e.ask(ctx, c, materializeOp{Head: head}, &out); err != nil { return err } + if keepCopyReady { + if err := cell.StampCopyReady(c.Root); err != nil { + return err + } + } + if keepCopyOffered { + offeredFile := rel(c, cell.CopyOfferedPath) + if _, err := os.Stat(offeredFile); os.IsNotExist(err) { + if err := writeFile(offeredFile, nil); err != nil { + return err + } + } + } if err := noteAdopted(c, head); err != nil { return err } diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go index 148ee0ef6a..70a025f95d 100644 --- a/internal/handoff/handoff.go +++ b/internal/handoff/handoff.go @@ -12,6 +12,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/fsreplace" "os" + "path/filepath" "time" "github.com/Agent-Field/codeaf/internal/cell" @@ -312,6 +313,30 @@ func freshDir(dir string) error { // staging folder is dropped. A copy is replaced by the staging folder. func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) error { defer Trace(t.OnPhase, "restore")() + // Snapshots arriving from another computer never carry this computer's local + // copy-ready or copy-offered markers, so installing over a `.moving` copy + // root must keep or re-stamp them lest its `.lock` sibling hide the chat or + // revive the untouched original. + keepCopyReady := exists(filepath.Join(c.Root, filepath.FromSlash(cell.CopyReadyPath))) || + (filepath.Base(filepath.Dir(c.Root)) == ".moving" && exists(c.Root+".lock")) + offeredFile := filepath.Join(c.Root, filepath.FromSlash(cell.CopyOfferedPath)) + keepCopyOffered := exists(offeredFile) + restoreCopyMarkers := func() error { + if keepCopyReady { + if err := cell.StampCopyReady(c.Root); err != nil { + return err + } + } + if keepCopyOffered && !exists(offeredFile) { + if err := os.MkdirAll(filepath.Dir(offeredFile), 0o700); err != nil { + return err + } + if err := os.WriteFile(offeredFile, nil, 0o600); err != nil { + return err + } + } + return nil + } if t.InPlace == nil || !t.InPlace(c) { if t.Prepare != nil { if err := freshDir(stage.Root); err != nil { @@ -324,11 +349,17 @@ func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) err return err } } - return t.replace(ctx, stage, c) + if err := t.replace(ctx, stage, c); err != nil { + return err + } + return restoreCopyMarkers() } if err := t.Fetch.Fetch(ctx, c, head); err != nil { return err } + if err := restoreCopyMarkers(); err != nil { + return err + } return os.RemoveAll(stage.Root) } From 80e9d6d35dfc5cbf8c8a5aa3d880b7477be8492d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:46:26 -0400 Subject: [PATCH 2/5] List each chat once from both the bucket and its copy folder BucketSessionDirs is now the one walk over a project bucket and its .moving copy folder. Home, recent sessions, knowledge history and session workspaces all go through it. It reads .moving at most once and does no copy lookups at all when a bucket has no copies. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_layout.go | 14 +- internal/session/copy_contract_test.go | 225 +++++++++++++++++++++++++ internal/session/knowledge_history.go | 22 +-- internal/session/recentplace.go | 39 +++-- internal/session/world.go | 175 ++++++++++++++++--- 5 files changed, 425 insertions(+), 50 deletions(-) diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 1ae4e5be93..3160e1890a 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -538,7 +538,7 @@ func v3NextSession(current session.Place, workspace string) (session.Place, erro // cannot be inherited: it is minted with the folder, below. root = workspace } - return v3MintSession(filepath.Dir(dir), root, + return v3MintSession(session.BucketOf(current.Transcript()), root, v3StampLaunchDir(v3LaunchDir(), root), current.Owned) } bucket, err := v3ProjectDir(workspace) @@ -653,16 +653,12 @@ type v3Folder struct { // deliverables is never empty whatever its journal says — those are somebody's // content, and no groom of a bookkeeping file may take them. func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { - entries, err := os.ReadDir(bucket) - if err != nil { + sessionDirs := session.BucketSessionDirs(bucket) + if len(sessionDirs) == 0 { return nil, nil } protected, known := v3TeamConversations() - for _, entry := range entries { - if !entry.IsDir() { - continue - } - dir := cell.Copied(filepath.Join(bucket, entry.Name())) + for _, dir := range sessionDirs { place := session.Place{Dir: dir} if _, err := os.Stat(place.Transcript()); err != nil { continue @@ -671,7 +667,7 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { folder.meta, _ = session.LoadMeta(dir) folder.at = folder.meta.LastUserAt if folder.at.IsZero() { - if info, err := entry.Info(); err == nil { + if info, err := os.Stat(dir); err == nil { folder.at = info.ModTime() } } diff --git a/internal/session/copy_contract_test.go b/internal/session/copy_contract_test.go index 71a4766134..3c3ff1dbda 100644 --- a/internal/session/copy_contract_test.go +++ b/internal/session/copy_contract_test.go @@ -2,12 +2,14 @@ package session import ( "context" + "fmt" "os" "path/filepath" "testing" "time" "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/store" ) @@ -68,3 +70,226 @@ func TestC29CopyCarriesTruthAndReplacesTheListRow(t *testing.T) { t.Fatal("copy edited original") } } + +// TestBucketSessionDirsKeepsMovingAndLegacyChatsOnce verifies that ReadWorld, +// Adopt, RecentSessions, SessionWorkspaces and knowledgeHistory.Inventory list +// standalone `.moving` chats alongside copied `.moving` chats and uncopied +// legacy chats, each once, while hiding unfinished `.moving` copy scratch folders. +func TestBucketSessionDirsKeepsMovingAndLegacyChatsOnce(t *testing.T) { + homeDir := t.TempDir() + t.Setenv(home.EnvVar, homeDir) + root := PlacesRoot() + bucket := filepath.Join(root, "proj") + workspace := t.TempDir() + now := time.Unix(1700001000, 0) + + writeChat := func(dir, id, title string, at time.Time) { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + place := Place{Dir: dir} + if err := os.MkdirAll(filepath.Dir(place.Transcript()), 0o700); err != nil { + t.Fatal(err) + } + body := `{"type":"session","version":1,"id":"` + id + `"}` + "\n" + + `{"type":"message","role":"user","content":"` + title + `"}` + "\n" + if err := os.WriteFile(place.Transcript(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: id, Title: title, Workspace: workspace, Created: at, LastUserAt: at}); err != nil { + t.Fatal(err) + } + } + + // 1. Legacy chat with no copy. + legacyDir := filepath.Join(bucket, "legacy-one") + writeChat(legacyDir, "legacy-one", "Legacy One", now.Add(-3*time.Minute)) + + // 2. Legacy chat with a completed `.moving` copy. + copiedOrig := filepath.Join(bucket, "copied-orig") + writeChat(copiedOrig, "copied-orig", "Stale Original", now.Add(-10*time.Minute)) + copiedCell, err := cell.CopyLegacy(t.Context(), copiedOrig, func(ctx context.Context, c cell.Cell) error { + return PrepareCopy(ctx, copiedOrig, c, nil) + }) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(copiedCell.Root, Meta{ID: copiedCell.ID, Title: "Copied Moving", Workspace: workspace, Created: now.Add(-10 * time.Minute), LastUserAt: now.Add(-2 * time.Minute)}); err != nil { + t.Fatal(err) + } + + // 3. Standalone `.moving` chat with no top-level legacy folder. + standaloneCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + writeChat(standaloneCell.Root, standaloneCell.ID, "Standalone Moving", now.Add(-1*time.Minute)) + + // 4. Unfinished `.moving` scratch copy without `copy-ready`. + unfinishedOrig := filepath.Join(bucket, "unfinished-orig") + writeChat(unfinishedOrig, "unfinished-orig", "Unfinished Original", now) + scratch := cell.CopyRoot(unfinishedOrig) + if err := os.WriteFile(scratch+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + writeChat(scratch, filepath.Base(scratch), "Half Made Scratch", now) + + world := ReadWorld(root) + rows := world.Sessions() + if len(rows) != 4 { + t.Fatalf("ReadWorld returned %d sessions, want 4: %+v", len(rows), rows) + } + recent := RecentSessions(bucket, 10) + if len(recent) != 4 { + t.Fatalf("RecentSessions returned %d summaries, want 4: %+v", len(recent), recent) + } + + // Adopt an unspoken open `.moving` shell into the bucket's project. + shellCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(shellCell.Root, Meta{ID: shellCell.ID, Workspace: workspace, Created: now}); err != nil { + t.Fatal(err) + } + shellTranscript := Place{Dir: shellCell.Root}.Transcript() + if !world.Adopt(root, SessionRow{Transcript: shellTranscript, Workspace: workspace}, now) { + t.Fatal("World.Adopt rejected an open unspoken .moving session") + } + if len(world.Sessions()) != 5 { + t.Fatalf("World.Sessions after Adopt = %d, want 5", len(world.Sessions())) + } + + workspaces := SessionWorkspaces() + for _, wantID := range []string{"legacy-one", copiedCell.ID, standaloneCell.ID, "unfinished-orig", shellCell.ID} { + if workspaces[wantID] != workspace { + t.Fatalf("SessionWorkspaces[%q] = %q, want %q", wantID, workspaces[wantID], workspace) + } + } + if got, exists := workspaces[filepath.Base(scratch)]; exists { + t.Fatalf("SessionWorkspaces answered %q for unfinished .moving scratch copy %q", got, filepath.Base(scratch)) + } + if got, exists := workspaces["copied-orig"]; exists { + t.Fatalf("SessionWorkspaces answered %q for replaced legacy original \"copied-orig\"", got) + } + + journals, err := (knowledgeHistory{root: root}).Inventory(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(journals) != 4 { + t.Fatalf("knowledgeHistory.Inventory returned %d journals, want 4: %+v", len(journals), journals) + } +} + +// TestBucketWithoutMovingMakesNoCopyLookups counts copy-root and copy-resolution +// lookups through seams so a bucket with many chats and no `.moving` directory +// pays zero per-folder copy work, and a bucket with `.moving` resolves only the +// originals whose copy roots exist in `.moving` without resolving a second time +// in readSessionRow. +func TestBucketWithoutMovingMakesNoCopyLookups(t *testing.T) { + homeDir := t.TempDir() + t.Setenv(home.EnvVar, homeDir) + root := PlacesRoot() + bucket := filepath.Join(root, "many-chats") + workspace := t.TempDir() + now := time.Unix(1700010000, 0) + + const totalChats = 200 + var sampleTranscripts []string + for i := 0; i < totalChats; i++ { + id := fmt.Sprintf("chat-%04d", i) + dir := filepath.Join(bucket, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + place := Place{Dir: dir} + body := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"chat %d\"}\n", id, i) + if err := os.WriteFile(place.Transcript(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + at := now.Add(time.Duration(i) * time.Second) + if err := SaveMeta(dir, Meta{ID: id, Title: fmt.Sprintf("Chat %d", i), Workspace: workspace, Created: at, LastUserAt: at}); err != nil { + t.Fatal(err) + } + if i < 10 { + sampleTranscripts = append(sampleTranscripts, place.Transcript()) + } + } + + var copyRootCalls, copiedCalls, openAtCalls int + prevCopyRoot, prevCopied, prevOpenAt := bucketCopyRoot, bucketCopied, bucketOpenAt + bucketCopyRoot = func(original string) string { + copyRootCalls++ + return prevCopyRoot(original) + } + bucketCopied = func(original string) string { + copiedCalls++ + return prevCopied(original) + } + bucketOpenAt = func(root, id string) (cell.Cell, error) { + openAtCalls++ + return prevOpenAt(root, id) + } + t.Cleanup(func() { + bucketCopyRoot = prevCopyRoot + bucketCopied = prevCopied + bucketOpenAt = prevOpenAt + }) + + t.Run("no_moving_folder", func(t *testing.T) { + copyRootCalls, copiedCalls, openAtCalls = 0, 0, 0 + dirs := BucketSessionDirs(bucket) + if len(dirs) != totalChats { + t.Fatalf("BucketSessionDirs returned %d dirs, want %d", len(dirs), totalChats) + } + world := ReadWorld(root) + if got := len(world.Sessions()); got != totalChats { + t.Fatalf("ReadWorld returned %d sessions, want %d", got, totalChats) + } + workspaces := SessionWorkspaces() + if len(workspaces) != totalChats { + t.Fatalf("SessionWorkspaces returned %d entries, want %d", len(workspaces), totalChats) + } + rows := ReadRows(sampleTranscripts) + if len(rows) != len(sampleTranscripts) { + t.Fatalf("ReadRows returned %d rows, want %d", len(rows), len(sampleTranscripts)) + } + if copyRootCalls != 0 || copiedCalls != 0 || openAtCalls != 0 { + t.Fatalf("bucket without .moving made copy lookups: copyRoot=%d copied=%d openAt=%d, want 0/0/0", + copyRootCalls, copiedCalls, openAtCalls) + } + }) + + t.Run("one_copy_among_many_chats", func(t *testing.T) { + copiedOrig := filepath.Join(bucket, "chat-0000") + copiedCell, err := cell.CopyLegacy(t.Context(), copiedOrig, func(ctx context.Context, c cell.Cell) error { + return PrepareCopy(ctx, copiedOrig, c, nil) + }) + if err != nil { + t.Fatal(err) + } + if err := SaveMeta(copiedCell.Root, Meta{ + ID: copiedCell.ID, + Title: "Copied Chat 0", + Workspace: workspace, + Created: now, + LastUserAt: now.Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + + copyRootCalls, copiedCalls, openAtCalls = 0, 0, 0 + world := ReadWorld(root) + if got := len(world.Sessions()); got != totalChats { + t.Fatalf("ReadWorld returned %d sessions, want %d", got, totalChats) + } + if copiedCalls != 1 { + t.Fatalf("ReadWorld called cell.Copied %d times for 1 copy among %d chats, want 1 (no uncopied lookups and no double lookup in readSessionRow)", + copiedCalls, totalChats) + } + if openAtCalls != 0 { + t.Fatalf("ReadWorld called extra cell.OpenAt %d times, want 0", openAtCalls) + } + }) +} diff --git a/internal/session/knowledge_history.go b/internal/session/knowledge_history.go index a0b61418e6..4098d2cea1 100644 --- a/internal/session/knowledge_history.go +++ b/internal/session/knowledge_history.go @@ -16,7 +16,6 @@ import ( "strings" "sync" - "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/knowledge" ) @@ -198,17 +197,20 @@ func (h knowledgeHistory) Inventory(ctx context.Context) ([]knowledge.Journal, e if err = ctx.Err(); err != nil { return nil, err } - path := filepath.Join(dir, entry.Name()) - if !entry.IsDir() { - if strings.HasSuffix(entry.Name(), ".jsonl") { - owners[strings.TrimSuffix(entry.Name(), ".jsonl")] = path - if err = add(path, strings.TrimSuffix(entry.Name(), ".jsonl"), "", false); err != nil { - return nil, err - } - } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { continue } - place := Place{Dir: cell.Copied(path)} + path := filepath.Join(dir, entry.Name()) + owners[strings.TrimSuffix(entry.Name(), ".jsonl")] = path + if err = add(path, strings.TrimSuffix(entry.Name(), ".jsonl"), "", false); err != nil { + return nil, err + } + } + for _, sessionDir := range BucketSessionDirs(dir) { + if err = ctx.Err(); err != nil { + return nil, err + } + place := Place{Dir: sessionDir} owners[place.ID()] = place.Transcript() if _, err = os.Stat(filepath.Join(place.Dir, conversationDeletedFile)); err == nil { deletedOwners[place.ID()] = true diff --git a/internal/session/recentplace.go b/internal/session/recentplace.go index 78472eb7ea..d0fc0975b0 100644 --- a/internal/session/recentplace.go +++ b/internal/session/recentplace.go @@ -25,7 +25,6 @@ package session import ( - "github.com/Agent-Field/codeaf/internal/cell" "os" "path/filepath" "sort" @@ -45,6 +44,7 @@ func RecentSessions(dir string, limit int) []Summary { return nil } type candidate struct { + id string file string meta Meta at time.Time @@ -52,22 +52,25 @@ func RecentSessions(dir string, limit int) []Summary { files := make([]candidate, 0, len(entries)) for _, entry := range entries { name := entry.Name() - if !entry.IsDir() { - if !strings.HasSuffix(name, ".jsonl") { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - files = append(files, candidate{file: filepath.Join(dir, name), at: info.ModTime()}) + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { continue } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, candidate{ + id: strings.TrimSuffix(name, ".jsonl"), + file: filepath.Join(dir, name), + at: info.ModTime(), + }) + } + for _, sessionDir := range BucketSessionDirs(dir) { // A DIRECTORY IS A SESSION WHEN IT HOLDS A TRANSCRIPT, asked through // [Place] so that where a transcript lives inside a folder is decided in // one file. Everything else under the v3 home — locks, the index, a // directory somebody made by hand — simply has no transcript in it. - place := Place{Dir: cell.Copied(filepath.Join(dir, name))} + place := Place{Dir: sessionDir} info, err := os.Stat(place.Transcript()) if err != nil || info.IsDir() { continue @@ -77,21 +80,33 @@ func RecentSessions(dir string, limit int) []Summary { if at.IsZero() { at = info.ModTime() } - files = append(files, candidate{file: place.Transcript(), meta: meta, at: at}) + files = append(files, candidate{ + id: firstWord(meta.ID, place.ID()), + file: place.Transcript(), + meta: meta, + at: at, + }) } // The cheap approximation of "newest" decides which files are worth opening, // and what the files themselves said decides the answer's order. sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) }) found := make([]Summary, 0, limit) + seenID := make(map[string]bool, limit) for read, file := range files { if len(found) >= limit || read >= peekBudget { break } + if file.id != "" && seenID[file.id] { + continue + } summary, ok := Peek(file.file) if !ok { continue } + if file.id != "" { + seenID[file.id] = true + } // Peek records the path it opened. The listing sets it again so the // path a mention resolves is the transcript this walk opened. summary.File = file.file diff --git a/internal/session/world.go b/internal/session/world.go index 50ac98416e..5785de7185 100644 --- a/internal/session/world.go +++ b/internal/session/world.go @@ -473,15 +473,7 @@ func SessionWorkspaces() map[string]string { if !bucket.IsDir() { continue } - sessions, err := os.ReadDir(filepath.Join(root, bucket.Name())) - if err != nil { - continue - } - for _, entry := range sessions { - if !entry.IsDir() { - continue - } - dir := filepath.Join(root, bucket.Name(), entry.Name()) + for _, dir := range BucketSessionDirs(filepath.Join(root, bucket.Name())) { meta, err := LoadMeta(dir) if err != nil || meta.ID == "" || strings.TrimSpace(meta.Workspace) == "" { continue @@ -492,6 +484,120 @@ func SessionWorkspaces() map[string]string { return answered } +var ( + bucketCopyRoot = cell.CopyRoot + bucketCopied = cell.Copied + bucketOpenAt = cell.OpenAt +) + +// BucketSessionDirs returns every conversation folder in one project bucket, +// whether it lives at the bucket's top level or in the bucket's `.moving` copy +// root, each once. A completed copy in `.moving` replaces its untouched legacy +// original, while an unfinished copy scratch folder stays hidden so a half-made +// copy can never appear beside or instead of the original. +// +// It reads `.moving` at most once up front and checks copy roots only for +// originals whose destination name is present in that `.moving` directory; a +// bucket with no `.moving` directory pays zero per-folder copy work. +func BucketSessionDirs(bucket string) []string { + entries, err := os.ReadDir(bucket) + if err != nil { + return nil + } + hasMoving := false + for _, entry := range entries { + if entry.Name() == ".moving" { + hasMoving = true + break + } + } + var ( + movingDir string + movingEntries []os.DirEntry + movingDirs map[string]bool + movingLocks map[string]bool + ) + if hasMoving { + movingDir = filepath.Join(bucket, ".moving") + if list, readErr := os.ReadDir(movingDir); readErr == nil { + movingEntries = list + for _, entry := range list { + name := entry.Name() + if strings.HasSuffix(name, ".lock") { + if movingLocks == nil { + movingLocks = make(map[string]bool) + } + movingLocks[strings.TrimSuffix(name, ".lock")] = true + continue + } + if !entry.IsDir() || skipBucketDirName(name) { + continue + } + if movingDirs == nil { + movingDirs = make(map[string]bool, len(list)) + } + movingDirs[name] = true + } + } + } + seen := make(map[string]bool, len(entries)+len(movingDirs)) + dirs := make([]string, 0, len(entries)+len(movingDirs)) + var claimedCopy map[string]bool + if len(movingDirs) > 0 { + claimedCopy = make(map[string]bool, len(movingDirs)) + } + for _, entry := range entries { + if !entry.IsDir() || skipBucketDirName(entry.Name()) { + continue + } + orig := filepath.Clean(filepath.Join(bucket, entry.Name())) + resolved := orig + if len(movingDirs) > 0 { + copyName := filepath.Base(bucketCopyRoot(orig)) + if movingDirs[copyName] { + claimedCopy[copyName] = true + resolved = filepath.Clean(bucketCopied(orig)) + } + } + if !seen[resolved] { + seen[resolved] = true + dirs = append(dirs, resolved) + } + } + for _, entry := range movingEntries { + name := entry.Name() + if !entry.IsDir() || skipBucketDirName(name) || claimedCopy[name] { + continue + } + mDir := filepath.Clean(filepath.Join(movingDir, name)) + if seen[mDir] { + continue + } + if movingLocks[name] { + // CopyLegacy creates `.lock` before populating `.moving/` + // and stamps `copy-ready` only after sealing; if the legacy original + // is gone, an unfinished copy still stays hidden. + if _, readyErr := os.Stat(filepath.Join(mDir, filepath.FromSlash(cell.CopyReadyPath))); readyErr != nil { + continue + } + if _, openErr := bucketOpenAt(mDir, name); openErr != nil { + continue + } + } + seen[mDir] = true + dirs = append(dirs, mDir) + } + return dirs +} + +func skipBucketDirName(name string) bool { + return strings.HasPrefix(name, ".") || + strings.HasSuffix(name, ".lock") || + strings.HasSuffix(name, ".taking") || + strings.HasSuffix(name, ".arriving") || + strings.HasSuffix(name, ".replaced") +} + // ReadRows is the rows of the named conversations and of nothing else, keyed by // each transcript's cleaned path: one [readSessionRow] per name, read exactly as // the walk reads that folder. @@ -549,7 +655,7 @@ func (w *World) Adopt(root string, seed SessionRow, now time.Time) bool { if transcript == "." || !isFolder { return false } - bucketDir := filepath.Dir(dir) + bucketDir := BucketOf(transcript) if filepath.Dir(bucketDir) != filepath.Clean(strings.TrimSpace(root)) { return false } @@ -656,8 +762,8 @@ func readWorld(root string, now time.Time) World { // launch that opened and closed is not a project, and a section header over no // rows is a heading that says nothing. func readProject(dir, bucket string, now time.Time) (Project, bool) { - entries, err := os.ReadDir(dir) - if err != nil { + sessionDirs := BucketSessionDirs(dir) + if len(sessionDirs) == 0 { return Project{}, false } // The index is the BUCKET's, read once for every session in it @@ -673,11 +779,9 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { } project := Project{Bucket: bucket, Dir: dir} - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - row, ok := readSessionRow(filepath.Join(dir, entry.Name()), entry.Name(), now) + byID := make(map[string]int, len(sessionDirs)) + for _, sessionDir := range sessionDirs { + row, ok := readResolvedSessionRow(sessionDir, filepath.Base(sessionDir), now) if !ok { continue } @@ -685,6 +789,13 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { project.Path = projectPath(row) } row.Tasks = rollUp(byTask[row.ID], row) + if idx, exists := byID[row.ID]; exists { + if preferSessionRow(row, project.Sessions[idx]) { + project.Sessions[idx] = row + } + continue + } + byID[row.ID] = len(project.Sessions) project.Sessions = append(project.Sessions, row) } if len(project.Sessions) == 0 { @@ -719,6 +830,20 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { return project, true } +func preferSessionRow(candidate, existing SessionRow) bool { + if candidate.Open != existing.Open { + return candidate.Open + } + if candidate.Archived != existing.Archived { + return !candidate.Archived + } + if !candidate.At.Equal(existing.At) { + return candidate.At.After(existing.At) + } + return filepath.Base(filepath.Dir(candidate.Dir)) == ".moving" && + filepath.Base(filepath.Dir(existing.Dir)) != ".moving" +} + // readSessionRow reads one session folder, and answers false for a folder that // is not a conversation somebody has had. // @@ -731,9 +856,21 @@ func readProject(dir, bucket string, now time.Time) (Project, bool) { // cannot say what it is, stays, because hiding somebody's conversation on the // strength of a lookup file is the more expensive mistake. func readSessionRow(dir, id string, now time.Time) (SessionRow, bool) { - if copy := cell.Copied(dir); copy != dir { - dir, id = copy, filepath.Base(copy) + // Callers such as ReadRows pass an explicit folder path rather than a + // pre-resolved BucketSessionDirs entry; resolve a `.moving` copy only when + // the folder sits at the bucket's top level and the bucket actually has a + // `.moving` directory. + if filepath.Base(filepath.Dir(dir)) != ".moving" { + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), ".moving")); err == nil { + if copy := bucketCopied(dir); copy != dir { + dir, id = copy, filepath.Base(copy) + } + } } + return readResolvedSessionRow(dir, id, now) +} + +func readResolvedSessionRow(dir, id string, now time.Time) (SessionRow, bool) { pending := false if _, err := os.Stat(filepath.Join(dir, conversationDeletedFile)); err == nil { if _, err := os.Stat(filepath.Join(dir, conversationTaskCleanupFile)); err != nil { From 1c017afc221112d0240869f958564f7f0f9bb5c8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:46:26 -0400 Subject: [PATCH 3/5] Sync off keeps every chat on this computer listed, open and writable With sync off, or quiet before pairing, a completed copy still opens, saved hold receipts stay dormant so a local send never waits on them, and no sync drive starts. A displaced window still refuses to send. Doctor skips unfinished copy folders and sends nothing but one read to the sync service. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/doctor_repair.go | 6 +- cmd/codeaf/doctor_repair_test.go | 114 ++++ cmd/codeaf/olderchats.go | 8 +- cmd/codeaf/sync_off_home_test.go | 494 ++++++++++++++++++ cmd/codeaf/syncdrive.go | 2 +- cmd/codeaf/synctake.go | 19 + internal/manual/chat/home.md | 6 + .../manual/chat/relay-hosted-or-your-own.md | 2 +- internal/manual/chat_test.go | 39 ++ internal/tui3/displaced_offline_send_test.go | 32 ++ 10 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 cmd/codeaf/sync_off_home_test.go diff --git a/cmd/codeaf/doctor_repair.go b/cmd/codeaf/doctor_repair.go index ee0e723b4b..447deb9019 100644 --- a/cmd/codeaf/doctor_repair.go +++ b/cmd/codeaf/doctor_repair.go @@ -164,9 +164,13 @@ func scanLocalChatInventory() localChatInventory { if strings.HasSuffix(base, ".replaced") { continue } + if filepath.Base(filepath.Dir(entry)) == ".moving" && fileExists(entry+".lock") && !fileExists(filepath.Join(entry, filepath.FromSlash(cell.CopyReadyPath))) { + continue + } hasTranscript := fileExists(session.Place{Dir: entry}.Transcript()) hasCell := fileExists(filepath.Join(entry, filepath.FromSlash(cell.MetaPath))) - if hasTranscript || hasCell { + hasMeta := fileExists(filepath.Join(entry, "meta.json")) + if hasTranscript || hasCell || hasMeta { id := base if meta, err := session.LoadMeta(entry); err == nil && strings.TrimSpace(meta.ID) != "" { id = strings.TrimSpace(meta.ID) diff --git a/cmd/codeaf/doctor_repair_test.go b/cmd/codeaf/doctor_repair_test.go index 7cff4fcb8d..dcf83e00aa 100644 --- a/cmd/codeaf/doctor_repair_test.go +++ b/cmd/codeaf/doctor_repair_test.go @@ -5,10 +5,12 @@ import ( "bytes" "context" "encoding/json" + "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" @@ -372,3 +374,115 @@ func TestDoctorFindsAndRepairsSyncAndEngineStoreSafely(t *testing.T) { t.Fatalf("doctor --fix deleted .taking folder containing unique turns: %v", err) } } + +// TestDoctorDoesNotCreateRecordsOnSyncService verifies that read-only +// `codeaf doctor` makes only a read-only listing call under the person's own +// identity and creates no namespaces, devices, cells, or Raft stores on the +// sync service (and makes zero requests when sync is off or unpaired). +func TestDoctorDoesNotCreateRecordsOnSyncService(t *testing.T) { + celltest.Available(t) + storeDir := t.TempDir() + svc := relayserve.New(relayserve.Config{Store: storeDir}) + t.Cleanup(func() { _ = svc.Close() }) + + var mu sync.Mutex + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + svc.Handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + + homeA := t.TempDir() + t.Setenv(home.EnvVar, homeA) + t.Setenv(syncsetup.URLVar, srv.URL) + + if _, err := identity.Ensure(homeA); err != nil { + t.Fatal(err) + } + syncA, ok, err := syncsetup.Open(homeA) + if err != nil || !ok { + t.Fatalf("syncsetup.Open(homeA) = (%v, %v)", ok, err) + } + if err := syncsetup.PutOwnDevice(context.Background(), syncA.Dir, syncA.Identity, syncA.Device, "desk"); err != nil { + t.Fatal(err) + } + + readNames := func(dir string) []string { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + slices.Sort(names) + return names + } + + dirNamesBefore := readNames(filepath.Join(storeDir, "directory")) + raftNamesBefore := readNames(filepath.Join(storeDir, "raft")) + + mu.Lock() + requests = nil + mu.Unlock() + + dbPath := filepath.Join(t.TempDir(), "graph.db") + var out bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &out, 0); err != nil { + t.Fatal(err) + } + + mu.Lock() + gotRequests := slices.Clone(requests) + requests = nil + mu.Unlock() + + if !slices.Equal(gotRequests, []string{"GET /v1/dir/list"}) { + t.Fatalf("doctor HTTP requests = %v, want only [GET /v1/dir/list]", gotRequests) + } + if got := readNames(filepath.Join(storeDir, "directory")); !slices.Equal(got, dirNamesBefore) { + t.Fatalf("relay directory files changed after doctor: got %v, want %v", got, dirNamesBefore) + } + if got := readNames(filepath.Join(storeDir, "raft")); !slices.Equal(got, raftNamesBefore) { + t.Fatalf("relay raft files changed after doctor: got %v, want %v", got, raftNamesBefore) + } + + // Sync off: zero HTTP requests. + t.Setenv(syncsetup.URLVar, "off") + var offOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &offOut, 0); err != nil { + t.Fatal(err) + } + mu.Lock() + offRequests := slices.Clone(requests) + requests = nil + mu.Unlock() + if len(offRequests) != 0 { + t.Fatalf("doctor sent %v with CODEAF_SYNC_URL=off, want none", offRequests) + } + + // Unpaired solo home: zero HTTP requests. + soloHome := t.TempDir() + if _, err := identity.EnsureSolo(soloHome); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, soloHome) + t.Setenv(syncsetup.URLVar, srv.URL) + var soloOut bytes.Buffer + if err := runDoctorWith([]string{"--db", dbPath}, &soloOut, 0); err != nil { + t.Fatal(err) + } + mu.Lock() + soloRequests := slices.Clone(requests) + mu.Unlock() + if len(soloRequests) != 0 { + t.Fatalf("doctor sent %v on unpaired solo home, want none", soloRequests) + } +} diff --git a/cmd/codeaf/olderchats.go b/cmd/codeaf/olderchats.go index c473b36f77..09a5ad2037 100644 --- a/cmd/codeaf/olderchats.go +++ b/cmd/codeaf/olderchats.go @@ -164,8 +164,11 @@ func olderChatCopyable(meta session.Meta) bool { // movablePlace resolves a completed copy or prepares one when the person opens // an older chat. Selection runs on the existing asynchronous opening door. +// Sync off means not talking to the service, never forgetting a completed copy +// already on this computer, so an existing `.moving` copy is opened even when +// sync is quiet or off. func movablePlace(ctx context.Context, place session.Place) (session.Place, error) { - if place.Dir == "" || !cell.Available() || !syncsetup.MayTalk(home.Dir()) { + if place.Dir == "" { return place, nil } if _, err := cell.OpenAt(place.Dir, place.ID()); err == nil { @@ -174,6 +177,9 @@ func movablePlace(ctx context.Context, place session.Place) (session.Place, erro original := place.Dir copied := cell.Copied(original) if copied == original { + if !cell.Available() || !syncsetup.MayTalk(home.Dir()) { + return place, nil + } if _, err := os.Stat(filepath.Join(original, "transcript.jsonl")); os.IsNotExist(err) { return place, nil } diff --git a/cmd/codeaf/sync_off_home_test.go b/cmd/codeaf/sync_off_home_test.go new file mode 100644 index 0000000000..488de02fdb --- /dev/null +++ b/cmd/codeaf/sync_off_home_test.go @@ -0,0 +1,494 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +type syncOffFrameCapture struct { + mu sync.Mutex + bytes.Buffer +} + +func (c *syncOffFrameCapture) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.Buffer.Write(p) +} + +func (c *syncOffFrameCapture) Plain() string { + c.mu.Lock() + raw := c.Buffer.String() + c.mu.Unlock() + return ansi.Strip(raw) +} + +// TestSyncOffHomeKeepsEveryChatOpensAndWrites verifies Contract 1 end to end: +// with CODEAF_SYNC_URL=off (or sync otherwise off), Home lists every chat on +// this computer — current ones in `.moving` and legacy ones alike — each once, +// never talks to the sync service, and each chat opens and can be written. +func TestSyncOffHomeKeepsEveryChatOpensAndWrites(t *testing.T) { + celltest.Available(t) + + var modelTurns atomic.Int64 + modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/models") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"test/model"}]}`)) + return + } + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + seq := modelTurns.Add(1) + reply := fmt.Sprintf("sync-off-reply-%d", seq) + var req struct { + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + } + if json.Unmarshal(body, &req) == nil { + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role == "user" { + if s, ok := req.Messages[i].Content.(string); ok && strings.TrimSpace(s) != "" { + reply = "ack:" + strings.TrimSpace(s) + break + } + } + } + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}]}\n\n"+ + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", reply) + })) + t.Cleanup(modelServer.Close) + + for _, mode := range []string{"CODEAF_SYNC_URL_off", "sync_otherwise_off"} { + t.Run(mode, func(t *testing.T) { + var relayRequests atomic.Int64 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + relayRequests.Add(1) + http.Error(w, "sync is off; relay must not be contacted", http.StatusForbidden) + })) + t.Cleanup(relay.Close) + + shortHome, err := os.MkdirTemp("", "af2072") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + sockets, _ := filepath.Glob(filepath.Join(shortHome, "v3", "engine-*.sock")) + for _, socket := range sockets { + _ = (cellstore.Daemon{Socket: socket}).Stop(context.Background()) + } + _ = os.RemoveAll(shortHome) + }) + + t.Setenv("HOME", shortHome) + t.Setenv(home.EnvVar, shortHome) + t.Setenv("CODEAF_PROFILE_DIR", shortHome) + t.Setenv("OPENROUTER_API_KEY", "smoke-test-key") + t.Setenv("CODEAF_BASE_URL", modelServer.URL) + t.Setenv("CODEAF_MODEL", "test/model") + t.Setenv("CODEAF_DAILY_BUDGET", "0") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("CODEAF_TELEMETRY_ENDPOINT", "") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv(codeupdate.NoUpdateCheckEnv, "1") + t.Setenv("CODEAF_NO_AUTOMATIONS", "1") + + workspace := t.TempDir() + for _, dir := range []string{"objects", "refs/heads"} { + if err := os.MkdirAll(filepath.Join(workspace, ".git", dir), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(workspace, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o600); err != nil { + t.Fatal(err) + } + + bucket, err := v3ProjectDir(workspace) + if err != nil { + t.Fatal(err) + } + baseAt := time.Now().UTC().Truncate(time.Second) + + seedSpokenTranscript := func(path, id, firstPrompt, firstReply string) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf( + "{\"type\":\"session\",\"version\":1,\"id\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"user\",\"content\":%q}\n"+ + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":%q}\n", + id, firstPrompt, firstReply, + ) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + + // 1. Uncopied legacy chat at /. + const legacyID = "01J00000000000000000LEGACY" + legacyDir := filepath.Join(bucket, legacyID) + seedSpokenTranscript(filepath.Join(legacyDir, "transcript.jsonl"), legacyID, "Legacy Uncopied Chat", "initial legacy answer") + if err := session.SaveMeta(legacyDir, session.Meta{ + ID: legacyID, + Title: "Legacy Uncopied Chat", + Workspace: workspace, + Created: baseAt.Add(-10 * time.Minute), + LastUserAt: baseAt.Add(-4 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 2. Legacy chat at / with a completed copy in /.moving/. + const copiedOrigID = "01J00000000000000000COPIED" + copiedOrigDir := filepath.Join(bucket, copiedOrigID) + seedSpokenTranscript(filepath.Join(copiedOrigDir, "transcript.jsonl"), copiedOrigID, "Stale Original Title", "before copy answer") + if err := session.SaveMeta(copiedOrigDir, session.Meta{ + ID: copiedOrigID, + Title: "Stale Original Title", + Workspace: workspace, + Created: baseAt.Add(-9 * time.Minute), + LastUserAt: baseAt.Add(-8 * time.Minute), + }); err != nil { + t.Fatal(err) + } + copiedCell, err := cell.CopyLegacy(context.Background(), copiedOrigDir, func(ctx context.Context, c cell.Cell) error { + return session.PrepareCopy(ctx, copiedOrigDir, c, nil) + }) + if err != nil { + t.Fatalf("CopyLegacy: %v", err) + } + seedSpokenTranscript(filepath.Join(copiedCell.Root, filepath.FromSlash(cell.TranscriptPath)), copiedCell.ID, "Copied Moving Chat", "after copy answer") + if err := session.SaveMeta(copiedCell.Root, session.Meta{ + ID: copiedCell.ID, + Title: "Copied Moving Chat", + Workspace: workspace, + Created: baseAt.Add(-9 * time.Minute), + LastUserAt: baseAt.Add(-3 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 3. Standalone current chat in /.moving/ with no top-level legacy folder. + standaloneCell, err := cell.CreateIn(filepath.Join(bucket, ".moving"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatalf("CreateIn(.moving): %v", err) + } + seedSpokenTranscript(filepath.Join(standaloneCell.Root, filepath.FromSlash(cell.TranscriptPath)), standaloneCell.ID, "Standalone Moving Chat", "initial moving answer") + if err := session.SaveMeta(standaloneCell.Root, session.Meta{ + ID: standaloneCell.ID, + Title: "Standalone Moving Chat", + Workspace: workspace, + Created: baseAt.Add(-7 * time.Minute), + LastUserAt: baseAt.Add(-2 * time.Minute), + }); err != nil { + t.Fatal(err) + } + + // 4. Legacy chat whose `.moving` scratch copy has NOT finished (`copy-ready` absent). + const unfinishedID = "01J0000000000000UNFINISHED" + unfinishedDir := filepath.Join(bucket, unfinishedID) + seedSpokenTranscript(filepath.Join(unfinishedDir, "transcript.jsonl"), unfinishedID, "Unfinished Copy Chat", "unfinished copy answer") + if err := session.SaveMeta(unfinishedDir, session.Meta{ + ID: unfinishedID, + Title: "Unfinished Copy Chat", + Workspace: workspace, + Created: baseAt.Add(-6 * time.Minute), + LastUserAt: baseAt.Add(-1 * time.Minute), + }); err != nil { + t.Fatal(err) + } + unfinishedScratch := cell.CopyRoot(unfinishedDir) + if err := os.WriteFile(unfinishedScratch+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + seedSpokenTranscript(filepath.Join(unfinishedScratch, filepath.FromSlash(cell.TranscriptPath)), filepath.Base(unfinishedScratch), "half made", "half made") + if err := session.SaveMeta(unfinishedScratch, session.Meta{ + ID: filepath.Base(unfinishedScratch), + Title: "Half Made Scratch Copy", + Workspace: workspace, + Created: baseAt.Add(-5 * time.Minute), + LastUserAt: baseAt, + }); err != nil { + t.Fatal(err) + } + + // Verify v3NextSession from a `.moving` chat mints into the project bucket, never nested `.moving/.moving`. + sibling, err := v3NextSession(session.Place{Dir: standaloneCell.Root, Workspace: workspace}, workspace) + if err != nil { + t.Fatalf("v3NextSession from .moving chat: %v", err) + } + if session.BucketOf(sibling.Transcript()) != filepath.Clean(bucket) || strings.Contains(sibling.Dir, filepath.Join(".moving", ".moving")) { + t.Fatalf("v3NextSession minted sibling in wrong directory: %s (bucket=%s)", sibling.Dir, bucket) + } + _ = os.RemoveAll(sibling.Dir) + + // Seed an identity and a saved hold receipt from an earlier sync session so + // turning sync off exercises previously-held `.moving` chats as well. + var id identity.Identity + switch mode { + case "CODEAF_SYNC_URL_off": + id, err = identity.Ensure(shortHome) + if err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "off") + case "sync_otherwise_off": + // Solo unpaired home with a configured relay URL: sync is quiet/off + // and must neither talk to the service nor block local chats. + id, err = identity.EnsureSolo(shortHome) + if err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, relay.URL) + } + dev, err := identity.LoadDevice(shortHome) + if err != nil { + t.Fatal(err) + } + ledger := cellstore.LedgerName(relay.URL, id.ID()) + holdSum := sha256.Sum256([]byte(ledger + "/" + dev.ID() + "/" + standaloneCell.ID)) + holdPath := filepath.Join(shortHome, "v3", "sync", "holds", hex.EncodeToString(holdSum[:])) + if err := os.MkdirAll(filepath.Dir(holdPath), 0o700); err != nil { + t.Fatal(err) + } + holdRaw, _ := json.Marshal(map[string]any{"cell": standaloneCell.ID, "fence": 9}) + if err := os.WriteFile(holdPath, holdRaw, 0o600); err != nil { + t.Fatal(err) + } + + // Check 1: ReadHome lists every chat on this computer, each once. + world := session.ReadHome() + rows := world.Sessions() + if len(rows) != 4 { + t.Fatalf("ReadHome() returned %d chats, want 4: %+v", len(rows), rows) + } + wantByTitle := map[string]string{ + "Legacy Uncopied Chat": filepath.Join(legacyDir, "transcript.jsonl"), + "Copied Moving Chat": filepath.Join(copiedCell.Root, filepath.FromSlash(cell.TranscriptPath)), + "Standalone Moving Chat": filepath.Join(standaloneCell.Root, filepath.FromSlash(cell.TranscriptPath)), + "Unfinished Copy Chat": filepath.Join(unfinishedDir, "transcript.jsonl"), + } + seenTitles := map[string]int{} + for _, row := range rows { + seenTitles[row.Title]++ + wantTranscript, ok := wantByTitle[row.Title] + if !ok { + t.Fatalf("unexpected row on Home: title=%q id=%q transcript=%q", row.Title, row.ID, row.Transcript) + } + if filepath.Clean(row.Transcript) != filepath.Clean(wantTranscript) { + t.Fatalf("row %q transcript = %s, want %s", row.Title, row.Transcript, wantTranscript) + } + } + for title := range wantByTitle { + if seenTitles[title] != 1 { + t.Fatalf("Home listed %q %d times, want 1", title, seenTitles[title]) + } + } + + // Opening the legacy original path with sync off still resolves to its existing `.moving` copy. + resolvedCopy, err := movablePlace(context.Background(), session.Place{Dir: copiedOrigDir, Workspace: workspace}) + if err != nil { + t.Fatalf("movablePlace(copiedOrigDir): %v", err) + } + if filepath.Clean(resolvedCopy.Dir) != filepath.Clean(copiedCell.Root) { + t.Fatalf("movablePlace(copiedOrigDir) = %s, want existing copy %s", resolvedCopy.Dir, copiedCell.Root) + } + + // Check 2: Drive the real Home TUI listing through runSurface (`Landing: true`). + oldRun := runSurfaceProgram + oldExecutable := surfaceExecutable + oldRunningExecutable := surfaceRunningExecutable + t.Cleanup(func() { + runSurfaceProgram = oldRun + surfaceExecutable = oldExecutable + surfaceRunningExecutable = oldRunningExecutable + }) + fakeExe := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(fakeExe, []byte("exe"), 0o755); err != nil { + t.Fatal(err) + } + surfaceExecutable = func(func() (string, error)) (string, error) { return fakeExe, nil } + surfaceRunningExecutable = func() (string, error) { return fakeExe, nil } + + runWindow := func(landing bool, sessionFile string, interact func(in io.Writer, frame *syncOffFrameCapture)) { + proc, err := openV3ProcessWith("chat", true) + if err != nil { + t.Fatalf("openV3ProcessWith: %v", err) + } + defer proc.closeAll() + + seed := v3Options{ + Model: "test/model", + Interactive: true, + Yolo: true, + OneModel: true, + Session: sessionFile, + } + launch, err := openV3Launch(proc, seed) + if err != nil { + t.Fatalf("openV3Launch(%q): %v", sessionFile, err) + } + cfg := launch.Config + cfg.AskConsent = true + cfg, open := v3Shape(cfg, v3LanesHere()) + agent, cfg, _, err := openV3Agent(cfg, launch.Project, open) + if err != nil { + t.Fatalf("openV3Agent(%q): %v", sessionFile, err) + } + proc.track(agent) + settled := *launch + settled.Config, settled.Place = cfg, cfg.Place + settled.SessionFile = cfg.SessionFile + seam := &v3Seam{proc: proc, boot: &settled, seed: seed} + + frame := &syncOffFrameCapture{} + inR, inW := io.Pipe() + defer inR.Close() + defer inW.Close() + + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + options.Input = inR + options.Output = frame + options.Width, options.Height = 120, 40 + return tui3.Run(ctx, options) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- runSurface(ctx, tui3.Options{ + Agent: agent, + ProfileDir: proc.ProfileDir, + Workspace: cfg.Workspace, + Owned: cfg.Place.Owned, + SessionFile: cfg.SessionFile, + Resumed: launch.Resumed, + Landing: landing, + Start: seam.start, + Open: seam.resume, + }) + }() + + interact(inW, frame) + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("runSurface exit: %v", err) + } + } + + // Boot a fresh landing window in `workspace` and verify the real Home listing renders every chat once. + origWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origWD) }) + + runWindow(true, "", func(_ io.Writer, frame *syncOffFrameCapture) { + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + plain := frame.Plain() + allPresent := true + for title := range wantByTitle { + if !strings.Contains(plain, title) { + allPresent = false + break + } + } + if allPresent { + for _, unwanted := range []string{"Stale Original Title", "Half Made Scratch Copy"} { + if strings.Contains(plain, unwanted) { + t.Fatalf("Home rendered hidden/stale chat %q:\n%s", unwanted, plain) + } + } + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("Home screen did not list all 4 chats before deadline:\n%s", frame.Plain()) + }) + + // Check 3: Open each chat on Home and write a new turn through the real surface. + for _, title := range []string{ + "Legacy Uncopied Chat", + "Copied Moving Chat", + "Standalone Moving Chat", + "Unfinished Copy Chat", + } { + transcriptPath := wantByTitle[title] + prompt := "write while sync off for " + title + expectedReply := "ack:" + prompt + + runWindow(false, transcriptPath, func(in io.Writer, frame *syncOffFrameCapture) { + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if frame.Plain() != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + if _, err := io.WriteString(in, prompt+"\r"); err != nil { + t.Fatalf("write input for %q: %v", title, err) + } + writeDeadline := time.Now().Add(15 * time.Second) + for time.Now().Before(writeDeadline) { + raw, readErr := os.ReadFile(transcriptPath) + if readErr == nil && + strings.Contains(string(raw), prompt) && + strings.Contains(string(raw), expectedReply) { + return + } + time.Sleep(30 * time.Millisecond) + } + raw, _ := os.ReadFile(transcriptPath) + t.Fatalf("chat %q (%s) did not persist written turn:\ntranscript:\n%s\nscreen:\n%s", title, transcriptPath, string(raw), frame.Plain()) + }) + } + + // Check 4: Home still lists each of the 4 chats once and zero requests touched the sync service. + afterRows := session.ReadHome().Sessions() + if len(afterRows) != 4 { + t.Fatalf("ReadHome() after writing returned %d chats, want 4: %+v", len(afterRows), afterRows) + } + if got := relayRequests.Load(); got != 0 { + t.Fatalf("sync off contacted the sync service %d times, want 0", got) + } + }) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 8635e088c0..9bf6fb9a46 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -40,7 +40,7 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er if b == nil { return nil } - if syncsetup.Resolve(home.Dir()).Off || !cell.Available() { + if !syncsetup.Resolve(home.Dir()).On() || !cell.Available() { return nil } b.mu.Lock() diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 9b6f75fd81..6e8e45d638 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "os" "path/filepath" "time" @@ -75,6 +76,9 @@ type surfaceMachines struct{ *syncsetup.Sync } // local main chat, off the surface loop, so Home can tell when discarding a // collapsed copy loses nothing. func (s surfaceMachines) RowsAt(ctx context.Context) ([]chatlist.Row, uint64, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, nil + } rows, version, err := s.Sync.RowsAt(ctx) if err != nil || len(rows) == 0 { return rows, version, err @@ -83,6 +87,15 @@ func (s surfaceMachines) RowsAt(ctx context.Context) ([]chatlist.Row, uint64, er return rows, version, nil } +// SendLease withholds saved sync hold receipts while sync is off or quiet so a +// local chat never mistakes an earlier sync lease for another device holding it. +func (s surfaceMachines) SendLease(id string) (string, uint64, error) { + if !syncsetup.Resolve(s.Home).On() || (!s.Removed() && syncsetup.Quiet(s.Home)) { + return "", 0, os.ErrNotExist + } + return s.Sync.SendLease(id) +} + func (s surfaceMachines) Rows(ctx context.Context) ([]chatlist.Row, error) { rows, _, err := s.RowsAt(ctx) return rows, err @@ -138,6 +151,9 @@ func markLocalRedundantBranches(rows []chatlist.Row) { // RowsForChatSend uses a live writer's receipt or a never-shared chat before asking the relay. // A known outage keeps unknown copies unsent without repeating the network wait. func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offline, holding bool) ([]chatlist.Row, uint64, bool, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, true, nil + } root := session.DirOf(file) id := filepath.Base(root) if s.Removed() { @@ -169,6 +185,9 @@ func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offli } func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { + if !syncsetup.Resolve(s.Home).On() { + return nil, 0, nil + } if s.Relay != "" { current, ok, err := syncsetup.Open(s.Home) if err != nil { diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index e0d612ac1b..58779205e1 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2729,3 +2729,9 @@ If the laptop only slept or lost its connection, Home offers its latest chat and ## Empty untitled chats from your other devices — rows before the first saved turn An empty new chat on one of your other devices stays off Home until it has a saved turn. Opening a window there does not add an `untitled` row here, even while that window remains open and running. Its startup save alone does not make it a conversation you can continue. Once that chat has its first saved turn, it appears with the other chats you can continue. + +## Sync off and my chats are gone — CODEAF_SYNC_URL=off keeps every chat on this computer + +Turning sync off with `CODEAF_SYNC_URL=off` (or leaving sync off before pairing) means "do not talk to the sync service", never "forget my chats". Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. + +When an older chat was already copied into this version's store, Home lists and opens that copy even while sync is off, and the untouched original stays on disk beside it. Saved sync hold receipts from an earlier session stay dormant while sync is off, so sending a message in any local chat works without asking the sync service. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 074a1e8815..717134661b 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -26,7 +26,7 @@ CODEAF_SYNC_URL=https://relay.example.com codeaf ``` - **A web address** like `https://relay.example.com` or `http://host:8787`. Plain `http` is for trying it out inside an ssh tunnel. An address that is not a web address stops with `CODEAF_SYNC_URL is not a web address like http://host:8787`. -- **`off`** turns the relay off. Pairing then says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. +- **`off`** turns the relay off. Pairing then says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. - **Unset:** a computer that was paired with a named relay uses the relay the pairing saved. Otherwise it uses the hosted default above, if this build has one, and has none if it does not. Your own computers must all name the same relay and hold the same identity, or they see different chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8789e7587a..beb65bb6b3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4463,6 +4463,7 @@ func TestTheCrossComputerQuestionsReachTheFactThatAnswersThem(t *testing.T) { {"where does sync go by default", "relay-hosted-or-your-own", "https://codeaf.agentfield.ai/fabric"}, {"which relay does a dev build or staging build use", "relay-hosted-or-your-own", "https://caf-fabric-dev.instrument-santosh.workers.dev"}, {"do dev and stable builds sync with each other", "relay-hosted-or-your-own", "Channels do not sync with each other"}, + {"sync off and my chats are gone", "home", "Home still lists every chat on this computer"}, } { found := false for _, section := range Chat().Search(probe.asked, DefaultResults) { @@ -4521,3 +4522,41 @@ func TestWindowsManualQuotesTheShellRefusal(t *testing.T) { } } } + +// TestSyncOffManualQuotesExactSentences pins the retrieval probe and exact +// person-facing sentences for #2072 (sync off keeps every chat). +func TestSyncOffManualQuotesExactSentences(t *testing.T) { + for _, probe := range []struct { + asked string + page string + heading string + quotes []string + }{ + { + asked: "sync off and my chats are gone", + page: "home", + heading: "Sync off and my chats are gone", + quotes: []string{ + "CODEAF_SYNC_URL=off", + "chats from older versions alike", + "each once, and each opens and can be written", + }, + }, + } { + t.Run(probe.asked, func(t *testing.T) { + results := Chat().Search(probe.asked, DefaultResults) + for _, section := range results { + if section.Page != probe.page || !strings.HasPrefix(section.Title, probe.heading) { + continue + } + for _, quote := range probe.quotes { + if !strings.Contains(section.Body, quote) { + t.Errorf("section %q on %s missing exact quote %q", section.Title, section.Page, quote) + } + } + return + } + t.Fatalf("%q did not retrieve %q on %s within %d results", probe.asked, probe.heading, probe.page, DefaultResults) + }) + } +} diff --git a/internal/tui3/displaced_offline_send_test.go b/internal/tui3/displaced_offline_send_test.go index b8b9be73e8..148cf88857 100644 --- a/internal/tui3/displaced_offline_send_test.go +++ b/internal/tui3/displaced_offline_send_test.go @@ -42,3 +42,35 @@ func TestDisplacedOfflineWindowCannotBorrowAnEmptyListing(t *testing.T) { t.Fatal("the unsent draft has no explanation") } } + +type localEmptyAfterHoldSource struct{ *continuationListing } + +func (localEmptyAfterHoldSource) RowsForChatSend(context.Context, string, bool, bool) ([]chatlist.Row, uint64, bool, error) { + return nil, 0, true, nil +} +func (localEmptyAfterHoldSource) SendLease(string) (string, uint64, error) { + return "", 0, os.ErrNotExist +} + +// A window with sync wired that previously recorded a fence must never treat an +// empty local reading as permission to send after losing its hold. +func TestDisplacedWindowWithLocalEmptyReadingRefusesSend(t *testing.T) { + src := localEmptyAfterHoldSource{&continuationListing{ + row: chatlist.Row{Cell: heldCell, Mine: true, Status: chatlist.Here, Fence: 5}, + }} + a, agent := heldLab(t, src, &fakeTaker{}) + keepSurfaceWorkersInTest(t, a) + a.closeHome() + if got := a.machineRead.sendHolds[heldCell].fence; got != 5 { + t.Fatalf("sendHolds[%s].fence = %d, want 5", heldCell, got) + } + a.agent = displacedSendAgent{agent} + a.input.setText("do not fork this chat") + drive(t, a, key("enter")) + if len(agent.sent) != 0 || a.input.String() != "do not fork this chat" { + t.Fatalf("displaced window sent: sent=%v, draft=%q", agent.sent, a.input.String()) + } + if !strings.Contains(strings.Join(noteTexts(a), "\n"), chatlist.Superseded("another device")) { + t.Fatalf("expected superseded notice %q, got %v", chatlist.Superseded("another device"), noteTexts(a)) + } +} From 732e3f19f8b08aa76650421f91e3d9a08b83e329 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:47:44 -0400 Subject: [PATCH 4/5] Add the change entry for #2082 Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2082-sync-off-keeps-chats.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/changes/unreleased/2082-sync-off-keeps-chats.md diff --git a/docs/changes/unreleased/2082-sync-off-keeps-chats.md b/docs/changes/unreleased/2082-sync-off-keeps-chats.md new file mode 100644 index 0000000000..6d25b7bd34 --- /dev/null +++ b/docs/changes/unreleased/2082-sync-off-keeps-chats.md @@ -0,0 +1,11 @@ +--- +kind: fixed +title: Sync off keeps every chat listed and writable, and a copied older chat can no longer be wiped +pr: 2082 +surface: [chat, remote, docs] +invalidates: + - "With CODEAF_SYNC_URL=off or sync quiet before pairing, movablePlace returned before looking for a completed .moving copy, so the untouched original opened; now an existing copy opens whatever sync says, and only preparing a new copy needs sync." + - "A rewind or a take onto a .moving copy removed .cell/copy-ready, and opening the original afterwards ran CopyLegacy, which removed the copy and every turn since; now restores keep this computer's .cell bookkeeping (copy-ready, copy-offered, published.json, adopted.json are in chainNames), a take re-stamps the copy markers, and CopyLegacy never removes a destination holding sealed turns." + - "Each reader of a project bucket walked its folders and resolved cell.Copied on its own; now session.BucketSessionDirs is the one walk over a bucket and its .moving folder, reading .moving at most once and making no copy lookups when a bucket has none." + - "#2072's two doctor items (naming a sync service capability gap, naming a removed device still referenced locally) are not built: the capability header only comes back on a write, and local references store display labels that change on a rename." +--- From 2ac61e5309707866be9e57ea4cd30f8728da83b9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 22:00:58 -0400 Subject: [PATCH 5/5] Read a Home row's copy explanation from the row, not the wrapped screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With chats in the copy folder now listed, the row says `here · 2 copies …`, and the explanation wraps across its column with the projects panel's words in between, so a screen search missed it although the row said it whole. Co-Authored-By: Claude Opus 5.5 --- internal/tui3/continue_2070_test.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/tui3/continue_2070_test.go b/internal/tui3/continue_2070_test.go index 69b21a38b2..f46656510d 100644 --- a/internal/tui3/continue_2070_test.go +++ b/internal/tui3/continue_2070_test.go @@ -132,6 +132,23 @@ func countHomeRowsForID(a *app, id string) int { return n } +// homeRowWordsForID is everything Home's row for one chat says, its note and +// its description, without the layout that wraps them on the screen. +func homeRowWordsForID(a *app, id string) string { + var words []string + for _, line := range a.home.lines { + if line.cell == nil { + continue + } + mine := line.kind == homeSession && homeSessionID(line.row) == id + theirs := line.kind == homeMachineRow && line.remote != nil && line.remote.Cell == id + if mine || theirs { + words = append(words, line.cell.note, line.cell.noteShort, line.cell.sub) + } + } + return strings.Join(words, "\n") +} + // Contract 1: In one window, continuing three different chats last written on // another computer, one after another, each shows `continuing here…` and opens // that chat with its full history. @@ -583,14 +600,17 @@ func TestOneChatOneRowKeyedByIDAndCollapsedBranchCopies(t *testing.T) { if got := countHomeRowsForID(a, sharedID); got != 1 { t.Fatalf("rows for %s when held here = %d, want 1:\n%s", sharedID, got, homeText(a)) } + // The explanation is read from the row itself: on the screen it wraps + // wherever its column ends, and the next panel's words fill the gap. screen = homeText(a) + said := homeRowWordsForID(a, sharedID) for _, want := range []string{ "2 copies", "unsent turns set aside", "discarding archives", "3 turns not in this chat", } { - if !strings.Contains(screen, want) { + if !strings.Contains(said, want) { t.Fatalf("Home missing non-redundant copy explanation %q:\n%s", want, screen) } }