From 2e3e886a842f6ec430af8c53ac1c332193c9c63e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 13:49:30 -0400 Subject: [PATCH 1/5] fix(app): resolve cell chat folders and preserve existing receipts Use session.DirOf when app helpers need a conversation folder. Cell journals under .cell now resolve to the owning chat for managed logs, media scope, deliverables, task working copies, presence, receipts and lifecycle helpers. Read existing cell receipts from both locations and merge by entry id when saving new receipts at the chat root. Retain the old file, receipt anchors and fields, and refuse writes when either input cannot be read. Keep task, process and deletion records visible at their existing truth paths for lazy recovery, branch listings and confirmed branch lands. Preserve the legacy and unrecognized-transcript fallback, wire fields and the separately owned appAgentID function. Keep cleanup allowlists intact. Add real-cell, legacy and flat-path regressions for upgrades, readers and destructive file boundaries, and an unreleased change entry. task_branches now reads task records through Place.Tasks(), so a Place rooted at a cell chat finds .cell/tasks.json (a terminal caller with such a Place gains this too). Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/app.go | 2 +- cmd/codeaf/appblank.go | 2 +- cmd/codeaf/appclose.go | 8 +- cmd/codeaf/appcrewprocesspage.go | 2 +- cmd/codeaf/appfolders_test.go | 298 +++++++++++ cmd/codeaf/appfolders_upgrade_test.go | 560 ++++++++++++++++++++ cmd/codeaf/appfooters.go | 44 +- cmd/codeaf/applazy.go | 12 +- cmd/codeaf/appprocessactions.go | 2 +- cmd/codeaf/appprocesspage.go | 5 +- cmd/codeaf/apprender.go | 3 +- cmd/codeaf/appteamstart.go | 4 +- cmd/codeaf/apptidy.go | 5 +- docs/changes/unreleased/app-cell-folders.md | 18 + internal/session/task_branches.go | 4 +- 15 files changed, 935 insertions(+), 34 deletions(-) create mode 100644 cmd/codeaf/appfolders_test.go create mode 100644 cmd/codeaf/appfolders_upgrade_test.go create mode 100644 docs/changes/unreleased/app-cell-folders.md diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 4df794b4ca..8efd3f0d39 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -3736,7 +3736,7 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { tasks = append(tasks, task) } // Running is a live claim, not the checkpoint's permanent resting state. - presence, fresh := session.ReadSessionPresence(filepath.Dir(transcript), b.now()) + presence, fresh := session.ReadSessionPresence(session.DirOf(transcript), b.now()) for _, notice := range session.ReadSessionTasks(transcript) { if notice.State == session.TaskRunning && appTaskWasInterrupted(presence, fresh, strconv.FormatUint(notice.ID, 10), notice.StartedAt) { notice.State = session.TaskInterrupted diff --git a/cmd/codeaf/appblank.go b/cmd/codeaf/appblank.go index 32b71d3e44..ce6b6f94cf 100644 --- a/cmd/codeaf/appblank.go +++ b/cmd/codeaf/appblank.go @@ -137,7 +137,7 @@ func (b *appBackend) reapBlank(a *appAgent) bool { return false } transcript = filepath.Clean(transcript) - dir := filepath.Dir(transcript) + dir := session.DirOf(transcript) if info, err := os.Stat(dir); err != nil || !info.IsDir() { return false } diff --git a/cmd/codeaf/appclose.go b/cmd/codeaf/appclose.go index 2922b46cd8..807fd53b2b 100644 --- a/cmd/codeaf/appclose.go +++ b/cmd/codeaf/appclose.go @@ -311,7 +311,7 @@ func (b *appBackend) refreshElsewhereWith(a *appAgent, watch bool) { var dir string b.locked(func() { if a.live == nil && a.transcript != "" { - dir = filepath.Dir(a.transcript) + dir = session.DirOf(a.transcript) } }) if dir == "" { @@ -616,7 +616,7 @@ func appCloseOwnershipConflict(err error) bool { func (b *appBackend) abandonCloseToHolder(a *appAgent) bool { var transcript string b.locked(func() { transcript = a.transcript }) - p, fresh := session.ReadSessionPresence(filepath.Dir(transcript), b.now()) + p, fresh := session.ReadSessionPresence(session.DirOf(transcript), b.now()) if !fresh || p.PID == os.Getpid() { return false } @@ -637,7 +637,7 @@ func (b *appBackend) abandonCloseToHolder(a *appAgent) bool { if changed { b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.watchCloseClaim(filepath.Dir(transcript)) + b.watchCloseClaim(session.DirOf(transcript)) return true } @@ -683,7 +683,7 @@ func (b *appBackend) resetCloseAfterPerson(a *appAgent) (bool, error) { if last.IsZero() { return false, nil } - meta, err := session.LoadMeta(filepath.Dir(transcript)) + meta, err := session.LoadMeta(session.DirOf(transcript)) if err != nil { return false, err } diff --git a/cmd/codeaf/appcrewprocesspage.go b/cmd/codeaf/appcrewprocesspage.go index 277efdc84b..be0c68413a 100644 --- a/cmd/codeaf/appcrewprocesspage.go +++ b/cmd/codeaf/appcrewprocesspage.go @@ -892,7 +892,7 @@ func (b *appBackend) crewOwnerPage(ctx context.Context, o crewOwner, n int) (*ap b.locked(func() { if a := b.agents[o.id]; a != nil && a.live != nil && a.transcript != "" && a.transcript == o.file { live = a.live - convDir = filepath.Dir(a.transcript) + convDir = session.DirOf(a.transcript) } }) src := appProcessOwner{agent: o.id, live: live, convDir: convDir} diff --git a/cmd/codeaf/appfolders_test.go b/cmd/codeaf/appfolders_test.go new file mode 100644 index 0000000000..07b8fb9748 --- /dev/null +++ b/cmd/codeaf/appfolders_test.go @@ -0,0 +1,298 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +type appFolderFixture struct { + b *appBackend + a *appAgent + dir, transcript, id, workspace, artifacts string + clock *closeClock +} + +func eachAppFolder(t *testing.T, check func(*testing.T, appFolderFixture), layouts ...string) { + t.Helper() + if len(layouts) == 0 { + layouts = []string{"cell", "legacy", "flat"} + } + for _, layout := range layouts { + t.Run(layout, func(t *testing.T) { + appDayProfile(t) + id := session.NewSessionID() + bucket := filepath.Join(session.PlacesRoot(), "app-folder-fixtures") + dir := filepath.Join(bucket, id) + if layout == "cell" { + // v3NewFolder uses this same constructor and class for app chats. + c, err := cell.CreateIn(bucket, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + dir, id = c.Root, c.ID + } + transcript := filepath.Join(dir, "transcript.jsonl") + artifacts := filepath.Join(dir, "artifacts") + switch layout { + case "cell": + transcript = filepath.Join(dir, cell.TranscriptPath) + artifacts = filepath.Join(dir, cell.StateDir, "artifacts") + case "flat": + transcript = filepath.Join(dir, "other.jsonl") + } + for _, path := range []string{filepath.Join(dir, "work"), artifacts} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + workspace := t.TempDir() + clock := &closeClock{at: time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)} + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: workspace, Created: clock.now().Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + header, err := json.Marshal(map[string]any{"type": "session", "version": 1, "id": id}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcript, append(header, '\n'), 0o600); err != nil { + t.Fatal(err) + } + b := newAppBackend(newAppRecorder(), nil) + b.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + b.now, b.after = clock.now, clock.after + a := &appAgent{transcript: transcript, view: appwire.Agent{ID: id, Workspace: workspace, State: appwire.StateIdle}} + b.locked(func() { b.agents[id] = a }) + t.Cleanup(func() { _ = b.Close() }) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + oldIndex := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = oldIndex }) + check(t, appFolderFixture{b: b, a: a, dir: dir, transcript: transcript, id: id, workspace: workspace, artifacts: artifacts, clock: clock}) + }) + } +} + +// F1 accepts and serves the chat's managed log; F2 preserves legacy logs; +// F3 uses the transcript's own directory when its name is unrecognized. +func TestAppFolderProcessLogs(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + log := filepath.Join(f.dir, "logs", "jobs", "7.log") + if err := os.MkdirAll(filepath.Dir(log), 0o700); err != nil { + t.Fatal(err) + } + const output = "started\nfolder output\n" + if err := os.WriteFile(log, []byte(output), 0o600); err != nil { + t.Fatal(err) + } + job := session.JobNotice{ID: 7, Kind: session.JobKindCommand, State: session.JobDone, Started: f.clock.now().Add(-time.Minute), Ended: f.clock.now(), LogPath: log} + // The process store stays beside the journal, including inside .cell/. + seedRecords(t, filepath.Dir(f.transcript), job) + page, err := f.b.PageProcesses(context.Background(), appwire.ProcessPageAsk{Agent: f.id}) + if err != nil { + t.Fatal(err) + } + if len(page.Processes) != 1 || page.Processes[0].LastLine != "folder output" || page.Processes[0].LogGone { + t.Fatalf("process page = %+v", page) + } + id := appProcessID(f.id, job) + read, err := f.b.ReadProcessLog(context.Background(), appwire.ProcessLogAsk{ID: id}) + if err != nil { + t.Fatal(err) + } + if read.Text != output || !read.EOF || read.LogGone { + t.Fatalf("managed log = %+v", read) + } + actions, err := f.b.ProcessActions(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if !actions.Log.Available { + t.Fatalf("managed log action = %+v", actions.Log) + } + }) +} + +// F1 uses the cell chat's session id and roots for media reads and attachment +// records; F2 and F3 retain the same scope for legacy and unrecognized paths. +// F6 appends only to the global index and preserves the existing rows. +func TestAppFolderMediaScope(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + id, roots, err := f.b.mediaScope(f.id) + if err != nil { + t.Fatal(err) + } + want := []string{f.workspace, filepath.Join(f.dir, "work"), f.artifacts} + if id != f.id || !reflect.DeepEqual(roots, want) { + t.Fatalf("media scope = %q %v, want %q %v", id, roots, f.id, want) + } + picture := filepath.Join(f.dir, "work", "picture.png") + data := writePNG(t, picture, 3, 2) + source := session.RecordMedia(appMediaIndex(), session.Artifact{Path: picture, Session: f.id, Title: "picture.png", Kind: "image", Created: f.clock.now()}, session.GeneratedMedia(picture, data, "fixture")) + media, err := f.b.GetMedia(f.id, source) + if err != nil || media.State != appwire.MediaAvailable { + t.Fatalf("media in chat work folder = %+v, %v", media, err) + } + attached := filepath.Join(f.workspace, "attached.png") + writePNG(t, attached, 2, 3) + before := appFolderSnapshot(t, f.dir) + index, err := os.ReadFile(appMediaIndex()) + if err != nil { + t.Fatal(err) + } + f.b.recordAttachedMedia(f.a, []appwire.Attachment{{Kind: appwire.AttachFile, Path: attached}}) + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("recording attached media changed chat files") + } + if after, err := os.ReadFile(appMediaIndex()); err != nil || len(after) <= len(index) || !bytes.HasPrefix(after, index) { + t.Fatalf("recording attached media replaced the existing global index: %v", err) + } + page, err := f.b.ListMedia(f.id, "", 10) + if err != nil { + t.Fatal(err) + } + attachedFound := false + for _, source := range page.Sources { + attachedFound = attachedFound || source.Origin == session.MediaAttachment + } + if len(page.Sources) != 2 || !attachedFound { + t.Fatalf("media and attachment ownership = %+v", page) + } + }) +} + +// F1 roots deliverables and task working copies at the chat folder; F2 and F3 +// preserve the Place and readable deliverables for the other transcript paths. +func TestAppFolderDeliverablesPlace(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + known := f.b.deliverEnv().agent(f.id) + work := filepath.Join(f.dir, "work") + if !known.known || known.place.Dir != f.dir || known.place.Work() != work || known.place.Artifacts() != f.artifacts { + t.Fatalf("deliverables place = %+v", known) + } + if !reflect.DeepEqual(known.roots, []string{f.workspace, work, f.artifacts}) || !reflect.DeepEqual(known.folders, []string{work, f.artifacts}) { + t.Fatalf("deliverable roots = %v, folders = %v", known.roots, known.folders) + } + real, err := filepath.EvalSymlinks(f.dir) + if err != nil { + t.Fatal(err) + } + if got, want := known.place.Trees(), filepath.Join(real, "trees"); got != want { + t.Fatalf("task working copies = %q, want %q", got, want) + } + for _, folder := range []string{work, f.artifacts} { + path := filepath.Join(folder, "notes.md") + if err := os.WriteFile(path, []byte("folder deliverable\n"), 0o600); err != nil { + t.Fatal(err) + } + fact := session.DeliverableFact{ID: session.NewDeliverableID(), At: f.clock.now(), Origin: session.DeliverOriginAgent, Authored: true} + if !session.RecordArtifact(appMediaIndex(), session.Artifact{Path: path, Session: f.id, Title: "notes.md", Kind: "markdown", Created: f.clock.now(), Deliver: &fact}) { + t.Fatal("deliverable was not recorded") + } + got, err := f.b.GetDeliverable(f.id, fact.ID) + if err != nil || got.Live != "available" || got.Place != "agent_folder" { + t.Fatalf("deliverable in %s = %+v, %v", folder, got, err) + } + read, err := f.b.ReadDeliverable(f.id, fact.ID, "", appwire.DeliverableReadMax) + if err != nil || read.Text != "folder deliverable\n" { + t.Fatalf("read deliverable in %s = %+v, %v", folder, read, err) + } + } + }) +} + +// F1 reads presence and enrolls close claims at the same /presence.json +// the session writes; F2 and F3 preserve that path for legacy and flat files. +// F6 leaves every chat file intact while observing another holder. +func TestAppFolderClosePresence(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + presence := session.SessionPresence{Schema: 1, SessionID: f.id, Workspace: f.workspace, PID: os.Getpid() + 1, UpdatedAt: f.clock.now(), State: session.PresenceWorking} + raw, err := json.Marshal(presence) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(f.dir, "presence.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + if got, fresh := session.ReadSessionPresence(f.dir, f.clock.now()); !fresh || got.PID != presence.PID { + t.Fatalf("session presence = %+v, fresh = %v", got, fresh) + } + before := appFolderSnapshot(t, f.dir) + f.b.refreshElsewhere(f.a) + if f.a.view.State != appwire.StateWorking { + t.Fatalf("fresh holder state = %s", f.a.view.State) + } + f.b.closeRecovery.locked(func() { + claim := f.b.closeRecovery.claims[f.dir] + if len(f.b.closeRecovery.claims) != 1 || claim == nil || claim.presence.Dir != f.dir { + t.Errorf("close claims = %+v", f.b.closeRecovery.claims) + } + }) + f.b.locked(func() { f.a.view.State = appwire.StateIdle }) + if !f.b.abandonCloseToHolder(f.a) || f.a.view.State != appwire.StateWorking { + t.Fatalf("close recovery missed the holder at %s", f.dir) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("observing another holder changed chat files") + } + // A person's newer metadata clears the close guard from the same folder. + if err := f.b.state.write(func(state *appStateFile) error { + state.CloseResumes[f.id] = 1 + state.CloseResumedAt[f.id] = f.clock.now().Add(-time.Minute) + return nil + }); err != nil { + t.Fatal(err) + } + meta, err := session.LoadMeta(f.dir) + if err != nil { + t.Fatal(err) + } + meta.LastUserAt = f.clock.now() + if err := session.SaveMeta(f.dir, meta); err != nil { + t.Fatal(err) + } + if reset, err := f.b.resetCloseAfterPerson(f.a); err != nil || !reset { + t.Fatalf("new directions did not reset close recovery: %v, %v", reset, err) + } + }) +} + +// F1 keeps app receipts and crew deletion checks beside the cell chat; F2 and +// F3 keep their previous locations. F4 keeps the existing receipt wire fields. +func TestAppFolderSidecarsAndMembership(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + if got := f.b.renderDir(f.a); got != f.dir { + t.Fatalf("render folder = %q, want %q", got, f.dir) + } + if err := f.b.keepAppFooter(f.a, "folder-receipt", appwire.FooterStoppedByClose, appCloseStoppedWord); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(f.dir, appFootersName)); err != nil { + t.Fatal(err) + } + kept, err := appReadFooters(f.transcript) + if err != nil || len(kept) != 1 || kept[0].Entry.ID != "folder-receipt" || kept[0].Entry.Agent != f.id || kept[0].Entry.Text != appCloseStoppedWord { + t.Fatalf("saved receipt = %+v, %v", kept, err) + } + team := teamstore.Team{Members: []teamstore.Member{{Key: f.transcript, File: f.transcript}}} + if here, err := appConversationHere(team, f.transcript); err != nil || !here { + t.Fatalf("existing conversation = %v, %v", here, err) + } + if err := os.WriteFile(filepath.Join(f.dir, teamstore.ConversationDeletedFile), nil, 0o600); err != nil { + t.Fatal(err) + } + if here, err := appConversationHere(team, f.transcript); err != nil || here { + t.Fatalf("deleted conversation = %v, %v", here, err) + } + }) +} diff --git a/cmd/codeaf/appfolders_upgrade_test.go b/cmd/codeaf/appfolders_upgrade_test.go new file mode 100644 index 0000000000..76833d95ef --- /dev/null +++ b/cmd/codeaf/appfolders_upgrade_test.go @@ -0,0 +1,560 @@ +package main + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// F5 keeps old-path receipts visible through history, merges both files by +// entry id on write, and leaves the old file intact through repeated writes. +func TestAppFolderFooterUpgrade(t *testing.T) { + for _, corrected := range []bool{false, true} { + name := "old-only" + if corrected { + name = "both-locations" + } + t.Run(name, func(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + old := []appKeptFooter{ + {After: 0, Entry: appFooter("old", ".cell", 0, appwire.FooterStoppedByClose, "old receipt", f.clock.now())}, + {After: 0, Entry: appFooter("shared", f.id, 0, appwire.FooterResumedAfterQuit, "old shared receipt", f.clock.now())}, + } + old[0].Entry.Link = &appwire.EntryLink{Label: "Settings › Permissions", Target: "settings:permissions"} + old[0].Entry.Why = "Matches your saved rule" + oldPath := filepath.Join(filepath.Dir(f.transcript), appFootersName) + writeCloseJSON(t, oldPath, old) + want := append([]appKeptFooter(nil), old...) + if corrected { + current := []appKeptFooter{ + {After: 0, Entry: appFooter("shared", f.id, 0, appwire.FooterResumedAfterQuit, "corrected shared receipt", f.clock.now())}, + {After: 0, Entry: appFooter("current", f.id, 0, appwire.FooterStoppedByClose, "current receipt", f.clock.now())}, + } + writeCloseJSON(t, filepath.Join(f.dir, appFootersName), current) + want[1] = current[0] + want = append(want, current[1]) + } + before := appFolderSnapshot(t, f.dir) + assertHistory := func(want []appKeptFooter) { + t.Helper() + entries, more, err := f.b.Entries(f.id, "", 100) + if err != nil || more || len(entries) != len(want) { + t.Fatalf("receipt history = %+v, %v, %v; want %+v", entries, more, err, want) + } + for i, kept := range want { + if !reflect.DeepEqual(entries[i], kept.Entry) { + t.Fatalf("history receipt %d = %+v, want %+v", i, entries[i], kept.Entry) + } + } + } + assertHistory(want) + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("reading receipts changed the chat") + } + empty := "" + for write := 0; write < 2; write++ { + entry := appFooter("following-"+strconv.Itoa(write), f.id, 0, appwire.FooterStoppedByClose, "following receipt", f.clock.now()) + want = append(want, appKeptFooter{AfterID: &empty, Entry: entry}) + // A repeated id in one exchange is also one receipt. + if err := f.b.keepAppEntries(f.a, []appwire.Entry{entry, entry}, ""); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(f.dir, appFootersName)) + if err != nil { + t.Fatal(err) + } + var saved []appKeptFooter + if err := json.Unmarshal(raw, &saved); err != nil || !reflect.DeepEqual(saved, want) { + t.Fatalf("write %d kept %+v, %v; want %+v", write, saved, err, want) + } + assertHistory(want) + after := appFolderSnapshot(t, f.dir) + delete(after, appFootersName) + delete(before, appFootersName) + if !reflect.DeepEqual(after, before) { + t.Fatal("saving receipts changed files other than the corrected sidecar") + } + } + }, "cell") + }) + } +} + +// F5 refuses to replace either sidecar when one cannot be decoded; F6 keeps +// every file, including the old receipts, unchanged on that failure. +func TestAppFolderFooterUpgradeUnreadable(t *testing.T) { + for _, bad := range []string{"old", "corrected"} { + t.Run(bad, func(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + old := filepath.Join(filepath.Dir(f.transcript), appFootersName) + corrected := filepath.Join(f.dir, appFootersName) + kept := []appKeptFooter{{Entry: appFooter("kept", f.id, 0, appwire.FooterStoppedByClose, "kept", f.clock.now())}} + writeCloseJSON(t, old, kept) + writeCloseJSON(t, corrected, kept) + path := old + if bad == "corrected" { + path = corrected + } + if err := os.WriteFile(path, []byte("["), 0o600); err != nil { + t.Fatal(err) + } + before := appFolderSnapshot(t, f.dir) + if _, _, err := f.b.Entries(f.id, "", 100); err == nil { + t.Fatal("unreadable receipt history appeared empty") + } + if err := f.b.keepAppFooter(f.a, "next", appwire.FooterStoppedByClose, "next"); err == nil { + t.Fatal("saving over unreadable receipts succeeded") + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("failed upgrade changed the chat") + } + }, "cell") + }) + } +} + +// F6 removes the whole unused chat, keeps siblings, and preserves chats with +// work at the owning root or truth in .cell. F2 keeps the legacy judgement. +func TestAppFolderReapBlank(t *testing.T) { + for _, kept := range []string{"blank", "spoken", "unreadable", "work", "trees", "artifacts", "tasks", "turns", "crew"} { + t.Run(kept, func(t *testing.T) { + layouts := []string{"cell", "legacy"} + if kept == "turns" { + layouts = []string{"cell"} + } + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + for _, path := range []string{filepath.Join(f.dir, "work"), f.artifacts} { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + switch kept { + case "spoken": + appendCloseMessage(t, f.transcript, map[string]string{"type": "message", "role": "user", "content": "keep my chat"}) + case "unreadable": + if err := os.WriteFile(f.transcript, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + case "work", "trees", "artifacts": + path := filepath.Join(f.dir, kept) + if kept == "artifacts" { + path = f.artifacts + } + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "kept"), []byte("kept"), 0o600); err != nil { + t.Fatal(err) + } + case "turns": + if err := os.WriteFile(filepath.Join(f.dir, cellstore.TurnsPath), []byte("sealed turns"), 0o600); err != nil { + t.Fatal(err) + } + case "tasks": + if err := os.WriteFile((session.Place{Dir: f.dir}).Tasks(), []byte("saved tasks"), 0o600); err != nil { + t.Fatal(err) + } + case "crew": + if err := teamstore.Update(f.b.crewProfile(), func(file *teamstore.File) error { + id := teamstore.NewID() + file.Teams = append(file.Teams, teamstore.Team{ID: id, Name: "kept", Made: f.clock.now()}) + return file.AddMember(id, teamstore.Member{Key: f.transcript, File: f.transcript, Where: f.workspace}) + }); err != nil { + t.Fatal(err) + } + } + sibling := filepath.Join(filepath.Dir(f.dir), "sibling") + if err := os.MkdirAll(sibling, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sibling, "kept"), []byte("sibling"), 0o600); err != nil { + t.Fatal(err) + } + before, beside := appFolderSnapshot(t, f.dir), appFolderSnapshot(t, sibling) + f.a.minted = true + if gone := f.b.reapBlank(f.a); gone != (kept == "blank") { + t.Fatalf("reap %s = %v", kept, gone) + } + if kept == "blank" { + if _, err := os.Stat(f.dir); !os.IsNotExist(err) { + t.Fatalf("blank chat folder remains: %v", err) + } + f.b.locked(func() { + if f.b.agents[f.id] != nil { + t.Error("reaped chat remains registered") + } + }) + } else if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("reaping changed a protected chat") + } + if after := appFolderSnapshot(t, sibling); !reflect.DeepEqual(after, beside) { + t.Fatal("reaping changed a sibling") + } + }, layouts...) + }) + } +} + +// F6 keeps takeBack's metadata-only allowlist and root boundary. A cell's +// .cell directory is outside that allowlist, so it is retained in full; a +// legacy mint with only meta.json is removed. F2 preserves that legacy rule. +func TestAppFolderTakeBack(t *testing.T) { + for _, name := range []string{"mint-only", "used", "metadata-only-state"} { + t.Run(name, func(t *testing.T) { + layouts := []string{"cell", "legacy"} + if name == "metadata-only-state" { + layouts = []string{"cell"} + } + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + if name != "used" { + for _, path := range []string{filepath.Join(f.dir, "work"), f.artifacts, f.transcript} { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + } + if name == "metadata-only-state" { + for _, path := range []string{filepath.Join(f.dir, cell.EnvPath), filepath.Join(f.dir, cell.StateDir, "session.json")} { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + } + before := appFolderSnapshot(t, f.dir) + f.b.takeBack(f.transcript) + cellChat := filepath.Base(filepath.Dir(f.transcript)) == cell.StateDir + if !cellChat && name == "mint-only" { + if _, err := os.Stat(f.dir); !os.IsNotExist(err) { + t.Fatalf("unused legacy mint remains: %v", err) + } + } else if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("takeBack changed a folder outside its allowlist") + } + // An equally empty folder outside the places root is never removed. + outside := t.TempDir() + writeCloseJSON(t, filepath.Join(outside, "meta.json"), session.Meta{ID: "outside"}) + before = appFolderSnapshot(t, outside) + f.b.takeBack(filepath.Join(outside, "transcript.jsonl")) + if after := appFolderSnapshot(t, outside); !reflect.DeepEqual(after, before) { + t.Fatal("takeBack changed a folder outside the places root") + } + }, layouts...) + }) + } +} + +// F6 changes only the owning chat's archive metadata and leaves the cell's +// own meta, truth records and workspace intact. F2/F3 keep the other layouts. +func TestAppFolderArchiveAndReopen(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + meta, err := session.LoadMeta(f.dir) + if err != nil { + t.Fatal(err) + } + meta.Effort, meta.Approval = "high", "ask" + if err := session.SaveMeta(f.dir, meta); err != nil { + t.Fatal(err) + } + if old := filepath.Dir(f.transcript); old != f.dir { + if err := os.WriteFile(filepath.Join(old, "meta.lock"), nil, 0o600); err != nil { + t.Fatal(err) + } + } + before := appFolderSnapshot(t, f.dir) + for _, archived := range []bool{true, false} { + if archived { + err = f.b.Archive([]string{f.id}) + } else { + _, err = f.b.Reopen(f.id) + } + if err != nil { + t.Fatal(err) + } + got, err := session.LoadMeta(f.dir) + want := meta + want.Archived = archived + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("archive %v metadata = %+v, %v; want %+v", archived, got, err, want) + } + after := appFolderSnapshot(t, f.dir) + if raw, ok := after["meta.lock"]; !ok || raw != "" { + t.Fatal("session metadata lock is missing or was written as data") + } + for _, path := range []string{"meta.json", ".cell/session.json", "meta.lock"} { + delete(after, path) + delete(before, path) + } + if !reflect.DeepEqual(after, before) { + t.Fatal("archiving changed files other than session metadata") + } + var marked bool + if err := f.b.state.read(func(state *appStateFile) { _, marked = state.Archived[f.id] }); err != nil || marked != archived { + t.Fatalf("app archive mark = %v, %v", marked, err) + } + } + }) +} + +// F6 reads person activity from the session summary without rewriting any +// chat file, and clears only that chat's recovery guard in the app state. +func TestAppFolderCloseMetaFiles(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + meta, err := session.LoadMeta(f.dir) + if err != nil { + t.Fatal(err) + } + meta.LastUserAt = f.clock.now() + if err := session.SaveMeta(f.dir, meta); err != nil { + t.Fatal(err) + } + stamp := f.clock.now().Add(-time.Minute) + if err := f.b.state.write(func(state *appStateFile) error { + for _, id := range []string{f.id, "other"} { + state.CloseResumes[id] = 1 + state.CloseResumedAt[id] = stamp + state.ClosePending[id] = "pending" + state.ClosePendingAt[id] = stamp + } + return nil + }); err != nil { + t.Fatal(err) + } + before := appFolderSnapshot(t, f.dir) + if reset, err := f.b.resetCloseAfterPerson(f.a); err != nil || !reset { + t.Fatalf("reset close guard = %v, %v", reset, err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("reading person activity rewrote chat files") + } + if err := f.b.state.read(func(state *appStateFile) { + if state.CloseResumes[f.id] != 0 || !state.CloseResumedAt[f.id].IsZero() || state.ClosePending[f.id] != "" || !state.ClosePendingAt[f.id].IsZero() { + t.Error("the chat's close guard remains") + } + if state.CloseResumes["other"] != 1 || !state.CloseResumedAt["other"].Equal(stamp) || state.ClosePending["other"] != "pending" || !state.ClosePendingAt["other"].Equal(stamp) { + t.Error("another chat's close guard changed") + } + }); err != nil { + t.Fatal(err) + } + }) +} + +// F5/F6 resolve old and corrected artifact spellings to the same truth +// directory. Removing an unsent reference deletes only its private snapshot. +func TestAppFolderReferenceFiles(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + old := (session.Place{Dir: filepath.Dir(f.transcript), Owned: true}).Artifacts() + if old != f.artifacts { + t.Fatalf("artifact truth moved from %q to %q", old, f.artifacts) + } + original := filepath.Join(old, "original.png") + data := writePNG(t, original, 2, 2) + source := session.RecordMedia(appMediaIndex(), session.Artifact{Path: original, Session: f.id, Title: "original.png", Kind: "image", Created: f.clock.now()}, session.GeneratedMedia(original, data, "fixture")) + media, err := f.b.GetMedia(f.id, source) + if err != nil { + t.Fatal(err) + } + before := appFolderSnapshot(t, f.dir) + chip, err := f.b.AttachMedia(f.id, source, media.Version) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(filepath.Dir(chip.Path)) != f.artifacts { + t.Fatalf("reference was saved outside chat artifacts: %s", chip.Path) + } + if err := f.b.RemoveAttachment(f.id, chip.ID); err != nil { + t.Fatal(err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("removing a reference changed the source or other chat files") + } + }) +} + +// F1/F5 keep lazy recovery responsive to records already saved beside the +// journal after its folder moves to the owning root. F6 only reads those files. +func TestAppFolderLazyRecoveryFiles(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + run := &appCrewRun{since: f.clock.now(), wakeStamps: map[string]string{}} + read := appCrewRead{file: &teamstore.File{}} + if needed := f.b.neededMembers(run, read, false); len(needed) != 0 { + t.Fatalf("idle chat needs recovery: %+v", needed) + } + job := session.JobNotice{ID: 7, Kind: session.JobKindCommand, State: session.JobRunning, Started: f.clock.now()} + seedRecords(t, filepath.Dir(f.transcript), job) + before := appFolderSnapshot(t, f.dir) + needed := f.b.neededMembers(run, read, false) + if _, ok := needed[f.id]; !ok || len(needed) != 1 { + t.Fatalf("saved process did not wake recovery: %+v", needed) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("checking recovery changed chat files") + } + }) +} + +// F6 copies only the chosen deliverable, never overwrites the destination, +// and records Remove and Snapshot without changing the chat's source files. +func TestAppFolderDeliverableChanges(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + path := filepath.Join(f.dir, "work", "notes.md") + if err := os.WriteFile(path, []byte("kept notes\n"), 0o600); err != nil { + t.Fatal(err) + } + fact := session.DeliverableFact{ID: session.NewDeliverableID(), At: f.clock.now(), Origin: session.DeliverOriginAgent, State: session.DeliverDraft, Authored: true} + if !session.RecordArtifact(appMediaIndex(), session.Artifact{Path: path, Session: f.id, Title: "notes.md", Kind: "markdown", Created: f.clock.now(), Deliver: &fact}) { + t.Fatal("deliverable was not recorded") + } + dest := t.TempDir() + if err := os.WriteFile(filepath.Join(dest, "notes.md"), []byte("my notes\n"), 0o600); err != nil { + t.Fatal(err) + } + before, destination := appFolderSnapshot(t, f.dir), appFolderSnapshot(t, dest) + if _, err := f.b.KeepDeliverable(f.id, fact.ID, dest, "notes.md", ""); !hasCode(err, appwire.CodeConflict) { + t.Fatalf("existing destination was not refused: %v", err) + } + if after := appFolderSnapshot(t, dest); !reflect.DeepEqual(after, destination) { + t.Fatal("refused copy changed the destination") + } + kept, err := f.b.KeepDeliverable(f.id, fact.ID, dest, "copy.md", "") + if err != nil || kept.State != session.DeliverKept || kept.Kept == nil || kept.Kept.Path != filepath.Join(canon(dest), "copy.md") { + t.Fatalf("kept deliverable = %+v, %v", kept, err) + } + destination["copy.md"] = "kept notes\n" + if after := appFolderSnapshot(t, dest); !reflect.DeepEqual(after, destination) { + t.Fatal("copy changed files other than its named destination") + } + if err := f.b.RemoveDeliverable(f.id, fact.ID); err != nil { + t.Fatal(err) + } + if _, err := f.b.GetDeliverable(f.id, fact.ID); !hasCode(err, appwire.CodeNotFound) { + t.Fatalf("removed deliverable remains listed: %v", err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("keeping or removing a deliverable changed chat files") + } + shot := filepath.Join(f.artifacts, "snapshot.png") + writePNG(t, shot, 4, 4) + before = appFolderSnapshot(t, f.dir) + one, err := f.b.SnapshotDeliverable(f.id, shot, "http://localhost:3000/", "") + if err != nil { + t.Fatal(err) + } + two, err := f.b.SnapshotDeliverable(f.id, shot, "http://localhost:3000/", "") + if err != nil || two.ID != one.ID { + t.Fatalf("snapshot was not idempotent: %+v, %v", two, err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("registering a snapshot rewrote chat files") + } + }) +} + +// F1/F5 keep task records and tombstones at their existing truth paths. F6 +// rechecks them before landing, changes only the planned checkout, and keeps +// every file in the owning chat intact. These are real temporary git repos. +func TestAppFolderBranchLandFiles(t *testing.T) { + eachAppFolder(t, func(t *testing.T, f appFolderFixture) { + landGit(t, f.workspace, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(f.workspace, "a.txt"), []byte("base\n"), 0o600); err != nil { + t.Fatal(err) + } + landGit(t, f.workspace, "add", "a.txt") + landGit(t, f.workspace, "commit", "-q", "-m", "base") + landGit(t, f.workspace, "checkout", "-q", "-b", "task/paint-abc123") + if err := os.WriteFile(filepath.Join(f.workspace, "b.txt"), []byte("task\n"), 0o600); err != nil { + t.Fatal(err) + } + landGit(t, f.workspace, "add", "b.txt") + landGit(t, f.workspace, "commit", "-q", "-m", "task") + landGit(t, f.workspace, "checkout", "-q", "main") + place := session.Place{Dir: f.dir} + old := (session.Place{Dir: filepath.Dir(f.transcript)}).Tasks() + if old != place.Tasks() { + t.Fatalf("task truth moved from %q to %q", old, place.Tasks()) + } + doc := `{"type":"tasks","version":1,"seq":1,"nodes":[],"runs":[{"id":1,"title":"Paint","state":"done","branch":"task/paint-abc123"}]}` + if err := os.WriteFile(old, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + branches, err := f.b.TaskBranches(appwire.BranchesQuery{Agent: f.id}) + if err != nil || len(branches.Branches) != 1 || branches.Branches[0].Name != "task/paint-abc123" { + t.Fatalf("saved task branches = %+v, %v", branches, err) + } + head := landGit(t, f.workspace, "rev-parse", "HEAD") + plan, err := f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}) + if err != nil { + t.Fatal(err) + } + deleted := filepath.Join(filepath.Dir(f.transcript), ".deleted-tasks.json") + writeCloseJSON(t, deleted, map[string]bool{"1": true}) + before := appFolderSnapshot(t, f.dir) + branches, err = f.b.TaskBranches(appwire.BranchesQuery{Agent: f.id}) + if err != nil || len(branches.Branches) != 0 { + t.Fatalf("deleted task branches = %+v, %v", branches, err) + } + if _, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}); !hasCode(err, appwire.CodeConflict) { + t.Fatalf("landing a deleted task was not refused: %v", err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) || landGit(t, f.workspace, "rev-parse", "HEAD") != head { + t.Fatal("refused land changed the chat or checkout") + } + if err := os.Remove(deleted); err != nil { + t.Fatal(err) + } + before = appFolderSnapshot(t, f.dir) + plan, err = f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}) + if err != nil { + t.Fatal(err) + } + landed, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}) + if err != nil || landed.HeadBefore != head || landed.HeadAfter != plan.BranchHead || landGit(t, f.workspace, "rev-parse", "HEAD") != plan.BranchHead { + t.Fatalf("landed branch = %+v, %v", landed, err) + } + for name, want := range map[string]string{"a.txt": "base\n", "b.txt": "task\n"} { + if raw, err := os.ReadFile(filepath.Join(f.workspace, name)); err != nil || string(raw) != want { + t.Fatalf("checkout file %s = %q, %v", name, raw, err) + } + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("landing changed files in the chat folder") + } + }) +} + +func appFolderSnapshot(t *testing.T, dir string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + key := filepath.ToSlash(rel) + if entry.IsDir() { + files[key+"/"] = "" + return nil + } + raw, err := os.ReadFile(path) + files[key] = string(raw) + return err + }) + if err != nil { + t.Fatal(err) + } + return files +} diff --git a/cmd/codeaf/appfooters.go b/cmd/codeaf/appfooters.go index b63900cd15..1f3cd7f83a 100644 --- a/cmd/codeaf/appfooters.go +++ b/cmd/codeaf/appfooters.go @@ -35,7 +35,33 @@ func (b *appBackend) readAppFooters(transcript string) ([]appKeptFooter, error) } func appReadFooters(transcript string) ([]appKeptFooter, error) { - raw, err := os.ReadFile(filepath.Join(filepath.Dir(transcript), appFootersName)) + dir := session.DirOf(transcript) + kept, err := appReadFootersAt(filepath.Join(dir, appFootersName)) + if err != nil || dir == filepath.Dir(transcript) { + return kept, err + } + // Earlier app builds saved cell receipts beside the journal. Read both + // files so the next write carries them forward without removing the old one. + old, err := appReadFootersAt(filepath.Join(filepath.Dir(transcript), appFootersName)) + if err != nil { + return nil, err + } + merged := make([]appKeptFooter, 0, len(old)+len(kept)) + positions := make(map[string]int, len(old)+len(kept)) + for _, f := range append(old, kept...) { + if at, ok := positions[f.Entry.ID]; ok { + // The corrected file wins when both copies name the same receipt. + merged[at] = f + } else { + positions[f.Entry.ID] = len(merged) + merged = append(merged, f) + } + } + return merged, nil +} + +func appReadFootersAt(path string) ([]appKeptFooter, error) { + raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil } @@ -73,16 +99,14 @@ func (b *appBackend) keepAppEntries(a *appAgent, entries []appwire.Entry, afterC return err } pending := make([]appwire.Entry, 0, len(entries)) + seen := make(map[string]bool, len(kept)+len(entries)) + for _, f := range kept { + seen[f.Entry.ID] = true + } for _, entry := range entries { - exists := false - for _, f := range kept { - if f.Entry.ID == entry.ID { - exists = true - break - } - } - if !exists { + if !seen[entry.ID] { pending = append(pending, entry) + seen[entry.ID] = true } } if len(pending) == 0 { @@ -110,7 +134,7 @@ func (b *appBackend) keepAppEntries(a *appAgent, entries []appwire.Entry, afterC if err != nil { return err } - path := filepath.Join(filepath.Dir(transcript), appFootersName) + path := filepath.Join(session.DirOf(transcript), appFootersName) temp, err := os.CreateTemp(filepath.Dir(path), appFootersName+".*.new") if err != nil { return err diff --git a/cmd/codeaf/applazy.go b/cmd/codeaf/applazy.go index 9dba733b2a..2d056d997b 100644 --- a/cmd/codeaf/applazy.go +++ b/cmd/codeaf/applazy.go @@ -73,7 +73,7 @@ func (b *appBackend) neededMembers(run *appCrewRun, read appCrewRead, first bool needed[id] = run.held[id] continue } - stamp := b.lazyWorkStamp(row.Dir, run.held[id]) + stamp := b.lazyWorkStamp(row.Transcript, run.held[id]) // A failed open remains due even when its input files did not change. retry := false run.locked(func() { retry = run.unheld[id] != nil }) @@ -147,11 +147,15 @@ func (b *appBackend) lazyCandidates(run *appCrewRun, read appCrewRead, first boo return candidates } -func (b *appBackend) lazyWorkStamp(dir string, crews []string) string { +func (b *appBackend) lazyWorkStamp(transcript string, crews []string) string { profile := b.crewProfile() stamp := teamstore.Stamp(profile) + lazyStamp(config.BudgetConfigPath(profile)) - for _, name := range []string{"presence.json", "tasks.json", "team.json", "processes", "processes/head.json", "processes.json"} { - stamp += lazyStamp(filepath.Join(dir, name)) + stamp += lazyStamp(filepath.Join(session.DirOf(transcript), "presence.json")) + // Presence belongs to the folder; task and process records stay beside + // the journal, including inside .cell/. + truth := filepath.Dir(transcript) + for _, name := range []string{"tasks.json", "team.json", "processes", "processes/head.json", "processes.json"} { + stamp += lazyStamp(filepath.Join(truth, name)) } for _, crew := range crews { stamp += teamstore.TrafficStamp(profile, crew) diff --git a/cmd/codeaf/appprocessactions.go b/cmd/codeaf/appprocessactions.go index d743d3994f..a1d61791bd 100644 --- a/cmd/codeaf/appprocessactions.go +++ b/cmd/codeaf/appprocessactions.go @@ -132,7 +132,7 @@ func (b *appBackend) ProcessActions(ctx context.Context, id string) (*appwire.Pr var transcript string b.locked(func() { transcript = a.transcript }) if transcript != "" { - convDir = filepath.Dir(transcript) + convDir = session.DirOf(transcript) } } res.State = appProcessBase(agent, record).State diff --git a/cmd/codeaf/appprocesspage.go b/cmd/codeaf/appprocesspage.go index bc8b549040..b083988f41 100644 --- a/cmd/codeaf/appprocesspage.go +++ b/cmd/codeaf/appprocesspage.go @@ -13,7 +13,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "path/filepath" "strings" "github.com/Agent-Field/codeaf/internal/appwire" @@ -75,7 +74,7 @@ func (b *appBackend) PageProcesses(ctx context.Context, ask appwire.ProcessPageA b.locked(func() { transcript = a.transcript }) convDir := "" if transcript != "" { - convDir = filepath.Dir(transcript) + convDir = session.DirOf(transcript) } owner := appProcessOwner{ agent: ask.Agent, live: b.liveOf(a), convDir: convDir, @@ -472,7 +471,7 @@ func (b *appBackend) processRecord(ctx context.Context, id string, legacyList bo b.locked(func() { transcript = a.transcript }) convDir := "" if transcript != "" { - convDir = filepath.Dir(transcript) + convDir = session.DirOf(transcript) } var record session.JobNotice found := false diff --git a/cmd/codeaf/apprender.go b/cmd/codeaf/apprender.go index c910a209c9..3d46ae2da3 100644 --- a/cmd/codeaf/apprender.go +++ b/cmd/codeaf/apprender.go @@ -2,7 +2,6 @@ package main import ( "errors" - "path/filepath" "reflect" "github.com/Agent-Field/codeaf/internal/appwire" @@ -62,7 +61,7 @@ func (b *appBackend) renderDir(a *appAgent) string { if transcript == "" { return "" } - return filepath.Dir(transcript) + return session.DirOf(transcript) } // ListRenders pages the conversation's whole render history, newest first. diff --git a/cmd/codeaf/appteamstart.go b/cmd/codeaf/appteamstart.go index b9bce3a7c3..089d93867c 100644 --- a/cmd/codeaf/appteamstart.go +++ b/cmd/codeaf/appteamstart.go @@ -482,7 +482,7 @@ func appConversationHere(t teamstore.Team, key string) (bool, error) { var dirs []string for _, path := range []string{m.File, m.Key} { if filepath.IsAbs(path) { - dirs = append(dirs, filepath.Dir(path)) + dirs = append(dirs, session.DirOf(path)) } } for _, dir := range dirs { @@ -539,7 +539,7 @@ func (b *appBackend) takeBack(transcript string) { // under root and hold nothing but the lookup file mint wrote. A folder with // anything else in it is somebody's, and stays. func appUnmint(root, transcript string) { - dir := filepath.Dir(filepath.Clean(transcript)) + dir := session.DirOf(filepath.Clean(transcript)) rel, err := filepath.Rel(root, dir) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return diff --git a/cmd/codeaf/apptidy.go b/cmd/codeaf/apptidy.go index 2ac732c4c4..20b7aa5775 100644 --- a/cmd/codeaf/apptidy.go +++ b/cmd/codeaf/apptidy.go @@ -14,7 +14,6 @@ package main // every conversation's words are indexed in for messages. import ( - "path/filepath" "strconv" "strings" "time" @@ -146,7 +145,7 @@ func (b *appBackend) Archive(ids []string) error { var transcript string b.locked(func() { transcript = a.transcript }) if transcript != "" { - if err := session.SetArchived(filepath.Dir(transcript), true); err != nil { + if err := session.SetArchived(session.DirOf(transcript), true); err != nil { return appEngineError(err) } } @@ -183,7 +182,7 @@ func (b *appBackend) Reopen(id string) (appwire.Agent, error) { archived = archived || marked }) if archived && transcript != "" { - if err := session.SetArchived(filepath.Dir(transcript), false); err != nil { + if err := session.SetArchived(session.DirOf(transcript), false); err != nil { return appwire.Agent{}, appEngineError(err) } } diff --git a/docs/changes/unreleased/app-cell-folders.md b/docs/changes/unreleased/app-cell-folders.md new file mode 100644 index 0000000000..eed593d11a --- /dev/null +++ b/docs/changes/unreleased/app-cell-folders.md @@ -0,0 +1,18 @@ +--- +kind: fixed +title: Desktop app helpers use each cell chat's own folder +pr: 0 +surface: [engine, docs] +invalidates: + - Desktop helpers treated a cell journal's .cell directory as its conversation folder; they now resolve the owning chat folder while preserving legacy and flat-file paths. + - Earlier desktop builds saved cell chat receipts beside the journal; those receipts remain readable and merge by entry id into the chat folder on the next new receipt, while the old file stays intact. +--- + +Valid job logs in the chat's logs/jobs folder can be read again. Media scope, +deliverables, task working copies, presence and close recovery use the chat's +own roots. App receipts, archive marks, blank-chat cleanup and crew existence +checks use that same folder. Conversation identity and wire fields are unchanged. +Existing cell receipts stay in history after upgrading, including when receipts +already exist in both locations; writing again keeps one receipt per entry id. +Lazy recovery, task branch listings and confirmed branch lands still read task +and process records and task deletion marks from the cell's existing truth files. diff --git a/internal/session/task_branches.go b/internal/session/task_branches.go index 5780fd2040..502c1f8282 100644 --- a/internal/session/task_branches.go +++ b/internal/session/task_branches.go @@ -224,7 +224,7 @@ func readAtMost(ctx context.Context, path string, max int64) ([]byte, error) { // one step not interrupted midway: it is bounded by taskBranchRecordsMax, and // the clock is asked before and after it. func ownedTaskBranches(ctx context.Context, place Place) ([]namedBranch, error) { - content, err := readAtMost(ctx, place.join(placeTasks), taskBranchRecordsMax) + content, err := readAtMost(ctx, place.Tasks(), taskBranchRecordsMax) switch { case os.IsNotExist(err): return nil, nil @@ -234,7 +234,7 @@ func ownedTaskBranches(ctx context.Context, place Place) ([]namedBranch, error) return nil, fmt.Errorf("%w: %v", ErrTaskBranchRecords, err) } var deleted map[string]bool - raw, err := readAtMost(ctx, place.join(taskDeletedFile), taskBranchTombstonesMax) + raw, err := readAtMost(ctx, place.truth(taskDeletedFile), taskBranchTombstonesMax) switch { case os.IsNotExist(err): case errors.Is(err, ErrTaskBranchRecordsTooLarge), errors.Is(err, errTaskBranchTime): From f5e6accedb33fd9f0a155ff820667ab65a2a164b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 14:05:13 -0400 Subject: [PATCH 2/5] test(app): land the folder fixture's branch without the runner's git config A land refuses a repository whose git config runs a filter program, and the CI runner's global config carries one, so TestAppFolderBranchLandFiles was refused in every layout. Point the test at no global or system config, as appfilediff_test does. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/appfolders_upgrade_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/codeaf/appfolders_upgrade_test.go b/cmd/codeaf/appfolders_upgrade_test.go index 76833d95ef..bacd61d00b 100644 --- a/cmd/codeaf/appfolders_upgrade_test.go +++ b/cmd/codeaf/appfolders_upgrade_test.go @@ -465,6 +465,10 @@ func TestAppFolderDeliverableChanges(t *testing.T) { // rechecks them before landing, changes only the planned checkout, and keeps // every file in the owning chat intact. These are real temporary git repos. func TestAppFolderBranchLandFiles(t *testing.T) { + // A land refuses a repository whose git config runs a filter program; the + // runner's global config can carry one, so this repository sees none. + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") eachAppFolder(t, func(t *testing.T, f appFolderFixture) { landGit(t, f.workspace, "init", "-q", "-b", "main") if err := os.WriteFile(filepath.Join(f.workspace, "a.txt"), []byte("base\n"), 0o600); err != nil { From 9aea10c4e275352ef5f291274b4dd391afb6c4ef Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 14:22:49 -0400 Subject: [PATCH 3/5] test(app): give the folder land test a git home of its own The land strips every GIT_* variable from the git it runs, so GIT_CONFIG_GLOBAL never reached it and the runner's ~/.gitconfig filter still refused the land. Point HOME and XDG_CONFIG_HOME at a temporary home whose only config is a commit identity. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/appfolders_upgrade_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/appfolders_upgrade_test.go b/cmd/codeaf/appfolders_upgrade_test.go index bacd61d00b..732edb523a 100644 --- a/cmd/codeaf/appfolders_upgrade_test.go +++ b/cmd/codeaf/appfolders_upgrade_test.go @@ -465,8 +465,15 @@ func TestAppFolderDeliverableChanges(t *testing.T) { // rechecks them before landing, changes only the planned checkout, and keeps // every file in the owning chat intact. These are real temporary git repos. func TestAppFolderBranchLandFiles(t *testing.T) { - // A land refuses a repository whose git config runs a filter program; the - // runner's global config can carry one, so this repository sees none. + // A land refuses a repository whose git config runs a filter program, and + // the runner's global config carries one. The land strips GIT_* from its + // git, so only a home of the test's own keeps that config out. + gitHome := t.TempDir() + if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte("[user]\n\tname = t\n\temail = t@t\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", gitHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(gitHome, ".config")) t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) t.Setenv("GIT_CONFIG_NOSYSTEM", "1") eachAppFolder(t, func(t *testing.T, f appFolderFixture) { From e6643feb4d1bd4a7b0f877786a06e948217814db Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 14:53:17 -0400 Subject: [PATCH 4/5] test(app): check folder records before filter-blocked lands Keep cell, legacy and flat task-record and tombstone checks running when the production land planner sees a filter in the machine's git config. Assert the typed filter refusal, then skip only the nested land step. Preserve confirmation and checkout-file checks on hosts without filters. Check deleted-task planning and unchanged chat files and checkout HEAD before any skip. Remove ineffective GIT_CONFIG_* overrides: landEnv strips them, so they cannot suppress the runner's system filter. Leave the production refusal and all other land policy unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/appfolders_upgrade_test.go | 84 +++++++++++++++++---------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/cmd/codeaf/appfolders_upgrade_test.go b/cmd/codeaf/appfolders_upgrade_test.go index 732edb523a..96d08e62a9 100644 --- a/cmd/codeaf/appfolders_upgrade_test.go +++ b/cmd/codeaf/appfolders_upgrade_test.go @@ -463,19 +463,17 @@ func TestAppFolderDeliverableChanges(t *testing.T) { // F1/F5 keep task records and tombstones at their existing truth paths. F6 // rechecks them before landing, changes only the planned checkout, and keeps -// every file in the owning chat intact. These are real temporary git repos. +// every file in the owning chat intact. These are real temporary git repos; +// ownership checks still run when the machine's git filters prevent a land. func TestAppFolderBranchLandFiles(t *testing.T) { - // A land refuses a repository whose git config runs a filter program, and - // the runner's global config carries one. The land strips GIT_* from its - // git, so only a home of the test's own keeps that config out. + // Isolate the person's global config. The land strips GIT_* overrides, + // so the machine's system config still applies to its own git commands. gitHome := t.TempDir() if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte("[user]\n\tname = t\n\temail = t@t\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("HOME", gitHome) t.Setenv("XDG_CONFIG_HOME", filepath.Join(gitHome, ".config")) - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") eachAppFolder(t, func(t *testing.T, f appFolderFixture) { landGit(t, f.workspace, "init", "-q", "-b", "main") if err := os.WriteFile(filepath.Join(f.workspace, "a.txt"), []byte("base\n"), 0o600); err != nil { @@ -499,48 +497,74 @@ func TestAppFolderBranchLandFiles(t *testing.T) { if err := os.WriteFile(old, []byte(doc), 0o600); err != nil { t.Fatal(err) } + before := appFolderSnapshot(t, f.dir) branches, err := f.b.TaskBranches(appwire.BranchesQuery{Agent: f.id}) if err != nil || len(branches.Branches) != 1 || branches.Branches[0].Name != "task/paint-abc123" { t.Fatalf("saved task branches = %+v, %v", branches, err) } head := landGit(t, f.workspace, "rev-parse", "HEAD") plan, err := f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}) + filterRefused := false if err != nil { - t.Fatal(err) + wire, ok := err.(*appwire.Error) + if !ok || wire.Code != appwire.CodeConflict { + t.Fatal(err) + } + refusal, ok := wire.Detail.(appwire.BranchLandRefusal) + if !ok || refusal.Reason != session.LandFilter { + t.Fatal(err) + } + filterRefused = true + } else if plan.Plan == "" || plan.Revision == "" || plan.Task != "1" || plan.Title != "Paint" || plan.Branch != "task/paint-abc123" || plan.Head != head { + t.Fatalf("saved task plan = %+v", plan) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) || landGit(t, f.workspace, "rev-parse", "HEAD") != head { + t.Fatal("listing or planning changed the chat or checkout") } deleted := filepath.Join(filepath.Dir(f.transcript), ".deleted-tasks.json") writeCloseJSON(t, deleted, map[string]bool{"1": true}) - before := appFolderSnapshot(t, f.dir) + before = appFolderSnapshot(t, f.dir) branches, err = f.b.TaskBranches(appwire.BranchesQuery{Agent: f.id}) if err != nil || len(branches.Branches) != 0 { t.Fatalf("deleted task branches = %+v, %v", branches, err) } - if _, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}); !hasCode(err, appwire.CodeConflict) { - t.Fatalf("landing a deleted task was not refused: %v", err) + if _, err := f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}); !hasCode(err, appwire.CodeNotFound) { + t.Fatalf("planning a deleted task was not refused: %v", err) } if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) || landGit(t, f.workspace, "rev-parse", "HEAD") != head { - t.Fatal("refused land changed the chat or checkout") - } - if err := os.Remove(deleted); err != nil { - t.Fatal(err) - } - before = appFolderSnapshot(t, f.dir) - plan, err = f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}) - if err != nil { - t.Fatal(err) + t.Fatal("refused plan changed the chat or checkout") } - landed, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}) - if err != nil || landed.HeadBefore != head || landed.HeadAfter != plan.BranchHead || landGit(t, f.workspace, "rev-parse", "HEAD") != plan.BranchHead { - t.Fatalf("landed branch = %+v, %v", landed, err) - } - for name, want := range map[string]string{"a.txt": "base\n", "b.txt": "task\n"} { - if raw, err := os.ReadFile(filepath.Join(f.workspace, name)); err != nil || string(raw) != want { - t.Fatalf("checkout file %s = %q, %v", name, raw, err) + t.Run("land", func(t *testing.T) { + if filterRefused { + t.Skip("the land's own git environment reports a filter program; task records, tombstones and planning were checked above") } - } - if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { - t.Fatal("landing changed files in the chat folder") - } + if _, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}); !hasCode(err, appwire.CodeConflict) { + t.Fatalf("landing a deleted task was not refused: %v", err) + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) || landGit(t, f.workspace, "rev-parse", "HEAD") != head { + t.Fatal("refused land changed the chat or checkout") + } + if err := os.Remove(deleted); err != nil { + t.Fatal(err) + } + before = appFolderSnapshot(t, f.dir) + plan, err = f.b.PlanBranchLand(appwire.BranchLandPlanQuery{Agent: f.id, Branch: "task/paint-abc123"}) + if err != nil { + t.Fatal(err) + } + landed, err := f.b.LandBranch(appwire.BranchLandQuery{Agent: f.id, Plan: plan.Plan, Revision: plan.Revision}) + if err != nil || landed.HeadBefore != head || landed.HeadAfter != plan.BranchHead || landGit(t, f.workspace, "rev-parse", "HEAD") != plan.BranchHead { + t.Fatalf("landed branch = %+v, %v", landed, err) + } + for name, want := range map[string]string{"a.txt": "base\n", "b.txt": "task\n"} { + if raw, err := os.ReadFile(filepath.Join(f.workspace, name)); err != nil || string(raw) != want { + t.Fatalf("checkout file %s = %q, %v", name, raw, err) + } + } + if after := appFolderSnapshot(t, f.dir); !reflect.DeepEqual(after, before) { + t.Fatal("landing changed files in the chat folder") + } + }) }) } From d6d8584f79b71a45e574f3bcb9c936772b61ad3a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 26 Oct 2026 14:54:18 -0400 Subject: [PATCH 5/5] docs: attach the cell folder change entry to PR 1970 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../{app-cell-folders.md => 1970-app-cell-folders.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{app-cell-folders.md => 1970-app-cell-folders.md} (99%) diff --git a/docs/changes/unreleased/app-cell-folders.md b/docs/changes/unreleased/1970-app-cell-folders.md similarity index 99% rename from docs/changes/unreleased/app-cell-folders.md rename to docs/changes/unreleased/1970-app-cell-folders.md index eed593d11a..1c217c91ba 100644 --- a/docs/changes/unreleased/app-cell-folders.md +++ b/docs/changes/unreleased/1970-app-cell-folders.md @@ -1,7 +1,7 @@ --- kind: fixed title: Desktop app helpers use each cell chat's own folder -pr: 0 +pr: 1970 surface: [engine, docs] invalidates: - Desktop helpers treated a cell journal's .cell directory as its conversation folder; they now resolve the owning chat folder while preserving legacy and flat-file paths.