diff --git a/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md new file mode 100644 index 0000000000..6a5846c91e --- /dev/null +++ b/docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md @@ -0,0 +1,16 @@ +--- +kind: fixed +title: A computer that only opened codeaf can pair, and the local relay stops rebuilding in a loop +pr: 2540 +surface: [chat, engine, docs] +invalidates: + - "Opening codeaf once and quitting left an empty conversation that made codeaf pair refuse with 'this computer has local work'; a computer with nothing but that empty conversation now pairs by code or link without --replace, while a sent turn, saved memories, settings or any staged or committed file still refuse." + - "npm run dev for the hosted relay could rebuild and reload itself in a loop, because its build hook rewrote src/raft/core.wasm inside the watched folder, and its Node process grew to about 9 GB before aborting; an unchanged build now leaves core.wasm untouched and the local wrapper bounds Node's heap." +--- +A computer that has only been started holds nothing to lose, so it joins +without being told to replace. Files staged or committed in its own repository, +including ones whose names are only spaces, still count as work. + +The local relay's build hook builds the reactor beside the tree and moves it +into place only when its bytes differ, and `npm run dev` caps Node's heap at +2 GB unless the caller sets its own limit. diff --git a/internal/identity/identitytest/started.go b/internal/identity/identitytest/started.go new file mode 100644 index 0000000000..2caaff4751 --- /dev/null +++ b/internal/identity/identitytest/started.go @@ -0,0 +1,78 @@ +// Package identitytest builds a closed, unused chat through its storage owners. +package identitytest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellbudget" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/session" +) + +// OpenAndQuit exercises session creation without sending a request to a model. +// Its extra surface files were observed after skipping setup and typing /quit. +func OpenAndQuit(t *testing.T, home string, owned bool) session.Place { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + t.Setenv("CODEAF_NO_AUTOMATIONS", "1") + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + c, err := cell.CreateIn(filepath.Join(home, "v3/projects/project"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root, Workspace: t.TempDir(), Owned: owned} + if owned { + place.Workspace = filepath.Join(c.Root, "work") + if err := os.MkdirAll(place.Workspace, 0o700); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=codeaf", "-c", "user.email=test@localhost", "commit", "--allow-empty", "--no-verify", "-qm", "session opened"}} { + cmd := exec.Command("git", args...) //codeaf:plumbing An owned chat starts with an empty repository. + cmd.Dir = place.Workspace + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prepare empty workspace: %s: %v", out, err) + } + } + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: place.Workspace, Owned: owned}); err != nil { + t.Fatal(err) + } + a, err := session.New(session.Config{Workspace: place.Workspace, Model: "stub/scripted", BaseURL: "http://127.0.0.1:9/api/v1", APIKey: "stub", Place: place, SessionFile: place.Transcript()}) + if err != nil { + t.Fatal(err) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + if err := inventory.Record(c.Root, func(inv *inventory.Inventory) { inv.Workspace = place.Workspace }); err != nil { + t.Fatal(err) + } + if err := (cellstore.Guard{Home: home}).Screen(c, place.Workspace, filepath.Join(c.Root, cell.StateDir), nil); err != nil { + t.Fatal(err) + } + manager := cellbudget.New(cellstore.Engine{DataRoot: filepath.Join(home, "v3/stores")}, func(cell.Cell) string { return place.Workspace }, nil) + if err := manager.Open(t.Context(), c); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "v3/draft-started.json": `{"version":1,"owner":"chat"}`, + "v3/history.jsonl": "{\"text\":\"/quit\"}\n", + } { + if err := os.WriteFile(filepath.Join(home, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return place +} diff --git a/internal/identity/opened_chat_test.go b/internal/identity/opened_chat_test.go new file mode 100644 index 0000000000..837beb0405 --- /dev/null +++ b/internal/identity/opened_chat_test.go @@ -0,0 +1,48 @@ +package identity_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +func TestAComputerThatOnlyOpenedAndQuitAChatCanJoin(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(map[bool]string{false: "borrowed project", true: "owned project"}[owned], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, owned) + if ok, err := identity.Joinable(home); !ok || err != nil { + t.Fatalf("an opened and quit chat cannot join: %v, %v", ok, err) + } + for _, name := range []string{"memories.jsonl", "state.json", "env/notes.txt"} { + path := filepath.Join(place.Dir, ".cell", name) + if err := os.WriteFile(path, []byte("keep my work"), 0o600); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("%s must keep the home from joining: %v, %v", name, ok, err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(place.Dir, ".cell", "env/inventory.json") + for _, body := range []string{ + `{"V":1,"platform":{},"annotations":{"note":{"text":"keep"}}}`, + `{"V":1,"platform":{"notes":"keep"}}`, + `{"V":2,"platform":{}}`, + `{"V":1,"platform":{}} {"notes":"keep"}`, + } { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("inventory notes or unknown contents must remain work: %v, %v: %s", ok, err, body) + } + } + }) + } +} diff --git a/internal/identity/opened_repository_work_test.go b/internal/identity/opened_repository_work_test.go new file mode 100644 index 0000000000..0c279b9726 --- /dev/null +++ b/internal/identity/opened_repository_work_test.go @@ -0,0 +1,51 @@ +package identity_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +// Staged or committed work in an owned chat's own repository keeps the home +// from joining, even when the file has gone from the folder and its name is +// nothing but spaces, which a trimmed listing used to read as no name at all. +func TestStagedOrCommittedWorkInAnOwnedRepositoryKeepsAHomeFromJoining(t *testing.T) { + for _, name := range []string{" "} { + for _, step := range []string{"staged", "committed"} { + t.Run(step+" "+map[bool]string{true: "a blank name", false: name}[name == " "], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, true) + if ok, err := identity.Joinable(home); !ok || err != nil { + t.Fatalf("the untouched opened home must join first: %v, %v", ok, err) + } + work := place.Workspace + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"}, args...)...) + cmd.Dir = work + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + if err := os.WriteFile(filepath.Join(work, name), []byte("keep my work"), 0o600); err != nil { + t.Fatal(err) + } + git("add", "--", name) + if step == "committed" { + git("commit", "-q", "-m", "work") + } + // The file leaves the folder, so only Git still knows about it. + if err := os.Rename(filepath.Join(work, name), filepath.Join(t.TempDir(), "moved")); err != nil { + t.Fatal(err) + } + if ok, err := identity.Joinable(home); ok || err != nil { + t.Fatalf("%s work named %q must keep the home from joining: %v, %v", step, name, ok, err) + } + }) + } + } +} diff --git a/internal/identity/pristine.go b/internal/identity/pristine.go index 2bd60c7395..6e04e4e329 100644 --- a/internal/identity/pristine.go +++ b/internal/identity/pristine.go @@ -4,6 +4,8 @@ import ( "errors" "io/fs" "os" + + "github.com/Agent-Field/codeaf/internal/inventory/layout" ) // Pristine says whether the home holds nothing but what starting codeaf makes: @@ -72,8 +74,11 @@ var started = within(startupRules) // its transcript and memories are the chat itself. var withinSession = within(rules{ "meta.json": never, "meta.lock": never, "presence.json": never, + // An owned chat starts with an empty repository before any request is sent. + "work": within(rules{".git": unopenedRepository}), ".cell": within(rules{ - "meta.json": never, "session.json": never, ".furrowpolicy": never, "env": empty, + "meta.json": never, "session.json": never, ".furrowpolicy": never, + "env": within(rules{layout.File: startupInventory}), "memories.jsonl": empty, "transcript.jsonl": noTurns, }), }) diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go index 5a3cef782a..39e6e4c4fa 100644 --- a/internal/identity/pristine_judges.go +++ b/internal/identity/pristine_judges.go @@ -3,14 +3,17 @@ package identity import ( "bufio" "bytes" + "context" "database/sql" "encoding/json" "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/sqliteuri" "io" "os" + "os/exec" "path/filepath" "strings" + "time" _ "modernc.org/sqlite" // reads the memory graph, which is a SQLite file ) @@ -72,6 +75,48 @@ func pickerCache(path string) (bool, error) { // unknown is the judge of a name nobody declared, and it is always use. func unknown(string) (bool, error) { return true, nil } +// startupInventory admits the platform and locations recorded before any call. +// Observed tools, notes and all other fields still count as work. +func startupInventory(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return true, err + } + var inv struct { + V uint16 + Platform struct{ OS, Arch string } + Workspace string + ChatDir string `json:"chat_dir"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&inv) != nil || inv.V != 1 { + return true, nil + } + return decoder.Decode(new(json.RawMessage)) != io.EOF, nil +} + +// unopenedRepository keeps even deleted or staged project work from being +// mistaken for the empty repository made when an owned chat first opens. +// +// ANY NAME GIT PRINTS IS WORK. The names are read NUL-terminated and never +// trimmed: a file named only with spaces, staged and then deleted, printed a +// blank line that trimming discarded, and the repository passed as untouched. +// For the startup repository both listings print nothing at all. +func unopenedRepository(path string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, args := range [][]string{{"log", "--all", "--format=", "--name-only", "-z"}, {"ls-files", "--cached", "-z"}} { + cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) //codeaf:plumbing An empty owned repository contains no saved or staged files. + cmd.Dir = filepath.Dir(path) + raw, err := cmd.Output() + if err != nil || len(raw) > 0 { + return true, err + } + } + return false, nil +} + // empty is for what holds the person's words and bytes when it holds any: a // file with no bytes, or a folder of such files. func empty(path string) (bool, error) { diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index fdec670f3f..85df6ee212 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -10,10 +10,11 @@ package inventory import ( "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory/layout" ) // Path is the cell-relative location of the inventory file. -const Path = cell.EnvPath + "/inventory.json" +const Path = cell.EnvPath + "/" + layout.File const schemaV = 1 diff --git a/internal/inventory/layout/layout.go b/internal/inventory/layout/layout.go new file mode 100644 index 0000000000..25b9dd71c5 --- /dev/null +++ b/internal/inventory/layout/layout.go @@ -0,0 +1,6 @@ +// Package layout names inventory storage without depending on its live observers. +package layout + +// File is shared by inventory writers and the unused-home reader, which cannot +// import the live inventory package because its key storage depends on identity. +const File = "inventory.json" diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index b16765d7c2..a9cc47c9f5 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -93,6 +93,12 @@ With a self-hosted address (`CODEAF_SYNC_URL=` or `--via
`), the l A link is good for 10 minutes and one approval. If it ran out, run `codeaf pair` on the new device again. Ctrl+c while it waits ends with `Cancelled. Nothing was paired.` +## I only opened codeaf and quit — can this computer pair without --replace + +Yes. Skipping setup and quitting an empty chat counts as starting codeaf, with either a pairing code or a link. Its empty transcript, draft, command history, inventory and empty working folder do not require `--replace`. + +Once you send a message, that chat belongs to this computer. With one such chat, pairing says `this computer already has 1 chat of its own, so nothing was changed.` The rest of the message explains `--replace`: local chats stay here but stop syncing, and shared memories and settings are cleared. Saved knowledge, automations, project work, chat memories and unknown local files keep their existing protection. + ## The four-digit check — how I know the right computer is asking Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. diff --git a/internal/pair/opened_chat_test.go b/internal/pair/opened_chat_test.go new file mode 100644 index 0000000000..ae94d27e39 --- /dev/null +++ b/internal/pair/opened_chat_test.go @@ -0,0 +1,93 @@ +package pair + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/identity/identitytest" +) + +func TestCodeAndLinkPairingAcceptAnOpenedChatAndKeepASentChat(t *testing.T) { + for _, road := range []string{"code", "link"} { + t.Run(road, func(t *testing.T) { + for _, sent := range []bool{false, true} { + t.Run(map[bool]string{false: "only opened", true: "one sent turn"}[sent], func(t *testing.T) { + home := t.TempDir() + place := identitytest.OpenAndQuit(t, home, true) + before, err := identity.Load(home) + if err != nil { + t.Fatal(err) + } + if sent { + f, err := os.OpenFile(place.Transcript(), os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + _, writeErr := f.WriteString("{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}\n") + if err := errors.Join(writeErr, f.Close()); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var want identity.Identity + if road == "code" { + r := newChatRig(t) + want = r.a + ui := newScreen() + offer := offerAs(t, r, ui) + _, err = r.join(ctx, home, ui.nextCode(t).Shown(), newScreen()) + if !sent && err == nil { + if end := offer.wait(t); end.err != nil { + t.Fatal(end.err) + } + } + } else { + r := newLinkRig(t) + r.new = home + want, _ = identity.Load(r.old) + ui := &invites{got: make(chan Invite, 1)} + done := make(chan result, 1) + guard.Go("pair/opened-chat", func() { + joined, joinErr := JoinByLink(ctx, r.approver.Requests, r.joining(false), ui) + done <- result{joined, joinErr} + }) + if !sent { + select { + case in := <-ui.got: + asking, lookErr := r.approver.Look(ctx, in.Ref) + if lookErr != nil { + t.Fatal(lookErr) + } + if approveErr := r.approver.Approve(ctx, asking); approveErr != nil { + t.Fatal(approveErr) + } + case res := <-done: + t.Fatalf("an opened chat refused link pairing: %v", res.err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + err = waitResult(t, done).err + } + after, loadErr := identity.Load(home) + if loadErr != nil { + t.Fatal(loadErr) + } + if sent { + if !errors.Is(err, ErrDifferentChats) || !strings.HasPrefix(err.Error(), "this computer already has 1 chat of its own, so nothing was changed.") || after.ID() != before.ID() { + t.Fatalf("sent chat was not kept with its counted refusal: %v", err) + } + } else if err != nil || after.ID() != want.ID() { + t.Fatalf("an opened chat did not pair: %v", err) + } + }) + } + }) + } +} diff --git a/relay/hosted/test/local.sh b/relay/hosted/test/local.sh index acf048db62..9aa91ff0ef 100644 --- a/relay/hosted/test/local.sh +++ b/relay/hosted/test/local.sh @@ -14,6 +14,14 @@ export WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true export CLOUDFLARE_CF_FETCH_ENABLED=false export WRANGLER_REGISTRY_PATH="$local_state/registry" export CI=1 +# A RUNAWAY LOCAL SERVER DIES SMALL. Wrangler's Node process grew to about nine +# gigabytes across repeated reloads before aborting, and on WSL each abort wrote +# a crash dump of that size to the Windows drive. A bounded heap ends such a run +# at a size nobody has to clean up; an explicit caller setting still wins. +case " ${NODE_OPTIONS:-} " in + *" --max-old-space-size="*) ;; + *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=2048" ;; +esac mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$local_state/logs" child= cleanup() { diff --git a/scripts/raft-hosted-build.sh b/scripts/raft-hosted-build.sh index 60e6a964f2..88363566df 100644 --- a/scripts/raft-hosted-build.sh +++ b/scripts/raft-hosted-build.sh @@ -1,6 +1,21 @@ #!/bin/sh # Wrangler invokes this from relay/hosted. The generated reactor is bundled # through a static import and is never an independently deployed service. +# +# AN UNCHANGED BUILD MUST NOT TOUCH core.wasm. Wrangler watches src/ and runs +# this hook again on any change there, so rewriting the file with the same +# bytes re-triggered the build, which rewrote it again: under disk load the +# local dev server rebuilt and reloaded in a loop, and every reload of this +# eleven-megabyte module grew the Node process until it aborted at about nine +# gigabytes. The Go build is reproducible, so the reactor is built beside the +# tree and moved into place only when its bytes differ. set -eu cd ../.. -sh scripts/raft-proof-footprint.sh env GOOS=wasip1 GOARCH=wasm GOMEMLIMIT=192MiB go build -p=1 -trimpath -buildmode=c-shared -ldflags='-s -w' -o relay/hosted/src/raft/core.wasm ./cmd/codeaf-raft-wasm +out=relay/hosted/src/raft/core.wasm +built=$(mktemp "${TMPDIR:-/tmp}/codeaf-core-wasm.XXXXXX") +trap 'rm -f "$built"' EXIT +sh scripts/raft-proof-footprint.sh env GOOS=wasip1 GOARCH=wasm GOMEMLIMIT=192MiB go build -p=1 -trimpath -buildmode=c-shared -ldflags='-s -w' -o "$built" ./cmd/codeaf-raft-wasm +if ! cmp -s "$built" "$out"; then + cp "$built" "$out.tmp" + mv -f "$out.tmp" "$out" +fi