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..96d08e62a9 --- /dev/null +++ b/cmd/codeaf/appfolders_upgrade_test.go @@ -0,0 +1,595 @@ +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; +// ownership checks still run when the machine's git filters prevent a land. +func TestAppFolderBranchLandFiles(t *testing.T) { + // 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")) + 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) + } + 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 { + 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) + 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.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 plan changed the chat or checkout") + } + 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 _, 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/1970-app-cell-folders.md b/docs/changes/unreleased/1970-app-cell-folders.md new file mode 100644 index 0000000000..1c217c91ba --- /dev/null +++ b/docs/changes/unreleased/1970-app-cell-folders.md @@ -0,0 +1,18 @@ +--- +kind: fixed +title: Desktop app helpers use each cell chat's own folder +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. + - 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):