Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,32 @@ func encodeWorkspace(workspace string) string {
// explicit workspace — the engine's --workspace, a door that has already
// resolved one — is taken as given and is always borrowed: it is a place
// somebody named, and naming a place is asking to work in it.
//
// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. Every answer here is
// a key this surface reads and writes sessions, tasks and memory under, so the
// spelling a terminal carried into the directory (a path reached through a
// symlink) may not leak into it — the engine's door resolved (#1761), and a
// bucket minted from the alias here would still have been a second project,
// a second history, for one folder. A path that does not resolve keeps its
// spelling.
func v3Workspace(cwd, explicit string) (string, bool) {
if named := strings.TrimSpace(explicit); named != "" {
return named, false
return v3CanonicalPlace(named), false
}
if root, found := v3GitRoot(cwd); found {
return root, false
return v3CanonicalPlace(root), false
}
return v3CanonicalPlace(cwd), v3NoProjectPlace(cwd)
}

// v3CanonicalPlace resolves the links in a path that is about to become a key,
// and hands back the spelling it was given when the path does not resolve — a
// workspace not on disk yet, a test's imagined directory.
func v3CanonicalPlace(dir string) string {
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
return resolved
}
return cwd, v3NoProjectPlace(cwd)
return dir
}

// v3NoProjectPlace reports whether a directory is somewhere the person stood by
Expand Down Expand Up @@ -277,6 +295,19 @@ func v3ResolveSession(explicit, workspace, launchDir string, owned bool) (v3Sess
// happened.
return v3Session{Place: place, Transcript: place.Transcript(), Resumed: resumed, Bucket: bucket}, nil
}

// A CONVERSATION AN OLDER BUILD FILED UNDER ANOTHER SPELLING IS STILL THIS
// WORKSPACE'S CONVERSATION. Before the project key was resolved (#1761),
// buckets were minted from whatever spelling the terminal carried, and an
// upgrade that reads only the canonical bucket would open a fresh journal
// beside every conversation a person already had. The newest spoken folder
// whose recorded workspace resolves to this folder resumes where it lives —
// no merge, no move; the canonical bucket simply takes over from here.
if legacy, legacyBucket, found := v3LegacyFolder(workspace); found {
place := v3PlaceFor(legacy.dir, workspace, owned || legacy.meta.Owned)
return v3Session{Place: place, Transcript: place.Transcript(), Resumed: true, Bucket: legacyBucket}, nil
}

place, err := v3MintSession(bucket, workspace, launchDir, owned)
if err != nil {
return v3Session{}, err
Expand All @@ -285,6 +316,58 @@ func v3ResolveSession(explicit, workspace, launchDir string, owned bool) (v3Sess
return v3Session{Place: place, Transcript: place.Transcript(), Bucket: bucket}, nil
}

// v3LegacyFolder searches the project buckets OTHER than this workspace's own
// for the newest conversation somebody has spoken in whose recorded workspace
// ([session.Meta.Workspace]) resolves to the same folder, and answers it with
// the bucket it lives in. It is the upgrade road for buckets minted before the
// project key was resolved (#1761): the bucket name is a spelling's encoding and
// cannot be reversed, but every folder cites its own workspace, and a citation
// that resolves to this folder is this folder's conversation. Empty folders and
// folders that cite nothing are not candidates — a resume must come back to
// words, and a citation that was never written cannot be trusted to spellings.
func v3LegacyFolder(workspace string) (v3Folder, string, bool) {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return v3Folder{}, "", false
}
resolved, err := filepath.EvalSymlinks(filepath.Clean(workspace))
if err != nil {
return v3Folder{}, "", false
}
root := home.Join("v3", "projects")
entries, err := os.ReadDir(root)
if err != nil {
return v3Folder{}, "", false
}
canonical := filepath.Join(root, encodeWorkspace(workspace))
var newest v3Folder
newestBucket := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
bucket := filepath.Join(root, entry.Name())
if bucket == canonical {
continue
}
spoken, _ := v3ScanBucket(bucket)
for _, folder := range spoken {
recorded := strings.TrimSpace(folder.meta.Workspace)
if recorded == "" {
continue
}
resolvedRecorded, err := filepath.EvalSymlinks(filepath.Clean(recorded))
if err != nil || resolvedRecorded != resolved {
continue
}
if newestBucket == "" || folder.at.After(newest.at) {
newest, newestBucket = folder, bucket
}
}
}
return newest, newestBucket, newestBucket != ""
}

// v3PickFolder resumes the newest conversation somebody has spoken in. A
// writer mints a fresh identity when there are only unused conversations: an
// old empty identity could be assigned to a team between discovery and opening.
Expand Down Expand Up @@ -327,6 +410,11 @@ func v3LatestTranscript(workspace string) string {
spoken, empty := v3ScanBucket(bucket)
folder, _, found := v3PickFolder(spoken, empty, false)
if !found {
// A CONVERSATION AN OLDER BUILD FILED UNDER ANOTHER SPELLING IS STILL
// THIS WORKSPACE'S CONVERSATION ([v3LegacyFolder]).
if legacy, _, found := v3LegacyFolder(workspace); found {
return session.Place{Dir: legacy.dir}.Transcript()
}
return ""
}
return session.Place{Dir: folder.dir}.Transcript()
Expand Down
70 changes: 70 additions & 0 deletions cmd/codeaf/chatv3_legacy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
)

// THE CONVERSATIONS AN OLDER BUILD FILED UNDER ANOTHER SPELLING ARE STILL THIS
// FOLDER'S CONVERSATIONS. The upgrade regression for the resolved project key:
// a bucket minted before #1761 was named by the raw spelling the terminal
// carried, and a launch that reads only the canonical bucket would open a fresh
// journal beside every conversation a person already had. The newest spoken
// folder whose recorded workspace resolves to this folder resumes where it
// lives; a comprehensive merge of historical buckets is not required, and none
// is attempted here.
func TestAConversationInALegacyBucketStillResumes(t *testing.T) {
t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state"))
target := t.TempDir()
link := filepath.Join(t.TempDir(), "linkedrepo")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatal(err)
}

// The old build wrote the bucket under the alias and cited the alias in the
// folder's own meta — the citation is the identity, the bucket name is not.
legacyBucket := filepath.Join(home.Join("v3", "projects"), encodeWorkspace(link))
held := writeV3Session(t, legacyBucket, "00000000000000e1", "this morning", time.Now().Add(-time.Hour))
meta, err := session.LoadMeta(held)
if err != nil {
t.Fatal(err)
}
meta.Workspace = link
if err := session.SaveMeta(held, meta); err != nil {
t.Fatal(err)
}

// A launch that stands on the resolved folder resumes the held
// conversation, from the bucket it already lives in.
want := filepath.Join(held, "transcript.jsonl")
found, err := v3ResolveSession("", resolved, resolved, false)
if err != nil {
t.Fatal(err)
}
if found.Transcript != want {
t.Fatalf("the launch opened %q, want the conversation the old build filed (%s)", found.Transcript, want)
}
if !found.Resumed {
t.Fatal("a conversation somebody has spoken in resumed as a fresh one")
}
if found.Bucket != legacyBucket {
t.Fatalf("the resumed conversation was filed under %q, want the bucket it lives in %q", found.Bucket, legacyBucket)
}

// And the engine's hello key — the question asked before anything opens —
// answers with the same conversation, so a plain hello joins it rather than
// booting a second agent onto the journal.
if key := engineHelloKey(remote.Hello{Version: remote.Version, Workspace: resolved}, resolved, ""); key != want {
t.Fatalf("a plain hello keyed to %q, want the held conversation (%s)", key, want)
}
}
55 changes: 55 additions & 0 deletions cmd/codeaf/chatv3_symlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"os"
"path/filepath"
"testing"
)

// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. The surface-side
// regression for issue #1761: the bucket key a launch reads and writes sessions
// under used to carry whatever spelling the terminal walked in with, so one
// folder reached through a link was a second project — a second history —
// beside the same folder named by its resolved path.
func TestAProjectIsNamedByTheFolderNotTheSpelling(t *testing.T) {
target := t.TempDir()
link := filepath.Join(t.TempDir(), "linkedrepo")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}

// A non-git directory reached through the link keys the same bucket as the
// folder it reaches.
aliasProject, _ := v3Workspace(link, "")
if aliasProject != resolved {
t.Fatalf("the alias spelling became the project: got %q, want %q", aliasProject, resolved)
}
bucketFromAlias, err := v3ProjectDir(aliasProject)
if err != nil {
t.Fatalf("v3ProjectDir(alias): %v", err)
}
bucketFromTarget, err := v3ProjectDir(resolved)
if err != nil {
t.Fatalf("v3ProjectDir(target): %v", err)
}
if bucketFromAlias != bucketFromTarget {
t.Fatalf("one folder was given two buckets: alias=%q target=%q", bucketFromAlias, bucketFromTarget)
}

// Naming the folder explicitly keys one project either way.
namedAlias, borrowed := v3Workspace("", link)
if namedAlias != resolved || borrowed {
t.Fatalf("an explicit workspace keys %q (borrowed=%v), want %q borrowed", namedAlias, borrowed, resolved)
}

// And a path that does not resolve keeps its spelling.
imagined := filepath.Join(t.TempDir(), "nowhere-at-all")
kept, _ := v3Workspace(imagined, "")
if kept != imagined {
t.Fatalf("a path that is not on disk was rewritten: got %q, want %q", kept, imagined)
}
}
16 changes: 16 additions & 0 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,14 @@ func engineWorkspace(path string) (string, error) {
}
path = strings.TrimSpace(path)
if path == "" {
// THE DEFAULT IS THE SAME DOOR AS THE NAMED ONE. Omitting --workspace
// means the home directory, and home reaches the resolution below only
// when somebody TYPES it — an omitted flag returned the raw spelling, so
// a home behind a symlink keyed two hosts depending on how it was said
// (#1761).
if resolved, err := filepath.EvalSymlinks(home); err == nil {
home = resolved
}
return home, nil
}
expanded, err := expandHome(path)
Expand All @@ -1354,6 +1362,14 @@ func engineWorkspace(path string) (string, error) {
expanded = filepath.Join(home, expanded)
}
expanded = filepath.Clean(expanded)
// A WORKSPACE IS NAMED BY ITS RESOLVED PATH, so a folder reached through a
// symlink keys the same project, conversation history and engine host as
// the folder it points at. Clean alone leaves both spellings distinct and
// sha256(Clean(...)) in enginehost.where then builds one host directory per
// spelling — two engines, two histories, one folder.
if resolved, err := filepath.EvalSymlinks(expanded); err == nil {
expanded = resolved
}
info, err := os.Stat(expanded)
if err != nil {
return "", fmt.Errorf("open %s: %w", expanded, err)
Expand Down
8 changes: 7 additions & 1 deletion cmd/codeaf/engine_stale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,11 +807,17 @@ func TestStatusSaysNoneWhenNothingHoldsTheWorkspace(t *testing.T) {
shortEngineHome(t)
home := t.TempDir()
t.Setenv("HOME", home)
// The status door names the workspace by its resolved path (#1761); the
// expectation follows the same spelling rather than the one typed in.
resolved, err := engineWorkspace(home)
if err != nil {
t.Fatal(err)
}
var out strings.Builder
if err := runEngineStatus(&out, home); err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(out.String()); got != "no engine is holding "+home+" on this machine" {
if got := strings.TrimSpace(out.String()); got != "no engine is holding "+resolved+" on this machine" {
t.Fatalf("status of an empty workspace said %q", got)
}
}
Loading
Loading